Compare commits
11 commits
dota2-2441
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bac78eb8fa | ||
|
|
43e37679ed | ||
|
|
36441687ff | ||
|
|
a9665e55f9 | ||
|
|
22ab973f0c | ||
|
|
fb652c39ae | ||
|
|
3410a79b6a | ||
|
|
71ce34edd2 | ||
|
|
3de955c4ff | ||
|
|
0bc70ac309 | ||
|
|
c458b4cb50 |
42 changed files with 90320 additions and 718 deletions
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
- uses: actions/checkout@v6.0.2
|
||||
- name: Clear artifacts from any earlier run
|
||||
run: rm -rf fuzz/artifacts
|
||||
- name: Fuzz — 5 targets x 2 workers x 30s
|
||||
- name: Fuzz — 8 targets x 2 workers x 30s
|
||||
run: bash fuzz.sh 30 2
|
||||
- name: Fail on any crash / timeout / OOM
|
||||
run: |
|
||||
|
|
@ -72,4 +72,4 @@ jobs:
|
|||
release-dir: dist
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
override: true
|
||||
release-notes: "`source2rosetta-gen` ${{ github.ref_name }} — renders a published gamedata release into your framework's format: CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, or a typed C# SDK. Download it, `chmod +x`, and point it at the `gamedata-<game>.json` / `netvars-<game>.json` from a per-game release (`cs2-latest`, `dota2-latest`). Usage: see `crates/source2rosetta-core/README.md`. Linux x86-64."
|
||||
release-notes: "`source2rosetta-gen` ${{ github.ref_name }} — renders a published release into your framework's format: CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, a typed C# SDK, or the Dota script API. A `--format` names WHO the output is for and writes every file that consumer reads, so `--out` is a directory. Download it, `chmod +x`, and point it at the `rosetta-<game>.json` from a per-game release (`cs2-latest`, `dota2-latest`): `source2rosetta-gen --from rosetta-cs2.json --format cssharp --out ./out`. Usage: see `crates/source2rosetta-core/README.md`. Linux x86-64."
|
||||
|
|
|
|||
|
|
@ -86,9 +86,6 @@ jobs:
|
|||
|
||||
- name: Produce — validate the contribution live (no model fold; the build is unchanged)
|
||||
run: |
|
||||
# The model already contains this build (derive folded it), and the forward-only guard needs the
|
||||
# target to sort strictly after the model's latest. "<buildid>-<patch>" does: it shares the buildid
|
||||
# prefix and is longer, and the next update's buildid still sorts above it.
|
||||
ln -sfn "$GAME_DIR" "work/$BUILDID-$PATCH"
|
||||
./target/release/source2rosetta --game "$GAME" produce \
|
||||
--seed "seed-$GAME.patched.json" \
|
||||
|
|
@ -100,9 +97,6 @@ jobs:
|
|||
cp "seed-$GAME.patched.json" "dist/seed-$GAME.json"
|
||||
|
||||
- name: Drop the re-folded model, compress the seed
|
||||
# A contribution does not change the binary, so the sidecar fold just re-folds a build the model
|
||||
# already has. Publishing that would append a duplicate row and grow the model on every PR — so the
|
||||
# model asset is left alone and `<game>-latest` keeps the one the last derive published.
|
||||
run: |
|
||||
rm -f "dist/model-$GAME.json"
|
||||
gzip -6 "dist/seed-$GAME.json"
|
||||
|
|
|
|||
|
|
@ -20,24 +20,47 @@ 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:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
|
||||
- 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"
|
||||
# No password here: the runner holds a cached refresh token from a one-time interactive login, so
|
||||
# this is non-interactive. If it ever fails with a login error the token has lapsed — re-run
|
||||
# `steamcmd +login $STEAM_USER` once on the runner, as the runner user.
|
||||
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: |
|
||||
|
|
@ -52,24 +75,30 @@ jobs:
|
|||
- name: Build the deriver
|
||||
run: cargo build --release
|
||||
|
||||
- name: Fetch the previous model + seed (the two non-user-facing release artifacts)
|
||||
- name: Fetch the previous model + seed (or take a manually-placed override)
|
||||
run: |
|
||||
mkdir -p in dist work
|
||||
curl -fsSL -o in/model.gz "$RELEASE_BASE/$GAME-latest/model-$GAME.json.gz"
|
||||
gunzip -c in/model.gz > "in/model-$GAME.json"
|
||||
curl -fsSL -o in/seed.gz "$RELEASE_BASE/$GAME-latest/seed-$GAME.json.gz"
|
||||
gunzip -c in/seed.gz > "in/seed-$GAME.json"
|
||||
OVR="$OVERRIDE_DIR/$GAME"
|
||||
for stem in "model-$GAME.json" "seed-$GAME.json"; do
|
||||
if [ -f "$OVR/$stem" ]; then
|
||||
echo "::warning::baseline OVERRIDE in use for $stem — taken from $OVR, not the $GAME-latest release"
|
||||
cp "$OVR/$stem" "in/$stem"
|
||||
echo "OVERRIDE_USED=1" >> "$GITHUB_ENV"
|
||||
else
|
||||
curl -fsSL -o "in/$stem.gz" "$RELEASE_BASE/$GAME-latest/$stem.gz"
|
||||
gunzip -c "in/$stem.gz" > "in/$stem"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Produce — derive + validate-live + typed netvars + fold model N -> N+1
|
||||
run: |
|
||||
# --target is a buildid-named SYMLINK to the install, not the install path itself: the deriver labels
|
||||
# a build by its directory NAME, and the forward-only guard demands each target sort strictly after
|
||||
# the model's latest build. Passing ".../game" would label every build "game", so the SECOND run
|
||||
# would be rejected as not-newer. Buildids are monotonic and sort after the corpus's date labels.
|
||||
ln -sfn "$GAME_DIR" "work/$BUILDID"
|
||||
./target/release/source2rosetta --game "$GAME" produce \
|
||||
--seed "in/seed-$GAME.json" \
|
||||
--corpus-model "in/model-$GAME.json" \
|
||||
--prototypes mappings/prototypes.json \
|
||||
--semantics "mappings/semantics-$GAME.json" \
|
||||
--ehandle-classes mappings/ehandle-classes.json \
|
||||
--target "work/$BUILDID" \
|
||||
--game-dir "$GAME_DIR" \
|
||||
--version "$GAME-$BUILDID-0" \
|
||||
|
|
@ -104,3 +133,9 @@ jobs:
|
|||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
override: true
|
||||
release-notes: "Rolling ${{ env.GAME }} gamedata — always the newest build (currently ${{ env.BUILDID }}). Stable URL; assets overwritten each update."
|
||||
|
||||
- name: Consume the baseline override
|
||||
if: env.OVERRIDE_USED != ''
|
||||
run: |
|
||||
rm -f "$OVERRIDE_DIR/$GAME"/*
|
||||
echo "override consumed — subsequent $GAME runs resume from the $GAME-latest release"
|
||||
|
|
|
|||
|
|
@ -52,6 +52,24 @@ The catalogues and dictionaries that give the derived offsets/signatures their n
|
|||
[hazedumper](https://github.com/frk1/hazedumper) (frk1).
|
||||
- **macOS symbol ground-truth** — [dota-2-symbols](https://github.com/a2x/dota-2-symbols) (a2x): symbolicated
|
||||
Dota builds that anchor cross-game name transfer.
|
||||
- **Declared prototypes** (`mappings/prototypes.json`) — the parameter and return types committed with this
|
||||
repository are harvested from the macOS symbols above, from `SH_DECL_HOOK` declarations in open-source
|
||||
mods, and from [modsharp-public](https://github.com/Kxnrl/modsharp-public) (Kxnrl, AGPL-3.0-or-later),
|
||||
whose gamedata-keyed function-pointer typedefs are the source of most of the declared RETURN types. Only
|
||||
files ModSharp itself authored are mined; the ten marked "modified from alliedmodders/hl2sdk/tree/cs2"
|
||||
are denylisted. Also from [CS2Fixes](https://github.com/Source2ZE/CS2Fixes) (Source2ZE, GPL-3.0), whose
|
||||
detour block declares each hooked engine function as a real function-pointer type; those are joined to
|
||||
our names by BYTE PATTERN or by a label that already is one of our names, never by a bare method name.
|
||||
Its vendored `sdk/` (alliedmodders/hl2sdk) is quarantined like every other copy, and its
|
||||
`serversideclient.h` is excluded on provenance — it carries a `@Wend4r` annotation, i.e. it descends
|
||||
from a fork this project rejected, and every copy of that header across the ecosystem shares the lineage.
|
||||
Also from [cs2kz-metamod](https://github.com/KZGlobalTeam/cs2kz-metamod) (KZGlobalTeam, AGPL-3.0), whose
|
||||
detour block covers the movement surface no other source declares, and from
|
||||
[plugify-plugin-s2sdk](https://github.com/untrustedmodders/plugify-plugin-s2sdk) (untrustedmodders,
|
||||
GPL-3.0), whose `addresses` table and hook aliases are full function-pointer types and whose
|
||||
`CALL_VIRTUAL` sites contribute return types. Both are joined by the same two routes. plugify's
|
||||
`external/` is excluded entirely: two of its submodules are Wend4r forks, one of them the very
|
||||
sourcesdk tree rejected above, so only its own `src/` is ever read.
|
||||
- **Dota / Deadlock gamedata** — [McDota](https://github.com/LWSS/McDota) (LWSS),
|
||||
[dota2dumped](https://github.com/ikhsanprasetyo/dota2dumped) & [Dota2Cheat](https://github.com/ikhsanprasetyo/Dota2Cheat)
|
||||
(ikhsanprasetyo), [D2VDump](https://github.com/ModDota/D2VDump) (ModDota),
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -248,7 +248,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "source2rosetta-core"
|
||||
version = "0.1.0"
|
||||
version = "3.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
|
|
|||
809
README.md
809
README.md
|
|
@ -7,12 +7,17 @@ When Valve ships an engine update, every Metamod / CounterStrikeSharp plugin bre
|
|||
Here it takes **about half an hour, with nobody involved.** A timer notices the new build, re-derives the whole surface from the stripped `.so` libraries the dedicated server maps, launches its own vanilla server and *calls the functions* to prove they resolve, then publishes to a fixed URL. No one is paged and nothing is hand-checked — and if any stage fails, the run stops and the previous release stays up. What ships is never a guess.
|
||||
|
||||
```sh
|
||||
# always the newest build
|
||||
curl -fsSLO https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest/gamedata-cs2.json
|
||||
curl -fsSLO https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest/netvars-cs2.json
|
||||
R=https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest # always the newest build
|
||||
curl -fsSLO $R/rosetta-cs2.json
|
||||
```
|
||||
|
||||
The output is framework-neutral; `source2rosetta-gen` renders it into whatever your stack speaks. The deriver behind it is a standalone Rust tool — you only need that if you're self-hosting the pipeline or adding a game.
|
||||
**One file per game.** One record per function: where it is, what its machine code was measured to take, what
|
||||
a declaration says it takes, what the binary declares may be done with it, and what it does in plain language
|
||||
— plus the typed schema, and the surfaces that are not function-keyed (the Pulse registry, entity outputs,
|
||||
classnames, ConVars). `manifest.json` says which build you got. The
|
||||
[artifacts section](#artifacts-schemas--output-formats) covers the shape.
|
||||
|
||||
The output is framework-neutral; `source2rosetta-gen` renders it into whatever your stack speaks — one command writes both your framework's locator format and **typed call sites** for the same functions. The deriver behind it is a standalone Rust tool — you only need that if you're self-hosting the pipeline or adding a game.
|
||||
|
||||
## Docs
|
||||
|
||||
|
|
@ -23,14 +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 | typed schema | model | one-time distill |
|
||||
|---|---|---|---|---|
|
||||
| **CS2** | ~1,150 `core` + ~1,200 `high_confidence`, all live-validated, plus ~4,400 `experimental` name guesses | ~1,900 classes / ~12,300 fields | ~48 MB (a few MB gzipped) | ~15 min |
|
||||
| **Dota 2** | ~1,900 `core` + ~1,000 `high_confidence`, plus ~6,100 `experimental` | ~2,960 classes / ~17,700 fields | ~570 MB | ~1 hr |
|
||||
| | derived functions | declared surface | typed prototypes | typed schema | model | one-time distill |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **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 |
|
||||
|
||||
Both derive **0-dropped** — every offset and signature that ships passed live validation. 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.
|
||||
**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: **+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 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -42,61 +51,443 @@ Each game runs its own loop, independently:
|
|||
2. On a change it updates the install and runs a single `produce`: derive → live-validate → typed netvars → roll the model forward.
|
||||
3. It publishes an immutable `<game>-<buildid>-<patch>` snapshot, then moves `<game>-latest` onto it.
|
||||
|
||||
A CS2 update never rebuilds Dota, and vice versa. Two rules keep it honest: every stage **hard-fails rather than substituting** an older or on-disk input, and every entry in `core` / `high_confidence` is confirmed against the live process before it ships. A failed run publishes nothing and leaves the previous release standing.
|
||||
A CS2 update never rebuilds Dota, and vice versa. Two rules keep it honest: every stage **hard-fails rather than substituting** an older or on-disk input, and every `core` / `high_confidence` entry that *can* be checked against the live process is checked before it ships. (A handful legitimately cannot — see [`validated` is three-valued](#validated-is-three-valued).) A failed run publishes nothing and leaves the previous release standing.
|
||||
|
||||
| you want | use |
|
||||
|---|---|
|
||||
| the newest build, always | `…/releases/download/cs2-latest/gamedata-cs2.json` |
|
||||
| a specific build, pinned | `…/releases/download/cs2-<buildid>-0/gamedata-cs2.json` |
|
||||
| the newest build, always | `…/releases/download/cs2-latest/rosetta-cs2.json` |
|
||||
| a specific build, pinned | `…/releases/download/cs2-<buildid>-0/rosetta-cs2.json` |
|
||||
| to know what you got | `manifest.json` — carries `version = <game>-<buildid>-<patch>` |
|
||||
|
||||
Follow `-latest` to adopt updates as they land, or pin a buildid tag to adopt them deliberately; old snapshots stay up either way. Whichever you choose, **check the manifest's build id against the server you're actually running** before loading — that is what stops stale offsets meeting a changed binary. (`patch` counts rebuilds on the same binary, e.g. a merged contribution.)
|
||||
|
||||
---
|
||||
|
||||
## How it works — read → derive → validate → emit
|
||||
## What can you build with this?
|
||||
|
||||
The artifacts answer four different questions, and most useful work joins two or more of them:
|
||||
|
||||
- **`functions` — where the code is.** Every record is a hook point or a call target: a byte signature or an RTTI vtable slot, tiered and, for `core`/`high_confidence`, checked against a running server.
|
||||
- **`schema` — what the state is.** Field offsets and types for every SchemaSystem class, plus the base graph, the enum tables and per-type sizes. This is the half that needs no hooking at all: a great deal of gameplay is readable and writable as plain memory.
|
||||
- **`prototype`, on each record — whether it is safe to call.** A declared prototype joined to the register footprint measured in *this* build, with a verdict per function. `verified` and `lower-bound` are callable; `mismatch` says the prototype in circulation is wrong for this binary.
|
||||
- **`bindings` and `surfaces` — what the binary declares about itself.** Console commands and ConVars with decoded flags, entity inputs and outputs, map classname → C++ class, the typed Pulse registry with a callable entry point per binding, and the VScript registry — the surface Valve exposes to Lua, each entry pairing a script-facing name with a C++ name, an English description and a declared return type.
|
||||
|
||||
Three of those are newer than the rest and worth calling out, because they change what a plugin can do:
|
||||
|
||||
**The VScript registry closes the biggest gap in the Dota surface.** 1,841 bindings on Dota and 300 on CS2, each pairing the name a script author types with the C++ name, Valve's own English description, and a declared return type — and 1,599 / 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, 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.
|
||||
|
||||
Tiers stay visible throughout. `core` and `high_confidence` are buildable today; the `experimental` band is fenced off at the end and is a different kind of thing entirely.
|
||||
|
||||
---
|
||||
|
||||
### CS2
|
||||
|
||||
#### Movement and player physics
|
||||
|
||||
The whole per-tick movement chain is individually hookable — `PhysicsSimulate` → `ProcessMovement` → `MoveInit` → `CheckParameters` → `PlayerMove` → `FullWalkMove` → `{Friction, AirMove/AirAccelerate, CategorizePosition, CheckVelocity, StartGravity, CheckFalling}` → `PostPlayerMove`. Every stage is `core` with a validated signature, and 22 ABI entries declare the `CMoveData*` they hand you. `AirAccelerate` resolves as `void(CCSPlayer_MovementServices*, CMoveData*, Vector&, float, float)`, `verified`, with a measured footprint of three integer and two float registers — so a surf/KZ/bhop server that rewrites air acceleration in flight is a detour and two float writes, not a reverse-engineering project. Watch two edges: `PreWalkMove`'s prototype is `unverified`, and `GroundAccelerate` has a validated signature but no declared prototype at all (footprint only).
|
||||
|
||||
Jumping splits into two independently hookable implementations — `CheckJumpButtonModern(CCSPlayerModernJump*, CMoveData*)` and `CheckJumpButtonLegacy(CCSPlayerLegacyJump*, CMoveData*)`, both `verified` — and both jump objects are fully laid out, down to sub-tick press and landing fractions. That plus `m_flAccumulatedJumpError` and `m_bHasWalkMovedSinceLastJump` is enough for either a legitimising autohop or a bhop-script detector built on press-phase distribution.
|
||||
|
||||
Surf detection does not need heuristics: `CCSPlayer_MovementServices::m_flTicksSinceLastSurfingDetected` is the engine's own signal, sitting next to the ground normal, surface friction and the surface-property token. Per-player speed control is `CCSPlayerPawn::GetPlayerMaxSpeed` (`verified`) plus `m_flMaxspeed`, `m_flStamina` and `m_flVelocityModifier`. Crouch work has a real predicate to hook — `CanUnduck` is `bool(CCSPlayer_MovementServices*, CMoveData*)`, so returning false pins a player crouched with full engine consistency rather than by poking a netvar. And the input side is intercepted at `CPlayer_MovementServices::RunCommand`, with `m_nButtons`, the fully enumerated 64-bit `InputBitMask_t`, the `uint32[64]` per-button last-press command numbers, and the four sub-tick move fractions all readable.
|
||||
|
||||
What you do not get: `TryPlayerMove`, `WalkMove`, `Accelerate` and `TracePlayerBBox` are all `unresolved` (`sig-drifted`) this build, and `CMoveData` itself is not a schema class — you get a correctly-typed pointer and no field offsets.
|
||||
|
||||
#### Combat, damage and tracing
|
||||
|
||||
`CBaseEntity::TakeDamage` is the funnel and `CCSPlayerPawn::OnTakeDamage_Alive` the player-specific override, but the interesting part is that you do not need a constructor to build a damage packet: `CTakeDamageInfo` is laid out completely — 22 fields over 280 bytes — and `CTakeDamageResult` (15 fields) tells you what the engine actually did, including `m_flPreModifiedDamage` beside `m_flDamageDealt` and a `m_bWasDamageSuppressed` flag. `DamageTypes_t`, `HitGroup_t` and the 21-flag `TakeDamageFlags_t` (`DFLAG_PREVENT_DEATH`, `DFLAG_IGNORE_ARMOR`, …) give you the switchboard. Two 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.
|
||||
|
||||
Weapon rebalancing is entirely schema work. `CCSWeaponBaseVData` is 84 fields over 2,216 bytes: damage, headshot multiplier, armour ratio, penetration, range falloff, cycle time, seven per-stance inaccuracy terms, four recoil terms, the spray-recovery transition bullets, price, kill award — and `m_nRecoilSeed`/`m_nSpreadSeed`, the per-weapon RNG seeds that generate CS2's deterministic spray patterns. Reach it with `FindWeaponVDataByName` (`verified`); there is no member offset from a weapon entity to its VData.
|
||||
|
||||
Tracing has one fully `verified`, params-complete entry point — `TraceShape(const void*, const Ray_t&, const Vector&, const Vector&, const CTraceFilter*, trace_t*)` — corroborated by two independent declarations. The catch is structural and worth planning for: `Ray_t`, `trace_t`, `CGameTrace` and `CTraceFilter` are not schema types, so you get the call shape and must supply the struct definitions. If you would rather not, `CPulseServerFuncs::GetTraceHit` is a fully typed ray cast with six named return values, and `DealDamage`/`DealRadiusDamage` sit beside it — a complete custom hitscan weapon with no native code.
|
||||
|
||||
For inventory and loadout rules, eight `CCSPlayer_WeaponServices` entry points carry `verified`, params-complete prototypes including `this` (`EquipWeapon`, `DropWeapon`, `SwitchWeapon`, `GetWeaponBySlot`, and the `CanEquip`/`CanSwitch`/`CanUse` predicates), and `CCSPlayer_ItemServices::CanAcquire` is a verified buy gate. Skins and StatTrak are pure offsets: the `CEconEntity` fallback block, `CAttributeContainer::m_Item` → a fully laid-out `CEconItemView`, and `CAttributeList::SetOrAddAttributeValueByName` (`verified`) to write.
|
||||
|
||||
Two things make this domain unusually workable. The entire combat pipeline lands in `core` and `high_confidence` — the experimental band contributes nothing to it. And `CCSScript_EntityScript` comes out as a contiguous named vtable run with a three-stage damage pipeline — `OnBeforePlayerDamage`, `OnModifyPlayerDamage`, `OnPlayerDamage` (veto / scale / observe) — which is exactly the shape a perk or RPG mod wants, though none of those slots has a typed prototype, only a measured register count.
|
||||
|
||||
#### Bots and the nav mesh
|
||||
|
||||
`CCSPlayerPawn::m_pBot` is a typed `CCSBot*` on the pawn, and `CCSBot` is 140 typed fields over 24,088 bytes: current enemy, visible parts, goal position, path index, heard noise, panic and hurry timers, stuck state with a velocity ring buffer, radio timestamps, and the whole aim model (`m_lookPitch`/`m_lookYaw` with velocities, `m_aimError`, `m_aimFocus`, the reaction-queue indices). Every `CountdownTimer` is itself a schema class, so timers are readable *and* writable. A live bot-brain inspector, a custom difficulty curve, or an anti-stuck watchdog all need zero hooking.
|
||||
|
||||
The aim and behaviour functions — `UpdateLookAngles`, `UpdateReactionQueue`, `BendLineOfSight`, `FindMostDangerousThreat`, plus the direct verbs `SetBotEnemy`, `SetState`, `Panic`, `Blind`, `Retreat` — are `core` or `high_confidence` byte signatures, but **none has a declared prototype**: not one `CCSBot::` method carries one. You get an address and a register count.
|
||||
|
||||
The state machine is more limited than it first looks. All 16 behaviour states appear as `core` entries and a `OnEnter=0 / OnUpdate=1 / OnExit=2 / GetName=3` slot convention is visible across them. But those offset-form entries carry no class binding — `IdleState::OnEnter`, `HideState::OnEnter` and `PickupHostageState::OnEnter` are all bare "slot 0" — so resolving a specific state's vtable needs a class pointer the artifacts do not supply. Eleven of the state methods (including `CCSBot::SetState` and eight `OnUpdate` slots) ship as byte signatures instead, and those you can resolve directly.
|
||||
|
||||
Nav work is better served from the sides than the middle. `CPulseServerFuncs::GetEntityNavMeshPosition` and `GetEntityHeightAboveNavMesh` are typed with no signature dependency at all; `CCSBot::m_playerTravelDistance` is a `float32[64]` of nav travel distance from that bot to every player slot, maintained by the engine every tick — real walk distances for free. Runtime map flow is `CFuncNavBlocker` with `m_nBlockedTeamNumber` (per-team blocking) driven by `BlockNav`/`UnblockNav`, whose handlers are `verified` and params-complete. The 44 `nav_*` editor commands survive in the dedicated server with verified `void(CCommandContext*, CCommand*)` handlers, including `nav_check_connectivity` — but nearly all of them carry the `cheat` flag, so a CI map-validation harness runs with `sv_cheats` on, not on a public server. There is no `CNavArea` or `CNavMesh` schema class: area-level data means calling engine functions with pointers you obtained from other engine functions.
|
||||
|
||||
#### Entities, spawning and map mechanics
|
||||
|
||||
The runtime-spawn chain is `core` and `verified` end to end: `CreateEntityByName` → `CEntityKeyValues::FindOrCreateKeyValues`/`SetString` → `CEntityKeyValues::AddConnectionDesc` → `CGameEntitySystem::DispatchSpawn`. `AddConnectionDesc`'s nine-parameter prototype maps field-for-field onto the schema struct `EntityIOConnectionData_t`, and its `targetType` argument is enumerated by `EntityIOTargetType_t` — prototype, struct layout and legal constants derived separately and agreeing. The 474-entry classname table (`prop_dynamic` → `CDynamicProp`, and the alias forms Valve registers separately) tells you what to pass. One hole: nothing here tells you how to *allocate* the `CEntityKeyValues` object.
|
||||
|
||||
Entity I/O is where one hook shape covers an enormous surface. 715 CS2 inputs are enumerated with class, handler symbol and address, 673 have a live signature, and the overwhelming majority carry the same `verified` prototype `void(CEntityInstance*, InputData_t&)` — so a single trampoline plus a dispatch table keyed by address is an IO firewall for community maps. Do check before assuming universality: about fifteen handlers declare something else (`CBaseFilter::InputTestActivator` takes a `CBaseEntity*`; `CGamePlayerEquip::InputTriggerForActivatedPlayer` takes an `InputData_t*`; the `CMathCounter` arithmetic inputs declare only `inputdata_t&`), and thirteen are verdict `unverified`. To fire rather than intercept, `CGameEntitySystem::AddEntityIOEvent` is `verified` with a full 10-parameter prototype including the delay float. The limit: `InputData_t` and `Variant_t` have no schema layout, so you get the hook point and not a payload decoder.
|
||||
|
||||
Trigger volumes have a single global arbiter — `CBaseTrigger::PassesTriggerFilters` is `verified` `bool(CBaseEntity*)` at vtable 270 — and a passive reader: `m_hTouchingEntities` is a live handle vector at a fixed offset. Collision surgery is netvar-only and complete: `VPhysicsCollisionAttribute_t` exposes `m_nInteractsAs`/`m_nInteractsWith`/`m_nInteractsExclude` as three `uint64` masks, which is exactly the knob a no-block plugin flips.
|
||||
|
||||
`func_mover` is a trap for anyone working from the IO graph: it has zero entity inputs and zero entity outputs, so an audit of that graph concludes it is inert. Its entire runtime surface is 44 typed `CFuncMoverAPI` Pulse methods plus 98 netvar fields. CS2's spline-mover system lives outside the classic IO graph.
|
||||
|
||||
#### Game rules, teams and economy
|
||||
|
||||
`CCSGameRules` is 189 fields over 70,720 bytes, reached by finding `cs_gamerules` and reading `CCSGameRulesProxy::m_pGameRules` — the bare `GameRulesPointer` signature is `unresolved` this build, so use the entity route. From there `RestartRound` and `GoToIntermission` are `verified`, `TerminateRound` is `lower-bound` with a delay/reason/reward-vector prototype, and warmup, phase and reset drivers are validated addresses with **no** declared prototype — a real gap in an otherwise strong domain. The round-end presentation block (winner, reason, message string, fun-fact token, mute flags) is all writable, and `m_iMatchStats_RoundResults[30]` with the two per-round alive-count arrays means a full competitive match history is readable from one pointer with no event subscription.
|
||||
|
||||
Money is a plain `int32`: `m_pInGameMoneyServices` → `m_iAccount`, with start/spent/next-round fields beside it and the whole team loss-bonus model on the rules object. Note there is **no per-player money function at any tier** — no `AddAccount`, no `GiveMoney` anywhere in the search — so an economy mod writes the ledger and replicates with `NetworkStateChanged` (`verified`; its sibling `StateChanged` is verdict `mismatch`, so use the right one).
|
||||
|
||||
Team moves have three independent paths — `CBaseEntity::ChangeTeam` at vtable 102, `CBasePlayerController::SwitchSteam` (`verified`), and the command path — gated by `CCSGameRules::WillTeamHaveRoomForPlayer` (`verified`). Two of the alternatives are weaker than they look: `CCSPlayerController::SwitchTeam` and `HandleCommandJoinTeam` are `unverified`, and `abi:CCSPlayerController::ChangeTeam` is `return-only` (no parameter list). Respawn waves are two arrays on the rules object (`m_TeamRespawnWaveTimes`, `m_flNextRespawnWave`), and spawn-point pools with round-robin cursors sit beside them — though the engine's *choice* of spawn point is not exposed as a hookable function. One inconsistency to resolve in-engine before relying on either: `CCSPlayerController::Respawn` and `CCSPlayerController::RoundRespawn` are both reported at vtable 272 while the base-class `RoundRespawn` is at 270 — two names cannot share one slot.
|
||||
|
||||
Stats need no events at all. `CSMatchStats_t` derives from `CSPerRoundStats_t` and already tracks `m_i1v1Count`/`m_i1v1Wins`, `m_i1v2*`, `m_iEntryCount`/`m_iEntryWins`, `m_iEnemy5Ks` through `m_iEnemy2Ks`, and shots fired vs shots on target — the clutch and entry numbers most stats plugins recompute from kill events.
|
||||
|
||||
#### Effects, sound and networking
|
||||
|
||||
The lowest-risk half of this domain needs no signatures: `env_shake`, `env_fade`, `env_beam`, `env_instructor_hint`, `env_fog_controller`, `env_tonemap_controller` and the render/glow block on `CBaseModelEntity` are all datadesc-sourced inputs plus exact schema offsets, with their enums (`ShakeCommand_t`, `ViewFadeMode_t`, `BeamType_t`, `RenderFx_t`) fully enumerated. `CParticleSystem` networks 64 entity-attached control points *and* 64 control-point names, so a beam tracking two moving players is state, not a call.
|
||||
|
||||
`UTIL_DispatchEffect`/`UTIL_DispatchEffectFilter` are `core`, `verified`, params-complete, and their one non-trivial argument — `CEffectData` — is fully mapped (20 fields, 112 bytes, SysV `memory`). That pairing is the ideal case these artifacts exist to produce.
|
||||
|
||||
Recipient filters are the gap in this domain, and several otherwise-attractive routes run through them. The filtered dispatchers (`UTIL_DispatchParticleEffectFilter_Position`/`_Attachment`, `UTIL_SayTextFilter`, `UTIL_SayText2Filter`, `SoundOpGameSystem::StartSoundEventString`) are real and verified — but the *only* recipient-filter symbol anywhere in the catalogue is `CRecipientFilter::AddAllPlayers`, whose ABI entry is `unverified` with an empty parameter list. There is no `AddRecipient`, no per-team filter, no single-user filter. You get "dispatch to everyone" and a filter-shaped hole you must fill from your own framework. The genuinely per-client route that *is* covered is the `point_soundevent` entity: `StartSoundOnSingleClient` targets one player index and fires an `m_onSoundFinished` output when the sound ends.
|
||||
|
||||
The sound-operator system itself is unusually complete — start-by-string, the 11-argument raw start, set-param-string, and stop-with-filter are all `core` with `verified`, params-complete prototypes from a single provenance. The exception is flagged loudly: `SoundOpGameSystem::StopSoundEvent` is verdict `mismatch` (measured footprint exceeds declared), so use `StopSoundEventFilter`.
|
||||
|
||||
Both game-event stacks ship: the legacy `CGameEventManager` with fixed vtable getters, and the modern `CGameEventSystem` as a contiguous `verified` run (`PostEventAbstract` at 15, `PostEntityEventAbstract` at 17, register/unregister at 12/13). Bind to the concrete classes — the `IGameEventManager2::`/`IGameEventSystem::` interface aliases are all `unresolved`. Transport for custom messages is there (`CServerSideClient::SendNetMessage` is `core`/`verified`; the broadcast, channel and registry entries are `high_confidence`, several of them AI-derived names that passed live validation), but **no protobuf field layouts exist anywhere** — you get the pipe and the message id, never the payload shape.
|
||||
|
||||
Voice routing splits cleanly: `CServerSideClient::IsHearingClient` (`verified`, vtable 21) is the per-listener decision hook and `CLCMsg_VoiceData` (vtable 39) the inbound handler — both `core`. Everything that would let you touch the raw voice *stream* (`IsProximityHearingClient`, `SendVoiceData`, `ProcessVoiceData`) is experimental, i.e. a guess.
|
||||
|
||||
#### Performance, profiling and integrity
|
||||
|
||||
A real tick profiler is buildable because both halves are present: `IGameSystem::LoopPostInitAllSystems->pEventDispatcher` is the `core` anchor, and the payloads it delivers are schema-laid-out — `EventAdvanceTick_t::m_nTotalTicksThisFrame` tells you the server ran N ticks in one frame, `EventSetTime_t::m_flRenderFrameTimeUnbounded` is the pre-clamp cost that reveals a hitch. Bring your own monotonic clock: there is no `Plat_FloatTime` in the artifacts.
|
||||
|
||||
Per-entity think attribution is pure schema. `CBaseEntity::m_aThinkFunctions` is a `CUtlVector<thinkfunc_t>` and `thinkfunc_t` exposes the raw `m_think` function pointer, the `CUtlStringToken` naming the context, and next/last think ticks — so you can walk every entity, see which contexts are due this tick, and wrap only those. The same `m_think` pointer doubles as an integrity signal: it must land inside `libserver`'s text range.
|
||||
|
||||
That is one leg of a defensive integrity monitor for the server process an operator owns. The others: 628 `core` and 1,401 `high_confidence` signatures have a wildcard-free first eight bytes, which is exactly where an inline detour lands, so prologue snapshots detect another module hooking the engine underneath you; and 705 ABI entries carry an explicit vtable index, so named slots can be snapshotted and diffed. Every locator names its owning library, so "is this pointer still in the module that owns it" is answerable per key.
|
||||
|
||||
The console surface is the most mechanically reliable thing in the release. 755 of CS2's 784 registered commands carry a hookable `ConCommand::<name>` locator with a `verified` prototype — that is *all* commands, not a diagnostics subset, though the diagnostics family within it is broad (`stats_print`, `sv_packstats` with its `clear` argument, the `vprof_*` family, `mem_dump`, `net_stats_json`, `status_json`, `lrucache_stats`, `check_nofilefd`). Hooking is the reliable direction: the artifacts give you the callback address, not the `CCommand` layout needed to synthesize a call. `logaddress_add_http` ships log fan-out to an arbitrary URI with no sidecar, and tier0 carries a complete scripted-test harness (`Test_StartScript`, `Test_LoopForNumSeconds`, `Test_Checkpoint`, `Test_ExitProcess` with a chosen exit code) that is a CI rig Valve already wrote.
|
||||
|
||||
Because command flags are decoded, the client-reachable attack surface is exactly enumerable rather than folklore: **30 CS2 commands carry `client_can_execute`**, including `ent_setpos` and `ent_setang` — which move *arbitrary entities* — alongside `give`, `god`, `noclip`, `kill`, `explode`, `setpos_player`, `callvote` and `replay_start`. Exactly two carry `server_can_execute` (`echo`, `play`), which answers a question plugin authors argue about: the server cannot push arbitrary console commands to clients through the normal path. That static audit is solid, and it now extends to ConVars: their flags are decoded the same way, so `cheat`-guarded and `replicated` tunables are enumerable rather than assumed. Detecting a *runtime* change to any of those flags is a different matter — every cvar-registry accessor (`CCvar::GetConVarFlags`, `CCvar::FindCommand`, `CCvar::RegisterConCommand`) is experimental with a guessed name.
|
||||
|
||||
---
|
||||
|
||||
### Dota 2
|
||||
|
||||
Dota's surface is materially larger, and the difference is structural rather than incidental: **3,528 registered entity classnames against CS2's 474**, and 2,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.
|
||||
|
||||
**They are in `high_confidence`, via the VScript registry, and that is recent.** `AddNewModifier`, `CastAbilityOnTarget`, `AddItemByName`, `ChangeTeam`, `ModifyGold`, `AddExperience` — cast, apply, give, pay, add a modifier — all fold as located names with a Valve-declared return. Earlier releases of this file said Dota's strength was observation and CS2's was invocation; that is no longer the split. What separates them now is that CS2's verbs are mostly native methods while Dota's are mostly script wrappers, which mainly changes which argument shapes you get for free.
|
||||
|
||||
#### Custom game rules
|
||||
|
||||
`CDOTABaseGameMode` is 110 networked fields at exact offsets, and it is recognisably the Lua `GameRules:GetGameModeEntity():SetXxx()` API re-expressed as memory: fog of war, custom XP curves (`m_nCustomXPRequiredToReachNextLevel` is a networked int vector — replace the whole curve), respawn scaling, buyback rules, the attribute-to-stat coefficients (`m_flStrengthHP`, `m_flAgilityArmor`, `m_flIntelligenceSpellAmpPercent`), per-rune-type toggles as a `bool[10]` indexed by `DOTA_RUNES`, custom shops, ability-upgrade whitelists, HUD visibility bits, camera distance and min/max attack speed. Reach it via `CGameSystemReallocatingFactory<CGameRulesGameSystem,…>::GetStaticGameSystem` → `CDOTAGamerulesProxy::m_pGameRules` → `m_hGameModeEntity`. Four of these knobs also have typed Pulse setters that avoid raw writes.
|
||||
|
||||
`CDOTAGameRules` itself is 326 fields and reads like a design document: the Roshan respawn *phase machine* is explicit (`ERoshanSpawnPhase` = ALIVE / BASE_TIMER / VARIABLE_TIMER — the variable window is a modelled state), pause has per-player budgets, and there are three distinct kinds of night with separate timers and a `HeroID_t` attributing which hero caused it.
|
||||
|
||||
#### Modifiers, and the catalogue nobody else has
|
||||
|
||||
919 `CModifierFactory<…>` entries sit in `core` with live-validated byte signatures — 812 `::Create`, 71 `::Destroy`, 36 `::IsSameType`, covering 826 distinct modifier classes including the Lua-backed ones (`CDOTA_Modifier_Lua`, the three motion variants, `CDOTA_Modifier_ScriptedMotionController`). The class name is not inferred; it is the template argument of a symbol Valve shipped. That gives you a complete, name-accurate index of every shipped modifier implementation plus a per-class hook point. `Create` is nullary, so hook the *return*, not the arguments.
|
||||
|
||||
The vocabulary is complete too: `modifierfunction` has all 409 `MODIFIER_PROPERTY_*`/`MODIFIER_EVENT_*` values, `modifierstate` all 65 states, and `CDOTA_BaseNPC::m_nUnitState64` is a `uint64` — one read decodes stunned/silenced/rooted/hexed/disarmed/magic-immune for any unit. `CDOTA_Buff` is 38 fields including `m_hScriptScope`, the handle back into the Lua object.
|
||||
|
||||
The wall used to be that nothing could APPLY one. **The VScript registry breaks it:** `AddNewModifier` folds as a `high_confidence` locator with a Valve-declared return, alongside the rest of the script-facing verb set — `AddItem`, `AddItemByName`, `CastAbilityOnTarget`, `CastAbilityNoTarget`, `ChangeTeam`, `AddSpeechBubble`. None of these existed at any tier before it landed. They are the script WRAPPERS rather than the underlying C++ methods, which is the right target anyway: their argument shapes are the ones Valve declared for a content author to call safely.
|
||||
|
||||
What is still missing is the read side of the same surface. `RemoveModifierByName` is not in the registry either, and `CDOTA_ModifierManager` still exposes only 7 of its 904 bytes — no vector of active buffs. So you can hook creation, read a buff you hold, and now apply one; you still cannot enumerate a unit's modifiers, except through the debug command `dota_modifier_test <entityindex> <modifiername> <duration>`, whose handler is `verified`.
|
||||
|
||||
#### Match telemetry
|
||||
|
||||
`DataTeamPlayer_t` is 96 fields and splits gold 23 ways — hero kill, creep, neutral, summon, bounty, Roshan, building, courier, ward kill, ability, deny, comeback, income, shared — matching the 23-value `EDOTA_ModifyGold_Reason` enum one for one, with the four spending buckets and `m_iGoldLostToDeath` beside them. `CDOTA_PlayerResource` adds `m_playerAbilityUpgradeOrder` as `AbilityID_t[25][24]` and `m_playerAbilityUpgradeTimes` alongside it — the complete skill build for every slot *with timestamps* — plus a 24×24 hero-damage matrix and a 24×24 assist matrix. This is replay-parser-grade data available live from schema offsets.
|
||||
|
||||
The engine also publishes things people normally reconstruct from replays: `m_fCreepDistanceSafe`/`Mid`/`Off` are per-team lane-equilibrium floats refreshed on their own timer, `m_flAvailableLaneGold` is a lane gold pool, and `CDOTAGameRules::m_hEnemyCreepsInBase` is literally a handle vector of creeps in your base. `CDOTA_NeutralSpawner` records `m_iStackingCreditPlayerID` and a per-team `m_bSeenClearedByTeam`, so camp analytics needs no heuristics. Every creep death carries `m_flTimeOfDeath`, `m_vWsKillOrigin` and `m_vWsKillDirection`.
|
||||
|
||||
Reading was the strong direction until the VScript registry landed; the economy is now writable. `ModifyGold`, `ModifyGoldFiltered` ("gives this hero some gold, using the gold filter"), `SetGold`, `SpendGold` and `AddExperience` are all located, as are the passive knobs — `SetGoldPerTick`, `SetGoldTickTime`, `SetStartingGold`, `SetLoseGoldOnDeath`, `SetMinimumGoldBounty` / `SetMaximumGoldBounty`. Better than a mutator, the FILTERS are exposed too: `SetModifyGoldFilter`, `SetModifyExperienceFilter` and `SetExecuteOrderFilter` install a script callback the engine consults, which is interception rather than a race with the engine's own bookkeeping. Only the experimental `CDOTATurboGameMode::FilterModifyGold` was reachable before.
|
||||
|
||||
#### Map, encounters and scripting
|
||||
|
||||
`CDOTA_ScriptedSpawner` has a 22-method Pulse API — `SpawnNPC`, `SetNPCType`, `SetCustomNPCName`, `SetHealth`, `SetInvulnerable`, `SetNPCWaypoint`, `UseAbility`, `SetAutomaticallyRespawn` — backed by an 18-field entity with three IO outputs (`m_OnAllUnitsKilled`, `m_OnUnitKilled`, `m_OnHealthLow`). Around it: `CDOTA_MapTree_API` cuts and regrows individual trees, `CDOTA_SimpleObstruction_API` toggles blockers whose schema separately controls FoW-blocking versus nav-blocking, `CDOTA_BaseNPC_API` issues movement orders and speech bubbles, `CDOTA_BaseNPC_Building_API` pushes invulnerability refcounts. That is a fairly complete undocumented encounter-scripting system, and the whole map's tree state is one `uint64[256]` bitfield — 16,384 trees in 2 KB.
|
||||
|
||||
The Pulse registry is self-documenting: all 500 Dota bindings are typed with parameter names and, for many, Valve's own English description read out of the binary. Note the tier is per-game, though — Dota's Pulse *runtime* entry points (`CPulseSystem::CreateInstance`, `CPulseGraphInstance::Unserialize`, `CPulseTypeManager::FindTypeByName`) are all experimental, where CS2 has `core` equivalents. The same applies to `CGameEntitySystem::AddEntityIOEvent`: `core` in CS2, experimental in Dota. Tiers are per-game, so check the tier in the artifact for the game you are targeting — a shared engine symbol can be first-class in one and a guess in the other.
|
||||
|
||||
`CLuaVM` comes out as a near-complete `IScriptVM` map — vtable slots 0 through 63, with `RegisterFunction`, `SetValue`, `CreateScope`, `LookupFunction`, `ExecuteFunction` and `RegisterScriptClass` all `verified` — so a native plugin can compile and run Lua into a live custom game, register C++ functions into the addon namespace, and redirect script output. Several slots carry an honest `mismatch` verdict (`GetRootTable`, `CreateTable`, `RegisterInstance`, `CScriptManager::CreateVM`): the slot index is good, the declaration is not. And `CBaseEntity_SharedAPI` exposes `RunScriptCode`, `CallScriptFunction` and `CallGlobalScriptFunction` as Pulse methods, so the two scripting systems bridge in both directions.
|
||||
|
||||
For test rigs, all 107 `dota_*` commands share one `verified` prototype and a validated handler address, so a single ~20-line detour shim covers the whole rules console surface. 67 Dota commands carry `client_can_execute` with no cheat bit — including `dota_create_unit`, `dota_create_item`, `dota_spawn_creeps`, `dota_spawn_neutrals` and `dota_treerespawn` — which is simultaneously a modding shortcut and a server-hardening checklist.
|
||||
|
||||
---
|
||||
|
||||
### What the schema and prototypes add
|
||||
|
||||
A flat offset dump cannot do any of the following, and each one is a real failure mode without it.
|
||||
|
||||
**`bases` makes inherited fields reachable at all.** `CCSPlayerPawn` declares 104 fields and *none* of them is health, team, life state or move type. Walking the base chain to `CEntityInstance` flattens it to 297 and puts `m_iHealth` at 1456, `m_iTeamNum` at 1572, `m_lifeState` at 1464. The same walk turns a map classname into a field table: `trigger_multiple` goes from 1 own field to 158 flattened.
|
||||
|
||||
**`bases` is also the only place multiple inheritance is expressed.** Treating a `CEconEntity` as `IHasAttributes` requires adding 3,136 bytes; for `CChicken` it is 3,728. In Dota, 16 ability classes carry a second base at +2144 — `CDOTA_Ability_Morphling_Waveform` and friends inherit `CHorizontalMotionController` there, `CDOTA_Ability_DataDriven` inherits `CDOTA_ActionRunner`. A naive `(Base*)ptr` cast at any of these sites corrupts memory silently.
|
||||
|
||||
**`enums` recovers field width, not just readability.** 812 CS2 fields report `size: 0`; the enum's own size is what makes them decodable. `CBaseEntity::m_MoveType`, `m_nPreviouslySetMoveType` and `m_nActualMoveType` sit at 1491/1492/1493 and are only three consecutive `u8`s because `MoveType_t` is one byte wide. Beyond that, 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,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,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.
|
||||
|
||||
---
|
||||
|
||||
### The experimental band — read this before using any of it
|
||||
|
||||
`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 / 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: 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.
|
||||
|
||||
The only defensible workflow for anything in this band: pick a candidate, check the measured footprint matches the semantics you expect, then confirm behaviour in-engine yourself before shipping.
|
||||
|
||||
---
|
||||
|
||||
### What is not covered
|
||||
|
||||
- **ConVars ship names, help and flags — but no defaults or ranges.** The default value is built in a stack structure at the registration site rather than passed as a literal, so it is not recoverable the way the rest is. `min`/`max` likewise. If you need the shipped default, read it off a running server.
|
||||
- **No protobuf field layouts.** You get message ids and class names; you must supply the `.proto` definitions.
|
||||
- **No game-event name tables.** The event *system* is there (post, register, the legacy bridge); the names (`player_death`, `dota_player_gained_level`) are not. On CS2 the practical substitutes are function-level equivalents and `logic_gameevent_listener`, which needs only a string.
|
||||
- **No content names.** No `.vpcf` particle systems, no sound events, no model paths, no Dota KeyValues gameplay data (no ability special values, no hero base stats, no item costs).
|
||||
- **Server-side only.** Neither game's artifacts contain a `client` library. No Panorama, no client prediction, no client-side anticheat surface.
|
||||
- **Linux x86-64 only.** Every signature object carries exactly `{library, linux}`.
|
||||
- **Some struct types are named but not laid out** — `CMoveData`, `CUserCmd`, `Ray_t`, `trace_t`, `InputData_t`, `Variant_t`, `EmitSound_t`, `SpawnGroup_t`. They appear in verified prototypes; you can pass pointers through them and cannot construct or inspect them from these files.
|
||||
- **Bitfield netvars are unusable.** 52 CS2 / 122 Dota fields typed `bitfield:N` all report offset 0 and size 0 — the name is there, the location is not.
|
||||
- **Nothing marks a field as networked.** A field entry is `{offset, type, kind, size, name_hash}`; there is no replicated/server-only distinction, so send-table-aware tooling is out of scope.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Six stages: **read → derive → locate → measure → validate → emit.** The organising distinction, which everything else hangs off:
|
||||
|
||||
> **Some sources LOCATE a function. Others only DOCUMENT it.**
|
||||
> A source that pairs a name with an address gives you a locator. A source that pairs a name with
|
||||
> documentation gives you a catalogue entry and nothing to call. Conflating the two is the single most
|
||||
> expensive mistake this project has made, and the artifacts keep them apart deliberately.
|
||||
|
||||
### 1. Read — the whole server, not just `libserver`
|
||||
|
||||
A dedicated server maps roughly 22 shared libraries, and `libserver.so` is only a fraction of the reachable engine surface. source2rosetta reads **all of them** (multilib): `libserver`, `libengine2`, `libtier0`, `libnetworksystem`, `libschemasystem`, and the rest — each locator it emits carries the library it belongs to. ELF parsing is done by hand; `.rela.dyn` relocations are resolved so that `.data.rel.ro` pointer slots (zero on disk) come back as their true as-loaded values.
|
||||
A dedicated server maps roughly 22 shared libraries, and `libserver.so` is only a fraction of the reachable engine surface. source2rosetta reads **all of them** (multilib) — `libserver`, `libengine2`, `libtier0`, `libnetworksystem`, `libschemasystem`, and the rest — and every locator it emits carries the library it belongs to.
|
||||
|
||||
### 2. Derive
|
||||
ELF parsing is by hand. Three things make a stripped binary readable at all:
|
||||
|
||||
**Offsets & netvars — near-deterministic.** Vtable offsets come from **Itanium C++ RTTI**: the type hierarchy and vtable layout are read straight out of the binary, so a method's slot index is a fact, not a guess. Field layouts come from **Valve's own SchemaSystem** reflection tables — the engine emits class/field metadata (name, type, offset) as static data for its own use, and source2rosetta reads it directly. No fingerprints, no guessing.
|
||||
- **Relocations are resolved.** Every `SHT_RELA` section is processed (matched by type and flags, never by section name), so `.data.rel.ro` pointer slots — zero on disk — come back as their true as-loaded values. Three relocation types are handled; anything else is left alone.
|
||||
- **`.eh_frame` enumeration** via `PT_GNU_EH_FRAME` recovers function extents where unwind info survives. Valve strips it from the *game* code, so this covers the statically-linked runtime tail and not much else — which is exactly why the next item exists.
|
||||
- **Candidate entries** = relocation code-pointers (every vtable slot) ∪ decoded near-call targets, unioned with the `.eh_frame` starts. This is the function list everything downstream iterates.
|
||||
|
||||
**Non-virtual signatures — located, then verified.** A stripped, non-virtual function has no slot and no symbol, so it has to be *found*. source2rosetta computes a **recompilation-invariant fingerprint** of each catalogued function — CFG shape, call-graph degree, mnemonic histograms, imported-symbol references. These are deliberately **abstracted statistics, never raw bytes**. Locating a function in a new build is **nearest-history under a plain, unweighted L1 distance** over those features, accepted only within a small fixed recompile threshold. To be exact about what this is *not*: there is **no trained model, no machine learning, no learned or weighted metric, no embedding network** — it's a deterministic nearest-neighbour lookup against recent history, and the per-game "model" it reads is a bundle of derived *facts*, not a network. Every located address is then **re-verified independently of the match**: a fresh byte-signature is regenerated at the predicted address and confirmed to be **unique** in the target library and on a **function prologue** — the same check a loader does. A match that doesn't verify isn't shipped.
|
||||
On top of that sits a whole-binary **cross-reference index**: for every referenced address, the instructions that reference it. That gives the string-anchored locator ("which function uses this string?"), which several later stages depend on.
|
||||
|
||||
**ABI-shape guard.** A byte-signature sees a function's *body* drift and re-derives it, but it can't see the *argument list* change while the prologue stays recognisable — the sig still resolves and points at real code, yet a caller using the old prototype passes the wrong registers. source2rosetta recovers each function's observable **SysV-AMD64 ABI shape** (which argument registers are live-in, plus the return class) via a bounded backward-liveness pass and diffs it across builds, flagging exactly those prototype changes and marking struct-by-value (sret) returns that are unsafe to blind-call.
|
||||
**"Which function" is an attribution, and it is only as good as the entry list.** Nearest-entry-at-or-below is
|
||||
how an instruction is credited to a function, and CS2 strips `.eh_frame` from the game code, so a
|
||||
`[entry, next_entry)` range routinely spans a real function plus unindexed neighbours and inherits their
|
||||
strings. Anchor RECOVERY — using an anchor to locate a function that no byte-signature found — therefore
|
||||
applies the same test anchor DERIVATION does: every instruction that loads the string must fall inside the
|
||||
candidate's own flow-reachable code, walked from its entry. On CS2 that rejects 38 of 476 recoveries; on
|
||||
Dota, whose FDEs span the whole `.text`, it rejects 4. The asymmetry is the point — the test fires where the
|
||||
attribution is genuinely weak and stays quiet where it is not.
|
||||
|
||||
### 3. Validate — against a live server, not a spec
|
||||
All Source-2 `.so` files link at vaddr 0, so a runtime address is simply `load_base + file_vaddr`.
|
||||
|
||||
This is what separates source2rosetta from a static dumper. `produce` and `integration-test` **launch their own** vanilla dedicated server for the game (bots on an empty deathmatch for pawn games; a pawn-less game like Dota waits on a `ready_class` proxy) — no Steam, no separate instance, no human. Then, reading the running process through `/proc/<pid>/mem` (read-only ptrace — no injection, no debugger), it checks against ground truth:
|
||||
### 2. Derive from the binary's own reflection
|
||||
|
||||
Valve compiles two reflection systems into every module, and both are read directly.
|
||||
|
||||
**RTTI** gives the type hierarchy and vtable layout: which classes exist, what they inherit, and the ordering of every vtable. Where a slot is read *directly* — the fold's offset locators and the experimental band — a slot index is a fact, not a guess.
|
||||
|
||||
**SchemaSystem** gives class → field metadata. Be precise about what is static here: **name and offset are in the file; the TYPE is not.** A field's type pointer is a null placeholder on disk and is populated only at runtime, which is why the typed schema requires a live process and why an offline run states `"schema": null` instead.
|
||||
|
||||
### 3. Names Valve ships in the binary — four sources, and only three locate
|
||||
|
||||
Every Source-2 module names some of its own functions. This is ground truth from the shipped binary — not inference, not cross-game transfer — so it outranks every derived name, and it re-derives on every build with no input to maintain and nothing to bootstrap: the source travels with the binary.
|
||||
|
||||
| source | what it pairs | locates? |
|
||||
|---|---|---|
|
||||
| **entity-IO datadesc** | the C++ handler name (`InputKill`) with the handler's address | **yes** |
|
||||
| **console-command registration** | the command name (`bot_add`) with its callback | **yes** |
|
||||
| **VScript binding registry** | a script-facing name (`TakeDamage`) with a C++ name, an English description, a return type and the implementation | **yes** |
|
||||
| **Pulse binding registry** | a qualified `Class::Method` with display name, description, call policy, a full typed signature and an invocation shim | **not as a C++ symbol** |
|
||||
|
||||
**The VScript registry is the fourth, and it is the only one that states a RETURN TYPE.** Valve exposes a subset of the C++ surface to script — Lua in Dota's custom games, a smaller set in CS2 — and each exposed method is registered with a descriptor carrying both names, Valve's own prose, a `ScriptDataType_t` return and a pointer to the implementation. That is a locator, a prototype and documentation in one record.
|
||||
|
||||
It is not a table walk, for the same reason the Pulse signatures are not: **the descriptors are built at runtime and are zeroes on disk.** A scan of Dota's `libserver.so` finds 2,268,664 `R_X86_64_RELATIVE` relocations and not one points at a description string. What is static is the code that fills them in, so the same answer applies — constant-propagate the initialiser rather than read the table. Three distinct registration forms are recovered: a packed pair of name pointers, a single string duplicated when both names are the same, and a record base copied between registers mid-construction.
|
||||
|
||||
The return-type decoding is DERIVED rather than assumed. An early reading fitted two observations to Source 1's historical `FIELD_*` ordering and was wrong; joined against 389 bindings whose return type Valve's published dump states, `5` is `int` and `6` is `bool`. The raw word ships beside the decoding regardless.
|
||||
|
||||
**It is very nearly disjoint from everything else here, and the exceptions are measured rather than assumed.** On Dota not one of the 1,652 implementations shares an address with an existing catalogue entry, and no name is shared either — a `Script_TakeDamage` is a script-facing WRAPPER, a different function from the `TakeDamage` it wraps. **On CS2 twelve are not wrappers**: `SetAbsOrigin`, `SetAbsVelocity`, `ScriptSetAbsAngles`, `Script_SetModelScale` and eight others are bound straight to the native method, so they land on an address the catalogue already names. That is a fact about the two games' bindings rather than a defect — where a native signature is already script-callable, Valve binds it directly — and both names now ship, each [declaring the other as an alias](#functions--one-record-each) instead of appearing as two unrelated functions.
|
||||
|
||||
An earlier revision of this section generalised the Dota measurement to both games and said the surface was disjoint outright. It is not, and the alias field is how a consumer sees where.
|
||||
|
||||
A C++ name registered at more than one address is a different case and is still dropped rather than guessed (2 of 1,650 on Dota, 0 on CS2), the same rule the ambiguous datadesc handlers follow.
|
||||
|
||||
**Two of the Pulse record's three code pointers do not locate anything, and the mistake is instructive.** An early pass folded the pair at `+24`/`+32` as locators for a headline `+782` names. They are *descriptor accessors* — every CS2 `libserver` binding measures the same empty `int=0 float=0` footprint, and they disassemble to a lazy-init singleton returning a static vector. Folding them would have shipped `CBaseEntityAPI::GetAbsOrigin` pointing at a zero-argument accessor: a locator that resolves, passes live validation as executable code, and is still the wrong function.
|
||||
|
||||
**The third pointer, at `+72`, is a real entry point** — one per binding, never shared. It is not the bound C++ method and cannot be folded as one (that method's address is genuinely unrecoverable offline; the shim dispatches indirectly). It is a fixed-signature marshalling stub, and calling it *invokes the binding*. That is verified by doing it: `SetRenderAlpha` and `SetRenderColor` were called on a live CS2 server and moved the entity's `m_clrRender`, and a `SetRenderColor` on Dota set the RGB bytes while leaving the alpha byte untouched — proof that dispatch honours the binding's DECLARED `PulseValueType_t`, so a caller cannot smuggle a mistyped argument past it. Every eligible binding is re-checked on each derive by calling it with a sentinel handle (see [the standing oracles](#the-standing-oracles)).
|
||||
|
||||
So the honest yield from this table is **zero C++ symbol locators, a complete typed API surface, and a callable entry point per binding** — all of which ship under `surfaces.pulse`, with `call.needs` stating what a host must supply for each.
|
||||
|
||||
**The datadesc handlers are class-qualified** by joining each record's array against the SchemaSystem: a datadesc array also carries field descriptors, and a `(member, offset)` pair is something the schema states from an entirely different table, so the class whose schema satisfies every pair in the array owns it. 653 of 715 CS2 handlers qualify this way. This is what makes `InputEnable` — a distinct handler on 48 classes — nameable at all; unqualified names that resolve to several addresses are still **dropped, not guessed**. Note for consumers: qualification **renamed 343 shipped keys** (`InputActivateSkybox` → `CAmbientGeneric::InputActivateSkybox`).
|
||||
|
||||
**Console commands are read from the registration call, not a registry walk.** CS2 registers through a handle-based `ConCommandRef` whose registry lives in tier0, so there is no static `ConCommand` object to scan for and nothing in the file points at a command name. The registration *call* still does — an ordinary call from a static initializer whose name, handler, description and flags are all constants in the instruction stream. source2rosetta tracks what each argument register provably holds and reads the vector at every call: **784 commands across 20 of the 22 libraries on CS2, 855 on Dota** (the two misses are libraries whose registrar the shape test does not find). The registrar is identified by what it *does* — it opens by writing the invalid-handle sentinel into its `ConCommandRef` — never by address and never by ranking call sites, so a library with three commands is as readable as one with three hundred.
|
||||
|
||||
Because name and handler come from one instruction sequence, Valve's own published command dump checks the result: **746 of 746 descriptions agree exactly, with no disagreements**, 742 of 780 distinct names appear in that dump, and 12 flag bits are matched to the flag names it prints. Three further flag bits occur and are deliberately left unnamed.
|
||||
|
||||
**Typed Pulse signatures, read offline.** A binding's record does not carry its signature; the accessors beside it return the parameter and return vectors, which are built at runtime and therefore zero in the file. The *code* that builds them is not, and every field is written to a fixed address from a `lea` or an immediate — so constant-propagating through the initializer recovers the full typed signature with no running process: **999/999 CS2 records and 859/859 Dota**, with parameter names, `PulseValueType_t` types, and the schema type a value refers to where the binding names one. Cross-checked against Valve's published metadata dump: **435 of 437** CS2 server bindings agree exactly, **293 of 294** on Dota (every difference a global-event binding, where Valve documents the same vector as an out-param).
|
||||
|
||||
### 4. Locate across builds — for everything Valve doesn't name
|
||||
|
||||
A stripped non-virtual function has no slot and no symbol, so it has to be *found*.
|
||||
|
||||
**The fingerprint** is a recompilation-invariant statistical description of a function: bounded-CFG structural counts, an 18-bucket mnemonic-class histogram, a 32-bucket sketch of the printable `.rodata` strings it references, and one hop of call-graph context (distinct callees plus an aggregate of each callee's own instructions, calls and branches) — **63 dimensions**, deliberately abstracted statistics and never raw bytes.
|
||||
|
||||
To be exact about what this is *not*: **no trained model, no machine learning, no learned or weighted metric, no embedding network.** Matching is nearest-history under a plain unweighted **L1** distance, accepted within a fixed threshold. The per-game "model" is a bundle of derived *facts* — vtable-alignment hops, reference-fingerprint windows, ABI-shape consensus, slot timelines — not a network.
|
||||
|
||||
**A signature resolves by one of three outcomes, and the artifact says which:**
|
||||
|
||||
1. **Strict** — a fingerprint match inside the threshold.
|
||||
2. **Lenient** — no fingerprint check, but two or more distinct era-signatures vote for the same address.
|
||||
3. **Unverified fallback** — a single candidate that every check rejected, shipped anyway and marked **`catalogue-unverified`**. This is **0.4% of CS2's core and 69.8% of Dota's**, so on Dota it is the common case, not an edge case. Read the marker.
|
||||
|
||||
Whatever the route, the shipped byte-signature is regenerated at the resolved address and confirmed **unique in its library**. That is a *usability* check, not corroboration of identity — the pattern is generated *from* that address, so a wrong address yields a signature that is unique and equally wrong. Identity comes from the match, the vote, or live validation; uniqueness only ensures a loader can find it.
|
||||
|
||||
**Catalogue vtable offsets are chained, not read.** For a named method, the slot in a *new* build is derived: dated slots are chained forward through per-build-pair vtable alignments that are themselves fingerprint-scored, then a recency-weighted vote emits a slot only above **80% confidence**. RTTI supplies the vtable and the ordering; which slot a named method occupies is an inference with a stated bar.
|
||||
|
||||
### 5. Measure the ABI — the guard a byte-signature can't provide
|
||||
|
||||
A signature sees a function's *body* drift and re-derives it. It cannot see the *argument list* change while the prologue stays recognisable — the signature still resolves and points at real code, yet a caller using the old prototype loads the wrong registers.
|
||||
|
||||
So each function's observable **SysV-AMD64 shape** (which argument registers are live-in, plus the return class) is recovered by a bounded backward-liveness pass and diffed across builds, flagging exactly those prototype changes and marking struct-by-value returns that are unsafe to blind-call.
|
||||
|
||||
The footprint is a deliberate **lower bound** — a callee that ignores an argument reads fewer registers than it is passed — and that is checked rather than asserted. Valve's entity-IO datadesc declares hundreds of independent handlers to one fixed prototype, and every one measures within it: **CS2 715/715, Dota 624/624**, on every derive.
|
||||
|
||||
Types cannot be recovered from a stripped binary, so each record's `prototype` joins a *declared* prototype to that measurement and judges it. See [the verdicts](#prototype--declared-types-judged-against-this-build).
|
||||
|
||||
### 6. Validate — against a live server, not a spec
|
||||
|
||||
This is what separates source2rosetta from a static dumper. `produce` and `integration-test` **launch their own** vanilla dedicated server (bots on an empty deathmatch for pawn games; a pawn-less game like Dota waits on a `ready_class` proxy) — no Steam, no separate instance, no human.
|
||||
|
||||
Two access paths, and they differ:
|
||||
|
||||
- **Reading** is `/proc/<pid>/mem` — no attach, no stop, no injection.
|
||||
- **Calling** is a real debugger attach: `PTRACE_ATTACH`, save registers, write a scratch frame, run, restore. No injected *code*, but the process is stopped and its registers are written.
|
||||
|
||||
What gets checked:
|
||||
|
||||
- every **offset** lands on a real vtable slot, and every **signature** on live executable code;
|
||||
- a gamedata function is **actually called** via ptrace to prove it's the semantically right function, not a plausible byte-match (pawn games);
|
||||
- a gamedata function is **actually called** to prove it is the semantically right function, not a plausible byte-match (pawn games);
|
||||
- derived probes are **fuzzed across changing game state** for many iterations;
|
||||
- field **types** are read from the live process for the typed netvars — and fields that are non-null live but zero on disk (e.g. `m_pSchemaBinding`) confirm the reader is seeing real live state, not stale disk bytes.
|
||||
- field **types** are read live for the typed netvars — and fields that are non-null live but zero on disk confirm the reader is seeing real runtime state, not stale disk bytes;
|
||||
- a **hooked** function (a mod detoured it) is detected by a byte diff at a uniquely-resolved prologue and reported rather than failed.
|
||||
|
||||
The contract is blunt: **"degrades or stops loudly, never lies."** An entry live validation confidently rejects is dropped, not shipped under a banner claiming it resolves; if the oracle can't run, it fails loudly rather than emit an unverified result.
|
||||
The contract is blunt: **"degrades or stops loudly, never lies."** An entry live validation confidently rejects is dropped, not shipped under a banner claiming it resolves; if the oracle cannot run, the run fails rather than emit an unverified result.
|
||||
|
||||
### 4. Confidence tiers — nothing vanishes silently
|
||||
|
||||
The output is a per-game **monolith** in which every catalogue entry is accounted for, sorted into four tiers:
|
||||
### 7. Confidence tiers — nothing vanishes silently
|
||||
|
||||
| tier | meaning |
|
||||
|---|---|
|
||||
| `core` | derived and, in a full run, **live-validated** — the load-bearing gamedata |
|
||||
| `high_confidence` | corroborated names folded in as verified offsets/sigs (dictionary-exact or macOS ground-truth transfer) |
|
||||
| `experimental` | the least-filtered band — every graded name guess, each with a **resolvable locator** but an **unverified name** |
|
||||
| `unresolved` | catalogued but not confidently produced this build, with a reason (`sig-drifted`, `offset-low-conf`, …) and no locator |
|
||||
| `core` | derived and, in a full run, live-validated — the load-bearing gamedata |
|
||||
| `high_confidence` | names folded in as verified offsets/sigs — Valve's own in-binary sources (`valve-table` provenance, ground truth) first, then macOS ground-truth transfer, dictionary-exact, and gated extrapolation |
|
||||
| `experimental` | the least-filtered band — every graded name guess, each with a **resolvable locator** but an **unverified name**. **Never live-validated.** |
|
||||
| `unresolved` | catalogued but not confidently produced this build, with a closed-vocabulary reason (`sig-drifted`, `offset-low-conf`, `unresolved`, `abi-drift`, `name-contradicted`) and no locator |
|
||||
|
||||
A function that couldn't be derived this build shows up as `unresolved` with a reason — it never just disappears.
|
||||
A function that couldn't be derived this build shows up as `unresolved` with a reason — it never just
|
||||
disappears, and that is now a checked property rather than a habit: after every pass, whatever the catalogue
|
||||
named and no locator or flag accounts for is swept into `unresolved` with a reason naming which of the three
|
||||
things went wrong. Measured on the current builds, `core + high_confidence + experimental + unresolved`
|
||||
accounts for **1,601 of 1,601 CS2 catalogue names and 2,131 of 2,131 on Dota**.
|
||||
|
||||
### 5. Emit
|
||||
The reason matters as much as the presence, because most of what the sweep catches was never a function:
|
||||
86 CS2 names and 193 Dota ones carry only an `offset` variant (a raw member offset this tool does not
|
||||
derive) or name no library to search — the catalogue is harvested from other people's dumps, and a dumper's
|
||||
own JSON keys (`build_number`, `dwEntityList`, `attack`) arrive looking exactly like function names.
|
||||
Reporting those as "functions we failed to locate" would trade one silence for a louder untruth.
|
||||
|
||||
The canonical model and every format emitter live in `crates/source2rosetta-core`; the deriver writes format-neutral JSON and the tiny `source2rosetta-gen` binary renders it into any framework's shape (see [Artifacts & formats](#artifacts-schemas--output-formats)).
|
||||
**Naming is not derivation.** AI-assisted name extrapolation exists but is producer-side dev tooling, not part of the shipped deriver; every proposed name is gated on self-naming or ground-truth corroboration and then live-validated. A wrong *name* mislabels a real slot; it never touches the **offset**, which comes from RTTI.
|
||||
|
||||
Two things to keep straight. **Multi-game:** the ELF / RTTI / SysV / SchemaSystem machinery is engine-generic; game-specific knobs (library set, launch spec, schema-probe classes, pawn anchor) live on a `profile::GameProfile`, and `--game <cs2|dota2>` selects one — adding a game is a `const` plus a clap arm. **Naming is not derivation:** AI-assisted name extrapolation exists but is producer-side dev tooling, not part of the shipped deriver, and every proposed name is gated on self-naming or ground-truth corroboration and then live-validated. A wrong *name* only mislabels a real slot; it never touches the **offset**, which comes from RTTI.
|
||||
---
|
||||
|
||||
## What it guarantees — and what it refuses to ship
|
||||
|
||||
The tool's strongest claim is not what it produces but what it declines to produce.
|
||||
|
||||
### Release floors
|
||||
|
||||
A `produce` run **aborts rather than publish** a collapsed artifact. Each surface is gated separately, because each is matched by its own record shape — a Valve reshape that breaks one leaves the others intact, and a summed floor would stay satisfied while a whole surface silently vanished:
|
||||
|
||||
| surface | floored on | why it needs its own |
|
||||
|---|---|---|
|
||||
| Pulse bindings | registry record count | the registry can move independently of the typed signatures |
|
||||
| typed Pulse signatures | recovered-signature count | the registry can read perfectly while the descriptor layout moves — every binding would ship signature-less |
|
||||
| entity-IO records | inputs + outputs | |
|
||||
| entity classnames | factory-record count | |
|
||||
| console commands | recovered-command count | the registrar is found by SHAPE, so a reworked constructor yields **zero** commands rather than wrong ones — correct, and invisible without this |
|
||||
| host-callable Pulse shims | count of `call.needs == "args-only"` | the registry can read perfectly and every signature recover perfectly while a codegen change makes each shim appear to read another argument — retiring the one callable tier without failing anything |
|
||||
| ConVars | recovered-convar count | found by a DIFFERENT test than commands — convergence of registrar wrappers on a shared core, not a sentinel in the callee — so it can collapse while commands keep working |
|
||||
| VScript bindings | recovered-binding count | a THIRD identification test again: a record base computed by the initialiser's own `idx*5 << 4 + [class+0x28]`. The floor is set deliberately loose because the reader recovers three distinct registration forms, and losing any ONE of them would still clear a tight floor while quietly dropping a third of the surface |
|
||||
| VScript owning classes | attributed-binding count, **full runs only** | the one floor an offline run skips rather than fails, because zero is correct there by construction — the class is live-only. It is separate from the row above because it fails in the opposite direction: that floor guards the offline READER against a Valve reshape, this one guards the LIVE WALK, and the walk breaking leaves every binding recovered, described and located with no class on any of them. `class` is what `gen`'s `moddota` format groups by, so the release would clear every other gate while both renderers emitted nothing |
|
||||
| typed netvars | fraction of fields typed | a runtime type-layout reshape resolves every field "untyped" and would otherwise ship a typeless schema at exit 0 |
|
||||
| schema enums | enum count | read by shape like the class table, so a reshape yields zero rather than wrong |
|
||||
| schema classes | class count | the largest table, and the one every other schema claim rests on — the `schema` section itself, the entity-output and datadesc joins, the derived type layouts, and half the identity check's conjunction. Nothing else covers it: the enum floor passes at zero classes, and the live oracle's own class gate is SKIPPED below its minimum sample, which is the collapse range |
|
||||
| derived functions | `core` + `high_confidence` count | the headline product, and the one place a pass-RATE gate cannot help: the live oracle scores entries that reached the gamedata document, and a signature that failed to resolve never enters it — so a derive emitting forty functions instead of four thousand passes at 100% |
|
||||
| live validation | pass rate, above a minimum sample | covers the derived locators too, not only the Valve-table oracles: the stage judging the primary product used to print its tally and discard it |
|
||||
|
||||
### `validated` is three-valued
|
||||
|
||||
`true` / `false` / `null`, and `null` is not a synonym for failure. Three cases ship `null` legitimately: the library is not mapped in the vanilla server, the class is not a vtable class (an engine special or a carried member offset), or the entry has no locator to check. An **offline run ships the whole monolith `null`** — absence of validation, not failed validation. Treat `null` as "not checked here", never as "checked and passed".
|
||||
|
||||
**`true` means every locator the entry carries was checked, not the first one found.** An entry may hold a signature *and* a vtable offset — five CS2 `core` entries do — and each is a separate claim about the running server. Both are validated; the entry is dropped if either is confidently bad; and if any half went unchecked the whole entry degrades to `null` rather than letting the checked half vouch for the other. That last rule is the one that matters to a hooking consumer: those offsets ship into ModSharp's `VFuncs` and Metamod's `Offsets`, where a wrong slot is what crashes a plugin.
|
||||
|
||||
### The standing oracles
|
||||
|
||||
A dozen free, mostly two-sided checks run on **every** derive and are reported. Two-sided means the two halves are read from different places by readers that don't know about each other, so agreement is evidence and disagreement is a defect:
|
||||
|
||||
- **entity-IO ABI** — hundreds of independent handlers against one declared prototype: CS2 715/715, Dota 624/624.
|
||||
- **console-command ABI** — the callback *form* comes from the registration site, the *arity* from a liveness pass over a different function body: CS2 784/784, Dota 855/855.
|
||||
- **console-command distinctness** — 784 commands → 783 distinct handlers (855 → 854 on Dota). Catches a layout change that would start returning a shared dispatch thunk: still resolving, still validating, still wrong.
|
||||
- **Pulse receiver cross-check** — the registry's policy flag vs the independently recovered parameter list: 999/999 and 859/859 registrations.
|
||||
- **entity-output ↔ schema join** — 226/226 CS2, 186/187 Dota.
|
||||
- **EHANDLE class grouping** — Valve's naming vs the binary's destructor addresses: 0 of 44 CS2 / 41 Dota groups carry two classes.
|
||||
- **Pulse element stride** — derived by consensus per image, unanimous across six libraries in both games.
|
||||
- **live schema oracle** — offline layout vs the running process: 852/852 CS2, 1,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.
|
||||
|
||||
**One of these is not like the others, and it is the newest: the IDENTITY check.** Every oracle above verifies that a locator *resolves* — that the address is real, the slot is real, the declaration matches the measurement. None of them asks whether it resolves to the **right function**, and a wrong locator passes all of them: the address holds real code, so live validation confirms it; the pattern is unique in its library, so the scan is clean; and if the impostor happens to take the same number of arguments, the ABI verdict reads `verified`.
|
||||
|
||||
So one check asks the other question, from two things the binary states about an address and a name that is a `Class::Method`:
|
||||
|
||||
- **Valve's VScript registry names the address something else.** Ground truth — the binary naming its own function.
|
||||
- **The code operates on a different class.** A `CFoo::` method reaches its object through `this`, so every `this + N` it touches must satisfy `N < sizeof(CFoo)`, and the SchemaSystem states that size offline.
|
||||
|
||||
**Both must hold, and the conjunction is the whole design.** Either alone rejects good entries, measured rather than supposed: a dozen CS2 bindings are bound *straight* to the native method instead of through a script wrapper, so `SetAbsOrigin` and `CBaseEntity::SetAbsOrigin` legitimately share an address (as do `ScriptSetSize` and `CBaseModelEntity::SetCollisionBounds`, whose names do not even resemble each other); and separately, six entries reach past their class because their NAME carries the wrong prefix while the locator is fine — four `CPathMover::` entries that are really `CFuncMover` setters, two `CBasePlayerController::` that are really `CCSPlayerController`. All eight of those still ship.
|
||||
|
||||
Across the ~3,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.
|
||||
|
||||
That sentence described the design before it described the code. The fold-path call passed the library's FILE name to a map keyed by its SHORT one, so the lookup missed, the `?` returned, and the check silently passed on every call — while the line four below it applied the very normalisation the lookup omitted. It was dead on the one path the paragraph above says matters most, and the shipped model carried **eight** fingerprint observations plus a consensus ABI for `DispatchTraceAttack` — the wrong function's, `Trigger(hActivator, hCaller)`'s `int=3`. Re-distilling with the check live removes exactly that one name and nothing else.
|
||||
|
||||
Worth stating precisely, because the honest version is less dramatic than it sounds: the contamination was **latent, never active**. The emit-time check rejected the entry regardless of what the model held, so a derive against the contaminated model and one against the clean model produce byte-identical artifacts. What the dead guard cost was not a wrong locator today but a loaded one tomorrow — had the conjunction's other half ever drifted (Valve renames the binding, the class size moves), those eight observations were sitting ready to confirm the decoy as correct.
|
||||
|
||||
The `this`-tracking is deliberately conservative — a register stops holding `this` on any write that is not a move from another register already holding it, all caller-saved registers drop across a `call`, and a path merge keeps only what holds on both paths — so the error direction is a missed contradiction, never a false accusation.
|
||||
|
||||
Two of these have found real defects. The entity-IO ABI check, on its first run: 34 of 715 handlers measured float arguments a `void(ptr, ref)` cannot have, which traced to the ABI reader treating a `call` as fall-through so a callee's *return* propagated backwards as a phantom argument. And the descriptor oracle settled a question the multi-library duplicate check had been reporting with no way to resolve — for every binding it can read, the two libraries' accounts are identical and both match the live descriptor, so the reported disagreement is not in the parameter or return lists.
|
||||
|
||||
### What is NOT gated
|
||||
|
||||
Stated because "we check things" is worthless without a boundary. There is **no** floor on the live-fuzz fault rate or the RTTI class / base-graph size. A regression in either is reported, not refused.
|
||||
|
||||
The list used to be longer. The validate-live drop rate, the schema class count and the derived function count were all ungated and are now floored — see the table above. They are called out here rather than quietly removed because the gap they left was structural rather than an oversight of three numbers: **every gate measured what was emitted, never what failed to be emitted**, so a collapsed derive scored 100% on a shrinking denominator.
|
||||
|
||||
**One oracle is currently reporting.** The Pulse registry is keyed by qualified name, so a binding registered by several modules keeps one row, and every duplicate is compared against the row already present. On the current builds **CS2 flags 331 of 419 repeat registrations and Dota 271 of 359** as disagreeing — so for those names `surfaces.pulse` carries one module's account of the signature, not a merged one. It is flagged on every run and is not yet resolved; if you consume `params`/`returns` for a multiply-registered binding, know that.
|
||||
|
||||
*Which* module's account it carries is now the highest-precedence one, matching the rule `GameProfile::libs` states and every sibling table in that fold already followed — the earlier library wins, with one exception: a later library that recovered a typed signature replaces an earlier one that did not, since precedence must not cost information. It previously kept the LAST registration read, so 224 of 580 CS2 rows shipped another module's account of a name libserver also registers — and, because the live shim and descriptor oracles run against the server image, the rows that got verified were not the rows that got shipped.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -113,27 +504,31 @@ cargo build --release # → ./target/release/source2roset
|
|||
cargo build --release -p source2rosetta-core # → ./target/release/source2rosetta-gen
|
||||
```
|
||||
|
||||
**Prerequisites.** Stable Rust for both binaries. The live half additionally needs a game install and **ptrace permission** (same user with `yama/ptrace_scope=0`, or `CAP_SYS_PTRACE`). The CI fuzz gate needs nightly Rust, `cargo-fuzz` and GNU `parallel`. The release runner needs `steamcmd`, `jq` and `python3`. Dependencies are deliberately few — ELF parsing, disassembly and the parallel primitive are in-tree rather than pulled in.
|
||||
|
||||
`--game <cs2|dota2>` is a global flag (default `cs2`), given before the subcommand: `source2rosetta --game dota2 produce …`.
|
||||
|
||||
| command | one line |
|
||||
|---|---|
|
||||
| `produce` | The whole per-game build in one command: derive → fold → (with `--game-dir`) validate-live + typed netvars → roll the model forward, into `--out-dir`. **`--game-dir` present = full live-validated build; absent = fast offline build (gamedata + model only). That flag is the entire offline/full switch.** |
|
||||
| `corpus-model` | Distill a corpus of past builds into one shippable model (vtable-alignment hops, reference fingerprints, slot timelines), so future derivation needs only the model + the target binary, not the corpus. |
|
||||
| `fold-model` | Roll an existing model forward by ONE build (`model N + build → N+1`), reading only the model and that one binary — equal to a full re-distill. The production update path (also a sidecar inside `produce`). |
|
||||
| `fold-model` | Roll an existing model forward by ONE build (`model N + build → N+1`), reading only the model and that one binary. The production update path (also a sidecar inside `produce`). |
|
||||
| `integration-test` | Stand-alone CI live oracle: launch a vanilla server, populate it, and verify derived gamedata against it — schema oracle, a semantic ptrace CALL on a live pawn, and (with `--gamedata`) a full validate-live plus optional live fuzzing. |
|
||||
| `backfill` | Give an extrapolated name a real cross-build timeline — resolve its string anchor in every corpus build, or chain a vtable slot through the model — and report history depth + consistency (how a guess graduates to first-class). |
|
||||
| `classify-change` | `--prev`/`--new` → `skip` / `normal` / `shift` + the exact % of function bodies that changed, comparing with position-dependent bytes masked so a pure layout shift reads as unchanged. Decides whether a build even warrants a re-derive. |
|
||||
| `filter-corpus` | Collapse runs of code-identical builds to one representative, label each transition `normal`/`shift`, and segment the timeline into toolchain eras. Writes the selection manifest the distill reads. |
|
||||
| `classify-change` | `--prev`/`--new` → `skip` / `normal` / `shift` + the exact % of function bodies that changed, comparing with position-dependent bytes masked so a pure layout shift reads as unchanged. Enumerates functions over relocation code-pointers ∪ decoded call targets ∪ `.eh_frame` starts — the FDE list alone covers ~12% of a CS2 binary, since Valve strips unwind info from the game code and leaves it only for the statically-linked runtime tail. An **operator primitive** — nothing in the shipped pipeline invokes it; the poller dispatches a derive on any buildid change. |
|
||||
| `filter-corpus` | Collapse runs of code-identical builds to one representative, label each transition `normal`/`shift`, and segment the timeline into toolchain eras. Writes an **advisory** selection manifest; the distill does not read it (see [corpus curation](#getting-the-corpus-only-to-bootstrap-a-model)). |
|
||||
|
||||
### Quickstart
|
||||
|
||||
Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/releases)**. For the offline path you need one file from there — the model (`model-<game>.json`) — plus the derive inputs, which ship in this repo under `mappings/`. Put the downloaded model wherever you like; the examples assume it's in the working directory.
|
||||
Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/releases)**. For the offline path you need one file from there — the model (`model-<game>.json`) — plus the derive inputs, which ship in this repo under `mappings/`.
|
||||
|
||||
```sh
|
||||
# OFFLINE — derive gamedata + roll the model forward. No server, fully deterministic.
|
||||
./target/release/source2rosetta --game cs2 produce \
|
||||
--seed mappings/seed-cs2.json \
|
||||
--corpus-model model-cs2.json \
|
||||
--prototypes mappings/prototypes.json \
|
||||
--semantics mappings/semantics-cs2.json \
|
||||
--target <build-dir> \
|
||||
--out-dir out
|
||||
|
||||
|
|
@ -142,23 +537,38 @@ Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/re
|
|||
./target/release/source2rosetta --game cs2 produce \
|
||||
--seed mappings/seed-cs2.json \
|
||||
--corpus-model model-cs2.json \
|
||||
--prototypes mappings/prototypes.json \
|
||||
--semantics mappings/semantics-cs2.json \
|
||||
--target <build-dir> \
|
||||
--game-dir <cs2-install> \
|
||||
--out-dir out
|
||||
```
|
||||
|
||||
- `--target <dir>` (required) — the build **directory** to derive from; its libraries are searched by name, so pass the directory, not a bare `.so`.
|
||||
- `--seed <bundle>` — one file bundling every derive input (catalogue + optional naming/offset/sig sections). The loose equivalent is `--catalogue <file>` plus the optional `--promotable` / `--candidates` / `--full-names` / `--extra-offsets` / `--extra-sigs`, all defaulting to empty — **so a brand-new game needs only a catalogue to start deriving.**
|
||||
- Corpus signal — exactly one of `--corpus-model <model.json>` (the normal path: forward-derive from the model + the target binary, and roll the model N→N+1 as a sidecar) or `--corpus <dir>` (fingerprint the raw build binaries on the fly).
|
||||
- `--target <dir>` — the build **directory** to derive from; `produce` requires a directory and its libraries are searched by name. (Other subcommands accept a bare `.so` as well, which is how `classify-change --prev` is used.)
|
||||
- `--game-dir <install>` — must be the **`game/` subtree** of the install, the same directory layout the dedicated server is launched from.
|
||||
- `--seed <bundle>` — one file bundling every derive input. The loose equivalent is `--catalogue <file>` plus the optional `--promotable` / `--candidates` / `--full-names` / `--extra-offsets` / `--extra-sigs`, all defaulting to empty — **so a brand-new game needs only a catalogue to start deriving.** The two forms are mutually exclusive and the CLI says so: `--seed` supplies all six, so passing one alongside it is a usage error rather than an input silently dropped.
|
||||
- Corpus signal — exactly one of `--corpus-model <model.json>` (the normal path: forward-derive from the model + target binary, rolling the model N→N+1 as a sidecar) or `--corpus <dir>` (fingerprint raw build binaries on the fly).
|
||||
|
||||
Model-based derives are **forward-only**: the model describes history up to its newest build, so pointing one at an *older* target is not supported.
|
||||
|
||||
The launched server writes its log to `TMPDIR/<token>-produce.log`, one file per game, and binds a **fixed port** — so two games cannot be produced concurrently on one host without changing it.
|
||||
|
||||
---
|
||||
|
||||
## Fork it & distill your own model
|
||||
|
||||
Nothing is hosted — fork it, `cargo build --release`, and point it at a build on disk. Two ways to run, depending on whether you already have a model:
|
||||
Nothing is hosted — fork it, `cargo build --release`, and point it at a build on disk.
|
||||
|
||||
- **Have a model** (downloaded from releases, or distilled): `--corpus-model model-<game>.json` forward-derives from the model plus *only the target binary* — no corpus on disk. This is the normal path.
|
||||
- **No model yet:** distill one from a corpus of past builds. "Distilling" is what this project means by "training" — there's no ML (see [Derive](#2-derive) above); the model is the bundle of facts already described: vtable-alignment hops, reference-fingerprint windows, ABI-shape consensus, slot timelines.
|
||||
### What a fork inherits, and what it must supply
|
||||
|
||||
Everything engine-generic is inherited: ELF/RTTI/SchemaSystem/SysV reading, fingerprinting, the model machinery, live validation, the emitters. Two things a fork must produce for itself:
|
||||
|
||||
- **A catalogue** — the one required input. It is the list of functions you want gamedata *for*: each entry a name plus whatever historical evidence exists (dated vtable slots, per-era signatures, string anchors). Everything else in the seed is optional and defaults to empty. Without a catalogue the tool has nothing to look for.
|
||||
- **A model**, distilled from a corpus of past builds — or downloaded from a release if you're forking this project's games.
|
||||
|
||||
The **seed bundle** collapses the loose inputs into one file with sections for catalogue, promotable names, candidates, full names, extra offsets, extra sigs, and contributions. `mappings/naming/` is a large frozen input with no in-repo producer — it is data, not something a build regenerates.
|
||||
|
||||
### Distilling
|
||||
|
||||
```sh
|
||||
# Distill a corpus into a model (streaming, bounded RAM even over Dota's ~1k builds).
|
||||
|
|
@ -168,11 +578,17 @@ Nothing is hosted — fork it, `cargo build --release`, and point it at a build
|
|||
--out model-cs2.json
|
||||
```
|
||||
|
||||
`--class-scope` (default `clean` — every real game class, enough for any modding offset to derive model-only) picks which classes get slot hops. **Whatever scope you distill with, `fold-model` and `produce`'s sidecar fold must use the same one.**
|
||||
`--class-scope` picks which classes get vtable-slot hops — the timelines that let a consumer derive an offset from the model alone:
|
||||
|
||||
- `clean` (default) — every real game class, dropping template instantiations, protobuf message shapes and NetworkVar chainers, whose hops nobody derives an offset from.
|
||||
- `all` — those too.
|
||||
- `catalogue` — only what the catalogue names. The catalogue's own classes are always included regardless of scope.
|
||||
|
||||
**Whatever scope you distill with, `fold-model` and `produce`'s sidecar fold must use the same one.** The model records its scope and the fold asserts on it, so a mismatch fails loudly — but CI does not pass the flag, so a non-default scope requires a workflow change too.
|
||||
|
||||
### Keeping a model fresh — the incremental fold
|
||||
|
||||
Once a model exists you never need the corpus again. `fold-model` rolls it forward one build, reading only the model plus the single new binary — identical to a full re-distill:
|
||||
Once a model exists you never need the corpus again. `fold-model` rolls it forward one build, reading only the model plus the single new binary:
|
||||
|
||||
```sh
|
||||
./target/release/source2rosetta --game cs2 fold-model \
|
||||
|
|
@ -182,94 +598,307 @@ Once a model exists you never need the corpus again. `fold-model` rolls it forwa
|
|||
--out model-cs2.next.json
|
||||
```
|
||||
|
||||
`produce --corpus-model` runs exactly this fold as a sidecar, so a full build both derives *and* advances the model in one command. (`--class-scope` must match the model's.)
|
||||
`produce --corpus-model` runs exactly this fold as a sidecar, so a full build both derives *and* advances the model in one command.
|
||||
|
||||
The fold equals a full re-distill over the same builds under **three** conditions: the same `--class-scope` (asserted), the same catalogue the model was distilled from (**not** checked — production always folds with the distill's catalogue, but a newcomer gets only a warning), and no class re-appearing across the model's latest-build boundary. In that last case a class the model has never seen is back-filled with absent history — reduced coverage for that name until the next full re-distill, never a wrong offset.
|
||||
|
||||
### Getting the corpus (only to bootstrap a model)
|
||||
|
||||
A corpus is a directory of past builds, one subdirectory of `.so` files per build (`corpus/binaries/<label>/*.so`). Fetch it yourself, one time:
|
||||
|
||||
1. Use **DepotDownloader** — the self-contained release binary from <https://github.com/SteamRE/DepotDownloader/releases>, **not** `dotnet tool install` (its NuGet package is pinned ancient).
|
||||
1. Use **DepotDownloader** — the self-contained release binary, **not** `dotnet tool install` (its NuGet package is pinned ancient).
|
||||
2. Pull manifests from the **Linux binaries depot `2347773`** — *not* the content depot `2347770`. `2347773`'s manifest only advances when the binaries actually change, so its history already *is* the list of real recompiles; content micropatches only bump `2347770`. Read the manifest history off SteamDB, not the Steam client.
|
||||
3. Download **oldest-first** (chronological = version order), then content-hash-dedup. `filter-corpus` further collapses code-identical builds and segments toolchain eras before you distill, so you never fingerprint the same code twice.
|
||||
3. Download **oldest-first** (chronological = version order), then content-hash-dedup.
|
||||
|
||||
A **partial corpus is fine** — fewer labels is a shallower history, not a broken model; skip very old manifests if they're un-downloadable.
|
||||
`filter-corpus` then collapses code-identical builds and segments toolchain eras, so you never fingerprint the same code twice. Its manifest is **advisory** — `corpus-model` reads a *directory*, not the manifest — so the usual pattern is to materialise the kept set as a directory of symlinks and point the distill at that. Nothing checks that the directory matches the manifest; that is on you.
|
||||
|
||||
A **partial corpus is fine** — fewer labels is a shallower history, not a broken model.
|
||||
|
||||
### Adding a game
|
||||
|
||||
Add a `profile::GameProfile` const (library set, schema-probe classes, launch spec, pawn anchor, dead-weight knobs) plus one `--game` clap-enum arm, then point `corpus-model` at that game's corpus. The rest is engine-generic.
|
||||
Three edits: a `profile::GameProfile` const, a `Game` enum variant, and a match arm in `main`. The profile carries the library set, launch spec, pawn anchor, dead-weight vocabulary, per-surface floors and the game/content keys.
|
||||
|
||||
That is the mechanical part. The untested-per-game work is everything the profile *cannot* express: whether the engine era's SchemaSystem layout matches an existing one, whether the game boots to a state where the live oracle can run, and whether its dead-weight vocabulary actually filters that game's junk.
|
||||
|
||||
---
|
||||
|
||||
## Artifacts, schemas & output formats
|
||||
|
||||
A full `produce` run writes a small, self-contained release set per game into `--out-dir`:
|
||||
A full `produce` run writes a self-contained release set per game into `--out-dir`:
|
||||
|
||||
| File | What it is | When |
|
||||
|------|-----------|------|
|
||||
| `gamedata-<game>.json` | The **monolith** — the tiered function catalogue (signatures + vtable offsets) with provenance and live-validation folded inline | always |
|
||||
| `netvars-<game>.json` | The **typed schema** — every SchemaSystem class → field → offset/type | full (`--game-dir`) runs only |
|
||||
| `model-<game>.json` | The **per-game model** — the distilled facts derivation reads instead of the corpus (the shippable artifact) | when the run folds an existing model (`--corpus-model`) |
|
||||
| `rosetta-<game>.json` | **The release** — one record per function, plus the typed schema and the surfaces that are not function-keyed | always |
|
||||
| `model-<game>.json` | The **per-game model** — the distilled facts derivation reads instead of the corpus | when the run folds an existing model |
|
||||
| `manifest.json` | Volatile release metadata: `{ version, artifacts: [...] }` | always |
|
||||
|
||||
Wall-clock and other volatile metadata live only in `manifest.json`; the monolith and schema carry no timestamp, so they're **byte-reproducible** — the same build in yields the same JSON out.
|
||||
Wall-clock and other volatile metadata live only in `manifest.json`; the release artifact carries no
|
||||
timestamp, so it is **byte-reproducible** — the same build in yields the same JSON out. Verified rather
|
||||
than asserted: two consecutive derives over identical inputs produce byte-identical `rosetta-<game>.json`,
|
||||
`model-<game>.json` and `manifest.json`.
|
||||
|
||||
### `gamedata-<game>.json` — the monolith
|
||||
Reproducibility is a property that has to be defended at every tie-break, not only at the obvious ones. A
|
||||
`HashMap` iterated to build an output is the usual culprit, but so is a *stable* sort on a key that repeats:
|
||||
two libraries registering the same schema class, or one class registering a script name twice, leave the
|
||||
survivor of the dedup decided by hash order. Those are sorted on a total key now.
|
||||
|
||||
### `rosetta-<game>.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"meta": { "game_key", "game", "source_build", "version",
|
||||
"counts": { "core", "high_confidence", "experimental", "unresolved" } },
|
||||
"core": { "<fn name>": <MonoEntry>, ... },
|
||||
"high_confidence": { "<fn name>": <MonoEntry>, ... },
|
||||
"experimental": { "<fn name>": <MonoEntry>, ... },
|
||||
"unresolved": { "<fn name>": { "reason", "detail" }, ... }
|
||||
"meta": { "game_key", "game", "source_build", "version", "counts", "alias_groups",
|
||||
"aliased_names", "merged", "joined" },
|
||||
"functions": { "<fn name>": <FunctionRecord>, ... },
|
||||
"unresolved": { "<fn name>": { "reason", "detail" }, ... },
|
||||
"schema": { "classes", "bases", "enums", "types", "meta" }, // null on an offline build
|
||||
"surfaces": { "pulse", "entity_outputs", "entity_classes", "convars", "unjoined" }
|
||||
}
|
||||
```
|
||||
|
||||
A **`MonoEntry`** is a locator (flattened to the top level) plus its grading. The locator obeys a strict **signature-XOR-offset** invariant — 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` byte pattern with `?` wildcards:
|
||||
**One record per function is the whole point.** Where a function is, what its machine code was measured to
|
||||
take, what a declaration says it takes, what the binary declares may be done with it, and what it means are
|
||||
five different KINDS of fact about one thing — separately derived, separately trustworthy, and useless apart.
|
||||
A consumer answering "may I call this, and how" needed four files open at once to find out.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"signature": { "library": "server", "linux": "55 48 89 ? E5" }, // non-virtual: located scan pattern
|
||||
"offset": 158, // virtual: RTTI slot index (a bare int)
|
||||
"class": "CCSPlayerPawn", // experimental offsets only: the vtable class, for an eyeball check
|
||||
"provenance": { "tier", "source", "confidence", "self_named", "by_value",
|
||||
"rationale", "corroboration", "abi_drift", ... }, // grading; fields present by tier
|
||||
"validated": true // true = passed live validation · false = rejected · null = not validated (offline)
|
||||
}
|
||||
```
|
||||
What is NOT function-keyed stays whole under `surfaces`: a Pulse binding names no C++ function, an entity
|
||||
output is a member rather than a method, a classname binds one name to another, and a ConVar is
|
||||
configuration rather than code.
|
||||
|
||||
`validated: false` entries stay in the file for transparency but are **dropped by every renderer**.
|
||||
### `functions` — one record each
|
||||
|
||||
> The `source2rosetta-gen` renderers write a slightly different *on-disk* shape (plural `{"signatures":{…}}` / `{"offsets":{…}}` for CounterStrikeSharp-family output). The keys above are the **canonical model JSON** as `gamedata-<game>.json` stores it.
|
||||
A **`FunctionRecord`** opens with its `tier` (`core` / `high_confidence` / `experimental`) and the locator
|
||||
flattened to the top level. A virtual method ships as a bare integer `offset` (its RTTI vtable slot index); a
|
||||
non-virtual function as a `signature` object with the `library` it scans and a space-hex `linux` pattern with
|
||||
`?` wildcards. Usually one or the other — but **a record may carry both**, and a handful of `core` CS2 records
|
||||
do, so a consumer (and the live oracle) has to judge every locator a record holds rather than the first it
|
||||
finds.
|
||||
|
||||
### `netvars-<game>.json` — the typed schema
|
||||
Two further locator keys appear where they were established:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"meta": { "game_key", "source_build", "typed", "untyped" },
|
||||
"classes": { "<class>": { "<field>": { "offset", "type", "kind", "size", "name_hash" } } }
|
||||
}
|
||||
```
|
||||
- **`class`** — for an `offset` entry, the class whose vtable the slot was measured on. A slot index alone
|
||||
locates nothing, since it only means anything relative to a particular vtable. It is the record's own name
|
||||
class, stated explicitly: the deriver keys its slot timelines and alignment hops by that class and chains
|
||||
through it, so "the class chained through" and "the class in the name" are one fact. Directly folded
|
||||
offsets (the multilib ground-truth path) omit it and leave the consumer to split the name.
|
||||
- **`anchors`** — distinctive string literals the function references, each unique to it within its library.
|
||||
Not a locator competing with the signature and the offset, but a supplement with a *different failure
|
||||
mode*: a byte signature is a snapshot of one build's codegen, while a string survives a recompile that
|
||||
moves instructions. Emitted alongside the signature, never instead of it.
|
||||
|
||||
Offsets come from SchemaSystem tables (available offline); `type` / `kind` / `size` are read from the live process during a full run (`kind` ∈ `ref` | `ptr` | `fixed_array`). A full run refuses to ship a schema whose fields resolved wholesale-untyped rather than emit a typeless file — the same "stop loudly" contract.
|
||||
Then, each labelled by where it came from:
|
||||
|
||||
| field | what it is |
|
||||
|---|---|
|
||||
| `measured` | the SysV argument footprint read out of THIS build's machine code — a lower bound, never an over-count |
|
||||
| `validated` | the live-validation verdict, **three-valued** — see [below](#validated-is-three-valued) |
|
||||
| `aliases` | the other shipped names for this same function |
|
||||
| `provenance` | how the name and locator were arrived at, and at what confidence |
|
||||
| `prototype` | the DECLARED parameter and return types, and this build's verdict on them — see [the verdicts](#prototype--declared-types-judged-against-this-build) |
|
||||
| `bindings` | what the binary itself declares may be done with this function — see [below](#bindings--what-the-binary-declares) |
|
||||
| `description` | what it does, in plain language, and whether that text is derived or generated |
|
||||
|
||||
**`aliases` — the other shipped names for the same function, and the one key that is about the RELEASE rather
|
||||
than the locator.** Several names on one function is normal here and not a defect: the catalogue is assembled
|
||||
from independent sources that spell the same function differently, so `CreateEntityByName`,
|
||||
`UTIL::CreateEntityByName`, `CBaseEntity::CreateEntityByName` and `CGameEntitySystem::CreateEntityByName` are
|
||||
four names on one address. Dropping three of them would break whichever spelling a given consumer's code
|
||||
already uses, so all four ship and each one names the other three. On CS2 that is **80 functions covering 172
|
||||
names, 4.3% of the resolved surface**; on Dota it is a single group, because that catalogue is far less of a
|
||||
merge.
|
||||
|
||||
Read it before treating a tier count as a function count. `counts.core + counts.high_confidence` = 3,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
|
||||
resolved address and confirmed unique in its library, so an identical `(library, pattern)` pair cannot be two
|
||||
functions, and a vtable entry keys on `(class, slot)`. **Two limits, both deliberate.** `experimental` is
|
||||
excluded, because a shared target between two unverified guesses is not evidence of a shared meaning (that
|
||||
band states the converse relation — one name guessed at several addresses — through `provenance.collision`).
|
||||
And a bare `offset` with no `class` is never grouped: it names no vtable, so two of them carrying slot 3 are
|
||||
not evidence of anything. Grouping those anyway would assert 1,080 CS2 names to be aliases of each other in 70
|
||||
fictitious groups. So an entry with no `aliases` means *none was derivable*, which for a bare slot is not the
|
||||
same as *none exists*.
|
||||
|
||||
Console-command handlers are keyed **`ConCommand::<name>`**. The prefix says what the entry *is* — the handler
|
||||
bound to that command — rather than claiming a C++ symbol; `ent_fire`'s real method name appears nowhere in
|
||||
the binary.
|
||||
|
||||
### `prototype` — declared types, judged against this build
|
||||
|
||||
A locator says *where* a function is; it never says what it takes. Types cannot be recovered from a stripped
|
||||
binary, so they come from declarations — and a declaration must be checked before anything calls through it,
|
||||
because a stale one produces a call that resolves, validates, and loads the wrong registers.
|
||||
|
||||
**The verdict is the product**, and there are six:
|
||||
|
||||
| verdict | meaning |
|
||||
|---|---|
|
||||
| `verified` | declared arity matches the footprint measured in *this* build |
|
||||
| `lower-bound` | the declaration passes registers the callee never reads, and contradicts it in no register class — safe to call, but not the same claim as an exact match |
|
||||
| `mismatch` | the callee reads a register the declaration does not mention — **do not call through it** |
|
||||
| `return-only` | a return type is known and no parameter list, so there is no arity claim to check |
|
||||
| `unverified` | nothing to check it against |
|
||||
| `ambiguous` | several signatures on offer and no measurement to separate them |
|
||||
|
||||
Types come from three places, and `provenance` says which: a source declaration; the engine's own **dispatch
|
||||
contract**; or, for a return with neither, the measured register class (written `ret=…` so it can never be
|
||||
mistaken for a declared type — the measured class is wrong about known-void functions roughly seven times in
|
||||
eight).
|
||||
|
||||
**Two dispatch contracts exist**, and both are stronger than any header because nobody has to have written the
|
||||
function down for the way the engine invokes it to be known: an entity-IO handler is invoked through
|
||||
`void(CEntityInstance*, InputData_t&)`; a console-command handler through the command-context and command
|
||||
pair, plus a receiver where the registration dispatches through an object. Which of the three callback forms a
|
||||
command uses is recorded at the registration site, so the contract is keyed on it rather than assumed. A
|
||||
contract is judged as a **lower bound** — it describes how the function is *invoked*, so only an over-count
|
||||
refutes it.
|
||||
|
||||
Parameters are spelled as **pointers** (`CCommandContext*`, `CCommand*`), not as the C++ reference types.
|
||||
`vtable` is present exactly when the locator is a vtable offset that live validation confirmed: a slot is only
|
||||
reachable through an object, so it states a receiver no measurement can see.
|
||||
|
||||
### `bindings` — what the binary declares
|
||||
|
||||
A LIST, because one function can be several — and because a `kind` tells a consumer which it got:
|
||||
|
||||
- `entity-input` — the map-facing input name this handler answers, and the class the datadesc join qualified.
|
||||
- `command` — the console name, Valve's help text, decoded flags with the raw word beside them, and which of
|
||||
the three callback forms the registration used.
|
||||
- `vscript` — the script-facing name a content author types, the owning class, Valve's own description, and
|
||||
the declared return type.
|
||||
|
||||
**A row is attached only when the record cannot contradict it.** A name is unique only within a module:
|
||||
`AddOutput` is registered in three libraries as three different functions, and `cl_particles_dumplist` in two,
|
||||
while the catalogue holds one entry under each name. Asserting every registration onto that one record would
|
||||
be a claim about code it does not locate — so a row joins on matching library (or onto a vtable-located record,
|
||||
which names no library to contradict), and the rest are stated under `surfaces.unjoined`.
|
||||
|
||||
### `schema` — the typed schema, and it is LIVE-ONLY
|
||||
|
||||
Every SchemaSystem class → field → offset and type, plus two sections that are easy to miss and load-bearing:
|
||||
`bases`, the class base graph, without which an inherited field is unresolvable; and `types`, per-type size
|
||||
and SysV register class, needed to compute a by-value argument's register cost. Of a field's attributes,
|
||||
`type` and `kind` are read from the **live process**; `size` and the name hash are derived offline.
|
||||
|
||||
**An offline build states `"schema": null`** — an explicit null, not an absent key, because absence would be
|
||||
indistinguishable from a build that resolved zero classes.
|
||||
|
||||
### `surfaces` — what is not function-keyed
|
||||
|
||||
- `pulse` — bindings keyed by qualified `Class::Method`, each with Valve's display name and description, a
|
||||
decoded call policy (receiver kind, mutating, blocking) with the raw words beside it, the recovered typed
|
||||
signature, and — where the record carries one — a `shim` address plus a `call` block saying how to invoke
|
||||
it. `call.needs` is the field to read:
|
||||
- `args-only` — callable with an argument array and nothing else; the other argument slots may be null.
|
||||
**Validated by calling every such binding on a live server of both games.**
|
||||
- `output-sink` — it returns a value, and writes through a register-file object a host does not have. Read
|
||||
the state through the `schema` section instead; the Pulse getters are redundant with schema fields.
|
||||
- `pulse-context` — needs a live `CPulseExecCursor` or graph instance. Not host-callable.
|
||||
- `other-slots` — reads an argument whose role is not established (the `CPulseCell_*` family, which are
|
||||
graph *node* implementations rather than API bindings). Not host-callable.
|
||||
|
||||
`call.reads` carries the measured slot list beside the decoding, so a build that changes the contract is
|
||||
re-readable rather than silently mis-labelled.
|
||||
- `entity_outputs` — the events an entity fires and where the subscriber list lives on the instance.
|
||||
- `entity_classes` — map classname → the C++ class it constructs (`func_door` → `CBaseDoor`). Names to names,
|
||||
no addresses.
|
||||
- `convars` — the configuration half of the console surface: name, help text, decoded flags, the raw flags
|
||||
word, and the ConVar object's address. Emitted for the **metadata**, not as a locator — a consumer finds a
|
||||
convar by name at runtime with no gamedata at all, so the name alone would add nothing. The flags are the
|
||||
payload: `cheat`, `replicated`, `archive`, `notify` are engine-*declared* authority, which is what lets a
|
||||
host decide what a plugin may change without a hand-maintained allowlist. Decoded by a **convar-specific**
|
||||
bit table, not the command one — see [Known limitations](#known-limitations).
|
||||
- `unjoined` — declared rows that belong to no function record here, split by kind, each keeping its own
|
||||
`library` and `addr`. Two things put a row here and they differ in kind: **nothing located it** (the
|
||||
implementation did not resolve, or an ambiguous handler name was dropped rather than guessed), or **the name
|
||||
belongs to another module's function**, as above. Keeping them is what makes the fold lossless — every row
|
||||
the deriver read is either on the function it describes or stated here.
|
||||
|
||||
`unresolved` sits beside `functions` rather than inside it: those names have no locator, so they are not
|
||||
function records. `reason` comes from a closed vocabulary: `sig-drifted`, `offset-low-conf`, `unresolved`,
|
||||
`abi-drift`, `name-contradicted`.
|
||||
|
||||
**`name-contradicted` is the newest and means something different from the rest.** The others say a locator was
|
||||
not *found*; this one says a locator **was** found and the binary proves it is a different function, so it was
|
||||
refused. The entry needs a new locator rather than another build — see [the identity check](#the-standing-oracles).
|
||||
|
||||
### `model-<game>.json` — the per-game model
|
||||
|
||||
The distilled per-game **facts** — vtable-alignment hops, reference-fingerprint windows, ABI-shape consensus, slot timelines — the artifact derivation reads *instead of* the corpus. What "distill a model" produces (above).
|
||||
The distilled facts derivation reads instead of the corpus. Not a consumer artifact.
|
||||
|
||||
### Rendering — the `gen` binary
|
||||
|
||||
The monolith and schema are format-neutral; **`source2rosetta-gen`** renders them, so the deriver never changes when a new consumer format is added.
|
||||
`source2rosetta-gen --from rosetta-<game>.json --format <who-it-is-for> --out <dir>` writes the files that
|
||||
consumer reads. A framework gets **two**: the gamedata its loader resolves through, and the typed call sites
|
||||
that go through it — `cssharp`, `metamod` (also SourceMod's VDF), `modsharp`, `swiftly`, `plugify`. Beside
|
||||
them, `cs-sdk` / `netvars` render the schema, and `moddota` renders the VScript surface for the Dota
|
||||
custom-game ecosystem — both shapes it publishes, `api.json` and `api.d.ts` — grouping members by owning class
|
||||
with Valve's own description as the doc comment.
|
||||
|
||||
**Gamedata** (`--from gamedata-<game>.json`, at a cumulative `--tier core | high_confidence | experimental`, default `high_confidence`): `cssharp` (CounterStrikeSharp), `metamod` (Metamod/SourceMod VDF), `modsharp`, `swiftly`, `plugify`, `model` (the canonical model re-serialized). **Schema** (`--netvars netvars-<game>.json`): `cs-sdk` (a typed C# SDK — one `static class` per schema class, `const int` field offsets tagged with their type), `netvars` (a flat `{ class: { field: offset } }` map). Full render walkthrough: **[gen binary README](crates/source2rosetta-core/README.md)**.
|
||||
Every published artifact renders all nine, because a release is always derived against a running server. It
|
||||
is only a **locally derived OFFLINE artifact** that renders fewer: the schema is live-only and so is the
|
||||
owning class `moddota` groups by, so those three have nothing to work from and `gen` says so rather than
|
||||
writing an empty file.
|
||||
|
||||
**The artifact states its own game and the output follows it**, so there is no `--game` flag. Two formats are
|
||||
game-keyed and both key by the game DIRECTORY, which is what `meta.game_key` holds: Metamod's
|
||||
`Games { csgo | dota }` section, which a plugin looks up by the engine's own `GetGameDir()`, and Plugify's
|
||||
top-level key, matched against the `S2SDK_GAME_NAME` its plugin was built with. Two consumers cannot run on
|
||||
Dota 2 at all — CounterStrikeSharp and Swiftly both resolve their binaries out of `<dir>/csgo/bin/` — and
|
||||
`gen` declines those rather than write a file that can never load, with `--force` to override.
|
||||
|
||||
**Every call site carries its description into the generated source**, as a C# XML doc comment, a C++ header
|
||||
comment, or a field in the data formats — so hovering `CBaseEntity_SetParent` in an editor says what it does
|
||||
instead of only what it takes. Each one states **whose sentence it is**: Valve's own, read from a registry in
|
||||
the binary, or this project's reading of the build. Valve's always wins where both exist, and a generated one
|
||||
only ever fills a gap. On the rendered surface that is 2,073 CS2 and 2,326 Dota call sites, 717 / 774 of them
|
||||
in Valve's own words.
|
||||
|
||||
Two things to know before you diff outputs:
|
||||
|
||||
- The default `cssharp` gamedata output is **JSONC** — it carries comment banners, so a strict JSON parser
|
||||
will reject it.
|
||||
- The `swiftly` gamedata format emits **signature entries only**; vtable-offset entries are omitted (that is
|
||||
over a thousand entries on CS2), because that framework takes offsets through a separate file.
|
||||
- The `modsharp` format emits a **`refs`** block where one was derived — `refs.strings` for string anchors and
|
||||
`refs.vtable` for an offset entry's class, both ModSharp's own keys. An entry with anchors and no byte
|
||||
pattern is emitted as `refs`-only, which is how several of ModSharp's own hand-written entries are written.
|
||||
|
||||
See [source2rosetta-gen](crates/source2rosetta-core/README.md) for the full format table.
|
||||
|
||||
## Provenance
|
||||
|
||||
This is a publishable tool, so where declarations and names come from is a hard boundary, not a footnote.
|
||||
|
||||
**Harvested:** Valve-published artifacts (the shipped binaries themselves, Valve's own metadata and command dumps, symbolicated older macOS builds) and legitimately licensed open-source projects, credited in [ATTRIBUTIONS.md](ATTRIBUTIONS.md).
|
||||
|
||||
**Excluded, and this is enforced rather than intended:** the 2020 CS:GO source leak and anything descending from it; `hl2sdk`-derived vendored SDK trees, which are quarantined on ambiguous provenance; and one widely-copied engine header that carries an annotation tying it to a fork whose own commit messages reference leaked code — every copy of it across the ecosystem shares that lineage, so all of them are denylisted. Where a harvested repository vendors a rejected tree as a submodule, only that project's own sources are read, and the extractor **asserts** the exclusion holds rather than assuming it — a denylist that matches nothing reads as a guarantee while enforcing nothing.
|
||||
|
||||
**Joining is by function, never by name.** A third-party plugin keys its gamedata by its own labels, so a declaration is attached only when the label *is* one of our names, or when its byte signature matches exactly one of ours in the same library. Matching on a bare method name is gated hard and withdrawn when the binary contradicts it.
|
||||
|
||||
**The corpus is never redistributed.** It is Valve's binaries; the model distilled from it contains derived facts, not code.
|
||||
|
||||
---
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Linux x86-64 only.** SysV register classification, `/proc`-based validation and ptrace are all platform-specific.
|
||||
- **Dota's core leans on the unverified fallback** — 69.8% of it, against CS2's 0.4%. Those entries are marked; treat the marker as real.
|
||||
- **`experimental` is never live-validated.** Resolvable locator, unverified name.
|
||||
- **Offline runs state `"schema": null` and no `validated` verdicts**, because field types and validation both require a running process.
|
||||
- **Two games cannot be produced concurrently** on one host (fixed server port).
|
||||
- **The Pulse duplicate-registration disagreement is open** — see [What is NOT gated](#what-is-not-gated).
|
||||
- **ConVar flag bits are decoded by a convar-specific table, and three bits are unnamed.** FCVAR is not one flag space across object types — decoding convars with the *command* table mislabels bit 0 on 185 Dota and 56 CS2 convars with a name Valve's own dumps give to none of them. The convar table was derived against both games' published dumps (1,939 convars pooled) keeping only bits that hold at 100% precision; bits 0, 1 and 2 are set often and match nothing cleanly, so they stay unnamed and survive in `flags_raw`.
|
||||
- **String anchors cover a minority, by design.** 480 catalogued + 77 derived on CS2, 103 + 22 on Dota. Two conditions do the filtering: an anchor names a *function*, so an entry whose locator points mid-function (a hook site rather than a prologue) gets none; and the string must be referenced only from inside that function. Most shipped functions reference no string unique to them, which is the real ceiling — 859 of 1,505 CS2 candidates fail on that alone.
|
||||
- **Some declared returns are decided by source order.** Where sources disagree on a return type, the untrusted source is ranked last, but among the trusted ones the order is the order they were merged. A handful of names are settled that way, which is stated rather than papered over.
|
||||
|
||||
---
|
||||
|
||||
## Copyright
|
||||
|
||||
The build **corpus** — Valve's `.so` files (~86 GB) — is never shipped and never baked into a release. The published artifacts (model, gamedata, netvars) are *designed to* contain **derived facts** — vtable offsets, abstracted fingerprint statistics, and byte scan-patterns — rather than copies of the original code. That a statistic *about* code is a fact and not a copy is **the project's position, not settled law** — reverse-engineering Valve binaries under AGPL is exactly the territory a court hasn't ruled on, so use accordingly. Leaked or proprietary game source was deliberately kept out of the corpus and the naming pipeline; see [ATTRIBUTIONS.md](ATTRIBUTIONS.md).
|
||||
Valve, Counter-Strike, Dota and Source 2 are trademarks of Valve Corporation. This project is not affiliated with or endorsed by Valve. It ships no Valve code and no Valve binaries — only facts derived from publicly shipped files.
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE). Built on a decade of community work — see **[ATTRIBUTIONS.md](ATTRIBUTIONS.md)** first.
|
||||
AGPL-3.0. See [LICENSE](LICENSE).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "source2rosetta-core"
|
||||
version = "0.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"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
# source2rosetta-gen
|
||||
|
||||
Render a published [source2rosetta](../../README.md) gamedata release into whatever format your framework
|
||||
reads. `source2rosetta` does the hard part — deriving CS2 / Dota 2 gamedata from the stripped engine and
|
||||
validating it on a live server — and publishes two JSON files per game. `source2rosetta-gen` turns those into
|
||||
CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, or a typed C# SDK, locally, in a second.
|
||||
Render a published [source2rosetta](../../README.md) release into whatever format your framework reads.
|
||||
`source2rosetta` does the hard part — deriving CS2 / Dota 2 gamedata from the stripped engine and
|
||||
validating it on a live server — and publishes **one file per game**, `rosetta-<game>.json`.
|
||||
`source2rosetta-gen` turns that into CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, a
|
||||
typed C# SDK, or the Dota script API, locally, in a second.
|
||||
|
||||
It's deliberately tiny: it links only `source2rosetta-core` (serde + the format emitters) — **no** ELF reader,
|
||||
no disassembler, no ptrace. So a consumer who "just wants the files" downloads one release + this small binary
|
||||
and generates exactly what they need, instead of every format being pre-baked into the release.
|
||||
no disassembler, no ptrace. So a consumer who "just wants the files" downloads one release + this small
|
||||
binary and generates exactly what they need, instead of every format being pre-baked into the release.
|
||||
|
||||
## Get it
|
||||
|
||||
|
|
@ -23,46 +24,140 @@ does **not** build it — use `-p source2rosetta-core` or `--workspace`.)
|
|||
|
||||
## Use it
|
||||
|
||||
Download the two artifacts for your game from the release page:
|
||||
|
||||
- `gamedata-<game>.json` — the derived gamedata (function signatures + vtable offsets), tiered by confidence.
|
||||
- `netvars-<game>.json` — the typed schema (every class's field offsets + runtime types).
|
||||
|
||||
Then point `gen` at whichever you need and pick a `--format`. Output goes to `--out`, or stdout if omitted.
|
||||
One input, one flag. `--format` says **who the output is for**; `--out` is a **directory**, because most
|
||||
formats write more than one file.
|
||||
|
||||
```sh
|
||||
# CounterStrikeSharp combined gamedata (the default)
|
||||
source2rosetta-gen --from gamedata-cs2.json --format cssharp --out gamedata.json
|
||||
R=https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest
|
||||
curl -fsSLO $R/rosetta-cs2.json
|
||||
|
||||
# Metamod / SourceMod gamedata VDF (one .games.txt)
|
||||
source2rosetta-gen --from gamedata-cs2.json --format metamod --out csgo.games.txt
|
||||
# CounterStrikeSharp: the combined gamedata + typed call sites for the same functions
|
||||
source2rosetta-gen --from rosetta-cs2.json --format cssharp --out ./csharp
|
||||
|
||||
# Swiftly / ModSharp / Plugify gamedata
|
||||
source2rosetta-gen --from gamedata-cs2.json --format swiftly --out gamedata.json
|
||||
# Metamod:Source / SourceMod: the gamedata VDF + a C++ prototype header
|
||||
source2rosetta-gen --from rosetta-cs2.json --format metamod --out ./mm
|
||||
|
||||
# Typed C# SDK from the schema — one `static class` per engine class, `const` field offsets + types
|
||||
source2rosetta-gen --netvars netvars-cs2.json --format cs-sdk --out Schema.cs
|
||||
# A typed C# SDK from the schema — one `static class` per engine class, `const` offsets + types
|
||||
source2rosetta-gen --from rosetta-cs2.json --format cs-sdk --out ./sdk
|
||||
|
||||
# Flat netvar offset map (class -> field -> offset)
|
||||
source2rosetta-gen --netvars netvars-cs2.json --format netvars --out netvars.json
|
||||
# The Dota script API: ModDota's dota-data shape AND the TypeScript declarations
|
||||
source2rosetta-gen --from rosetta-dota2.json --format moddota --out ./dota
|
||||
```
|
||||
|
||||
Each run prints what it wrote.
|
||||
|
||||
## Formats
|
||||
|
||||
| `--format` | needs | output |
|
||||
**A framework gets two files, and it needs both.** The gamedata says *where* a function is; the call sites say
|
||||
*how to call it*. They were separate inputs when the release was four files; one artifact makes them one
|
||||
command.
|
||||
|
||||
| `--format` | writes | notes |
|
||||
|---|---|---|
|
||||
| `cssharp` *(default)* | `--from` | CounterStrikeSharp combined gamedata (a commented, sectioned file) |
|
||||
| `metamod` | `--from` | Metamod:Source / SourceMod gamedata VDF (`.games.txt`) |
|
||||
| `modsharp` | `--from` | ModSharp gamedata JSON |
|
||||
| `swiftly` | `--from` | Swiftly gamedata JSON |
|
||||
| `plugify` | `--from` | Plugify gamedata JSON |
|
||||
| `model` | `--from` | the canonical model, re-serialized (format-neutral) |
|
||||
| `cs-sdk` | `--netvars` | typed C# SDK — `static class` per schema class, `const int` field offsets tagged with their type |
|
||||
| `netvars` | `--netvars` | flat schema map, `{ class: { field: offset } }` |
|
||||
| `cssharp` *(default)* | `gamedata.json` + `RosettaFunctions.cs` | the gamedata is **JSONC** — banner comments, so a strict JSON parser will reject it. The `.cs` is `MemoryFunction*` fields / `VirtualFunction*` factories |
|
||||
| `metamod` | `<game_key>.games.txt` + `rosetta_prototypes.h` | the VDF also covers SourceMod. Metamod plugins are C++, so the prototypes are a header of `using X_t = RET (*)(…)` plus an `X_vtidx` constant per slot |
|
||||
| `modsharp` | `gamedata.json` + `RosettaCalls.cs` | `[AddressKey]` interface for its Roslyn generator, plus a vtable-dispatch class |
|
||||
| `swiftly` | `gamedata.json` + `prototypes.json` | **signature entries only** in the gamedata; that framework takes offsets through a separate file |
|
||||
| `plugify` | `gamedata.json` + `prototypes.json` | runtime type arrays (`{"paramTypes":["pointer","string"],"retType":"void"}`) |
|
||||
| `cs-sdk` | `Schema.cs` | typed C# SDK: `static class` per schema class, `const int` field offsets tagged with their type, plus the engine's own enums at their real width |
|
||||
| `netvars` | `netvars.json` | flat schema map, `{ class: { field: offset } }` |
|
||||
| `moddota` | `api.json` + `api.d.ts` | the VScript API in ModDota `dota-data`'s shape (their toolchain renders from it), plus TypeScript declarations for authors using the published packages as-is — one `interface` per class, Valve's own description as the doc comment |
|
||||
| `flat` | `gamedata-flat.json` | the selected tiers as one name → locator map, format-neutral |
|
||||
|
||||
**Every published artifact renders every format on this list.** The releases are always derived against a
|
||||
running server, so nothing here is conditional on how the artifact was made. (If you derive your own, that
|
||||
changes — see [below](#if-you-derived-the-artifact-yourself).)
|
||||
|
||||
### Which games a format covers
|
||||
|
||||
**The artifact states its own game** (`meta.game_key`, `csgo` or `dota`) and the output follows it — there is
|
||||
no `--game` flag, because a second place to state one fact is a second place for it to be wrong.
|
||||
|
||||
Two formats are **game-keyed**, and for both the key is the game DIRECTORY the server runs out of, which is
|
||||
what `game_key` already holds:
|
||||
|
||||
- `metamod` writes `Games { <game_key> { … } }`. The consuming plugin looks that section up by the engine's
|
||||
own `GetGameDir()` — see [cs2kz-metamod's reader][kz] — so `dota` is what a Dota 2 plugin will look for.
|
||||
Metamod takes Dota 2 as a first-class SDK target ([`dota.json`][mm], `define: DOTA`, `source2: true`).
|
||||
- `plugify` writes `{ "<game_key>": { … } }`, matched against the `S2SDK_GAME_NAME` its s2sdk plugin was
|
||||
BUILT with (default `csgo`).
|
||||
|
||||
The rest are game-neutral in shape: `modsharp` and `cssharp` carry no game key at all (flat, keyed only by
|
||||
platform), and `flat` / `cs-sdk` / `netvars` / `moddota` are plain data.
|
||||
|
||||
**Two consumers cannot run on Dota 2 at all**, and `gen` declines rather than write a file that can never
|
||||
load: CounterStrikeSharp resolves its binaries out of `<dir>/csgo/bin/`, and Swiftly initialises against the
|
||||
`csgo` game directory. `--force` renders anyway. It is a warning rather than a rule on purpose — that is a
|
||||
claim about somebody else's project, read out of their source at one point in time, and projects add games.
|
||||
|
||||
**ModSharp is CS2-first but not excluded.** Its own paths are hardcoded (`../../csgo/steam.inf`), yet its
|
||||
gamedata carries no game key whatsoever — flat `Addresses` / `VFuncs`, platform-keyed — so the file rendered
|
||||
here is the same one whatever game the build targets.
|
||||
|
||||
[kz]: https://github.com/KZGlobalTeam/cs2kz-metamod/blob/dev/src/utils/gameconfig.cpp
|
||||
[mm]: https://github.com/alliedmodders/hl2sdk-manifests/blob/master/manifests/dota.json
|
||||
|
||||
### Descriptions
|
||||
|
||||
**Every call site is emitted with a sentence saying what the function is FOR**, wherever that target's
|
||||
readers hover: a C# XML `<summary>`, so IntelliSense shows it; a comment above the C++ typedef; a
|
||||
`description` field in the data formats. The prototype keeps a home of its own — a `<remarks>` in C#, the
|
||||
identity line in the header — so nothing is lost to make room. That is every emittable call site: 2,073 on
|
||||
CS2, 2,326 on Dota.
|
||||
|
||||
**Each one says whose sentence it is, and that is not decoration.** Some are Valve's own, read out of a
|
||||
registry in the binary (717 CS2 / 774 Dota); the rest are this project's reading of the build, and the two
|
||||
carry very different weight. So every generated source prints the origin beside the text, the data formats
|
||||
carry it as an id (`valve` / `derived` / `generated`), and `moddota` — which deliberately mirrors a shape the
|
||||
Dota ecosystem already publishes — keeps ours under keys of our own name rather than in the `description`
|
||||
field their toolchain renders as Valve's word.
|
||||
|
||||
**Valve's text always wins.** Where the binary documents a function, that is what ships; a generated
|
||||
description only ever fills a gap, so the two can never disagree in an output. Between Valve's own two
|
||||
registries the script one wins: a console registration's help text documents the COMMAND an operator types,
|
||||
while a script binding documents the function. In the `.d.ts`, a member Valve documents reads exactly as it
|
||||
did before — bare, the way the published types do — and only a gap Valve left is filled and marked.
|
||||
|
||||
`cs-sdk` and `netvars` render the schema, which is classes and field offsets. There are no functions in them
|
||||
to describe, so they carry none.
|
||||
|
||||
### What the call sites will and won't emit
|
||||
|
||||
Only functions the deriver could stand behind: `status: verified` **or `lower-bound`** (the declaration passes
|
||||
registers the callee never reads and contradicts it in none — safe to call, and marked as such in every
|
||||
output), a receiver settled by evidence (the declaration names it, a live-validated vtable slot proves it, or
|
||||
the measurement independently agrees), and every parameter mappable onto an ABI class. A function whose return
|
||||
**nobody declared** is still emitted — otherwise Dota would lose 2,304 of its 3,732 call sites — but it is
|
||||
marked as such in every output (`ret_declared` / `retDeclared` in the data formats, prose in the generated
|
||||
source), and the value is documented as the raw return register rather than a typed result.
|
||||
|
||||
**The two locator forms are not interchangeable, and every output distinguishes them.** A signature resolves to
|
||||
one address; a vtable slot is entered through the object, so the framework reaches it by a different call
|
||||
entirely — `VirtualFunctionVoid(instance, slot)` rather than `GameData.GetSignature(key)`, `GetVFuncIndex`
|
||||
rather than `GetAddress`, `(*(void***)self)[idx]` rather than a scanned pointer. Roughly a quarter of the
|
||||
call sites are vtable-located.
|
||||
|
||||
**A few entries carry BOTH**, which is worth knowing if you consume the gamedata rather than these call
|
||||
sites: `model::Entry` allows it and five CS2 `core` entries use it. Both locators are live-validated
|
||||
independently, and `validated: true` means both passed — an entry whose signature checked out but whose slot
|
||||
could not be reached ships `null`, never `true`. The call-site emitters here pick one form per function, so
|
||||
this only affects what you read out of the artifact directly.
|
||||
|
||||
**The receiver is always in the type list.** Where the declaration came from an Itanium-mangled symbol `this`
|
||||
is invisible, so it is prepended, spelled from the function's own class (`CBaseEntity*`, not `void*`) and
|
||||
marked `[this]` in the C++ header. It is a real register in the call frame — leaving it out shifts every
|
||||
argument by one.
|
||||
|
||||
**Two things `moddota` states honestly rather than guesses.** Parameters are declared `...args: any[]`,
|
||||
because the registry does not carry them — types appear only inside Valve's prose descriptions, inconsistently,
|
||||
in about a fifth of entries. It is visibly ugly on purpose: nobody should mistake these for complete
|
||||
declarations, and inventing plausible arity would emit declarations that lie rather than abstain. And
|
||||
`available` is always `server`, because a dedicated server never maps `libclient`, so this derivation cannot
|
||||
see the client side at all.
|
||||
|
||||
## Confidence tier
|
||||
|
||||
The gamedata formats (the `--from` ones) take a `--tier`, cumulative and defaulting to `high_confidence`:
|
||||
The locator half takes a `--tier`, cumulative and defaulting to `high_confidence`:
|
||||
|
||||
| `--tier` | includes |
|
||||
|---|---|
|
||||
|
|
@ -72,10 +167,40 @@ The gamedata formats (the `--from` ones) take a `--tier`, cumulative and default
|
|||
|
||||
```sh
|
||||
# only the rock-solid set:
|
||||
source2rosetta-gen --from gamedata-cs2.json --format cssharp --tier core --out gamedata.json
|
||||
source2rosetta-gen --from rosetta-cs2.json --format cssharp --tier core --out ./csharp
|
||||
```
|
||||
|
||||
The schema formats (`cs-sdk`, `netvars`) ignore `--tier`.
|
||||
The schema and script-API formats ignore `--tier`.
|
||||
|
||||
## What is in the artifact that `gen` does not render
|
||||
|
||||
`rosetta-<game>.json` is plain JSON and readable as-is, and it carries more than these formats consume: the
|
||||
Pulse binding registry (typed signatures, call policy, and a callable shim per binding), entity outputs,
|
||||
map classname → C++ class, ConVars with decoded flags, and the declared rows that belong to no function here
|
||||
(`surfaces.unjoined`). Those have no loader to render them into — read them directly. The per-function
|
||||
descriptions are in there too, and they DO reach the outputs, but only for the ~2,000 functions that become
|
||||
call sites; the artifact describes about twice as many. See the [artifacts section](../../README.md#artifacts-schemas--output-formats).
|
||||
|
||||
## If you derived the artifact yourself
|
||||
|
||||
Skip this if you downloaded the artifact — it is about `source2rosetta produce`, not about `gen`.
|
||||
|
||||
The releases are derived against a **running server**, which is where two whole surfaces come from. Run
|
||||
`produce` without `--game-dir` and it derives what it can offline, honestly, and states the rest as absent —
|
||||
so `gen` gets an artifact that is real but smaller, and three things follow:
|
||||
|
||||
- **`cs-sdk`, `netvars` and `moddota` have nothing to render**, and say so rather than writing an empty file.
|
||||
The schema's field TYPES are runtime-resolved, and a VScript binding's owning class is reached through a
|
||||
register loaded from memory, so neither is readable from the file alone. All three group by one or the
|
||||
other.
|
||||
- **No call site is reached through a vtable.** A slot is only recorded once live validation has confirmed it
|
||||
is really a vtable slot and not a carried member offset, so an offline run states no slot rather than guess
|
||||
one. Roughly a quarter of a live manifest's call sites are vtable-located; an offline one has none. Same
|
||||
shape, fewer entries — worth knowing before diffing two outputs made different ways.
|
||||
- **Nothing has been checked against a running server.** `validated` is `null` on every record rather than a
|
||||
verdict, and the locator half drops only what live validation confidently REJECTED — so an offline artifact
|
||||
keeps entries a live one would have thrown out. The tiers still mean what they mean; they just have not
|
||||
been tested.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,87 +1,211 @@
|
|||
//! `source2rosetta-gen` — the standalone generator. Reads the published monolith (`gamedata-<game>.json`
|
||||
//! from `source2rosetta produce`) and renders it into any framework's gamedata format at a chosen confidence
|
||||
//! tier.
|
||||
//! `source2rosetta-gen` — the standalone generator. Reads the published `rosetta-<game>.json` and writes
|
||||
//! the files one consumer needs: a framework's gamedata plus the typed call sites that resolve through it,
|
||||
//! a typed schema SDK, or the script API the Dota ecosystem publishes.
|
||||
//!
|
||||
//! It touches only the `model` + `render` layers — no ELF reader, no ptrace, no disassembler — so a consumer
|
||||
//! who "just wants the files" downloads one monolith + this small, rarely-changing binary and generates
|
||||
//! who "just wants the files" downloads one artifact and this small, rarely-changing binary and generates
|
||||
//! whatever their framework needs locally, instead of every format being pre-baked into releases. It lives in
|
||||
//! the `source2rosetta-core` crate (serde-only), so it stays genuinely lean.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser;
|
||||
use source2rosetta_core::{model, render};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "source2rosetta-gen",
|
||||
about = "Render a source2rosetta monolith into a framework gamedata format"
|
||||
version,
|
||||
about = "Render a source2rosetta release into the files your framework reads"
|
||||
)]
|
||||
struct Cli {
|
||||
/// The monolith `gamedata-<game>.json` (for a GAMEDATA --format: cssharp/metamod/modsharp/swiftly/plugify/model).
|
||||
/// The published `rosetta-<game>.json` — one artifact holding every surface.
|
||||
#[arg(long)]
|
||||
from: Option<PathBuf>,
|
||||
/// The typed `netvars-<game>.json` (for a SCHEMA --format: cs-sdk/netvars).
|
||||
#[arg(long)]
|
||||
netvars: Option<PathBuf>,
|
||||
/// Output format. GAMEDATA (needs --from): cssharp | metamod | modsharp | swiftly | plugify | model.
|
||||
/// SCHEMA (needs --netvars): cs-sdk (typed C# SDK) | netvars (flat offset map). cssharp = the
|
||||
/// `//`-bannered CS# combined file; metamod also covers SourceMod (the VDF `.games.txt`).
|
||||
from: PathBuf,
|
||||
/// Who the output is for, and it writes every file that consumer reads. FRAMEWORKS get a gamedata
|
||||
/// file and the typed call sites that resolve through it: cssharp | metamod (also SourceMod's VDF) |
|
||||
/// modsharp | swiftly | plugify. SCHEMA: cs-sdk (typed C# SDK) | netvars (flat offset map). SCRIPT
|
||||
/// API: moddota, which writes both shapes that ecosystem publishes (`api.json` + `api.d.ts`). Plus
|
||||
/// `flat`, a format-neutral name -> locator map.
|
||||
#[arg(long, default_value = "cssharp")]
|
||||
format: String,
|
||||
/// Confidence tier for a gamedata format (cumulative): core | high_confidence | experimental. Defaults to
|
||||
/// `high_confidence` (core + the promoted names). Ignored by schema formats.
|
||||
/// Confidence tier for the locator half, cumulative: core | high_confidence | experimental. Defaults to
|
||||
/// `high_confidence` (core + the promoted names). Ignored by the schema and script-API formats.
|
||||
#[arg(long, default_value = "high_confidence")]
|
||||
tier: String,
|
||||
/// Write here (default: stdout).
|
||||
/// Directory to write into (default: the working directory). A format writes more than one file, so
|
||||
/// this names a DIRECTORY rather than a file — the names are the ones each framework expects.
|
||||
#[arg(long, default_value = ".")]
|
||||
out: PathBuf,
|
||||
/// Render even where the consumer is not known to run on this artifact's game. What that claim rests
|
||||
/// on is in `render::GAME_SUPPORT`; it is read out of somebody else's source and they do add games,
|
||||
/// so it declines by default rather than refusing outright.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
force: bool,
|
||||
}
|
||||
|
||||
/// One file to write: the name the consuming framework expects, and what goes in it.
|
||||
struct Out {
|
||||
name: String,
|
||||
text: String,
|
||||
/// What this file is, for the line printed after writing it.
|
||||
what: &'static str,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let fmt = cli.format.as_str();
|
||||
|
||||
let text = if render::SCHEMA_FORMAT_IDS.contains(&fmt) {
|
||||
// schema formats render the typed netvars (class -> field -> offset/type), not the gamedata monolith.
|
||||
let path = cli.netvars.as_ref().context(
|
||||
"a schema --format (cs-sdk | netvars) requires --netvars <netvars-<game>.json>",
|
||||
)?;
|
||||
let schema: model::Schema = serde_json::from_str(&std::fs::read_to_string(path)?)
|
||||
.with_context(|| format!("parse netvars json {}", path.display()))?;
|
||||
render::schema_by_id(fmt)
|
||||
.expect("known schema format")
|
||||
.render(&schema)
|
||||
} else {
|
||||
let path = cli
|
||||
.from
|
||||
.as_ref()
|
||||
.context("a gamedata --format requires --from <gamedata-<game>.json>")?;
|
||||
let tier = model::TierSelect::from_id(&cli.tier).with_context(|| {
|
||||
format!(
|
||||
"unknown --tier {:?} (want one of: {})",
|
||||
cli.tier,
|
||||
model::TIER_IDS.join(" | ")
|
||||
)
|
||||
})?;
|
||||
let mono: model::Monolith = serde_json::from_str(&std::fs::read_to_string(path)?)
|
||||
.with_context(|| format!("parse monolith json {}", path.display()))?;
|
||||
match fmt {
|
||||
// cssharp is the bannered combined file (guaranteed + extrapolated sections), not a flat map.
|
||||
"cssharp" => render::render_monolith_cssharp(&mono, tier),
|
||||
f @ ("metamod" | "modsharp" | "swiftly" | "plugify" | "model") => render::by_id(f)
|
||||
.expect("known flat format")
|
||||
.render(&mono.select(tier)),
|
||||
other => bail!(
|
||||
"unknown --format {other:?} (gamedata: cssharp|metamod|modsharp|swiftly|plugify|model; \
|
||||
schema: cs-sdk|netvars)"
|
||||
),
|
||||
let text = std::fs::read_to_string(&cli.from)
|
||||
.with_context(|| format!("read {}", cli.from.display()))?;
|
||||
let r: model::Rosetta = serde_json::from_str(&text)
|
||||
.with_context(|| format!("parse {} as a rosetta artifact", cli.from.display()))?;
|
||||
let tier = model::TierSelect::from_id(&cli.tier).with_context(|| {
|
||||
format!(
|
||||
"unknown --tier {:?} (want one of: {})",
|
||||
cli.tier,
|
||||
model::TIER_IDS.join(" | ")
|
||||
)
|
||||
})?;
|
||||
|
||||
// Checked before anything is rendered: a file that cannot load on the game it was made for is worse
|
||||
// than no file, and the one thing worse than that is one written silently.
|
||||
if let Some(mismatch) = render::game_mismatch(fmt, &r.meta.game_key) {
|
||||
if !cli.force {
|
||||
bail!("{mismatch}\n Pass --force to render it anyway.");
|
||||
}
|
||||
eprintln!("warning: {mismatch}\n Rendering anyway (--force).");
|
||||
}
|
||||
|
||||
let outputs = match fmt {
|
||||
// A framework gets the pair: WHERE the functions are, and HOW to call them. They were two
|
||||
// invocations against two files; one artifact makes them one command, and a consumer who has the
|
||||
// locators without the call sites has half of what it takes to make a call.
|
||||
f @ ("cssharp" | "metamod" | "modsharp" | "swiftly" | "plugify") => {
|
||||
let gd = match f {
|
||||
// cssharp is the bannered combined file (guaranteed + extrapolated sections), not a flat map.
|
||||
"cssharp" => render::render_monolith_cssharp(&r.to_monolith(), tier),
|
||||
_ => render::by_id(f)
|
||||
.expect("known flat format")
|
||||
.render(&r.gamedata(tier)),
|
||||
};
|
||||
let calls = render::abi_by_id(f)
|
||||
.expect("known abi format")
|
||||
.render(&r.abi_manifest());
|
||||
let (gd_name, calls_name) = match f {
|
||||
"cssharp" => ("gamedata.json".into(), "RosettaFunctions.cs"),
|
||||
"metamod" => (
|
||||
format!("{}.games.txt", r.meta.game_key),
|
||||
"rosetta_prototypes.h",
|
||||
),
|
||||
"modsharp" => ("gamedata.json".into(), "RosettaCalls.cs"),
|
||||
_ => ("gamedata.json".into(), "prototypes.json"),
|
||||
};
|
||||
vec![
|
||||
Out {
|
||||
name: gd_name,
|
||||
text: gd,
|
||||
what: "locators",
|
||||
},
|
||||
Out {
|
||||
name: calls_name.into(),
|
||||
text: calls,
|
||||
what: "typed call sites",
|
||||
},
|
||||
]
|
||||
}
|
||||
"flat" => vec![Out {
|
||||
name: "gamedata-flat.json".into(),
|
||||
text: render::by_id("model")
|
||||
.expect("known flat format")
|
||||
.render(&r.gamedata(tier)),
|
||||
what: "locators, format-neutral",
|
||||
}],
|
||||
f @ ("cs-sdk" | "netvars") => {
|
||||
// Field types are runtime-resolved, so an offline build states no schema at all. Saying so is
|
||||
// better than writing an empty SDK that compiles and describes nothing.
|
||||
let schema = r.typed_schema().context(
|
||||
"this artifact's `schema` is null, so the schema formats have nothing to render — field \
|
||||
TYPES are resolved at runtime and are not in the file. Every PUBLISHED artifact carries \
|
||||
one, so this is a local OFFLINE derive: re-run `produce` with --game-dir, or take the \
|
||||
artifact from a release.",
|
||||
)?;
|
||||
let text = render::schema_by_id(f)
|
||||
.expect("known schema format")
|
||||
.render(&schema);
|
||||
vec![Out {
|
||||
name: if f == "cs-sdk" {
|
||||
"Schema.cs".into()
|
||||
} else {
|
||||
"netvars.json".into()
|
||||
},
|
||||
text,
|
||||
what: "typed schema",
|
||||
}]
|
||||
}
|
||||
// One consumer, two files, for the same reason a framework gets two: the ModDota ecosystem's
|
||||
// toolchain renders from the JSON, while an author working against the published packages reads
|
||||
// the declarations. Emitting one of them is answering half the question.
|
||||
"moddota" => {
|
||||
let vscript = r.vscript();
|
||||
// Both shapes group members by owning class, and the class is only readable from a running
|
||||
// server — so an offline artifact renders nothing, and an empty file would look like an
|
||||
// answer rather than a missing input. The two ways of having nothing to render are worth
|
||||
// telling apart: a game with no script VM at all is not a build that was run offline.
|
||||
if vscript.is_empty() {
|
||||
bail!(
|
||||
"this artifact carries no VScript bindings at all, so there is no script API to \
|
||||
render. Only Dota 2 and CS2 expose one — check you passed the right artifact."
|
||||
);
|
||||
}
|
||||
if vscript.iter().all(|v| v.class.is_none()) {
|
||||
bail!(
|
||||
"this artifact has {} VScript bindings and no owning class on any of them, and both \
|
||||
shapes group members BY class. The owning class is only readable from a running \
|
||||
server, and every PUBLISHED artifact carries it, so this is a local OFFLINE derive: \
|
||||
re-run `produce` with --game-dir, or take the artifact from a release.",
|
||||
vscript.len()
|
||||
);
|
||||
}
|
||||
let schema = r.typed_schema();
|
||||
let render_as = |id: &str| {
|
||||
render::bindings_by_id(id)
|
||||
.expect("known bindings format")
|
||||
.render(&vscript, schema.as_ref())
|
||||
};
|
||||
vec![
|
||||
Out {
|
||||
name: "api.json".into(),
|
||||
text: render_as("api-json"),
|
||||
what: "script API, ModDota `dota-data` shape",
|
||||
},
|
||||
Out {
|
||||
name: "api.d.ts".into(),
|
||||
text: render_as("dts"),
|
||||
what: "script API, TypeScript declarations",
|
||||
},
|
||||
]
|
||||
}
|
||||
other => bail!(
|
||||
"unknown --format {other:?} (want one of: {})",
|
||||
render::FORMAT_IDS.join(" | "),
|
||||
),
|
||||
};
|
||||
|
||||
match cli.out {
|
||||
Some(p) => std::fs::write(&p, text).with_context(|| format!("write {}", p.display()))?,
|
||||
None => println!("{text}"),
|
||||
write_all(&cli.out, &outputs)
|
||||
}
|
||||
|
||||
fn write_all(dir: &Path, outputs: &[Out]) -> Result<()> {
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
for o in outputs {
|
||||
let p = dir.join(&o.name);
|
||||
std::fs::write(&p, &o.text).with_context(|| format!("write {}", p.display()))?;
|
||||
println!(
|
||||
"{} ({}, {} KB)",
|
||||
p.display(),
|
||||
o.what,
|
||||
o.text.len().div_ceil(1024)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
12
fuzz.sh
12
fuzz.sh
|
|
@ -3,7 +3,9 @@
|
|||
#
|
||||
# Fans all offline-derivation fuzz targets out across the box with GNU parallel (each target getting
|
||||
# N libFuzzer workers), tee's per-target logs to a timestamped dir, then prints a coverage/execs/crash
|
||||
# table. Tuned for 8C/16T: 5 targets x 3 workers = 15 threads by default.
|
||||
# table. 8 targets x 3 workers = 24 threads by default — above an 8C/16T box's thread count, so pass a
|
||||
# smaller `workers` there rather than trusting the default (the thread count is computed from TARGETS,
|
||||
# so it moves when a target is added; this comment is the part that does not).
|
||||
#
|
||||
# Usage:
|
||||
# ./fuzz.sh [seconds] [workers] run for `seconds` (default 60) with `workers`/target (default 3),
|
||||
|
|
@ -14,12 +16,18 @@ set -euo pipefail
|
|||
CRATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # source2rosetta/ — cargo fuzz runs from here
|
||||
cd "$CRATE_DIR"
|
||||
|
||||
TARGETS=(fuzz_elf fuzz_schema fuzz_rtti fuzz_sig_abi fuzz_xref)
|
||||
TARGETS=(fuzz_elf fuzz_schema fuzz_rtti fuzz_sig_abi fuzz_xref fuzz_valvetab fuzz_pulse fuzz_concmd)
|
||||
FUZZ_ROOT="$CRATE_DIR/fuzz"
|
||||
LOG_BASE="$FUZZ_ROOT/logs"
|
||||
|
||||
# Ignore iced_x86's intentional one-time 'static decoder-table allocation (see lsan_suppressions.txt);
|
||||
# a real leak in our own code still fails the run.
|
||||
#
|
||||
# CAVEAT: an LSan suppression matches on SYMBOLIZED frames, so if symbolization stalls — which it can
|
||||
# under this script's own load, every target symbolizing at once — the frame list comes back bare and the
|
||||
# suppression misses. That surfaces as a spurious iced_x86 leak artifact under a decoder-heavy target
|
||||
# (fuzz_xref, fuzz_sig_abi). Before triaging one, replay it single-target: a false positive reports
|
||||
# "Suppressions used: iced_x86" and exits 0.
|
||||
export LSAN_OPTIONS="suppressions=$FUZZ_ROOT/lsan_suppressions.txt"
|
||||
|
||||
STATS_ONLY=false
|
||||
|
|
|
|||
|
|
@ -65,3 +65,24 @@ path = "fuzz_targets/fuzz_xref.rs"
|
|||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_valvetab"
|
||||
path = "fuzz_targets/fuzz_valvetab.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_pulse"
|
||||
path = "fuzz_targets/fuzz_pulse.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_concmd"
|
||||
path = "fuzz_targets/fuzz_concmd.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
|
|
|||
83
fuzz/fuzz_targets/fuzz_concmd.rs
Normal file
83
fuzz/fuzz_targets/fuzz_concmd.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#![no_main]
|
||||
//! Console-command extraction decodes every function in the image and does arithmetic on values the
|
||||
//! FILE controls at every step: `lea` displacements are added to tracked register contents, member
|
||||
//! offsets are added to a symbolic base, and the accessor window walks `arg3 + k*8` looking for a stored
|
||||
//! pointer. A crafted (or truncated) `.so` can make any of those wrap, point outside every section, or
|
||||
//! nest arbitrarily deep — and the reader must answer with fewer commands, never a panic.
|
||||
//!
|
||||
//! It also exercises the two indirections that resolve a callback (a static object's first virtual, and
|
||||
//! a constructor-stored member) against pointers the file chose, which is the same untrusted-chase shape
|
||||
//! the Valve table readers had to be hardened for.
|
||||
//!
|
||||
//! CONVAR extraction rides the same pass and is fuzzed here with it. It adds two things worth attacking:
|
||||
//! a per-registrar delegation walk (bounded call/tail-jump decoding at a file-chosen address) and a
|
||||
//! statistical argument-slot choice, both driven entirely by bytes the file controls.
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use source2rosetta::concmd;
|
||||
use source2rosetta::elf::CodeImage;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||
return;
|
||||
};
|
||||
let cmds = concmd::console_commands(&img);
|
||||
for c in &cmds {
|
||||
// A recovered handler is only ever accepted because it lands in executable code, so the reader
|
||||
// must never hand back one that does not — a caller treats this as a locator.
|
||||
assert!(
|
||||
img.is_code(c.handler),
|
||||
"a non-executable handler was recorded for {:?}",
|
||||
c.name
|
||||
);
|
||||
// The name gate is what stops prose and format strings being read as commands.
|
||||
assert!(
|
||||
!c.name.is_empty() && c.name.len() <= 64,
|
||||
"an implausible command name was recorded: {:?}",
|
||||
c.name
|
||||
);
|
||||
// Flag decoding is a pure bit test and must stay within the bits it claims to know.
|
||||
let named = concmd::flag_names(c.flags);
|
||||
assert!(named.len() <= 12, "more flag names than there are flag bits");
|
||||
let _ = (c.description.len(), c.form.describe(), c.flags);
|
||||
}
|
||||
// ---- ConVars: same pass, different registrar test ----
|
||||
let cvs = concmd::convars(&img, "server");
|
||||
for c in &cvs {
|
||||
// The name gate is the only thing separating a convar registration from any other call that
|
||||
// happens to pass a string, so it must hold on every row.
|
||||
assert!(
|
||||
!c.name.is_empty() && c.name.len() <= 64,
|
||||
"an implausible convar name was recorded: {:?}",
|
||||
c.name
|
||||
);
|
||||
// Flags are optional (a registrar with no identifiable slot reports none), but when present the
|
||||
// decode is a pure bit test over a table of 9 and cannot exceed it.
|
||||
if !c.flags_raw.is_empty() {
|
||||
let raw = u64::from_str_radix(c.flags_raw.trim_start_matches("0x"), 16)
|
||||
.expect("flags_raw is written as hex by this reader");
|
||||
assert!(raw <= u64::from(u32::MAX), "a convar flags word exceeded 32 bits");
|
||||
assert_eq!(
|
||||
c.flags.len(),
|
||||
concmd::convar_flag_names(raw).len(),
|
||||
"decoded flag names disagree with the raw word for {:?}",
|
||||
c.name
|
||||
);
|
||||
} else {
|
||||
assert!(c.flags.is_empty(), "flag names without a raw word for {:?}", c.name);
|
||||
}
|
||||
let _ = (c.description.len(), c.addr.len(), c.library.len());
|
||||
}
|
||||
// ConVars are deduped on (name, object address) for the same reason commands are.
|
||||
let mut cseen: Vec<(&str, &str)> = cvs.iter().map(|c| (c.name.as_str(), c.addr.as_str())).collect();
|
||||
let cbefore = cseen.len();
|
||||
cseen.sort_unstable();
|
||||
cseen.dedup();
|
||||
assert_eq!(cbefore, cseen.len(), "a duplicate (name, object) convar survived");
|
||||
|
||||
// Commands are deduped on (name, address), so no pair may survive twice.
|
||||
let mut seen: Vec<(&str, u64)> = cmds.iter().map(|c| (c.name.as_str(), c.handler)).collect();
|
||||
let before = seen.len();
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
assert_eq!(before, seen.len(), "a duplicate (name, handler) survived");
|
||||
});
|
||||
48
fuzz/fuzz_targets/fuzz_pulse.rs
Normal file
48
fuzz/fuzz_targets/fuzz_pulse.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
#![no_main]
|
||||
//! A Pulse binding's typed signature is reconstructed by DECODING the accessor that returns its
|
||||
//! descriptor: the reader follows arbitrary control flow, constant-propagates through it, and then
|
||||
//! dereferences whatever addresses that produced — a returned element count, a base pointer, a name
|
||||
//! pointer per element, and a receiver it chases one call deep. Every one of those is whatever the file
|
||||
//! says it is, so a crafted (or truncated) `.so` can aim them anywhere, into non-code, off the end of a
|
||||
//! section, or into a cycle. The reader must answer with fewer signatures, never a panic and never a
|
||||
//! runaway. Also exercises the invariant the whole stage rests on: a recovered list has exactly as many
|
||||
//! parameters as the accessor's own count says, so a shifted layout cannot ship as a short signature.
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use source2rosetta::elf::CodeImage;
|
||||
use source2rosetta::{pulse, valvetab};
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||
return;
|
||||
};
|
||||
let bindings = valvetab::pulse_bindings(&img);
|
||||
// The invocation shim's read-measurement walks a file-chosen address with its own span arithmetic,
|
||||
// and its verdict is emitted as `call.needs`, so it must degrade rather than panic or over-claim.
|
||||
for b in &bindings {
|
||||
if b.shim == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = pulse::shim_reads(&img, b.shim) {
|
||||
assert!(r.reads.len() <= 7, "more argument slots than the shim has");
|
||||
assert!(
|
||||
matches!(r.needs(), "args-only" | "output-sink" | "pulse-context" | "other-slots"),
|
||||
"needs() left its closed vocabulary"
|
||||
);
|
||||
}
|
||||
}
|
||||
let pairs: Vec<(u64, u64)> = bindings
|
||||
.iter()
|
||||
.take(64)
|
||||
.map(|b| (b.descriptor, b.arg_descriptor))
|
||||
.collect();
|
||||
let (sigs, stride, votes, _) = pulse::read_all(&img, &pairs, 1);
|
||||
assert_eq!(sigs.len(), pairs.len(), "one verdict per binding");
|
||||
assert!(votes == 0 || stride > 0, "a voted-for stride is never zero");
|
||||
for s in sigs.into_iter().flatten() {
|
||||
for p in s.args.iter().chain(&s.returns) {
|
||||
// A parameter that survived is fully formed: the name gate and the type gate both passed.
|
||||
assert!(!p.name.is_empty(), "shipped a nameless parameter");
|
||||
assert!(p.ty >= -1, "shipped a type below PVAL_VOID");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -12,7 +12,7 @@ fuzz_target!(|data: &[u8]| {
|
|||
};
|
||||
let vts = rtti::enumerate_vtables(&img, 128);
|
||||
for cv in vts.iter().take(32) {
|
||||
let _ = (&cv.name, &cv.mangled, cv.offset_to_top, cv.slots.len());
|
||||
let _ = (&cv.name, cv.offset_to_top, cv.slots.len());
|
||||
}
|
||||
// The by-name lookup path (mangling + candidate walk) on a name pulled from the input itself.
|
||||
if let Some(name) = vts.first().map(|c| c.name.clone()) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,20 @@ fuzz_target!(|data: &[u8]| {
|
|||
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||
return;
|
||||
};
|
||||
for c in schema::enumerate_schema(&img) {
|
||||
// The enum bindings are a SECOND table read the same reloc-driven way: a name pointer, a
|
||||
// width/count word, and an enumerator array whose length that word supplies. A crafted count is the
|
||||
// sharp edge — it drives the per-enumerator read loop — so the reader must bound it rather than
|
||||
// trust it.
|
||||
let classes = schema::enumerate_schema(&img);
|
||||
// The enum walk takes the classes because a class FIELD descriptor is byte-compatible with an enum
|
||||
// binding — so a crafted image can aim the field-array spans it derives from them anywhere too.
|
||||
for e in schema::enumerate_enums(&img, &classes) {
|
||||
let _ = (e.name.len(), e.size, e.align);
|
||||
for (n, v) in &e.values {
|
||||
let _ = (n.len(), *v);
|
||||
}
|
||||
}
|
||||
for c in &classes {
|
||||
let _ = c.primary_base();
|
||||
for f in &c.fields {
|
||||
let _ = (f.offset, f.name.len());
|
||||
|
|
|
|||
64
fuzz/fuzz_targets/fuzz_valvetab.rs
Normal file
64
fuzz/fuzz_targets/fuzz_valvetab.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#![no_main]
|
||||
//! Valve's in-binary name tables are read by walking every writable data section in 8-byte steps and
|
||||
//! treating each position as a candidate record — chasing a name pointer, two code pointers and, for the
|
||||
//! entity-IO table, an input-name pointer. Every one of those fields is whatever the file says it is, so a
|
||||
//! crafted (or truncated) `.so` can aim them anywhere; the readers must answer with fewer records, never a
|
||||
//! panic. Also exercises the accessors the derivation reads off each record, and the ambiguity rule that
|
||||
//! decides which names are safe to ship as locators.
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use source2rosetta::elf::CodeImage;
|
||||
use source2rosetta::valvetab;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||
return;
|
||||
};
|
||||
let pulse = valvetab::pulse_bindings(&img);
|
||||
for b in &pulse {
|
||||
let _ = (
|
||||
b.name.len(),
|
||||
b.display.as_ref().map(String::len),
|
||||
b.description.as_ref().map(String::len),
|
||||
b.descriptor,
|
||||
b.arg_descriptor,
|
||||
b.flags.raw,
|
||||
);
|
||||
}
|
||||
let inputs = valvetab::datadesc_inputs(&img);
|
||||
for i in &inputs {
|
||||
let _ = (i.handler.len(), i.io_name.len(), i.func);
|
||||
}
|
||||
// Array segmentation walks BACKWARD and forward from a confirmed input over addresses the file
|
||||
// controls, so every step is arithmetic on untrusted values — the same shape as the `r.base + 8`
|
||||
// overflow the Pulse reader had to be hardened against. It must yield fewer arrays, never a panic.
|
||||
let arrays = valvetab::datadesc_arrays(&img);
|
||||
for a in &arrays {
|
||||
// An array is only recorded because it holds an input, and a field descriptor's offset is read
|
||||
// as a u32 — so neither list may come back as something the caller has to re-validate.
|
||||
assert!(!a.inputs.is_empty(), "an array with no inputs was recorded");
|
||||
for (n, o) in &a.fields {
|
||||
let _ = (n.len(), *o);
|
||||
}
|
||||
}
|
||||
// Every input the array walk finds must also be one the direct reader finds: the two disagree only
|
||||
// if one of them is reading a record the other rejects, which is a contradiction worth catching.
|
||||
assert!(
|
||||
arrays.iter().map(|a| a.inputs.len()).sum::<usize>() <= inputs.len(),
|
||||
"the array walk claimed more inputs than the record reader accepts"
|
||||
);
|
||||
// The shipping gate: a name reaches gamedata only through here, so it is the part that must never
|
||||
// panic AND never over-claim — no name may survive with more than one address behind it.
|
||||
let (names, _dropped) = valvetab::names(&inputs);
|
||||
for n in &names {
|
||||
assert_eq!(
|
||||
inputs
|
||||
.iter()
|
||||
.filter(|i| i.handler == n.name)
|
||||
.map(|i| i.func)
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len(),
|
||||
1,
|
||||
"shipped an ambiguous handler name"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -11,12 +11,20 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Every target that takes a whole ELF image as its input, and therefore cannot reach anything
|
||||
/// interesting without a real one to start from. `fuzz_valvetab` and `fuzz_pulse` read Valve's
|
||||
/// in-binary tables out of `.data.rel.ro`, so an unseeded run spends its whole budget failing to
|
||||
/// mutate a valid ELF header into existence — the corpus they accumulate from a previous run hides
|
||||
/// that, right up until someone clones the repo or clears the corpus.
|
||||
const TARGETS: &[&str] = &[
|
||||
"fuzz_elf",
|
||||
"fuzz_schema",
|
||||
"fuzz_rtti",
|
||||
"fuzz_sig_abi",
|
||||
"fuzz_xref",
|
||||
"fuzz_valvetab",
|
||||
"fuzz_pulse",
|
||||
"fuzz_concmd",
|
||||
];
|
||||
|
||||
fn w16(v: &mut [u8], o: usize, x: u16) {
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@
|
|||
# one-time allocation as a leak on the first input that decodes an instruction of that encoding family.
|
||||
# It is NOT a leak: the tables are immutable process-lifetime globals, allocated once, reused forever.
|
||||
#
|
||||
# This is scoped to iced_x86 ONLY — a real leak in sigtrack's own code still fails the run.
|
||||
# This is scoped to iced_x86 ONLY — a real leak in source2rosetta's own code still fails the run.
|
||||
leak:iced_x86
|
||||
|
|
|
|||
1345
mappings/ehandle-classes.json
Normal file
1345
mappings/ehandle-classes.json
Normal file
File diff suppressed because it is too large
Load diff
50538
mappings/prototypes.json
Normal file
50538
mappings/prototypes.json
Normal file
File diff suppressed because it is too large
Load diff
12312
mappings/semantics-cs2.json
Normal file
12312
mappings/semantics-cs2.json
Normal file
File diff suppressed because it is too large
Load diff
12748
mappings/semantics-dota2.json
Normal file
12748
mappings/semantics-dota2.json
Normal file
File diff suppressed because it is too large
Load diff
363
src/abi.rs
363
src/abi.rs
|
|
@ -17,13 +17,23 @@
|
|||
//! (a rebuild doesn't change which arguments a function takes) and moves precisely when the prototype
|
||||
//! does — so comparing it across builds flags exactly the prototype changes the byte-sig misses.
|
||||
//!
|
||||
//! A `call` is modelled as clobbering every argument register (all 14 are caller-saved), so a value
|
||||
//! read after one can never be mistaken for an incoming argument — that is what keeps the count a lower
|
||||
//! bound rather than an occasional over-count.
|
||||
//!
|
||||
//! Known limits (all bias toward UNDER-counting = a missed flag, never a false one): a pure forwarding
|
||||
//! thunk (`jmp Helper`) reads no arg register of its own, so it shapes as `(0,0)`; an argument used
|
||||
//! only inside a jump-table (indirect-branch) case isn't followed, so it can be missed. Both stay
|
||||
//! stable across builds (a thunk stays a thunk), so they don't manufacture false transitions — the
|
||||
//! diff's `int==0` low-confidence bucket also absorbs the thunk case. `int_args` is the OBSERVABLE
|
||||
//! footprint = a lower bound on the declared prototype (a constant-returner reads nothing → `int=0`);
|
||||
//! that too is stable per function, so the cross-build diff still works.
|
||||
//! stable across builds (a thunk stays a thunk), so they don't manufacture false transitions. `int_args`
|
||||
//! is the OBSERVABLE footprint = a lower bound on the declared prototype (a constant-returner reads
|
||||
//! nothing → `int=0`); that too is stable per function, which is what lets a shape measured in one build
|
||||
//! be compared against the model's consensus in the next — see `pipeline::AbiSig::differs`, which treats an
|
||||
//! `Unknown` return class as "no disagreement" for exactly this reason, and the derive-time
|
||||
//! `FlagReason::AbiDrift` check that reports the survivors.
|
||||
//!
|
||||
//! The lower-bound property is MEASURED, not assumed: Valve's entity-IO datadesc declares hundreds of
|
||||
//! independent handlers to one fixed `void(CEntityInstance*, InputData_t&)` prototype, and every one of
|
||||
//! them measures within it (see `pipeline::within_io_prototype`). That oracle runs on each derive.
|
||||
|
||||
use crate::elf::CodeImage;
|
||||
use iced_x86::{
|
||||
|
|
@ -36,6 +46,8 @@ use std::collections::HashMap;
|
|||
/// bitmask over these 14 slots is a function's live-in argument set.
|
||||
const N_INT: usize = 6;
|
||||
const N_XMM: usize = 8;
|
||||
/// All 14 argument slots — the set a call clobbers wholesale (every one is caller-saved).
|
||||
const ARG_SLOTS: u16 = (1 << (N_INT + N_XMM)) - 1;
|
||||
|
||||
/// A function's recovered ABI shape: how many integer/pointer and floating arguments it reads, plus
|
||||
/// whether it also loads arguments off the stack (a 7th+ integer / 9th+ float argument, or a large
|
||||
|
|
@ -54,8 +66,15 @@ pub struct AbiShape {
|
|||
/// footprint: a change here (int↔float↔by-value) is a prototype change the arg counts alone miss, and
|
||||
/// `ByValue` marks the RVO/sret functions that are UNSAFE to blind-call — the caller must pass an
|
||||
/// output-buffer pointer in RDI, so calling with the object there makes the function WRITE into it
|
||||
/// (the `CSwapTeams::GetDisplayString` sret trap). Best-effort, with an explicit
|
||||
/// `Unknown` when the return path doesn't decode — so it only ever adds a signal, never a false one.
|
||||
/// (the `CSwapTeams::GetDisplayString` sret trap).
|
||||
///
|
||||
/// UNLIKE the argument footprint, this is NOT a conservative bound, and it is not evidence about the
|
||||
/// DECLARED return type. A callee cannot tell whether its caller reads the result register, so a `void`
|
||||
/// function that merely uses RAX or XMM0 as scratch reads back as `Int`/`Float`: measured against the
|
||||
/// entity-IO datadesc, whose handlers are all declared `void`, only ~12% classify as [`RetClass::Void`].
|
||||
/// What it IS good for is the two things it is used for — the `ByValue` blind-call safety flag (no false
|
||||
/// positive appeared across that same set), and cross-build DIFFING, where the classification is stable
|
||||
/// per function so a change really does mean the function changed.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Debug)]
|
||||
pub enum RetClass {
|
||||
/// No decodable return path (a forwarding thunk / tail call / undecoded) — no signal.
|
||||
|
|
@ -290,6 +309,19 @@ fn insn_effect(factory: &mut InstructionInfoFactory, insn: &Instruction) -> (u16
|
|||
use_m &= !(1 << slot);
|
||||
def_m |= 1 << slot;
|
||||
}
|
||||
// A CALL clobbers every caller-saved register, and all 14 argument registers are caller-saved —
|
||||
// only RBX/RBP/R12-R15 survive one. So nothing read AFTER a call can be an incoming argument: the
|
||||
// value must have been produced since, and anything the callee needed to outlive the call was
|
||||
// already copied somewhere safe (a read this analysis sees BEFORE the call). Modelling the clobber
|
||||
// is what keeps the footprint a lower bound; without it a float RETURNED by a callee and used
|
||||
// afterwards propagates back to the entry as a phantom float argument. Applied after `use_m` is
|
||||
// computed, so a register the call instruction itself reads (`call rdi`) still counts.
|
||||
if matches!(
|
||||
insn.flow_control(),
|
||||
FlowControl::Call | FlowControl::IndirectCall
|
||||
) {
|
||||
def_m = ARG_SLOTS;
|
||||
}
|
||||
(use_m, def_m, stack)
|
||||
}
|
||||
|
||||
|
|
@ -456,7 +488,16 @@ fn decode_region(img: &CodeImage, entry: u64) -> Option<(Vec<Insn>, bool, bool)>
|
|||
let next = start + insn.len() as u64;
|
||||
let mut succ = Vec::new();
|
||||
match insn.flow_control() {
|
||||
FlowControl::Return | FlowControl::IndirectBranch => {}
|
||||
// No successor. `Exception`/`Interrupt` (`ud2`, `int3`) are terminal here for the same reason
|
||||
// `Return` is: control does not continue to the next instruction, which is inter-function
|
||||
// padding. Following it would walk into the NEXT function and back-propagate ITS argument
|
||||
// reads into this one's live-in set — an over-count, the failure direction this module
|
||||
// promises not to have. Treating a hypothetical resuming `INT n` as terminal can only
|
||||
// under-count, which is the accepted direction.
|
||||
FlowControl::Return
|
||||
| FlowControl::IndirectBranch
|
||||
| FlowControl::Exception
|
||||
| FlowControl::Interrupt => {}
|
||||
FlowControl::UnconditionalBranch => {
|
||||
let t = insn.near_branch_target();
|
||||
if in_span(t) {
|
||||
|
|
@ -470,7 +511,9 @@ fn decode_region(img: &CodeImage, entry: u64) -> Option<(Vec<Insn>, bool, bool)>
|
|||
succ.push(t);
|
||||
}
|
||||
}
|
||||
_ => succ.push(next), // fall-through (incl. call/indirect-call: the call reads no arg regs)
|
||||
// Fall-through, including a call: control resumes at the next instruction, but the call has
|
||||
// already killed every argument register in `insn_effect`.
|
||||
_ => succ.push(next),
|
||||
}
|
||||
for &s in &succ {
|
||||
if !recs.contains_key(&s) {
|
||||
|
|
@ -553,10 +596,205 @@ pub fn abi_shape(img: &CodeImage, entry: u64) -> Option<AbiShape> {
|
|||
})
|
||||
}
|
||||
|
||||
/// The 16 general-purpose registers as a slot index, sub-registers folded to their 64-bit parent.
|
||||
///
|
||||
/// `pub(crate)` because it is a fixed SysV fact, not per-pass tuning: `concmd` and `vscript` index
|
||||
/// `[_; 16]` arrays by exactly this mapping and each carried its own copy of it. (Unlike `MAX_NAME`, or
|
||||
/// the two `V` lattices, which differ between those readers deliberately.)
|
||||
pub(crate) fn gp_slot(r: Register) -> Option<usize> {
|
||||
let full = r.full_register();
|
||||
(full.is_gpr64() && full != Register::RIP).then(|| full as usize - Register::RAX as 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, 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,
|
||||
Register::RDX,
|
||||
Register::RSI,
|
||||
Register::RDI,
|
||||
Register::R8,
|
||||
Register::R9,
|
||||
Register::R10,
|
||||
Register::R11,
|
||||
];
|
||||
|
||||
fn caller_saved_mask() -> u32 {
|
||||
CALLER_SAVED
|
||||
.iter()
|
||||
.filter_map(|&r| gp_slot(r))
|
||||
.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.
|
||||
///
|
||||
/// **What it is for.** Every other check in this project verifies that a locator RESOLVES; none verifies
|
||||
/// that it resolves to the RIGHT function. This one can, against a fact the SchemaSystem already states
|
||||
/// offline: a `CFoo::` method reaches its own object through `this`, so every `this + N` it touches must
|
||||
/// satisfy `N < sizeof(CFoo)`. Reaching past the end means the pointer is not a `CFoo`.
|
||||
///
|
||||
/// **Deliberately conservative, in the same direction as `abi_shape`.** A register stops holding `this`
|
||||
/// on any write that is not a move from another register already holding it, every caller-saved register
|
||||
/// is dropped across a `call`, and a path merge keeps only what holds on BOTH paths. So `this` is followed
|
||||
/// only where it is provably still `this`, and the error direction is a reach that is too SMALL — a missed
|
||||
/// contradiction rather than a false accusation against a correct entry.
|
||||
///
|
||||
/// Indexed memory operands (`(%rax,%rcx,8)`) are skipped: there the displacement is an array base rather
|
||||
/// than a field offset, so its magnitude says nothing about the object's size.
|
||||
pub fn this_reach(img: &CodeImage, entry: u64) -> Option<u64> {
|
||||
const MAX_SPAN: usize = 16 * 1024;
|
||||
const MAX_STEPS: usize = 6000;
|
||||
let code = img.code_at(entry)?;
|
||||
let cap = code.len().min(MAX_SPAN);
|
||||
let in_span = |t: u64| t >= entry && ((t - entry) as usize) < cap;
|
||||
let clobber = caller_saved_mask();
|
||||
|
||||
let mut factory = InstructionInfoFactory::new();
|
||||
let mut seen: HashMap<u64, u32> = HashMap::new();
|
||||
let rdi = 1u32 << gp_slot(Register::RDI)?;
|
||||
let mut work = vec![(entry, rdi)];
|
||||
let mut best: Option<u64> = None;
|
||||
let mut steps = 0usize;
|
||||
let mut insn = Instruction::default();
|
||||
|
||||
while let Some((ip, incoming)) = work.pop() {
|
||||
if !in_span(ip) {
|
||||
continue;
|
||||
}
|
||||
steps += 1;
|
||||
if steps > MAX_STEPS {
|
||||
break;
|
||||
}
|
||||
// Path merge is INTERSECTION: a register holds `this` here only if it did on every path in.
|
||||
let held = match seen.get(&ip) {
|
||||
Some(&prev) => {
|
||||
let merged = prev & incoming;
|
||||
if merged == prev {
|
||||
continue; // nothing new to propagate
|
||||
}
|
||||
merged
|
||||
}
|
||||
None => incoming,
|
||||
};
|
||||
seen.insert(ip, held);
|
||||
|
||||
let off = (ip - entry) as usize;
|
||||
let mut dec = Decoder::with_ip(64, &code[off..], ip, DecoderOptions::NONE);
|
||||
if !dec.can_decode() {
|
||||
continue;
|
||||
}
|
||||
dec.decode_out(&mut insn);
|
||||
if insn.is_invalid() || insn.len() == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Record every field access made through a register that still holds `this`.
|
||||
if insn.memory_index() == Register::None
|
||||
&& let Some(slot) = gp_slot(insn.memory_base())
|
||||
&& held & (1 << slot) != 0
|
||||
&& (0..insn.op_count()).any(|i| insn.op_kind(i) == OpKind::Memory)
|
||||
{
|
||||
let d = insn.memory_displacement64();
|
||||
if d < MAX_SPAN as u64 {
|
||||
best = Some(best.map_or(d, |b: u64| b.max(d)));
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate. A plain 64-bit register-to-register move carries `this`; anything else that writes
|
||||
// a register destroys whatever it held.
|
||||
let mut next = held;
|
||||
let is_reg_move = insn.mnemonic() == Mnemonic::Mov
|
||||
&& insn.op_count() == 2
|
||||
&& insn.op0_kind() == OpKind::Register
|
||||
&& insn.op1_kind() == OpKind::Register
|
||||
&& insn.op0_register().is_gpr64();
|
||||
let carried = is_reg_move
|
||||
.then(|| gp_slot(insn.op1_register()))
|
||||
.flatten()
|
||||
.filter(|&s| held & (1 << s) != 0)
|
||||
.and_then(|_| gp_slot(insn.op0_register()));
|
||||
for used in factory.info(&insn).used_registers() {
|
||||
if matches!(
|
||||
used.access(),
|
||||
OpAccess::Write | OpAccess::ReadWrite | OpAccess::CondWrite
|
||||
) && let Some(s) = gp_slot(used.register())
|
||||
{
|
||||
next &= !(1 << s);
|
||||
}
|
||||
}
|
||||
if let Some(s) = carried {
|
||||
next |= 1 << s;
|
||||
}
|
||||
if insn.flow_control() == FlowControl::Call
|
||||
|| insn.flow_control() == FlowControl::IndirectCall
|
||||
{
|
||||
next &= !clobber;
|
||||
}
|
||||
|
||||
let after = ip + insn.len() as u64;
|
||||
match insn.flow_control() {
|
||||
FlowControl::Return
|
||||
| FlowControl::IndirectBranch
|
||||
| FlowControl::Exception
|
||||
| FlowControl::Interrupt => {}
|
||||
FlowControl::UnconditionalBranch => {
|
||||
let t = insn.near_branch_target();
|
||||
if in_span(t) {
|
||||
work.push((t, next));
|
||||
}
|
||||
}
|
||||
FlowControl::ConditionalBranch => {
|
||||
work.push((after, next));
|
||||
let t = insn.near_branch_target();
|
||||
if in_span(t) {
|
||||
work.push((t, next));
|
||||
}
|
||||
}
|
||||
_ => work.push((after, next)),
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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.)
|
||||
|
|
@ -689,6 +927,40 @@ mod tests {
|
|||
assert_eq!(shape_of(&[0xF2, 0x0F, 0x51, 0xD9, 0xC3]).key(), (0, 2));
|
||||
}
|
||||
|
||||
// --- a call clobbers every argument register (all 14 are caller-saved) ---
|
||||
|
||||
#[test]
|
||||
fn value_read_after_a_call_is_not_an_argument() {
|
||||
// call +0 ; movaps xmm1, xmm0 ; ret — XMM0 here holds the CALLEE's float result, not an
|
||||
// incoming argument. Without the clobber this back-propagates to the entry as a phantom
|
||||
// float arg, which is how a `void(ptr, ref)` entity-IO handler measured as taking floats.
|
||||
assert_eq!(
|
||||
shape_of(&[0xE8, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x28, 0xC8, 0xC3]).key(),
|
||||
(0, 0)
|
||||
);
|
||||
// call +0 ; mov rax, rsi ; ret — same on the integer side.
|
||||
assert_eq!(
|
||||
shape_of(&[0xE8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x89, 0xF0, 0xC3]).key(),
|
||||
(0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_register_the_call_itself_reads_still_counts() {
|
||||
// call rdi ; ret — the clobber must not swallow the call instruction's OWN operand read.
|
||||
assert_eq!(shape_of(&[0xFF, 0xD7, 0xC3]).key(), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_before_the_call_still_counts() {
|
||||
// mov rbx, rsi ; call +0 ; ret — RSI is copied to a callee-saved register BEFORE the call,
|
||||
// which is exactly how a real argument survives one, so it is still an argument.
|
||||
assert_eq!(
|
||||
shape_of(&[0x48, 0x89, 0xF3, 0xE8, 0x00, 0x00, 0x00, 0x00, 0xC3]).key(),
|
||||
(2, 0)
|
||||
);
|
||||
}
|
||||
|
||||
// --- return class ---
|
||||
|
||||
#[test]
|
||||
|
|
@ -728,4 +1000,79 @@ mod tests {
|
|||
// ret — no result register written before returning.
|
||||
assert_eq!(shape_of(&[0xC3]).ret_class, RetClass::Void);
|
||||
}
|
||||
|
||||
// ---- this_reach: the identity check's measurement half. Every case here is one the FIELD-tracking
|
||||
// has to get right for the check to be usable as a rejection rather than a hint. ----
|
||||
|
||||
fn reach_of(bytes: &[u8]) -> Option<u64> {
|
||||
this_reach(&CodeImage::for_test(0x1000, bytes), 0x1000)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_reach_follows_a_move_into_a_callee_saved_register() {
|
||||
// mov %rdi,%r13 ; cmpb $0,0x7bc(%r13) ; ret
|
||||
// The shape that matters in practice: the prologue stashes `this` and every field access is
|
||||
// through the copy, so a tracker that only watches RDI measures nothing.
|
||||
assert_eq!(
|
||||
reach_of(&[
|
||||
0x49, 0x89, 0xFD, 0x41, 0x80, 0xBD, 0xBC, 0x07, 0x00, 0x00, 0x00, 0xC3
|
||||
]),
|
||||
Some(0x7bc)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_reach_stops_at_a_reloaded_register() {
|
||||
// mov 0x10(%rdi),%rdi ; mov 0x110(%rdi),%rax ; ret
|
||||
// RDI is REDEFINED from memory, so 0x110 is an offset into a different object. Crediting it to
|
||||
// `this` is exactly the false positive that made an earlier prototype of this check unusable.
|
||||
assert_eq!(
|
||||
reach_of(&[
|
||||
0x48, 0x8B, 0x7F, 0x10, 0x48, 0x8B, 0x87, 0x10, 0x01, 0x00, 0x00, 0xC3
|
||||
]),
|
||||
Some(0x10)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_reach_drops_caller_saved_registers_across_a_call() {
|
||||
// call +0 ; mov 0x200(%rdi),%rax ; ret
|
||||
// RDI is caller-saved, so after a call it holds whatever the callee left. A read through it is
|
||||
// not a read of `this`.
|
||||
assert_eq!(
|
||||
reach_of(&[
|
||||
0xE8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8B, 0x87, 0x00, 0x02, 0x00, 0x00, 0xC3
|
||||
]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_reach_keeps_callee_saved_copies_across_a_call() {
|
||||
// mov %rdi,%rbx ; call +0 ; mov 0x200(%rbx),%rax ; ret
|
||||
// The counterpart: RBX is callee-saved, so the copy survives and the access IS through `this`.
|
||||
assert_eq!(
|
||||
reach_of(&[
|
||||
0x48, 0x89, 0xFB, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8B, 0x83, 0x00, 0x02, 0x00,
|
||||
0x00, 0xC3
|
||||
]),
|
||||
Some(0x200)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_reach_ignores_indexed_operands() {
|
||||
// mov 0x900(%rdi,%rcx,8),%rax ; ret — an array walk; the displacement is a base, not a field
|
||||
// offset, so its magnitude says nothing about the object's size.
|
||||
assert_eq!(
|
||||
reach_of(&[0x48, 0x8B, 0x84, 0xCF, 0x00, 0x09, 0x00, 0x00, 0xC3]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_reach_is_none_when_this_is_never_dereferenced() {
|
||||
// xor %eax,%eax ; ret — a constant returner touches no object at all.
|
||||
assert_eq!(reach_of(&[0x31, 0xC0, 0xC3]), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
933
src/concmd.rs
Normal file
933
src/concmd.rs
Normal file
|
|
@ -0,0 +1,933 @@
|
|||
//! Console commands — the third place a stripped Source-2 module names its own functions, and the only
|
||||
//! one that is not a table.
|
||||
//!
|
||||
//! A `ConCommand` used to be a static object with a vtable, and scanning for that shape is the obvious
|
||||
//! route. It does not work on CS2: commands are registered through a handle-based `ConCommandRef` whose
|
||||
//! registry lives in tier0, so `libserver.so` holds no `ConCommand` object and no `_ZTV10ConCommand`
|
||||
//! relocation to one. Nothing static points at a command name — which is why this was carried for a long
|
||||
//! time as needing a LIVE process to walk the registry.
|
||||
//!
|
||||
//! It does not. The registration is an ordinary call from a static initialiser, and every argument is a
|
||||
//! constant in the instruction stream:
|
||||
//!
|
||||
//! ```text
|
||||
//! lea rdi, [rip+ref] ; the ConCommandRef this call fills in
|
||||
//! lea rsi, [rip+"bot_add"] ; the command name
|
||||
//! lea rdx, [rip+handler] ; the callback
|
||||
//! mov ecx, 0 ; which FORM the callback takes
|
||||
//! lea r8, [rip+"bot_add <t|ct> ..."]
|
||||
//! mov r9d, 0x80004 ; flags
|
||||
//! call <registrar>
|
||||
//! ```
|
||||
//!
|
||||
//! So the walk is offline: decode each function, track what the argument registers provably hold, and
|
||||
//! read the vector at every call. The name, the handler, the description and the flags all come from ONE
|
||||
//! instruction sequence, which is what makes the result checkable — Valve's own command dump states the
|
||||
//! name and description of each command, and those two arguments agreeing is evidence about the third.
|
||||
//!
|
||||
//! Like the table readers this is deliberately shape-driven: the registrar is recognised by what it DOES
|
||||
//! (it opens by writing the invalid-handle sentinel), a handler is accepted only if it lands in
|
||||
//! executable code, and a name only if it resolves to a plausible string. A layout change yields
|
||||
//! FEWER commands, never wrong ones.
|
||||
//!
|
||||
//! # ConVars, the other half of the same surface
|
||||
//!
|
||||
//! Convars register the same way and are read by the same pass, which is why they live here rather than in
|
||||
//! a module of their own — and, more importantly, they share the FCVAR flag space, so [`flag_names`] decodes
|
||||
//! both. A registration looks like:
|
||||
//!
|
||||
//! ```text
|
||||
//! lea r14, [rip+object] ; the ConVar itself — in .bss, so zero on disk
|
||||
//! lea rsi, [rip+"mp_maxrounds"] ; the name
|
||||
//! mov ecx, 0x282100 ; flags (bit 13 replicated, bit 19 release)
|
||||
//! lea r8, [rip+"max number of rounds…"] ; the help text
|
||||
//! call <registrar>
|
||||
//! ```
|
||||
//!
|
||||
//! **The object is in `.bss`**, so the scan-a-static-record route every other reader here uses is not
|
||||
//! available: on disk a ConVar is 344 zero bytes, and its name, flags and help exist only as arguments to
|
||||
//! the constructor call. Reading the call site is not a shortcut, it is the only offline route.
|
||||
//!
|
||||
//! The registrar is identified differently from the command one, and the difference is deliberate. A
|
||||
//! ConVar constructor has no equivalent of the invalid-handle sentinel to recognise it by, so the test is
|
||||
//! on the CALL SITE's argument shape — a convar-shaped name, prose-or-absent help, a flags word — and then
|
||||
//! on AGREEMENT: only a call target that presents that shape at `MIN_CONVAR_SITES` or more sites is
|
||||
//! accepted as a registrar. The doc on `inits_invalid_handle` warns that ranking call targets is wrong,
|
||||
//! and it is, for the shape it was warning about: "an argument that lands in executable code" fits far too
|
||||
//! much. Two strings with different character profiles plus a flags word plus a `.bss` pointer, repeated
|
||||
//! across dozens of sites, is a different order of evidence.
|
||||
|
||||
use crate::elf::CodeImage;
|
||||
use iced_x86::{
|
||||
Decoder, DecoderOptions, FlowControl, Instruction, InstructionInfoFactory, Mnemonic, OpAccess,
|
||||
OpKind, Register,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// SysV argument registers, by the GPR index [`gpr`] produces.
|
||||
const RCX: usize = 1;
|
||||
const RDX: usize = 2;
|
||||
const RSI: usize = 6;
|
||||
const RDI: usize = 7;
|
||||
const R8: usize = 8;
|
||||
const R9: usize = 9;
|
||||
/// 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;
|
||||
|
||||
/// Bytes of the member-accessor object searched for the handler. The object holds a vtable, the receiver
|
||||
/// and the member function; the window is scanned for a UNIQUE executable pointer rather than indexed at
|
||||
/// a fixed slot, so a layout change drops the record instead of quietly moving the answer.
|
||||
const ACCESSOR_WINDOW: i64 = 0x40;
|
||||
|
||||
/// Instruction bytes of a registrar prologue examined for the invalid-handle store.
|
||||
const PROLOGUE: u64 = 96;
|
||||
|
||||
/// How a registration passes its callback. Recorded because it says how much inference produced the
|
||||
/// address: [`CallbackForm::Direct`] is read straight off the call, the other two resolve one step
|
||||
/// further.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum CallbackForm {
|
||||
/// The argument IS the handler.
|
||||
Direct,
|
||||
/// The argument is a static interface object; the handler is its first virtual.
|
||||
Interface,
|
||||
/// The argument is a member of the object under construction; the handler is the executable pointer
|
||||
/// the enclosing constructor stores into it.
|
||||
Member,
|
||||
}
|
||||
|
||||
impl CallbackForm {
|
||||
pub fn describe(self) -> &'static str {
|
||||
match self {
|
||||
CallbackForm::Direct => "direct",
|
||||
CallbackForm::Interface => "interface",
|
||||
CallbackForm::Member => "member",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One registered console command.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConsoleCommand {
|
||||
/// The console-facing name, exactly as Valve compiled it (`bot_add`, `+bugvoice`).
|
||||
pub name: String,
|
||||
/// Address of the handler.
|
||||
pub handler: u64,
|
||||
/// The raw flags word. Ships raw beside [`flag_names`] so a build that repurposes a bit can be
|
||||
/// re-read rather than silently mis-labelled.
|
||||
pub flags: u64,
|
||||
/// Valve's own help text. Empty when the registration passes none.
|
||||
pub description: String,
|
||||
pub form: CallbackForm,
|
||||
}
|
||||
|
||||
/// The flag bits whose meaning is MEASURED, not assumed: each was matched against Valve's published
|
||||
/// command dump across 742 CS2 commands carrying both a derived flags word and Valve's flag names, and
|
||||
/// each of these twelve separates that dump exactly — every command with the bit has the name, every
|
||||
/// command with the name has the bit.
|
||||
///
|
||||
/// Three further bits are set in the wild (1, 2 and 33) and are NOT listed, because no name in the dump
|
||||
/// matches them. Note also that the dump's `developmentonly`, `defensive` and `gamedll` are labels the
|
||||
/// dumper derives (the first two from the ABSENCE of `release`, the last from which module registered
|
||||
/// the command) rather than bits — inventing bits for them is the mistake this table exists to avoid.
|
||||
const FLAG_BITS: [(u32, &str); 12] = [
|
||||
(0, "linked_concommand"),
|
||||
(4, "hidden"),
|
||||
(11, "unlogged"),
|
||||
(13, "replicated"),
|
||||
(14, "cheat"),
|
||||
(17, "dontrecord"),
|
||||
(19, "release"),
|
||||
(23, "vconsole_fuzzy_matching"),
|
||||
(24, "server_can_execute"),
|
||||
(25, "client_can_execute"),
|
||||
(27, "vconsole_set_focus"),
|
||||
(28, "clientcmd_can_execute"),
|
||||
];
|
||||
|
||||
/// CONVAR flag bits. A SEPARATE table from [`FLAG_BITS`], and the separation is not cosmetic.
|
||||
///
|
||||
/// The tempting assumption is that FCVAR is one flag space, so the command table decodes convars too. It
|
||||
/// does not, and shipping on that assumption mislabelled bit 0 as `linked_concommand` on 185 Dota and 56 CS2
|
||||
/// convars — a name that Valve's own dump gives to NONE of them. Whatever transforms the word on the way in
|
||||
/// (the registrar visibly masks a bit of it), the convar encoding is its own and has to be measured as its
|
||||
/// own.
|
||||
///
|
||||
/// Derived against Valve's published dumps for BOTH games — `GameTracking-{CS2,Dota2}/DumpSource2/
|
||||
/// convars.txt`, 1,939 convars pooled — keeping only bits whose flag holds at 100% precision. Bits 0, 1 and
|
||||
/// 2 are set often and match nothing cleanly; they stay unnamed and survive in `flags_raw`, which is what
|
||||
/// that field is for.
|
||||
const CONVAR_FLAG_BITS: [(u32, &str); 9] = [
|
||||
(4, "hidden"),
|
||||
(7, "archive"),
|
||||
(8, "notify"),
|
||||
(13, "replicated"),
|
||||
(14, "cheat"),
|
||||
(15, "per_user"),
|
||||
(17, "dontrecord"),
|
||||
(19, "release"),
|
||||
(21, "commandline_enforced"),
|
||||
];
|
||||
|
||||
/// The names of the bits set in a CONVAR's flags word that have a measured meaning.
|
||||
pub fn convar_flag_names(flags: u64) -> Vec<&'static str> {
|
||||
CONVAR_FLAG_BITS
|
||||
.iter()
|
||||
.filter(|(b, _)| flags & (1u64 << b) != 0)
|
||||
.map(|&(_, n)| n)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The names of the bits set in `flags` that have a measured meaning. Bits without one are omitted here
|
||||
/// and preserved in [`ConsoleCommand::flags`].
|
||||
pub fn flag_names(flags: u64) -> Vec<&'static str> {
|
||||
FLAG_BITS
|
||||
.iter()
|
||||
.filter(|(b, _)| flags & (1u64 << b) != 0)
|
||||
.map(|&(_, n)| n)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Index 0-15 of a GPR, after widening an 8/16/32-bit name to its 64-bit parent.
|
||||
/// The 64-bit parent register as a slot index. Thin wrapper over [`crate::abi::gp_slot`] — the mapping is a
|
||||
/// fixed SysV fact, and this file only narrows it to the `u8` its `[_; 16]` arrays index by.
|
||||
fn gpr(r: Register) -> Option<u8> {
|
||||
crate::abi::gp_slot(r).map(|s| s as u8)
|
||||
}
|
||||
|
||||
/// What a register provably holds. `Sym` is an offset from a value we never learned — a constructor's
|
||||
/// `this` — which is what lets a `lea rdx,[this+0x1c8]` argument be matched against a
|
||||
/// `mov [this+0x1e8],rax` store made by the same function.
|
||||
///
|
||||
/// The epoch is what makes that safe. Stores are collected across the WHOLE function, because the
|
||||
/// compiler sinks an accessor's handler store past the registration that consumes it. Over that span the
|
||||
/// base register is eventually reloaded — an epilogue's `pop rbx` alone would otherwise either discard
|
||||
/// every store or, worse, re-point them at a different object. Bumping a counter instead keeps each run
|
||||
/// of a register's life distinct, and only same-epoch pairs ever match.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum V {
|
||||
Unknown,
|
||||
Const(u64),
|
||||
Sym(u8, u32, i64),
|
||||
}
|
||||
|
||||
impl V {
|
||||
fn offset(self, d: i64) -> V {
|
||||
match self {
|
||||
V::Const(c) => V::Const(c.wrapping_add(d as u64)),
|
||||
V::Sym(b, e, dd) => V::Sym(b, e, dd.wrapping_add(d)),
|
||||
V::Unknown => V::Unknown,
|
||||
}
|
||||
}
|
||||
fn konst(self) -> Option<u64> {
|
||||
match self {
|
||||
V::Const(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End a register's current life, because something has overwritten it.
|
||||
///
|
||||
/// Called by EVERY arm that assigns to a register, not only the catch-all — which is the correction that
|
||||
/// matters. `mov`, `lea` and `xor` re-point a register just as surely as an unmodelled instruction does,
|
||||
/// so leaving their epoch alone let a base register be aimed at a second object while stores made against
|
||||
/// the FIRST still keyed to the same `(register, epoch)` pair — and a `lea rdx,[rbx+0x1c8]` for object B
|
||||
/// could then match a `mov [rbx+0x1e8],rax` that belonged to object A, attributing one constructor's
|
||||
/// handler to another's registration.
|
||||
fn end_life(epoch: &mut [u32; 16], d: u8) {
|
||||
epoch[d as usize] = epoch[d as usize].saturating_add(1);
|
||||
}
|
||||
|
||||
/// Address of a `this`-relative slot: base register, that register's epoch, displacement.
|
||||
type Slot = (u8, u32, i64);
|
||||
|
||||
/// One call to a registrar, with what its argument registers held and the `this`-relative constants its
|
||||
/// enclosing function stored.
|
||||
struct Site {
|
||||
/// The call target — which registrar this site went to. Convar registrars are identified by agreement
|
||||
/// across their sites, so the target has to survive collection.
|
||||
target: u64,
|
||||
args: [V; 16],
|
||||
stores: std::sync::Arc<HashMap<Slot, u64>>,
|
||||
}
|
||||
|
||||
/// Does `f` open by storing the invalid-handle sentinel into `*rdi`? That is what a `ConCommandRef`
|
||||
/// constructor does before it registers, and it identifies the registrar SEMANTICALLY.
|
||||
///
|
||||
/// Ranking call targets by how many look like registrations is the tempting alternative and it is wrong
|
||||
/// twice over: libvscript's top-ranked such target is not the registrar (it yields six confident,
|
||||
/// entirely fictional commands), and libschemasystem passes an object rather than a function so its
|
||||
/// registrar never ranks at all.
|
||||
fn inits_invalid_handle(img: &CodeImage, f: u64) -> bool {
|
||||
// Saturating: `f` is a decoded near-branch target, so a crafted image can put it at the top of the
|
||||
// address space and a plain add wraps the range inside out. Found by `fuzz_concmd`.
|
||||
let Some(code) = img.code_range(f, f.saturating_add(PROLOGUE)) else {
|
||||
return false;
|
||||
};
|
||||
let mut insn = Instruction::default();
|
||||
let mut dec = Decoder::with_ip(64, code, f, DecoderOptions::NONE);
|
||||
while dec.can_decode() {
|
||||
dec.decode_out(&mut insn);
|
||||
if insn.mnemonic() == Mnemonic::Mov
|
||||
&& insn.op0_kind() == OpKind::Memory
|
||||
&& insn.memory_base() == Register::RDI
|
||||
&& insn.memory_index() == Register::None
|
||||
&& insn.memory_displacement64() == 0
|
||||
&& insn.memory_size().size() == 8
|
||||
&& matches!(
|
||||
insn.op1_kind(),
|
||||
OpKind::Immediate32 | OpKind::Immediate32to64 | OpKind::Immediate64
|
||||
)
|
||||
&& insn.immediate64() == 0xffff
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if insn.flow_control() == FlowControl::Return {
|
||||
break;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A plausible console-command name: short, printable, no spaces or quoting.
|
||||
/// Whether `s` is shaped like a console COMMAND name.
|
||||
///
|
||||
/// Split from the read so a test can call the rule instead of restating it — restating it is how the
|
||||
/// convar test came to assert this rule while claiming to pin the other one, and would have passed with
|
||||
/// the two gates swapped.
|
||||
///
|
||||
/// Deliberately looser than [`is_convar_name`]: a command name may lead with punctuation, because the
|
||||
/// `+bugvoice` / `-bugvoice` on/off pairs are real commands and a convar can never be spelled that way.
|
||||
fn is_cmd_name(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= MAX_NAME
|
||||
&& s.bytes()
|
||||
.all(|c| c.is_ascii_graphic() && c != b'"' && c != b'%')
|
||||
}
|
||||
|
||||
fn cmd_name(img: &CodeImage, va: u64) -> Option<String> {
|
||||
let s = img.read_c_string(va)?;
|
||||
is_cmd_name(&s).then_some(s)
|
||||
}
|
||||
|
||||
/// Every console command `img` registers.
|
||||
pub fn console_commands(img: &CodeImage) -> Vec<ConsoleCommand> {
|
||||
// Memoised so the prologue test runs once per distinct call target rather than once per call, and so
|
||||
// only registrar calls are ever materialised as a Site.
|
||||
let mut is_reg: HashMap<u64, bool> = HashMap::new();
|
||||
let sites = collect_sites(img, |img, t, _| {
|
||||
*is_reg
|
||||
.entry(t)
|
||||
.or_insert_with(|| inits_invalid_handle(img, t))
|
||||
});
|
||||
interpret_commands(img, &sites)
|
||||
}
|
||||
|
||||
/// Walk every function, constant-propagate the argument registers, and keep the call sites `accept` wants.
|
||||
///
|
||||
/// Single-sourced deliberately: this pass is subtle — the epoch counter, the write-only invalidation, the
|
||||
/// straight-line reset per function — and two copies of it would drift. The command and convar readers differ
|
||||
/// only in which calls they keep and how they read the arguments, so that is all `accept` decides.
|
||||
fn collect_sites(
|
||||
img: &CodeImage,
|
||||
mut accept: impl FnMut(&CodeImage, u64, &[V; 16]) -> bool,
|
||||
) -> Vec<Site> {
|
||||
let entries = crate::locate::function_entries(img);
|
||||
|
||||
let mut sites: Vec<Site> = Vec::new();
|
||||
let mut factory = InstructionInfoFactory::new();
|
||||
|
||||
for (i, &start) in entries.iter().enumerate() {
|
||||
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
|
||||
let Some(code) = img.code_range(start, end) else {
|
||||
continue;
|
||||
};
|
||||
// Values are tracked straight-line and reset at each function entry. A branch into the middle of
|
||||
// a tracked run could carry a stale value forward, which is why every recovered command is
|
||||
// re-validated against the image: the name string resolves, the handler is executable.
|
||||
let mut val = [V::Unknown; 16];
|
||||
let mut epoch = [0u32; 16];
|
||||
let mut stores: HashMap<Slot, u64> = HashMap::new();
|
||||
let mut found: Vec<(u64, [V; 16])> = 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);
|
||||
|
||||
if insn.flow_control() == FlowControl::Call
|
||||
&& matches!(
|
||||
insn.op0_kind(),
|
||||
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
||||
)
|
||||
{
|
||||
let t = insn.near_branch_target();
|
||||
if accept(img, t, &val) {
|
||||
found.push((t, val));
|
||||
}
|
||||
// A call assigns to NINE registers at once, so it ends nine lives — the arm that most
|
||||
// needs the epoch bump and the one that was missing it. The Lea arm below mints a fresh
|
||||
// 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.
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// A store of a known constant to a `this`-relative slot: how the member form records its
|
||||
// handler.
|
||||
if insn.mnemonic() == Mnemonic::Mov
|
||||
&& insn.op0_kind() == OpKind::Memory
|
||||
&& insn.memory_index() == Register::None
|
||||
&& insn.op1_kind() == OpKind::Register
|
||||
&& let Some(b) = gpr(insn.memory_base())
|
||||
&& let Some(s) = gpr(insn.op1_register())
|
||||
&& let Some(v) = val[s as usize].konst()
|
||||
{
|
||||
let d = insn.memory_displacement64() as i64;
|
||||
match val[b as usize] {
|
||||
V::Unknown => {
|
||||
stores.insert((b, epoch[b as usize], d), v);
|
||||
}
|
||||
V::Sym(bb, e, dd) => {
|
||||
stores.insert((bb, e, dd.wrapping_add(d)), v);
|
||||
}
|
||||
// An absolute address needs no note: it can be read back off the image directly.
|
||||
V::Const(_) => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match insn.mnemonic() {
|
||||
// `lea r,[rip+d]` is a string/global/function address; `lea r,[base+d]` walks to a member.
|
||||
Mnemonic::Lea => {
|
||||
if let Some(d) = gpr(insn.op0_register()) {
|
||||
end_life(&mut epoch, d);
|
||||
val[d as usize] = if insn.is_ip_rel_memory_operand() {
|
||||
V::Const(insn.ip_rel_memory_address())
|
||||
} else if insn.memory_index() == Register::None {
|
||||
gpr(insn.memory_base())
|
||||
.map_or(V::Unknown, |b| match val[b as usize] {
|
||||
V::Unknown => V::Sym(b, epoch[b as usize], 0),
|
||||
v => v,
|
||||
})
|
||||
.offset(insn.memory_displacement64() as i64)
|
||||
} else {
|
||||
V::Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
Mnemonic::Mov => {
|
||||
if let Some(d) = gpr(insn.op0_register()) {
|
||||
end_life(&mut epoch, d);
|
||||
val[d as usize] = match insn.op1_kind() {
|
||||
OpKind::Immediate8to64
|
||||
| OpKind::Immediate32to64
|
||||
| OpKind::Immediate64 => V::Const(insn.immediate64()),
|
||||
OpKind::Immediate8 | OpKind::Immediate16 | OpKind::Immediate32 => {
|
||||
V::Const(u64::from(insn.immediate32()))
|
||||
}
|
||||
OpKind::Register => {
|
||||
gpr(insn.op1_register()).map_or(V::Unknown, |s| val[s as usize])
|
||||
}
|
||||
_ => V::Unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
// the compiler's idiomatic zero
|
||||
Mnemonic::Xor => {
|
||||
if let (Some(d), Some(s)) = (gpr(insn.op0_register()), gpr(insn.op1_register()))
|
||||
{
|
||||
end_life(&mut epoch, d);
|
||||
val[d as usize] = if d == s { V::Const(0) } else { V::Unknown };
|
||||
}
|
||||
}
|
||||
// Anything else: forget only what it WRITES. Invalidating every register OPERAND instead
|
||||
// ends the epoch of the `this` register on the first `push`/`cmp` against it, which is
|
||||
// most of a constructor and loses every store it made.
|
||||
_ => {
|
||||
for ur in factory.info(&insn).used_registers() {
|
||||
if matches!(
|
||||
ur.access(),
|
||||
OpAccess::Write | OpAccess::ReadWrite | OpAccess::CondWrite
|
||||
) && let Some(d) = gpr(ur.register())
|
||||
{
|
||||
end_life(&mut epoch, d);
|
||||
val[d as usize] = V::Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found.is_empty() {
|
||||
let stores = std::sync::Arc::new(stores);
|
||||
sites.extend(found.into_iter().map(|(target, args)| Site {
|
||||
target,
|
||||
args,
|
||||
stores: stores.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
sites
|
||||
}
|
||||
|
||||
/// Read command registrations out of collected sites.
|
||||
fn interpret_commands(img: &CodeImage, sites: &[Site]) -> Vec<ConsoleCommand> {
|
||||
let mut out: Vec<ConsoleCommand> = Vec::new();
|
||||
for s in sites {
|
||||
let Some(name) = s.args[RSI].konst().and_then(|v| cmd_name(img, v)) else {
|
||||
continue;
|
||||
};
|
||||
// The fourth argument is the callback TYPE, and several of its values (0, 2 and 4 all occur)
|
||||
// pass a raw function pointer. So accept on what the third argument provably IS rather than on
|
||||
// a decoded enum: if it lands in executable code, it is the handler. Type 1 alone passes an
|
||||
// INTERFACE, and that one does need the enum to tell the two indirections apart.
|
||||
let (handler, form) = match (s.args[RCX], s.args[RDX]) {
|
||||
(_, V::Const(p)) if img.is_code(p) => (Some(p), CallbackForm::Direct),
|
||||
(V::Const(1), V::Const(p)) => (
|
||||
img.read_ptr(p)
|
||||
.and_then(|vt| img.read_ptr(vt))
|
||||
.filter(|&f| img.is_code(f)),
|
||||
CallbackForm::Interface,
|
||||
),
|
||||
(V::Const(1), V::Sym(b, e, d)) => {
|
||||
// The displacement is accumulated with wrapping arithmetic from file-controlled
|
||||
// values, so walking the window must wrap too rather than overflow.
|
||||
let mut hits: Vec<u64> = (0..ACCESSOR_WINDOW / 8)
|
||||
.filter_map(|k| s.stores.get(&(b, e, d.wrapping_add(k * 8))).copied())
|
||||
.filter(|&v| img.is_code(v))
|
||||
.collect();
|
||||
// Sorted before de-duplicating so "exactly one" means one distinct address, not one
|
||||
// run of adjacent slots — an object may hold the same pointer at two offsets.
|
||||
hits.sort_unstable();
|
||||
hits.dedup();
|
||||
// Exactly one executable pointer in the object, or none is claimed.
|
||||
((hits.len() == 1).then(|| hits[0]), CallbackForm::Member)
|
||||
}
|
||||
_ => (None, CallbackForm::Direct),
|
||||
};
|
||||
// Flags are only recorded when they were actually read. Defaulting an untracked r9 to zero
|
||||
// would ship "no flags" for a command whose flags we merely failed to follow, and a consumer
|
||||
// cannot tell those apart.
|
||||
let (Some(handler), Some(flags)) = (handler, s.args[R9].konst()) else {
|
||||
continue;
|
||||
};
|
||||
out.push(ConsoleCommand {
|
||||
name,
|
||||
handler,
|
||||
flags,
|
||||
description: s.args[R8]
|
||||
.konst()
|
||||
.and_then(|v| img.read_c_string(v))
|
||||
.unwrap_or_default(),
|
||||
form,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| (&a.name, a.handler).cmp(&(&b.name, b.handler)));
|
||||
out.dedup_by(|a, b| a.name == b.name && a.handler == b.handler);
|
||||
out
|
||||
}
|
||||
|
||||
/// How many call sites must present the convar argument shape before a target counts as a registrar.
|
||||
///
|
||||
/// This is the whole safety margin for identifying convar registration by shape rather than by a semantic
|
||||
/// sentinel. A coincidental `(object, name-ish string, int, prose string)` call happens; forty of them to the
|
||||
/// same target does not. Measured on CS2 libserver the real registrars carry hundreds of sites each, so the
|
||||
/// bar sits far below the signal and far above the noise.
|
||||
const MIN_CONVAR_SITES: usize = 12;
|
||||
|
||||
/// A ConVar the module registers, as its registration states it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConVar {
|
||||
/// The console-facing name, e.g. `mp_maxrounds`.
|
||||
pub name: String,
|
||||
pub library: String,
|
||||
/// Valve's own help text; absent when the registration passes none.
|
||||
pub description: String,
|
||||
/// Flag bits with a measured meaning, decoded by [`convar_flag_names`] — NOT the command table, whose
|
||||
/// bit 0 name applies to no convar in either game's published dump.
|
||||
pub flags: Vec<String>,
|
||||
/// The raw flags word, kept beside the decoding so a build that repurposes a bit can be re-read rather
|
||||
/// than silently mis-labelled. Convars set bits commands never do (8 and 21 on CS2), and those have no
|
||||
/// name yet — this is where they survive.
|
||||
pub flags_raw: String,
|
||||
/// Address of the ConVar OBJECT. In `.bss`, so it holds nothing on disk; it is the anchor a runtime
|
||||
/// walks to reach the live value, and it is what distinguishes two registrations of the same name.
|
||||
pub addr: String,
|
||||
}
|
||||
|
||||
/// A plausible convar name: an identifier, lowercase by convention but not required, no spaces or prose.
|
||||
///
|
||||
/// Stricter than [`cmd_name`], which admits any printable run because commands like `+bugvoice` exist.
|
||||
/// A convar name is always an identifier, and the tighter gate is what keeps prose out of the name slot
|
||||
/// when the shape test is the only thing standing between a call site and a record.
|
||||
/// Whether `s` is shaped like a CONVAR name — stricter than [`is_cmd_name`] in both directions: it must
|
||||
/// LEAD with a letter or underscore, and its body admits only `[A-Za-z0-9_.]`.
|
||||
///
|
||||
/// This gate is the only shape check between a call site and an emitted ConVar record, so it is what keeps
|
||||
/// prose out of the name slot.
|
||||
fn is_convar_name(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= MAX_NAME
|
||||
&& s.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
&& s.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'.')
|
||||
}
|
||||
|
||||
fn convar_name(img: &CodeImage, va: u64) -> Option<String> {
|
||||
let s = img.read_c_string(va)?;
|
||||
is_convar_name(&s).then_some(s)
|
||||
}
|
||||
|
||||
/// Help text: prose, or nothing. Deliberately permissive about content and strict about being a real
|
||||
/// string — the point is to separate "this argument is a description" from "this argument is something else
|
||||
/// that happens to be a pointer".
|
||||
fn help_text(img: &CodeImage, va: u64) -> Option<String> {
|
||||
let s = img.read_c_string(va)?;
|
||||
(!s.is_empty() && s.len() <= 512 && s.is_ascii()).then_some(s)
|
||||
}
|
||||
|
||||
/// Does this call site look like a ConVar registration?
|
||||
///
|
||||
/// `rsi` a convar-shaped name, `rdi` a non-code address (the object), `rcx` a plausible flags word, and `r8`
|
||||
/// either help text or absent. Nothing here is sufficient alone; the caller additionally requires agreement
|
||||
/// across [`MIN_CONVAR_SITES`] sites to the same target.
|
||||
fn looks_like_convar_site(img: &CodeImage, args: &[V; 16]) -> bool {
|
||||
let Some(name) = args[RSI].konst() else {
|
||||
return false;
|
||||
};
|
||||
if convar_name(img, name).is_none() {
|
||||
return false;
|
||||
}
|
||||
// The object: a real address that is NOT code. A ConVar lives in writable data.
|
||||
match args[RDI].konst() {
|
||||
Some(o) if o != 0 && !img.is_code(o) => {}
|
||||
_ => return false,
|
||||
}
|
||||
// Flags: a 32-bit word. A pointer-sized value here means this is not the flags argument.
|
||||
match args[RCX].konst() {
|
||||
Some(f) if f <= u64::from(u32::MAX) => {}
|
||||
_ => return false,
|
||||
}
|
||||
// Help: present and prose, or genuinely absent. A non-zero value that is not a readable string means
|
||||
// the fifth argument is something else and this is not the registration shape.
|
||||
match args[R8] {
|
||||
V::Const(0) | V::Unknown => true,
|
||||
V::Const(p) => help_text(img, p).is_some(),
|
||||
V::Sym(..) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Functions `f` delegates to — direct calls AND tail jumps.
|
||||
///
|
||||
/// The tail jumps are the point. A convar registrar is a thin wrapper that arranges arguments and then
|
||||
/// `jmp`s to the core rather than calling it, so a collector that only counts `call` sees a wrapper
|
||||
/// delegate to nothing and the convergence that identifies the family disappears. Only branches LEAVING the
|
||||
/// scanned span count as delegation; a jump within it is ordinary control flow.
|
||||
fn callees(img: &CodeImage, f: u64) -> Vec<u64> {
|
||||
const SPAN: u64 = 0x400;
|
||||
let end = f.saturating_add(SPAN);
|
||||
let Some(code) = img.code_range(f, end) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut insn = Instruction::default();
|
||||
let mut dec = Decoder::with_ip(64, code, f, DecoderOptions::NONE);
|
||||
while dec.can_decode() {
|
||||
dec.decode_out(&mut insn);
|
||||
if !matches!(
|
||||
insn.op0_kind(),
|
||||
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let t = insn.near_branch_target();
|
||||
let delegates = match insn.flow_control() {
|
||||
FlowControl::Call => true,
|
||||
FlowControl::UnconditionalBranch => !(f..end).contains(&t),
|
||||
_ => false,
|
||||
};
|
||||
if delegates && img.is_code(t) {
|
||||
out.push(t);
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Which of the shape-matching targets are REAL convar registrars.
|
||||
///
|
||||
/// The argument shape alone is not enough, and this is the measurement that says so: on CS2 libserver it
|
||||
/// matches eleven targets, of which five register convars and six register something else with an
|
||||
/// identical footprint — animation events, mostly, which are also `(static object, identifier, int, prose)`.
|
||||
/// Checked against Valve's published convar dump the split is absolute: every one of those eleven targets is
|
||||
/// either 100% real convars or 0%. So the families ARE separable; the shape just is not what separates them.
|
||||
///
|
||||
/// What separates them is that the real registrars CONVERGE. Four of the five are wrappers that delegate to
|
||||
/// the fifth, which is itself a registrar — the cvar core. The false family shares no callee with them. So
|
||||
/// the core identifies itself: it is the candidate called by the most OTHER candidates. Accept it and its
|
||||
/// callers, reject everything else. On CS2 that yields exactly the five real registrars and 1,159 convars,
|
||||
/// with zero names absent from Valve's dump.
|
||||
///
|
||||
/// Deliberately NOT keyed off an address, a name, or Valve's dump: all three are per-build inputs this tool
|
||||
/// exists to avoid. The convergence is a property of the code in front of it.
|
||||
fn convar_registrars(img: &CodeImage, per_target: &HashMap<u64, usize>) -> Vec<u64> {
|
||||
let cands: Vec<u64> = per_target
|
||||
.iter()
|
||||
.filter(|&(_, &n)| n >= MIN_CONVAR_SITES)
|
||||
.map(|(&t, _)| t)
|
||||
.collect();
|
||||
let calls: HashMap<u64, Vec<u64>> = cands.iter().map(|&c| (c, callees(img, c))).collect();
|
||||
// How many candidates delegate to each function. The core does NOT have to be a candidate itself: on
|
||||
// CS2 it happens to take 28 registrations directly, but on Dota the shared core takes none, and
|
||||
// requiring it to be a candidate found nothing there at all.
|
||||
let mut inbound: HashMap<u64, usize> = HashMap::new();
|
||||
for (&from, tos) in &calls {
|
||||
for t in tos {
|
||||
if *t != from {
|
||||
*inbound.entry(*t).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ties broken by site count then address, so the choice cannot depend on hash order — this feeds a
|
||||
// byte-reproducible artifact.
|
||||
let Some((&core, &votes)) = inbound.iter().max_by_key(|&(t, n)| {
|
||||
(
|
||||
*n,
|
||||
per_target.get(t).copied().unwrap_or(0),
|
||||
std::cmp::Reverse(*t),
|
||||
)
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
// One wrapper proves nothing; a family of them is the signal. Below this the convergence is noise and
|
||||
// reporting NOTHING is the honest outcome — the profile floor then fails the release loudly.
|
||||
if votes < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut keep: Vec<u64> = Vec::new();
|
||||
if cands.contains(&core) {
|
||||
keep.push(core);
|
||||
}
|
||||
keep.extend(
|
||||
cands
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| calls.get(c).is_some_and(|t| t.contains(&core))),
|
||||
);
|
||||
keep.sort_unstable();
|
||||
keep.dedup();
|
||||
keep
|
||||
}
|
||||
|
||||
/// Which argument slot holds the FLAGS, for one registrar.
|
||||
///
|
||||
/// It is not the same slot for every registrar, and assuming it was is what first produced convars whose
|
||||
/// "flags" were `0x99dc60` — a `.rodata` pointer sitting in the slot a different overload uses for
|
||||
/// something else. The name slot is stable across all of them; nothing else is.
|
||||
///
|
||||
/// Found statistically, because flags REPEAT and pointers do not: across a registrar's sites the flags slot
|
||||
/// takes a small set of recurring words (`0x4000` alone appears 188 times on CS2), while a slot holding a
|
||||
/// string or an object address is very nearly unique per site. So the flags slot is the integer-shaped one
|
||||
/// with the lowest distinct-value ratio — and if nothing is clearly repetitive, this returns `None` and the
|
||||
/// registrar's convars ship with no decoded flags rather than with invented ones.
|
||||
fn flags_slot(sites: &[&Site]) -> Option<usize> {
|
||||
const CANDIDATES: [usize; 4] = [RDX, RCX, R8, R9];
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for slot in CANDIDATES {
|
||||
let vals: Vec<u64> = sites.iter().filter_map(|s| s.args[slot].konst()).collect();
|
||||
// Every value must fit a 32-bit flags word, and the slot must be present on nearly every site.
|
||||
if vals.len() * 4 < sites.len() * 3 || vals.iter().any(|&v| v > u64::from(u32::MAX)) {
|
||||
continue;
|
||||
}
|
||||
let mut d = vals.clone();
|
||||
d.sort_unstable();
|
||||
d.dedup();
|
||||
let ratio = d.len() as f64 / vals.len() as f64;
|
||||
if best.is_none_or(|(_, b)| ratio < b) {
|
||||
best = Some((slot, ratio));
|
||||
}
|
||||
}
|
||||
// A genuine flags slot repeats heavily. Anything above this is as unique as a pointer, which is what a
|
||||
// pointer is, and naming its bits would be fabrication.
|
||||
best.filter(|&(_, r)| r < 0.5).map(|(s, _)| s)
|
||||
}
|
||||
|
||||
/// Every ConVar `img` registers.
|
||||
pub fn convars(img: &CodeImage, library: &str) -> Vec<ConVar> {
|
||||
let sites = collect_sites(img, |img, _, args| looks_like_convar_site(img, args));
|
||||
let mut per_target: HashMap<u64, usize> = HashMap::new();
|
||||
for s in &sites {
|
||||
*per_target.entry(s.target).or_default() += 1;
|
||||
}
|
||||
let registrars = convar_registrars(img, &per_target);
|
||||
// Resolve the flags slot once per registrar, from all of that registrar's sites.
|
||||
let flag_of: HashMap<u64, Option<usize>> = registrars
|
||||
.iter()
|
||||
.map(|&r| {
|
||||
let mine: Vec<&Site> = sites.iter().filter(|s| s.target == r).collect();
|
||||
(r, flags_slot(&mine))
|
||||
})
|
||||
.collect();
|
||||
let mut out: Vec<ConVar> = Vec::new();
|
||||
for s in &sites {
|
||||
if !registrars.contains(&s.target) {
|
||||
continue;
|
||||
}
|
||||
let (Some(name), Some(obj)) = (
|
||||
s.args[RSI].konst().and_then(|v| convar_name(img, v)),
|
||||
s.args[RDI].konst(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
// Absent when this registrar has no identifiable flags slot: no decoded names, and a raw word of
|
||||
// zero that is honestly empty rather than a guess.
|
||||
let flags = flag_of
|
||||
.get(&s.target)
|
||||
.copied()
|
||||
.flatten()
|
||||
.and_then(|slot| s.args[slot].konst());
|
||||
out.push(ConVar {
|
||||
name,
|
||||
library: library.to_string(),
|
||||
description: s.args[R8]
|
||||
.konst()
|
||||
.filter(|&p| p != 0)
|
||||
.and_then(|p| help_text(img, p))
|
||||
.unwrap_or_default(),
|
||||
flags: flags
|
||||
.map(|f| {
|
||||
convar_flag_names(f)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
flags_raw: flags.map(|f| format!("{f:#x}")).unwrap_or_default(),
|
||||
addr: format!("{obj:#x}"),
|
||||
});
|
||||
}
|
||||
// One row per (name, object): the same convar is registered once, but a name can legitimately appear
|
||||
// twice across libraries and the object is what tells those apart.
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.addr.cmp(&b.addr)));
|
||||
out.dedup_by(|a, b| a.name == b.name && a.addr == b.addr);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---- command reader ----
|
||||
|
||||
#[test]
|
||||
fn only_measured_flag_bits_are_named() {
|
||||
// bot_add ships 0x80004 = bits 2 and 19. Bit 19 is `release`; bit 2 stays unnamed because no
|
||||
// name in Valve's dump matches it, and naming it anyway is the whole mistake to avoid.
|
||||
assert_eq!(flag_names(0x80004), vec!["release"]);
|
||||
// bot_place ships 0x4004 = bits 2 and 14 — bit 14 is `cheat`.
|
||||
assert_eq!(flag_names(0x4004), vec!["cheat"]);
|
||||
// A command with no flags names none, rather than falling back to a default.
|
||||
assert!(flag_names(0).is_empty());
|
||||
// Every listed bit is distinct and in range.
|
||||
let mut seen: Vec<u32> = FLAG_BITS.iter().map(|&(b, _)| b).collect();
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
assert_eq!(seen.len(), FLAG_BITS.len());
|
||||
assert!(FLAG_BITS.iter().all(|&(b, _)| b < 64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbolic_offsets_keep_the_base_and_track_the_epoch() {
|
||||
// A `this`-relative walk composes, so `lea rax,[rbx+0x1c8]` then `lea rdx,[rax+0x40]` addresses
|
||||
// the same object the constructor stored into.
|
||||
assert_eq!(V::Sym(3, 0, 0x1c8).offset(0x40), V::Sym(3, 0, 0x208));
|
||||
// A constant walk stays constant.
|
||||
assert_eq!(V::Const(0x1000).offset(8), V::Const(0x1008));
|
||||
// Nothing is invented from nothing.
|
||||
assert_eq!(V::Unknown.offset(8), V::Unknown);
|
||||
// Two runs of the same register never address each other's slots.
|
||||
assert_ne!(V::Sym(3, 0, 0x1c8), V::Sym(3, 1, 0x1c8));
|
||||
// Only a constant is a usable address.
|
||||
assert_eq!(V::Const(7).konst(), Some(7));
|
||||
assert_eq!(V::Sym(3, 0, 7).konst(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_arm_that_clobbers_a_register_ends_its_life() {
|
||||
// The invariant `end_life` documents, checked against the arm that breaks it most cheaply. A
|
||||
// CALL assigns to nine caller-saved registers at once; leaving their epochs alone made `rax`
|
||||
// after two successive calls one key space shared by two objects, so a store to `[rax+0x18]`
|
||||
// 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 = crate::abi::caller_saved_slots();
|
||||
let before: Vec<u32> = clobber.iter().map(|&c| epoch[c]).collect();
|
||||
for c in clobber {
|
||||
end_life(&mut epoch, c as u8);
|
||||
}
|
||||
for (i, &c) in clobber.iter().enumerate() {
|
||||
assert_eq!(
|
||||
epoch[c],
|
||||
before[i] + 1,
|
||||
"register {c} kept its epoch across a call"
|
||||
);
|
||||
// …and the two runs are therefore distinguishable keys, which is the point.
|
||||
assert_ne!(
|
||||
V::Sym(c as u8, before[i], 0x18),
|
||||
V::Sym(c as u8, epoch[c], 0x18)
|
||||
);
|
||||
}
|
||||
// A callee-saved register is NOT clobbered: the registration `this` a constructor threads
|
||||
// through survives the call, which is what the epoch widening was for in the first place.
|
||||
for saved in [3u8 /* rbx */, 12, 13, 14, 15] {
|
||||
assert!(
|
||||
!clobber.contains(&(saved as usize)),
|
||||
"r{saved} is callee-saved and must survive a call"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_name_is_an_identifier_not_prose() {
|
||||
// Calls the shipped rule, for the same reason as the convar test below it.
|
||||
assert!(is_cmd_name("bot_add"));
|
||||
assert!(is_cmd_name("+bugvoice")); // an on/off pair is a real command name
|
||||
assert!(!is_cmd_name("")); // an empty string is not a name
|
||||
assert!(!is_cmd_name("Adds a bot matching the given criteria.")); // a description (spaces)
|
||||
assert!(!is_cmd_name("%s: no varname specified\n")); // a format string
|
||||
assert!(!is_cmd_name(&"x".repeat(MAX_NAME + 1)));
|
||||
}
|
||||
|
||||
// ---- convar reader ----
|
||||
|
||||
#[test]
|
||||
fn a_convar_name_is_stricter_than_a_command_name() {
|
||||
// CALLS the gate rather than restating it. The previous version of this test re-implemented the
|
||||
// COMMAND rule and asserted only inputs both rules agree on, so it would have passed with the two
|
||||
// gates swapped — the exact regression it is named for.
|
||||
assert!(is_convar_name("sv_cheats"));
|
||||
assert!(is_convar_name("mp_roundtime_defuse"));
|
||||
assert!(is_convar_name("_internal.thing")); // leading underscore and a dot are both legal
|
||||
assert!(!is_convar_name(""));
|
||||
assert!(!is_convar_name("Set to 1 to enable cheats")); // help text, not a name
|
||||
assert!(!is_convar_name(&"x".repeat(MAX_NAME + 1)));
|
||||
|
||||
// The DISCRIMINATING cases — the ones that fail if the two gates are confused. A command may lead
|
||||
// with punctuation (the `+`/`-` on/off pairs); a convar may not, and admits no other punctuation.
|
||||
assert!(is_cmd_name("+bugvoice") && !is_convar_name("+bugvoice"));
|
||||
assert!(is_cmd_name("1st_arg") && !is_convar_name("1st_arg")); // digit-led
|
||||
assert!(is_cmd_name("say/all") && !is_convar_name("say/all"));
|
||||
}
|
||||
}
|
||||
44
src/elf.rs
44
src/elf.rs
|
|
@ -109,6 +109,21 @@ fn kind_tag_of(sym: &str) -> Option<KindTag> {
|
|||
const PT_GNU_EH_FRAME: u32 = 0x6474_e550;
|
||||
|
||||
impl CodeImage {
|
||||
/// A bare image wrapping one executable span — enough for a decoder test to run the REAL analysis
|
||||
/// over hand-assembled bytes instead of a parallel mock of it.
|
||||
#[cfg(test)]
|
||||
pub fn for_test(vaddr: u64, code: &[u8]) -> Self {
|
||||
Self {
|
||||
data: code.to_vec(),
|
||||
exec: vec![(0, vaddr, code.len())],
|
||||
secs: Vec::new(),
|
||||
sym_addr: HashMap::new(),
|
||||
reloc: HashMap::new(),
|
||||
reloc_by_val: HashMap::new(),
|
||||
kind_at: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
|
||||
Self::from_bytes(data)
|
||||
|
|
@ -325,6 +340,25 @@ impl CodeImage {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Allocated, initialised, WRITABLE data sections as `(vaddr, byte_len)` — `.data` and
|
||||
/// `.data.rel.ro`, where a module's static tables live. Returned as ranges rather than slices so
|
||||
/// callers keep reading through [`read_ptr`](Self::read_ptr) and get the relocated pointer values
|
||||
/// (a table of function pointers is relocation-driven; its raw file bytes are only incidentally
|
||||
/// correct).
|
||||
pub fn data_blocks(&self) -> Vec<(u64, usize)> {
|
||||
self.secs
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
s.flags & SHF_ALLOC != 0
|
||||
&& s.flags & SHF_WRITE != 0
|
||||
&& s.flags & SHF_EXECINSTR == 0
|
||||
&& s.typ != SHT_NOBITS
|
||||
&& s.off + s.size <= self.data.len()
|
||||
})
|
||||
.map(|s| (s.addr, s.size))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Relocation values that point into executable code — vtable slots and function pointers, i.e.
|
||||
/// a large set of real function entry addresses obtained without disassembling anything.
|
||||
pub fn code_pointer_targets(&self) -> Vec<u64> {
|
||||
|
|
@ -387,6 +421,16 @@ impl CodeImage {
|
|||
self.data_at(vaddr, 8).map(|b| u64le(b, 0))
|
||||
}
|
||||
|
||||
/// Does a relocation land ON this slot — i.e. is the qword here a POINTER the linker resolved,
|
||||
/// rather than a compile-time literal?
|
||||
///
|
||||
/// The distinction is what separates two records that are otherwise byte-compatible: a table of
|
||||
/// `{ name, integer }` pairs and a table of `{ name, pointer }` pairs read identically until you ask
|
||||
/// whether the second word was relocated.
|
||||
pub fn is_reloc_slot(&self, vaddr: u64) -> bool {
|
||||
self.reloc.contains_key(&vaddr)
|
||||
}
|
||||
|
||||
/// Slot vaddrs whose (relocated) pointer value equals `target`.
|
||||
pub fn ptrs_to(&self, target: u64) -> &[u64] {
|
||||
self.reloc_by_val.get(&target).map_or(&[], |v| v.as_slice())
|
||||
|
|
|
|||
30
src/lib.rs
30
src/lib.rs
|
|
@ -5,11 +5,16 @@
|
|||
//! out and scraping stdout.
|
||||
//!
|
||||
//! # Supported API surface
|
||||
//! A fork or embedder calls into these. Every engine entry point takes an explicit `&profile::GameProfile`
|
||||
//! (there is NO process-global — CS2 and Dota can be derived in the same process):
|
||||
//! A fork or embedder calls into these. Every engine entry point that needs game-specific knowledge takes
|
||||
//! an explicit `&profile::GameProfile` — there is NO process-global, so CS2 and Dota can be derived in the
|
||||
//! same process. (`classify_change_cmd` is the one exception, and takes none because it needs none: it
|
||||
//! 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), `fold_model_cmd` (roll model N → N+1), `backfill_cmd`
|
||||
//! (cross-build name/offset timelines), plus the `ClassScope` / `CorpusSource` inputs.
|
||||
//! `corpus_model_cmd` (distill the corpus model, taking a [`pipeline::ClassScope`]), `fold_model_cmd`
|
||||
//! (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`
|
||||
|
|
@ -20,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;
|
||||
|
|
@ -30,18 +37,27 @@ pub mod profile;
|
|||
|
||||
// ---- low-level engine (implementation detail; `pub` only for the fuzz harness, not a stable surface) ----
|
||||
pub mod abi;
|
||||
pub mod concmd;
|
||||
pub mod elf;
|
||||
pub mod emit;
|
||||
pub mod fingerprint;
|
||||
pub mod live;
|
||||
pub mod locate;
|
||||
pub mod par;
|
||||
pub mod prototypes;
|
||||
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};
|
||||
|
|
|
|||
225
src/live.rs
225
src/live.rs
|
|
@ -1,6 +1,15 @@
|
|||
//! Read-only window into a *running* CS2 server's memory — the runtime oracle that verifies the
|
||||
//! offline derivations against ground truth. No injection, no debugger: just `/proc/<pid>/mem` (needs
|
||||
//! ptrace access — same-user with `yama/ptrace_scope=0`, or `CAP_SYS_PTRACE`).
|
||||
//! Window into a *running* CS2 server — the runtime oracle that verifies the offline derivations against
|
||||
//! ground truth. Needs ptrace access (same-user with `yama/ptrace_scope=0`, or `CAP_SYS_PTRACE`).
|
||||
//!
|
||||
//! **Mostly reading, but not only reading, and the difference is worth stating plainly.** The bulk of this
|
||||
//! module reads `/proc/<pid>/mem`. Two things go further: `poke_bytes` writes bytes in with
|
||||
//! `PTRACE_POKEDATA`, and [`call_remote`] ATTACHES, saves the main thread's registers, builds a call frame
|
||||
//! and executes a function in the live process before restoring the thread exactly. Both exist because
|
||||
//! some claims cannot be checked any other way — a lazy-init singleton is zeroed until something calls its
|
||||
//! accessor — and both are used only against the narrow set of functions the derivation has already
|
||||
//! measured as safe to call (nullary, `this`-only, no game state). Nothing is injected and nothing
|
||||
//! persists: the process is left as it was found, and a faulting call is caught and the thread restored
|
||||
//! rather than allowed to kill the server.
|
||||
//!
|
||||
//! Offline we resolve `.rela.dyn` by hand to recover as-loaded pointer values; the running process is
|
||||
//! the authority on what those values actually are. So reading the same structures live and comparing
|
||||
|
|
@ -37,6 +46,19 @@ fn maps_path(line: &str) -> &str {
|
|||
rest.trim_start()
|
||||
}
|
||||
|
||||
/// The scheduler state character from `/proc/<pid>/stat` (`R`/`S`/`D`/`Z`/`T`/…), or `None` if the process
|
||||
/// is gone entirely. Parsed from AFTER the final `)`, because the comm field is parenthesised and may itself
|
||||
/// contain spaces and brackets — splitting the line on whitespace from the left gets this wrong for any
|
||||
/// process whose name has a space in it.
|
||||
fn proc_state(pid: u32) -> Option<char> {
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
stat[stat.rfind(')')? + 1..]
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.chars()
|
||||
.next()
|
||||
}
|
||||
|
||||
impl LiveProcess {
|
||||
pub fn attach(pid: u32) -> Result<Self> {
|
||||
let maps = std::fs::read_to_string(format!("/proc/{pid}/maps"))
|
||||
|
|
@ -80,10 +102,30 @@ impl LiveProcess {
|
|||
}
|
||||
}
|
||||
executable.sort_unstable();
|
||||
// Distinguish the two ways this fails, because they call for opposite responses and the kernel
|
||||
// reports BOTH as EACCES. If the process is gone, `/proc/<pid>` is gone with it — so check that
|
||||
// first: a server that CRASHED mid-derive otherwise reads as a permissions problem, and the
|
||||
// operator goes off tuning `ptrace_scope` for a fault that had nothing to do with it. (Seen: a
|
||||
// CS2 server crashed in Steam auth and this line blamed ptrace.)
|
||||
let mem = File::open(format!("/proc/{pid}/mem")).with_context(|| {
|
||||
format!(
|
||||
"open /proc/{pid}/mem — needs ptrace access (yama ptrace_scope=0 or run as root)"
|
||||
)
|
||||
match proc_state(pid) {
|
||||
// A crashed child stays a ZOMBIE until the parent reaps it, so `/proc/<pid>` still exists
|
||||
// and only `mem` is unreadable — an existence check alone reports it as a permissions
|
||||
// fault. Read the state instead.
|
||||
Some('Z') | None => format!(
|
||||
"the game process {pid} DIED during the live stage — it is {}, so there is nothing \
|
||||
left to read. This is NOT a ptrace-permission problem: check the server's own log \
|
||||
and /tmp/dumps for a minidump.",
|
||||
if proc_state(pid) == Some('Z') {
|
||||
"a zombie (crashed, not yet reaped)"
|
||||
} else {
|
||||
"gone"
|
||||
}
|
||||
),
|
||||
Some(_) => format!(
|
||||
"open /proc/{pid}/mem — needs ptrace access (yama ptrace_scope=0 or run as root)"
|
||||
),
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
mem,
|
||||
|
|
@ -209,14 +251,110 @@ pub struct CallResult {
|
|||
pub clean_return: bool,
|
||||
}
|
||||
|
||||
/// One argument to a remote call.
|
||||
///
|
||||
/// [`Arg::Scratch`] exists because a callee that takes a POINTER needs a structure to point at, and the
|
||||
/// address of that structure is not known until the call frame is laid out. Naming it relative to the
|
||||
/// scratch base lets the caller describe "argument 5 points at my blob" without knowing where the blob
|
||||
/// will land.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Arg {
|
||||
Val(u64),
|
||||
/// `scratch_base + addend`.
|
||||
Scratch(i64),
|
||||
}
|
||||
|
||||
/// A blob placed in the target's stack scratch before the call.
|
||||
pub struct Scratch<'a> {
|
||||
pub bytes: &'a [u8],
|
||||
/// `(offset, addend)` — write `scratch_base + addend` as a little-endian u64 at `offset` in the blob.
|
||||
/// This is how a pointer INSIDE the blob becomes absolute; an array-of-pointers argument is otherwise
|
||||
/// impossible to build, since every element has to name an address that does not exist yet.
|
||||
pub relocs: &'a [(usize, i64)],
|
||||
}
|
||||
|
||||
/// How long an injected call may run before it is abandoned and the thread restored.
|
||||
///
|
||||
/// Generous by design: every call site here is a nullary accessor or a `this`-only query, which returns in
|
||||
/// microseconds, so a second is four orders of magnitude of headroom and only a genuinely stuck callee
|
||||
/// reaches it. `clean_return: false` is then the honest verdict — the same one a faulting call gets.
|
||||
const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
/// Write `data` into the target at `addr`, a word at a time.
|
||||
///
|
||||
/// A trailing partial word is read back and merged rather than zero-filled: `PTRACE_POKEDATA` writes a
|
||||
/// whole word, so writing the tail without preserving the bytes past it would clobber memory the caller
|
||||
/// never asked to touch.
|
||||
unsafe fn poke_bytes(pid: i32, addr: u64, data: &[u8]) -> Result<()> {
|
||||
use anyhow::bail;
|
||||
let mut i = 0usize;
|
||||
while i < data.len() {
|
||||
let at = addr + i as u64;
|
||||
let n = (data.len() - i).min(8);
|
||||
let mut word = if n == 8 {
|
||||
[0u8; 8]
|
||||
} else {
|
||||
// PEEKDATA returns -1 both for an error and for a word whose value IS -1, so errno is the
|
||||
// only way to tell them apart and it must be cleared first.
|
||||
unsafe { *libc::__errno_location() = 0 };
|
||||
let cur = unsafe { libc::ptrace(libc::PTRACE_PEEKDATA, pid, at as usize, 0usize) };
|
||||
if cur == -1 && errno() != 0 {
|
||||
bail!("PEEKDATA at {at:#x} failed (errno {})", errno());
|
||||
}
|
||||
(cur as u64).to_le_bytes()
|
||||
};
|
||||
word[..n].copy_from_slice(&data[i..i + n]);
|
||||
let w = u64::from_le_bytes(word) as usize;
|
||||
if unsafe { libc::ptrace(libc::PTRACE_POKEDATA, pid, at as usize, w) } < 0 {
|
||||
bail!("POKEDATA at {at:#x} failed (errno {})", errno());
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call the function at runtime address `func` inside process `pid` with `args` (SysV: up to 6 in
|
||||
/// registers), via ptrace. Attaches, saves the main thread's registers, sets up a call frame whose
|
||||
/// return address is 0 (so the function traps on return, where we read RAX), runs it, then restores
|
||||
/// the thread exactly — the SIGSEGV from the return trap is suppressed. Needs ptrace permission
|
||||
/// (owned child, or same-user with ptrace_scope=0). UNSAFE: only call leaf-ish functions with valid args.
|
||||
pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
|
||||
let regs: Vec<Arg> = args.iter().map(|&v| Arg::Val(v)).collect();
|
||||
call_remote_ex(pid, func, ®s, &[], None)
|
||||
}
|
||||
|
||||
/// [`call_remote`] plus stack arguments and a scratch blob placed in the target.
|
||||
///
|
||||
/// Needed for callees that take more than six integer arguments or a pointer to a structure the caller has
|
||||
/// to build — neither of which the register-only form can express.
|
||||
///
|
||||
/// **Stack geometry**, descending from the interrupted `rsp`, chosen so three regions cannot collide:
|
||||
/// the 128-byte red zone is left alone (the interrupted frame lives there); the scratch blob sits at
|
||||
/// `rsp-1024`; the call frame starts at `rsp-2048`, so the callee's own stack — which grows DOWN from
|
||||
/// there — can never reach the scratch ABOVE it. Entry keeps SysV's `rsp % 16 == 8`, with the return
|
||||
/// address at `[rsp]` and stack argument *i* at `[rsp + 8 + 8i]`.
|
||||
pub fn call_remote_ex(
|
||||
pid: i32,
|
||||
func: u64,
|
||||
regs_in: &[Arg],
|
||||
stack_in: &[Arg],
|
||||
scratch: Option<Scratch<'_>>,
|
||||
) -> Result<CallResult> {
|
||||
use anyhow::bail;
|
||||
let dbg = std::env::var("SOURCE2ROSETTA_DBG").is_ok();
|
||||
if regs_in.len() > 6 {
|
||||
bail!("{} register arguments; SysV has 6", regs_in.len());
|
||||
}
|
||||
if let Some(s) = &scratch {
|
||||
// The blob lives in the 1 KiB between the frame and the red zone. Refuse rather than silently
|
||||
// overlap the call frame, which would corrupt the return address mid-call.
|
||||
if s.bytes.len() > 768 {
|
||||
bail!(
|
||||
"scratch blob is {} bytes; the reserved window is 768",
|
||||
s.bytes.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
unsafe {
|
||||
if libc::ptrace(libc::PTRACE_ATTACH, pid, 0usize, 0usize) < 0 {
|
||||
bail!(
|
||||
|
|
@ -249,6 +387,31 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
|
|||
// If we attached mid-syscall, orig_rax holds the syscall number and the kernel would run its
|
||||
// syscall-restart logic on our injected rip. Setting it to -1 says "no syscall in progress".
|
||||
regs.orig_rax = u64::MAX;
|
||||
|
||||
// Place the scratch blob first: every Arg::Scratch resolves against its base.
|
||||
let scratch_base = (saved.rsp - 1024) & !0xfu64;
|
||||
if let Some(s) = &scratch {
|
||||
let mut blob = s.bytes.to_vec();
|
||||
for &(off, addend) in s.relocs {
|
||||
let Some(dst) = blob.get_mut(off..off + 8) else {
|
||||
restore(&saved);
|
||||
bail!(
|
||||
"scratch reloc at {off} runs past the {}-byte blob",
|
||||
s.bytes.len()
|
||||
);
|
||||
};
|
||||
dst.copy_from_slice(&scratch_base.wrapping_add(addend as u64).to_le_bytes());
|
||||
}
|
||||
if let Err(e) = poke_bytes(pid, scratch_base, &blob) {
|
||||
restore(&saved);
|
||||
return Err(e.context(format!("placing scratch at {scratch_base:#x}")));
|
||||
}
|
||||
}
|
||||
let resolve = |a: Arg| match a {
|
||||
Arg::Val(v) => v,
|
||||
Arg::Scratch(addend) => scratch_base.wrapping_add(addend as u64),
|
||||
};
|
||||
|
||||
let slots = [
|
||||
&mut regs.rdi as *mut u64,
|
||||
&mut regs.rsi,
|
||||
|
|
@ -257,12 +420,12 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
|
|||
&mut regs.r8,
|
||||
&mut regs.r9,
|
||||
];
|
||||
for (i, &a) in args.iter().take(6).enumerate() {
|
||||
*slots[i] = a;
|
||||
for (i, &a) in regs_in.iter().enumerate() {
|
||||
*slots[i] = resolve(a);
|
||||
}
|
||||
// Scratch stack BELOW the 128-byte redzone so we never corrupt the interrupted frame; write a
|
||||
// Call frame well below the scratch, so the callee's downward stack growth cannot reach it. Write a
|
||||
// return address of 0 and keep SysV's `rsp % 16 == 8` at function entry.
|
||||
let mut sp = (saved.rsp - 512) & !0xfu64;
|
||||
let mut sp = (saved.rsp - 2048) & !0xfu64;
|
||||
sp -= 8;
|
||||
if libc::ptrace(libc::PTRACE_POKEDATA, pid, sp as usize, 0usize) < 0 {
|
||||
restore(&saved);
|
||||
|
|
@ -271,6 +434,17 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
|
|||
errno()
|
||||
);
|
||||
}
|
||||
// Stack arguments sit immediately above the return address, which is where the callee reads them.
|
||||
for (i, &a) in stack_in.iter().enumerate() {
|
||||
let at = sp + 8 + 8 * i as u64;
|
||||
if libc::ptrace(libc::PTRACE_POKEDATA, pid, at as usize, resolve(a) as usize) < 0 {
|
||||
restore(&saved);
|
||||
bail!(
|
||||
"POKEDATA(stack arg {i}) at {at:#x} failed (errno {})",
|
||||
errno()
|
||||
);
|
||||
}
|
||||
}
|
||||
let wrote = libc::ptrace(libc::PTRACE_PEEKDATA, pid, sp as usize, 0usize);
|
||||
regs.rsp = sp;
|
||||
regs.rip = func;
|
||||
|
|
@ -285,10 +459,37 @@ pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
|
|||
);
|
||||
}
|
||||
|
||||
// Run, absorbing any spurious signals, until the function returns into our null trap.
|
||||
// Run, absorbing any spurious signals, until the function returns into our null trap — or until
|
||||
// the deadline. BOUNDED, because the alternative is unbounded: the injected callee is chosen to
|
||||
// be leaf-ish, but "chosen to be" is not "proven to be", and one that blocks on a lock, a socket
|
||||
// or a condition variable would park this `waitpid` forever with the tracee STOPPED — hanging a
|
||||
// CI derive with no output and no timeout above it. A live check that cannot finish is a failed
|
||||
// live check, not a reason to stop the release from ever being decided.
|
||||
let deadline = std::time::Instant::now() + CALL_TIMEOUT;
|
||||
loop {
|
||||
libc::ptrace(libc::PTRACE_CONT, pid, 0usize, 0usize);
|
||||
if libc::waitpid(pid, &mut status, 0) < 0 || !libc::WIFSTOPPED(status) {
|
||||
// Polled rather than blocking, so the deadline is observable at all.
|
||||
let waited = loop {
|
||||
let r = libc::waitpid(pid, &mut status, libc::WNOHANG);
|
||||
if r != 0 {
|
||||
break r;
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break 0;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
};
|
||||
if waited == 0 {
|
||||
// Still RUNNING, so `restore` would fail ESRCH — stop it first, then put it back exactly.
|
||||
libc::kill(pid, libc::SIGSTOP);
|
||||
libc::waitpid(pid, &mut status, 0);
|
||||
restore(&saved);
|
||||
return Ok(CallResult {
|
||||
rax: 0,
|
||||
clean_return: false,
|
||||
});
|
||||
}
|
||||
if waited < 0 || !libc::WIFSTOPPED(status) {
|
||||
restore(&saved);
|
||||
bail!("target vanished mid-call (status {status:#x})");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,23 @@ use iced_x86::{Decoder, DecoderOptions, FlowControl, OpKind};
|
|||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Every plausible function ENTRY in the image, sorted and deduped: relocation code-pointers (every vtable
|
||||
/// slot, every stored function pointer) ∪ decoded `call` targets ∪ `.eh_frame` FDE starts.
|
||||
///
|
||||
/// The union is the point, and it is why this is one function rather than four lines repeated. CS2 strips
|
||||
/// `.eh_frame` from the game code — the FDE list covers the statically-linked runtime tail, roughly 8,327
|
||||
/// of libserver's ~70,000 functions — so an FDE-only list misses the entire gameplay region, while a
|
||||
/// relocation/call-target-only list misses the runtime tail that has no code pointer taken. Six callers
|
||||
/// need exactly this set: the xref index, the ConVar and VScript readers, the change digest, and both
|
||||
/// anchor passes. A fork adding PLT or ifunc entries edits here, once.
|
||||
pub fn function_entries(img: &CodeImage) -> Vec<u64> {
|
||||
let mut entries = candidate_entries(img);
|
||||
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
|
||||
entries.sort_unstable();
|
||||
entries.dedup();
|
||||
entries
|
||||
}
|
||||
|
||||
/// Every plausible function entry address in `img`: relocation values that point into code, plus
|
||||
/// the targets of direct near `call`s found by a linear sweep. Sorted, de-duplicated.
|
||||
pub fn candidate_entries(img: &CodeImage) -> Vec<u64> {
|
||||
|
|
|
|||
155
src/main.rs
155
src/main.rs
|
|
@ -1,5 +1,7 @@
|
|||
//! source2rosetta — CLI front-end. A thin clap layer over `source2rosetta::pipeline`: parse args,
|
||||
//! select the game profile, dispatch to the engine.
|
||||
//! source2rosetta — CLI front-end. A thin clap layer over BOTH engine halves —
|
||||
//! `source2rosetta::pipeline` (the offline derivation engine) and `source2rosetta::produce` (CI
|
||||
//! orchestration plus everything that drives a running server): parse args, select the game profile,
|
||||
//! dispatch.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
|
|
@ -53,12 +55,14 @@ enum Cmd {
|
|||
/// Server library to derive from; defaults to the active game's server lib.
|
||||
#[arg(long)]
|
||||
lib: Option<String>,
|
||||
/// Seconds to wait for the server to come up and bots to spawn alive.
|
||||
/// Seconds to wait for the server to come up and reach its readiness anchor — an alive bot pawn
|
||||
/// for a pawn game, a live `ready_class` instance otherwise.
|
||||
#[arg(long, default_value_t = 60)]
|
||||
wait: u64,
|
||||
#[arg(long)] // default resolved from the active game profile at dispatch
|
||||
map: Option<String>,
|
||||
/// Number of bots to fill the server with.
|
||||
/// Number of bots to fill the server with. A pawn-less game uses this only to size `-maxplayers`;
|
||||
/// nothing waits for a bot pawn there.
|
||||
#[arg(long, default_value_t = 9)]
|
||||
bots: u32,
|
||||
/// Optional gamedata json to also validate-live against the running server.
|
||||
|
|
@ -66,23 +70,28 @@ enum Cmd {
|
|||
gamedata: Option<PathBuf>,
|
||||
/// Write the validated (kept) gamedata here (with --gamedata) — so this one command owns the
|
||||
/// server AND persists the live-validated result, no separate validate-live needed.
|
||||
#[arg(long)]
|
||||
#[arg(long, requires = "gamedata")]
|
||||
out: Option<PathBuf>,
|
||||
/// Leave the launched server running instead of killing it after the test.
|
||||
#[arg(long)]
|
||||
keep: bool,
|
||||
/// With --gamedata, also run the LIVE fuzzer against this same server for N randomized probes
|
||||
/// (0 = off). Reuses the launched server — no separate `fuzz-live` run needed for CI.
|
||||
/// (0 = off). PAWN GAMES ONLY — a pawn-less game runs no live fuzz. Runs against the server THIS
|
||||
/// command launched: `integration-test` boots its own and does not attach to one `produce` left
|
||||
/// behind.
|
||||
#[arg(long, default_value_t = 500)]
|
||||
fuzz_iterations: usize,
|
||||
},
|
||||
/// The whole per-game build in ONE in-memory command: derive → fold → (if `--game-dir` is given)
|
||||
/// validate-live + typed netvars → fold model, writing the release set (`gamedata-`/`netvars-`/`model-`/
|
||||
/// `manifest`) into --out-dir. No per-stage intermediate files. **Pass `--game-dir` for a full,
|
||||
/// live-validated build; omit it for a fast OFFLINE build (gamedata + model only, no server).**
|
||||
/// validate-live + typed netvars → merge → fold model, writing the release set
|
||||
/// (`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 (gamedata + model only). The offline/full switch — no separate flag.
|
||||
/// OMIT for an offline build. The offline/full switch — no separate flag.
|
||||
#[arg(long = "game-dir")]
|
||||
game_dir: Option<PathBuf>,
|
||||
/// Dir holding the on-disk libs for make-sig + live validation (defaults to --game-dir, else --target).
|
||||
|
|
@ -91,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).
|
||||
|
|
@ -104,7 +114,7 @@ enum Cmd {
|
|||
corpus: Option<PathBuf>,
|
||||
/// Corpus-signal source B: a distilled `model-<game>.json` — forward-derives from the model + only the
|
||||
/// target binary (no corpus). Also triggers the sidecar fold (model N → N+1). See --corpus.
|
||||
#[arg(long)]
|
||||
#[arg(long, conflicts_with = "corpus")]
|
||||
corpus_model: Option<PathBuf>,
|
||||
/// The build DIRECTORY to DERIVE gamedata from — the primary input (its libs are searched by name).
|
||||
/// A bare `.so` path is not searched; pass the directory that contains it. REQUIRED.
|
||||
|
|
@ -112,23 +122,37 @@ 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.
|
||||
#[arg(long)]
|
||||
prototypes: Option<PathBuf>,
|
||||
/// Authored function descriptions (`mappings/semantics-<game>.json`), folded in beside each
|
||||
/// function. Static repo input, keyed on the NAME — omit and no function carries one.
|
||||
#[arg(long)]
|
||||
semantics: Option<PathBuf>,
|
||||
/// Valve's naming for the entity class behind each `PVAL_EHANDLE` Pulse parameter
|
||||
/// (`mappings/ehandle-classes.json`), propagated across the parameters this build's destructor
|
||||
/// addresses prove are the same type. Static repo input — omit and the bindings artifact simply
|
||||
/// states no class.
|
||||
#[arg(long)]
|
||||
ehandle_classes: Option<PathBuf>,
|
||||
/// Byte budget for signatures the FOLD generates (the extrapolated tiers). The derive's own
|
||||
/// `core` sigs use a separate fixed budget — this flag does not widen those.
|
||||
#[arg(long, default_value_t = 400)]
|
||||
|
|
@ -150,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,
|
||||
|
|
@ -174,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)]
|
||||
|
|
@ -218,7 +243,8 @@ enum Cmd {
|
|||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Classify how much a library changed between two builds — the CI branch primitive. Enumerates every
|
||||
/// function (`.eh_frame`) in each build and compares their bodies with the position-dependent bytes
|
||||
/// function in each build (relocation code-pointers ∪ decoded call targets ∪ `.eh_frame` starts —
|
||||
/// the FDE list alone covers ~12% of these binaries) and compares their bodies with the position-dependent bytes
|
||||
/// (RIP-relative displacements + near-branch targets) masked out, so the verdict is shift-invariant:
|
||||
/// a pure layout move (bodies unchanged, addresses shifted) reads as UNCHANGED, unlike a raw byte diff.
|
||||
/// Prints `skip` (nothing meaningful changed → no release), `normal` (an ordinary patch → re-derive) or
|
||||
|
|
@ -239,13 +265,15 @@ enum Cmd {
|
|||
lib: Option<String>,
|
||||
/// Extra `skip` tolerance: a changed-fraction below this also counts as `skip`. Default 0 —
|
||||
/// only a code-IDENTICAL build (0 functions changed) skips, so any real patch re-derives. Raise
|
||||
/// it (e.g. 0.01) to also skip changes under N%. (Calibration on 339 CS2 pairs: 311 are
|
||||
/// code-identical, real patches touch <=6 functions / <=0.08%, the 2 toolchain jumps are 34%/53%.)
|
||||
/// it (e.g. 0.01) to also skip changes under N%. The default is the one setting that does not
|
||||
/// depend on the calibration below: zero changed functions is zero at any denominator.
|
||||
#[arg(long, default_value_t = 0.0)]
|
||||
skip_below: f64,
|
||||
/// changed-fraction at or above this = `shift`. Default 0.20 — the CS2 corpus's real patches top
|
||||
/// out near 0.08% while its two toolchain jumps are 34%/53%, so 20% cleanly separates them with
|
||||
/// wide margin and (unlike 40%) doesn't misclassify the 34% jump as an ordinary patch.
|
||||
/// changed-fraction at or above this = `shift`. Default 0.20. Measured over 344 CS2 builds
|
||||
/// (~70,300 functions each): 82 are code-identical, the 252 ordinary patches run from 0.001% to
|
||||
/// 17.8% (median 0.12%), and the 9 toolchain jumps start at 22.4% and reach 93.8%. 0.20 sits in
|
||||
/// that gap — but the gap is ~4.6 points wide, not the wide margin an earlier calibration
|
||||
/// claimed, so recalibrate before trusting `shift` on another game or a re-cut corpus.
|
||||
#[arg(long, default_value_t = 0.20)]
|
||||
shift_above: f64,
|
||||
/// Emit a machine-readable JSON object instead of the human summary.
|
||||
|
|
@ -267,7 +295,8 @@ enum Cmd {
|
|||
/// code-identity collapses (any real change keeps the build code-distinct).
|
||||
#[arg(long, default_value_t = 0.0)]
|
||||
skip_below: f64,
|
||||
/// changed-fraction at or above this marks a toolchain shift = an era boundary (default 0.20).
|
||||
/// changed-fraction at or above this marks a toolchain shift = an era boundary (default 0.20; see
|
||||
/// `classify-change --shift-above` for what that number was measured against).
|
||||
#[arg(long, default_value_t = 0.20)]
|
||||
shift_above: f64,
|
||||
#[arg(long)]
|
||||
|
|
@ -284,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>,
|
||||
|
|
@ -356,6 +387,9 @@ fn main() -> Result<()> {
|
|||
full_names,
|
||||
extra_offsets,
|
||||
extra_sigs,
|
||||
prototypes,
|
||||
semantics,
|
||||
ehandle_classes,
|
||||
sig_cap,
|
||||
version,
|
||||
out_dir,
|
||||
|
|
@ -365,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 {
|
||||
|
|
@ -393,6 +430,9 @@ fn main() -> Result<()> {
|
|||
full_names: inputs.full_names.as_deref(),
|
||||
extra_offsets: inputs.extra_offsets.as_deref(),
|
||||
extra_sigs: inputs.extra_sigs.as_deref(),
|
||||
prototypes: prototypes.as_deref(),
|
||||
semantics: semantics.as_deref(),
|
||||
ehandle_classes: ehandle_classes.as_deref(),
|
||||
sig_cap,
|
||||
version: &version,
|
||||
out_dir: &out_dir,
|
||||
|
|
@ -480,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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2238
src/pipeline.rs
2238
src/pipeline.rs
File diff suppressed because it is too large
Load diff
1164
src/produce.rs
1164
src/produce.rs
File diff suppressed because it is too large
Load diff
151
src/profile.rs
151
src/profile.rs
|
|
@ -56,7 +56,13 @@ impl LaunchSpec {
|
|||
pub struct PawnAnchor {
|
||||
pub pawn_class: &'static str, // player-pawn RTTI class — the live-oracle instance anchor
|
||||
pub health_field: &'static str, // a reliable "is this instance alive" netvar
|
||||
pub is_player_pawn_slot: u64, // gamedata vtable offset of IsPlayerPawn (call-live smoke test)
|
||||
/// A RECORDED REFERENCE value for `IsPlayerPawn`'s vtable slot — cross-checked against, never called.
|
||||
///
|
||||
/// The live CALL test uses the slot THIS build derived and skips entirely when the build derived none;
|
||||
/// this constant only decides whether that run prints a "the slot moved, update me" note. It is not a
|
||||
/// fallback, and must not become one: the slot has taken six distinct values in ten months, and calling
|
||||
/// a stale index would inject a call to whatever now occupies it. A new game may record 0 until measured.
|
||||
pub is_player_pawn_slot: u64,
|
||||
}
|
||||
|
||||
pub struct GameProfile {
|
||||
|
|
@ -83,11 +89,96 @@ pub struct GameProfile {
|
|||
/// from is comparing different objects. A raise is a re-distill, not a config tweak — change it and the
|
||||
/// model together.
|
||||
pub max_vtable_slots: usize,
|
||||
/// Collapse tripwires, ONE PER INDEPENDENTLY-SHAPED TABLE the deriver reads out of the binary rather
|
||||
/// than deriving. Each is read by its own record shape, so a layout change Valve makes to one yields
|
||||
/// fewer records from that one alone — safe, but SILENT, and a release shipping zero of any of them at
|
||||
/// exit 0 is exactly the failure "degrades or stops loudly, never lies" exists to prevent.
|
||||
///
|
||||
/// Deliberately NOT one summed floor across all of them: a sum is satisfied by the tables that still
|
||||
/// work, so it cannot detect the single-table collapse it exists to catch. Set far below the observed
|
||||
/// count (collapse detectors, not tight bounds); a new game starts every field at 0 and gets no gate
|
||||
/// until someone measures one.
|
||||
pub min_pulse_bindings: usize,
|
||||
/// Bindings whose TYPED SIGNATURE was recovered from their descriptor initializer. Its own floor
|
||||
/// because it has its own failure mode: the registry can still read perfectly while the descriptor
|
||||
/// layout moves, and the result would be a release that ships every binding with no signature at all
|
||||
/// — a silent capability loss rather than a wrong answer, which is precisely what a floor is for.
|
||||
pub min_pulse_typed: usize,
|
||||
/// Floor on Pulse bindings whose invocation shim is HOST-CALLABLE (`call.needs == "args-only"`).
|
||||
/// Its own floor because it has its own failure mode: the registry can read perfectly and the
|
||||
/// signatures recover perfectly while a codegen change makes every shim appear to read another slot,
|
||||
/// which would silently retire the one callable tier instead of failing the release.
|
||||
pub min_pulse_callable: usize,
|
||||
pub min_entity_io: usize,
|
||||
pub min_entity_classes: usize,
|
||||
/// Console commands recovered from their registration calls. Its own floor because it has its own
|
||||
/// failure mode, and a quiet one: the registrar is identified by SHAPE (it opens by writing the
|
||||
/// invalid-handle sentinel), so a build that reworks that constructor yields zero commands rather
|
||||
/// than wrong ones — correct, and invisible without this.
|
||||
pub min_commands: usize,
|
||||
/// Floor on recovered ConVars. Its own floor because convar registration is identified by a DIFFERENT
|
||||
/// test from the command one — convergence of registrar wrappers on a shared core, not a sentinel in the
|
||||
/// callee — so it can fail while commands keep working.
|
||||
pub min_convars: usize,
|
||||
/// Floor on VScript bindings. Its own floor for the usual reason — a THIRD identification test,
|
||||
/// distinct from both the command sentinel and the convar convergence: a record base computed by the
|
||||
/// initialiser's own `idx*5 << 4 + [class+0x28]`. A codegen change that reshapes that arithmetic
|
||||
/// yields zero bindings while every other surface keeps reading perfectly.
|
||||
///
|
||||
/// Set well under the observed count, which is the house rule, but the margin here is deliberately
|
||||
/// wide: the reader recovers three distinct registration forms (the packed name pair, the
|
||||
/// `movddup` single-string form, and a base copied between registers), and losing any ONE of them
|
||||
/// would still clear a tight floor while quietly dropping a third of the surface.
|
||||
pub min_vscript: usize,
|
||||
/// Floor on VScript bindings attributed to an OWNING CLASS — and the only floor here that a full run
|
||||
/// checks and an offline one skips, because zero is correct by construction offline: the descriptor
|
||||
/// reaches its class through a register loaded from memory, so nothing static recovers it.
|
||||
///
|
||||
/// Separate from `min_vscript` because it fails independently and in the opposite direction. That floor
|
||||
/// guards the offline READER against a Valve reshape; this one guards the LIVE WALK — the string-anchor
|
||||
/// instance search, the owner read at the record's `+0x30`, the class-name read behind it. Any of those
|
||||
/// breaking leaves every binding recovered, described and located, with no class on any of them: a
|
||||
/// release that clears every other gate. It is `class` that `gen`'s `moddota` format GROUPS BY, so
|
||||
/// the artifact would ship intact while both of the files it writes came out empty.
|
||||
pub min_vscript_classed: usize,
|
||||
pub min_schema_enums: usize,
|
||||
/// Collapse floor for the recovered schema CLASS table — the largest table the deriver reads, and the
|
||||
/// one every other schema claim rests on: the artifact's whole `schema` section, the entity-output and
|
||||
/// datadesc joins, the derived type layouts, and `Identity::class_size`, which is half the identity
|
||||
/// check's conjunction.
|
||||
///
|
||||
/// It needs its own floor because nothing else covers it. `min_schema_enums` does not — `enumerate_enums`
|
||||
/// uses classes only to exclude field arrays, so it keeps passing at zero classes. The live oracle's
|
||||
/// class gate does not either: it is SKIPPED below `ORACLE_MIN_SAMPLE` checked classes, and the sample
|
||||
/// IS the class count, so a collapse into that range disables the check that would catch it. And the
|
||||
/// 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
|
||||
/// gap is structural rather than an oversight of one number: the live oracle gates a PASS RATE over
|
||||
/// entries that reached the gamedata document, and a signature that failed to resolve never enters it.
|
||||
/// So a derive that emits forty functions instead of four thousand passes at 100% — a stale corpus
|
||||
/// model, a `--target` from the wrong branch or a missing secondary library all land there.
|
||||
///
|
||||
/// A collapse detector, not a tight bound: set well below the observed count, like every sibling floor.
|
||||
pub min_core_functions: usize,
|
||||
/// Output game-key the game-keyed emitters use (Metamod `Games { <key> {..} }`, Plugify `{ "<key>": {..} }`).
|
||||
pub game_key: &'static str,
|
||||
/// The `--game` CLI token / per-release filename suffix (`cs2`, `dota2`) — distinct from `game_key` (the
|
||||
/// content-dir token `csgo`/`dota` that framework formats key on). Names the artifacts
|
||||
/// `gamedata-<token>.json` / `model-<token>.json` / `netvars-<token>.json`.
|
||||
/// `rosetta-<token>.json` / `model-<token>.json`.
|
||||
pub token: &'static str,
|
||||
/// Dedicated-server launcher binary under `bin/linuxsteamrt64/` (CS2: `cs2`).
|
||||
pub executable: &'static str,
|
||||
|
|
@ -108,7 +199,7 @@ pub struct GameProfile {
|
|||
pub soft_serializer: &'static [&'static str],
|
||||
/// Method-name prefixes for a this-only blind-callable boolean query — the live call-smoke-test gate.
|
||||
pub query_prefixes: &'static [&'static str],
|
||||
/// Live-oracle "famous field" spotlight: per class, the netvars whose live offsets `verify-live` prints
|
||||
/// Live-oracle "famous field" spotlight: per class, the netvars whose live offsets the oracle prints
|
||||
/// field-by-field (the ones mods actually read). CS2 gameplay fields on the generic `CBaseEntity`.
|
||||
pub spotlight_fields: &'static [(&'static str, &'static [&'static str])],
|
||||
/// Human-readable game name for the shipped gamedata banner.
|
||||
|
|
@ -159,6 +250,25 @@ pub const CS2: GameProfile = GameProfile {
|
|||
"libvscript.so",
|
||||
],
|
||||
max_vtable_slots: 2048,
|
||||
// observed: 580 Pulse, 715 inputs + 226 outputs, 474 entity classnames, 784 commands, 555 enums
|
||||
min_pulse_bindings: 300,
|
||||
min_pulse_typed: 300,
|
||||
min_pulse_callable: 90,
|
||||
min_entity_io: 400,
|
||||
min_entity_classes: 200,
|
||||
min_commands: 400,
|
||||
min_convars: 900,
|
||||
min_vscript: 180,
|
||||
// observed live: 271 of 300 bindings attributed across 24 classes
|
||||
min_vscript_classed: 150,
|
||||
min_schema_enums: 250,
|
||||
// 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",
|
||||
token: "cs2",
|
||||
executable: "cs2",
|
||||
|
|
@ -255,6 +365,24 @@ pub const DOTA: GameProfile = GameProfile {
|
|||
"libvscript.so",
|
||||
],
|
||||
max_vtable_slots: 2048,
|
||||
// observed: 500 Pulse, 624 inputs, 3,528 entity classnames, 855 commands, 743 enums
|
||||
min_pulse_bindings: 250,
|
||||
min_pulse_typed: 250,
|
||||
min_pulse_callable: 65,
|
||||
min_entity_io: 300,
|
||||
min_entity_classes: 1000,
|
||||
min_commands: 400,
|
||||
min_convars: 600,
|
||||
min_vscript: 1200,
|
||||
// observed live: 1,638 of 1,841 bindings attributed across 63 classes
|
||||
min_vscript_classed: 900,
|
||||
min_schema_enums: 350,
|
||||
// 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",
|
||||
token: "dota2",
|
||||
executable: "dota2", // bin/linuxsteamrt64/dota2
|
||||
|
|
@ -336,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
|
||||
|
|
|
|||
948
src/prototypes.rs
Normal file
948
src/prototypes.rs
Normal file
|
|
@ -0,0 +1,948 @@
|
|||
//! Join DECLARED prototypes to the MEASURED register footprint, and judge each one against the binary.
|
||||
//!
|
||||
//! Gamedata says WHERE a function is; it never says what it takes. Types cannot be recovered from a
|
||||
//! stripped binary, so they have to come from a declaration — and a declaration has to be checked before
|
||||
//! anything calls through it, because a stale one produces a call that resolves, passes live validation,
|
||||
//! and then loads the wrong registers. That check is the point of this module: the declared parameter
|
||||
//! list is converted to a SysV register footprint and compared against the footprint `abi` measured in
|
||||
//! the build being shipped.
|
||||
//!
|
||||
//! The declarations are STATIC input (`mappings/prototypes.json`) rather than rolling state — they are
|
||||
//! never folded forward, so unlike the model they live in the repository and need no baseline mechanism.
|
||||
//! What moves per build is the measurement they are judged against.
|
||||
|
||||
use crate::model;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::Path;
|
||||
|
||||
// The provenance ids this module READS are the ones the pipeline STAMPS, imported rather than re-spelled:
|
||||
// they are one fact — which evidence named the function — and two copies of it "kept in step" by a
|
||||
// comment is an invariant nothing enforces. A drift there would silently stop matching, and a prototype
|
||||
// that stops matching does not fail; it simply stops being claimed.
|
||||
use crate::pipeline::{VALVE_CONCOMMAND, VALVE_DATADESC, VALVE_VSCRIPT};
|
||||
|
||||
/// What the manifest calls a prototype that came from how the ENGINE invokes the function rather than
|
||||
/// from anyone's declaration of it. Declared here because only this module states it.
|
||||
const ENGINE_CONTRACT: &str = "engine-contract";
|
||||
|
||||
/// The prototype the engine invokes EVERY entity-IO handler through. Kept in step with
|
||||
/// `pipeline::IO_HANDLER_INT_ARGS` / `within_io_prototype`, which measure this same claim on every
|
||||
/// derive as a standing oracle — two halves of one fact, one asserting it and one checking it.
|
||||
const ENGINE_CONTRACT_PARAMS: [&str; 2] = ["CEntityInstance*", "InputData_t&"];
|
||||
|
||||
/// What the engine passes EVERY console-command callback, whatever form it takes.
|
||||
const CONCOMMAND_PARAMS: [&str; 2] = ["CCommandContext*", "CCommand*"];
|
||||
|
||||
/// The engine's dispatch contract for a console command, or `None` if `source` is not one.
|
||||
///
|
||||
/// The second contract in this module, and it needs the FORM where the entity-IO one needs nothing: a
|
||||
/// `direct` registration passes a plain function, while the two object forms dispatch through a
|
||||
/// receiver, so they take one more integer register. Declaring them all the same way would be wrong in
|
||||
/// whichever direction it erred — the 2-argument list makes every object form a `mismatch`, and the
|
||||
/// 3-argument list is judged as a lower bound, so it would quietly VERIFY a receiver that a direct
|
||||
/// handler does not have and hand a caller a prototype with a bogus leading argument.
|
||||
///
|
||||
/// Kept in step with `pipeline::concmd_int_args`, which measures this same claim on every derive as a
|
||||
/// standing oracle — two halves of one fact, one asserting it and one checking it.
|
||||
fn concommand_contract(source: &str) -> Option<Vec<String>> {
|
||||
let form = source
|
||||
.strip_prefix(VALVE_CONCOMMAND)
|
||||
.and_then(|r| r.strip_prefix(':'))?;
|
||||
let receiver = match form {
|
||||
"direct" => None,
|
||||
// The interface form's receiver is the callback interface itself; the member form's is whatever
|
||||
// object the registering constructor was building, which the binary does not name. `void*` says
|
||||
// "a receiver, type unknown" — the honest claim, and the one `most_specific` already ranks last.
|
||||
"interface" => Some("ICommandCallback*"),
|
||||
"member" => Some("void*"),
|
||||
// A form this build introduced and this code has never measured claims NOTHING.
|
||||
_ => return None,
|
||||
};
|
||||
Some(
|
||||
receiver
|
||||
.into_iter()
|
||||
.chain(CONCOMMAND_PARAMS)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Read the DIRECTION of a footprint disagreement, and decide what it means.
|
||||
///
|
||||
/// Split out because it is the whole content of the `mismatch` verdict, and it has to be callable: the
|
||||
/// tests used to re-implement this rule rather than call it, so the regression guard could only fail if
|
||||
/// someone edited both copies the same wrong way. One definition, two callers.
|
||||
///
|
||||
/// Only an over-READ refutes a declaration. `declared_over` alone is the documented LOWER-BOUND case —
|
||||
/// calling through it loads a register nobody reads, which is safe — while `measured_over` means the
|
||||
/// callee reads a register the declaration never mentions, which is not. `both` stays a mismatch: a
|
||||
/// class where the callee reads more is unsafe regardless of another class where it reads fewer. 81 of
|
||||
/// CS2's 140 former mismatches were the safe direction, reported as "does not describe this build".
|
||||
fn adjudicate_mismatch(
|
||||
chosen: &Candidate,
|
||||
s: &model::AbiShape,
|
||||
types: Option<&BTreeMap<String, model::TypeLayout>>,
|
||||
) -> (model::AbiStatus, &'static str) {
|
||||
let (i, f) = footprint(chosen.params, types);
|
||||
// The direction has to be read through the SAME allowance the verdict was, or the invisible `this`
|
||||
// reads as an over-count on its own: `CGameEvent::GetFloat` is declared `(char const*, float)` and
|
||||
// measures `int=2 float=0`, where the extra integer register is the receiver and the only real
|
||||
// disagreement is the float.
|
||||
let i = if chosen.complete {
|
||||
i
|
||||
} else {
|
||||
(i..=i + 1)
|
||||
.min_by_key(|d| d.abs_diff(s.int as usize))
|
||||
.expect("the range always has two elements")
|
||||
};
|
||||
let (i, f) = (i.min(6), f.min(8));
|
||||
let measured_over = s.int as usize > i || s.float as usize > f;
|
||||
let declared_over = i > s.int as usize || f > s.float as usize;
|
||||
let status = if declared_over && !measured_over {
|
||||
model::AbiStatus::LowerBound
|
||||
} else {
|
||||
model::AbiStatus::Mismatch
|
||||
};
|
||||
let note = match (measured_over, declared_over) {
|
||||
(true, true) => {
|
||||
"measured and declared footprints disagree in BOTH directions, in different register \
|
||||
classes: the callee reads a register the declaration does not mention AND the declaration \
|
||||
passes one the callee never reads"
|
||||
}
|
||||
(false, true) => {
|
||||
"the declaration passes registers the callee never reads, and contradicts it in no register \
|
||||
class — the measured footprint is a documented LOWER bound, so this is expected rather than \
|
||||
evidence against the declaration"
|
||||
}
|
||||
(true, false) => {
|
||||
"measured footprint EXCEEDS declared: the callee reads a register the declaration does not \
|
||||
mention, so this declaration does not describe this build"
|
||||
}
|
||||
_ => "the footprints disagree in neither direction, which a mismatch cannot be",
|
||||
};
|
||||
(status, note)
|
||||
}
|
||||
|
||||
/// One declared prototype as the frozen input records it.
|
||||
#[derive(Deserialize)]
|
||||
struct Decl {
|
||||
/// Absent where the source declared a return type but no parameter list — a `CALL_VIRTUAL(RET, …)`
|
||||
/// site passes VALUES, not types, so it says what comes back and nothing about what goes in. Such a
|
||||
/// declaration contributes a return type and never a signature candidate.
|
||||
#[serde(default)]
|
||||
params: Option<Vec<String>>,
|
||||
/// The parameter list is the FULL register-visible argument list, receiver included — a real
|
||||
/// function-pointer type rather than a mangled symbol. Those arities are matched EXACTLY; see
|
||||
/// [`agrees`] for why the alternative has to allow ±1.
|
||||
#[serde(default)]
|
||||
complete: bool,
|
||||
#[serde(rename = "const")]
|
||||
is_const: bool,
|
||||
provenance: String,
|
||||
/// Present only where the source could supply one — Itanium mangling omits return types, so the
|
||||
/// macOS-symbol majority has none.
|
||||
#[serde(default)]
|
||||
ret: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PrototypeDoc {
|
||||
prototypes: BTreeMap<String, Vec<Decl>>,
|
||||
/// Bare method names borne by exactly ONE qualified declaration — computed over the FULL declaration
|
||||
/// set, before pruning. It has to be: pruning removes declarations, so a name borne by dozens
|
||||
/// (`IAppSystem::GetTier`, `Reconnect`, `IsSingleton`) can look unique among what survives, and
|
||||
/// deriving uniqueness from the pruned map would re-open exactly the wrong-class matching the
|
||||
/// bare-name gate exists to prevent.
|
||||
#[serde(default)]
|
||||
bare_unique: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// The by-value SysV cost of the few engine math types, for the case where the derived layouts are not
|
||||
/// available (an offline run has no typed schema, so no `types` section).
|
||||
///
|
||||
/// This is a FALLBACK, not the source of truth. Every entry is reproduced exactly by the derived layouts
|
||||
/// (`Vector` is 12 bytes and SSE, which is two registers), so the two paths agree on everything it
|
||||
/// covers, and the derived path also answers the ~1,960 types it does not.
|
||||
///
|
||||
/// Measured: on the current declaration set NONE of these six ever reaches here, because every `Vector`
|
||||
/// in a declared prototype is a `Vector const&` or a `Vector*` and the pointer/reference test above
|
||||
/// catches it first. The table is kept anyway — it costs nothing, and a by-value math argument is exactly
|
||||
/// the case whose misclassification manufactured false mismatches in an early pass.
|
||||
const FALLBACK_SSE: &[(&str, usize)] = &[
|
||||
("Vector", 2),
|
||||
("QAngle", 2),
|
||||
("Vector2D", 1),
|
||||
("Vector4D", 2),
|
||||
("Quaternion", 2),
|
||||
("RadianEuler", 2),
|
||||
];
|
||||
|
||||
/// SysV register cost of ONE declared parameter, as `(integer, float)`.
|
||||
///
|
||||
/// The classification is not lexical, which is the trap this encodes: a pointer or a reference travels in
|
||||
/// an INTEGER register whatever it points at, while a small all-float aggregate travels in SSE registers —
|
||||
/// `Vector` is 3 floats, so it costs TWO SSE registers by value but ONE integer register by reference.
|
||||
/// Treating `Vector` as integer either way manufactures false mismatches.
|
||||
///
|
||||
/// Where the deriver's own type layouts are available they decide, because they answer this question for
|
||||
/// EVERY type rather than the handful anyone thought to tabulate: a size settles the memory case, and the
|
||||
/// derived SysV class settles the register case.
|
||||
fn classify(ty: &str, types: Option<&BTreeMap<String, model::TypeLayout>>) -> (usize, usize) {
|
||||
let t = ty.replace("const", "");
|
||||
let t = t.trim();
|
||||
if t.contains('*') || t.contains('&') {
|
||||
return (1, 0);
|
||||
}
|
||||
let base = t.split('<').next().unwrap_or(t).trim();
|
||||
if base == "float" || base == "double" {
|
||||
return (0, 1);
|
||||
}
|
||||
if let Some(l) = types.and_then(|m| m.get(base)) {
|
||||
// Eightbyte count — SysV assigns a register per 8 bytes of an aggregate small enough to travel
|
||||
// in them.
|
||||
let regs = l.size.div_ceil(8);
|
||||
return match l.sysv {
|
||||
model::SysvClass::Sse => (0, regs),
|
||||
model::SysvClass::Integer => (regs, 0),
|
||||
// Above the register budget an argument is copied to the STACK and consumes no register at
|
||||
// all — which the footprint comparison should see as zero, not as one.
|
||||
model::SysvClass::Memory => (0, 0),
|
||||
// Size known, composition not. Fall through to the assumption below rather than inventing a
|
||||
// classification the data does not support.
|
||||
model::SysvClass::Unknown => (1, 0),
|
||||
};
|
||||
}
|
||||
if let Some((_, n)) = FALLBACK_SSE.iter().find(|(k, _)| *k == base) {
|
||||
return (0, *n);
|
||||
}
|
||||
(1, 0)
|
||||
}
|
||||
|
||||
/// The declared parameter list's total register footprint.
|
||||
fn footprint(
|
||||
params: &[String],
|
||||
types: Option<&BTreeMap<String, model::TypeLayout>>,
|
||||
) -> (usize, usize) {
|
||||
params.iter().fold((0, 0), |(i, f), p| {
|
||||
let (a, b) = classify(p, types);
|
||||
(i + a, f + b)
|
||||
})
|
||||
}
|
||||
|
||||
/// Does a declared parameter list agree with the footprint measured in the binary?
|
||||
///
|
||||
/// One allowance is unconditional and is a property of the ABI rather than slack: only six integer
|
||||
/// argument registers exist, so a declared arity above six is compared as `min(n, 6)`.
|
||||
///
|
||||
/// The second is conditional, and that condition matters. Where the declaration came from a mangled
|
||||
/// symbol, `this` is invisible — a non-static member function and a static one mangle identically — so
|
||||
/// both `n` and `n + 1` have to be accepted, which is why some entries verify "only as static". A
|
||||
/// COMPLETE declaration is a function-pointer type that already names its receiver, so the same allowance
|
||||
/// there is pure slack that hides real staleness: `IScriptVM::CreateVM` is declared with one argument and
|
||||
/// measures two, and `SoundOpGameSystem::StopSoundEvent` is declared with two and measures three. Both
|
||||
/// would pass under `n + 1` while being exactly the case this manifest exists to catch.
|
||||
///
|
||||
/// The third case inverts the question. The engine's own dispatch contract cannot be stale, so equality
|
||||
/// is the wrong test for it: the measured footprint is a documented LOWER bound (a handler that ignores
|
||||
/// its `InputData_t&` reads one register, a forwarding thunk none), and 49 of CS2's 205 contract-only
|
||||
/// handlers measure fewer than the two the engine always passes. Only an over-count refutes it, which is
|
||||
/// the direction that would mean a caller loads a register the callee never reads.
|
||||
fn agrees(
|
||||
c: &Candidate,
|
||||
sh: &model::AbiShape,
|
||||
types: Option<&BTreeMap<String, model::TypeLayout>>,
|
||||
) -> bool {
|
||||
let (i, f) = footprint(c.params, types);
|
||||
if c.contract {
|
||||
// A by-value return is the one over-count the register counts cannot show: the caller passes a
|
||||
// hidden output pointer as argument 0 and every other argument shifts, which a `void` contract
|
||||
// says does not happen. `pipeline::within_io_prototype` rejects it for the same reason, so
|
||||
// checking it here keeps the manifest and the standing oracle from ever disagreeing. Empty on
|
||||
// both games today — the oracle reports 715/715 and 624/624 — which is why it is a guard.
|
||||
return sh.ret != "ret=byval"
|
||||
&& i.min(6) >= sh.int as usize
|
||||
&& f.min(8) >= sh.float as usize;
|
||||
}
|
||||
if f.min(8) != sh.float as usize {
|
||||
return false;
|
||||
}
|
||||
if c.complete {
|
||||
return i.min(6) == sh.int as usize;
|
||||
}
|
||||
[1usize, 0]
|
||||
.iter()
|
||||
.any(|t| (i + t).min(6) == sh.int as usize)
|
||||
}
|
||||
|
||||
/// One signature the declarations offer, and the convention it is written in.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Candidate<'a> {
|
||||
params: &'a Vec<String>,
|
||||
complete: bool,
|
||||
/// Carried from the declaration that offered this signature, so the chosen one's own const-ness
|
||||
/// travels with it rather than being taken from whichever declaration happened to be listed first.
|
||||
is_const: bool,
|
||||
/// Likewise the return type. Taking it from "the first declaration that has one" would pair the
|
||||
/// ACCEPTED parameter list with a REJECTED declaration's return — two sources describing one
|
||||
/// function, reported as though they were one description.
|
||||
ret: Option<&'a String>,
|
||||
/// This is the ENGINE'S dispatch contract rather than something a source declared, and both of its
|
||||
/// consequences follow from that one fact — it describes how the function is INVOKED, not what
|
||||
/// somebody believed about it. It cannot go stale, so [`agrees`] judges it as a lower bound; and it
|
||||
/// is reported as `matched_by: engine-contract`, so a consumer can tell "the engine calls it this
|
||||
/// way" from "someone wrote this down".
|
||||
contract: bool,
|
||||
}
|
||||
|
||||
/// Every DISTINCT signature a declaration set offers. Two declarations that write the same parameter
|
||||
/// list in the same convention are one candidate, not an overload.
|
||||
fn collect_candidates<'a>(decls: &'a [Decl], cands: &mut Vec<Candidate<'a>>) {
|
||||
for d in decls {
|
||||
if let Some(p) = d.params.as_ref()
|
||||
&& !cands
|
||||
.iter()
|
||||
.any(|c| c.params == p && c.complete == d.complete)
|
||||
{
|
||||
cands.push(Candidate {
|
||||
params: p,
|
||||
complete: d.complete,
|
||||
is_const: d.is_const,
|
||||
ret: d.ret.as_ref(),
|
||||
contract: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick between signatures that the measurement cannot separate: prefer the one that names its receiver,
|
||||
/// then the one that says the most (`void*` is the least specific thing a declaration can write), then
|
||||
/// alphabetically so the artifact is stable.
|
||||
fn most_specific<'a>(cands: &[Candidate<'a>]) -> Candidate<'a> {
|
||||
*cands
|
||||
.iter()
|
||||
.min_by_key(|c| {
|
||||
(
|
||||
!c.complete,
|
||||
c.params
|
||||
.iter()
|
||||
.filter(|p| p.replace(' ', "") == "void*")
|
||||
.count(),
|
||||
c.params.clone(),
|
||||
)
|
||||
})
|
||||
.expect("caller guarantees a non-empty set")
|
||||
}
|
||||
|
||||
/// Build the prototype manifest for one build: every shipped function that a declaration names, with the
|
||||
/// verdict its own binary gives that declaration.
|
||||
pub fn build_manifest(
|
||||
prototypes: &Path,
|
||||
mono: &model::Monolith,
|
||||
types: Option<&BTreeMap<String, model::TypeLayout>>,
|
||||
vscript_ret: Option<&BTreeMap<String, String>>,
|
||||
) -> Result<model::AbiManifest> {
|
||||
let doc: PrototypeDoc = serde_json::from_str(
|
||||
&std::fs::read_to_string(prototypes)
|
||||
.with_context(|| format!("read {}", prototypes.display()))?,
|
||||
)
|
||||
.context("parse prototypes json")?;
|
||||
|
||||
// Bare method name -> the qualified names declaring it, among the names present here. Uniqueness is
|
||||
// NOT decided from this map — see `bare_unique`.
|
||||
let mut by_bare: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||
for name in doc.prototypes.keys() {
|
||||
by_bare
|
||||
.entry(name.rsplit("::").next().unwrap_or(name))
|
||||
.or_default()
|
||||
.insert(name.as_str());
|
||||
}
|
||||
|
||||
// Bare method name -> how many SHIPPED functions bear it. The declaration side alone cannot gate
|
||||
// bare-name matching: uniqueness there says only that one declaration offers the name, never that
|
||||
// one function ANSWERS to it, and 46 bare names are borne by several shipped functions at once.
|
||||
// Without this, a single declaration is handed to every one of them — measured, and it shipped
|
||||
// `CTakeDamageInfo::Constructor` as `verified` taking a `CCSGameRules*`, because both footprints
|
||||
// are one pointer. The name has to be unique on BOTH sides or nothing can say which function the
|
||||
// declaration describes.
|
||||
let mut shipped_bare: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
for name in mono.core.keys().chain(mono.high_confidence.keys()) {
|
||||
*shipped_bare
|
||||
.entry(name.rsplit("::").next().unwrap_or(name))
|
||||
.or_default() += 1;
|
||||
}
|
||||
|
||||
// Measurements come from every tier: an experimental entry's shape is still a fact about the binary.
|
||||
let shapes: BTreeMap<&str, &model::AbiShape> =
|
||||
[&mono.core, &mono.high_confidence, &mono.experimental]
|
||||
.into_iter()
|
||||
.flat_map(|m| m.iter())
|
||||
.filter_map(|(n, e)| e.abi.as_ref().map(|a| (n.as_str(), a)))
|
||||
.collect();
|
||||
|
||||
let mut functions: BTreeMap<String, model::AbiEntry> = BTreeMap::new();
|
||||
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut bump = |k: &str| *counts.entry(k.to_string()).or_default() += 1;
|
||||
|
||||
for (tier, section) in [
|
||||
("core", &mono.core),
|
||||
("high_confidence", &mono.high_confidence),
|
||||
] {
|
||||
for name in section.keys() {
|
||||
let sh = shapes.get(name.as_str()).copied();
|
||||
let exact = doc.prototypes.get(name);
|
||||
// A bare name is claimed ONLY when exactly one qualified declaration bears it, exactly one
|
||||
// SHIPPED function bears it, and the binary can arbitrate. `SetAbsAngles` exists on many
|
||||
// classes; matching by bare name without a measurement to adjudicate is how an early pass
|
||||
// invented most of its mismatches, and matching without the shipped-side count is how one
|
||||
// declaration gets handed to a dozen unrelated functions.
|
||||
// NOT for a console command: `ConCommand::status` has no C++ method called `status`, so the
|
||||
// tail is a console name that merely looks like one. Matching it would hand an unrelated
|
||||
// declaration to a command handler on a pure spelling coincidence — and the uniqueness gate
|
||||
// cannot catch it, because the command IS the only shipped bearer of that bare name.
|
||||
let bare = name.rsplit("::").next().unwrap_or(name);
|
||||
let by_bare_hit = by_bare
|
||||
.get(bare)
|
||||
.filter(|_| !name.starts_with("ConCommand::"))
|
||||
.filter(|h| {
|
||||
h.len() == 1
|
||||
&& doc.bare_unique.contains(bare)
|
||||
&& shipped_bare.get(bare) == Some(&1)
|
||||
&& sh.is_some()
|
||||
})
|
||||
.map(|h| &doc.prototypes[*h.iter().next().unwrap()]);
|
||||
|
||||
// The ENGINE'S OWN dispatch contract, which is a declaration and a stronger one than any
|
||||
// third-party header: an entity-IO handler is only ever invoked through
|
||||
// `void(CEntityInstance*, InputData_t&)`. It states the WHOLE prototype, and both halves
|
||||
// matter for the same reason — nobody has to have written this function down for it to be
|
||||
// known, because the engine's dispatch settles it.
|
||||
//
|
||||
// The return is where the alternative is weakest: the measured register class is wrong
|
||||
// about known-void functions roughly seven times in eight, because a callee cannot tell
|
||||
// whether its caller reads RAX and scratch use reads back as `ret=int`. The parameters are
|
||||
// where the COVERAGE is: 205 CS2 handlers that no declaration names get a real, checkable
|
||||
// signature instead of a return and a shrug. The deriver already establishes which names
|
||||
// came from the datadesc; this is that fact reaching the manifest.
|
||||
//
|
||||
// Two contracts reach this point now: the entity-IO one above, and the console-command one
|
||||
// (see `concommand_contract`), which the engine states just as firmly and which covers 755
|
||||
// more CS2 names that no declaration anywhere describes.
|
||||
let src = section
|
||||
.get(name)
|
||||
.and_then(|e| e.provenance.source.as_deref());
|
||||
let contract_params: Vec<String> = match src {
|
||||
Some(VALVE_DATADESC) => ENGINE_CONTRACT_PARAMS.map(str::to_string).to_vec(),
|
||||
Some(s) => concommand_contract(s).unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let is_contract = !contract_params.is_empty();
|
||||
let contract_ret = is_contract.then(|| "void".to_string());
|
||||
|
||||
// The slot a vtable-offset locator resolves through, and ONLY when the live oracle
|
||||
// confirmed it (`OffVerdict::Live`). Recorded on every verdict rather than only the ones
|
||||
// that pass the emitters' gate, because it describes the LOCATOR, not the declaration —
|
||||
// and it is the one piece of evidence that settles a receiver the footprint cannot see.
|
||||
// See `model::AbiEntry::vtable` for why validation is part of the condition.
|
||||
let vtable = section
|
||||
.get(name)
|
||||
.filter(|e| e.validated == Some(true))
|
||||
.and_then(|e| e.locator.offset);
|
||||
|
||||
let decls: &[Decl] = exact.or(by_bare_hit).map_or(&[][..], |v| v.as_slice());
|
||||
|
||||
// The SCRIPT VM'S OWN declared return, for a name the registry states. It ranks above the
|
||||
// measured register class for the reason spelled out below: a callee cannot tell whether its
|
||||
// caller reads RAX, so measurement is wrong about known-void functions roughly seven times
|
||||
// in eight — and `void` is what the registry declares for 849 of Dota's bindings, which is
|
||||
// exactly the population measurement gets wrong. It ranks BELOW a real declaration only to
|
||||
// keep "a source wrote this down" ahead of anything derived; in practice the two never
|
||||
// compete, because no VScript name is also a declared name (measured: zero overlap).
|
||||
//
|
||||
// Read BEFORE the gate below, not after: a registry-declared return is on its own enough to
|
||||
// have something to say about a function, so a name carrying one must not be skipped for
|
||||
// having no parameter list. That is precisely the `return-only` case.
|
||||
let vs_ret = vscript_ret.and_then(|m| m.get(name)).cloned();
|
||||
let has_vs_ret = vs_ret.is_some();
|
||||
|
||||
if decls.is_empty() && !is_contract && !has_vs_ret {
|
||||
bump(&format!("{tier}:none"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Any DECLARED return type on offer, used only where the chosen signature carries none of
|
||||
// its own (a return-only declaration has no signature to choose). A source declaration is
|
||||
// preferred over the engine contract only because it is the more specific claim; they
|
||||
// disagree on exactly one function in the current set. The measured register class is the
|
||||
// last resort — a much weaker statement, labelled as such in the artifact's own docs.
|
||||
let any_ret = decls.iter().find_map(|d| d.ret.clone());
|
||||
let contract = contract_ret.clone();
|
||||
let fallback_ret = move || {
|
||||
any_ret
|
||||
.clone()
|
||||
.or(contract)
|
||||
.or(vs_ret)
|
||||
.or_else(|| sh.map(|s| s.ret.clone()))
|
||||
};
|
||||
let mut provenance: Vec<String> = decls
|
||||
.iter()
|
||||
.map(|d| d.provenance.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
if is_contract {
|
||||
provenance.push(ENGINE_CONTRACT.to_string());
|
||||
}
|
||||
if has_vs_ret {
|
||||
provenance.push(VALVE_VSCRIPT.to_string());
|
||||
}
|
||||
|
||||
// The contract goes in FIRST, so that where it and a declaration both fit the measurement,
|
||||
// `most_specific` reports the one that names its receiver — which the contract always does
|
||||
// and a mangled symbol never can. Nothing is lost by that: `overloads` lists every
|
||||
// signature that was on offer, including the more specific class a source may have named.
|
||||
let mut cands: Vec<Candidate> = Vec::new();
|
||||
if is_contract {
|
||||
cands.push(Candidate {
|
||||
params: &contract_params,
|
||||
complete: true,
|
||||
is_const: false,
|
||||
ret: None,
|
||||
contract: true,
|
||||
});
|
||||
}
|
||||
collect_candidates(decls, &mut cands);
|
||||
// `bare-name` is a CLAIM — "one declaration bears this method name and the measurement could
|
||||
// adjudicate" — so it must not be the fallback for an entry that was never name-matched at
|
||||
// all. A registry-declared return with no declaration behind it is neither exact nor
|
||||
// bare-name; it is the script VM stating its own contract, and it says so.
|
||||
let matched_by = if exact.is_some() {
|
||||
"exact"
|
||||
} else if decls.is_empty() && has_vs_ret {
|
||||
VALVE_VSCRIPT
|
||||
} else {
|
||||
"bare-name"
|
||||
};
|
||||
// Nothing here declares a parameter list — the source said what comes back and stayed silent
|
||||
// about what goes in. There is no arity claim, so there is nothing for the binary to confirm
|
||||
// or refute, and saying "verified" or "mismatch" would claim a check that never happened.
|
||||
//
|
||||
// NOT attempted: reaching for a bare-name declaration's parameter list to fill the gap. It
|
||||
// cannot help, and the reason is structural — the exact declaration is itself a bearer of
|
||||
// that bare name, so the gate's uniqueness test can only pass when the bare-name owner IS
|
||||
// the exact name, which yields these same declarations again. `CBaseEntity::GetEyePosition`
|
||||
// is the case: it stays `return-only` because the only parameter list on offer belongs to
|
||||
// `IBody::GetEyePosition`, a different class.
|
||||
if cands.is_empty() {
|
||||
bump(&format!("{tier}:return-only"));
|
||||
bump(&format!("status:{}", model::AbiStatus::ReturnOnly.as_str()));
|
||||
functions.insert(
|
||||
name.clone(),
|
||||
model::AbiEntry {
|
||||
tier: tier.to_string(),
|
||||
matched_by: matched_by.to_string(),
|
||||
status: model::AbiStatus::ReturnOnly,
|
||||
ret: fallback_ret(),
|
||||
provenance,
|
||||
derived: sh.cloned(),
|
||||
vtable,
|
||||
..model::AbiEntry::blank()
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// The signatures on offer, deduped by SPELLING: the same list written in both conventions is
|
||||
// one thing a reader has to choose between, not two.
|
||||
let all_sigs: Vec<Vec<String>> = cands
|
||||
.iter()
|
||||
.map(|c| c.params.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut note = None;
|
||||
let chosen: Option<Candidate> = if cands.len() == 1 {
|
||||
Some(cands[0])
|
||||
} else if let Some(s) = sh {
|
||||
// Signatures the declarations alone cannot separate: let the measurement pick.
|
||||
let fits: Vec<Candidate> = cands
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| agrees(c, s, types))
|
||||
.collect();
|
||||
match fits.len() {
|
||||
// NONE of them agrees. That is not an ambiguity — it is the same verdict for every
|
||||
// candidate, so whichever is reported the answer is "no declaration on offer
|
||||
// describes this build", which is precisely what `mismatch` says and what a caller
|
||||
// needs to know. Calling it `ambiguous` would report a doubt that does not exist.
|
||||
0 => Some(most_specific(&cands)),
|
||||
1 => {
|
||||
note = Some("overload resolved by measured footprint".to_string());
|
||||
Some(fits[0])
|
||||
}
|
||||
// Every survivor agrees with the binary, so the FOOTPRINT is settled and only the type
|
||||
// spellings differ — two sources naming the same argument `void*` and
|
||||
// `CTakeDamageResult*`. Reporting that as `ambiguous` would understate what is known.
|
||||
n => {
|
||||
note = Some(format!(
|
||||
"{n} of {} declarations agree with the measured footprint and differ only \
|
||||
in the types they name; the most specific of those is reported, and \
|
||||
`overloads` lists every signature that was on offer, agreeing or not",
|
||||
cands.len()
|
||||
));
|
||||
Some(most_specific(&fits))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Reachable only with NO measurement and more than one signature on offer: nothing can
|
||||
// separate them, which is the one thing `ambiguous` is for.
|
||||
let Some(chosen) = chosen else {
|
||||
bump(&format!("{tier}:overloaded"));
|
||||
// Counted like every other verdict. Omitting it left `meta.counts` — documented as the
|
||||
// verdict tally — silently missing a status that entries in the file actually carry.
|
||||
bump(&format!("status:{}", model::AbiStatus::Ambiguous.as_str()));
|
||||
functions.insert(
|
||||
name.clone(),
|
||||
model::AbiEntry {
|
||||
tier: tier.to_string(),
|
||||
matched_by: matched_by.to_string(),
|
||||
status: model::AbiStatus::Ambiguous,
|
||||
// The ambiguity is about the PARAMETER list; a declared return type that every
|
||||
// candidate agrees on is not in doubt and is not dropped with them.
|
||||
ret: fallback_ret(),
|
||||
provenance,
|
||||
derived: sh.cloned(),
|
||||
overloads: Some(all_sigs),
|
||||
vtable,
|
||||
..model::AbiEntry::blank()
|
||||
},
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut status = match sh {
|
||||
None => model::AbiStatus::Unverified,
|
||||
Some(s) if agrees(&chosen, s, types) => model::AbiStatus::Verified,
|
||||
Some(_) => model::AbiStatus::Mismatch,
|
||||
};
|
||||
// A mismatch has two directions and they mean opposite things. Declared ABOVE measured is the
|
||||
// documented lower-bound case — a callee that ignores an argument, or a thunk that reads none
|
||||
// of its own — and calling through it merely loads a register nobody reads. Declared BELOW
|
||||
// measured is the dangerous one: the callee reads an argument the declaration never mentions.
|
||||
//
|
||||
// And a declaration can be wrong in BOTH directions at once, in different register classes,
|
||||
// which an either/or test reports as whichever it happens to check first. `FindUseEntity` is
|
||||
// the case: declared `(CCSPlayer_UseServices*, float)` and measured `int=3 float=0`, so it
|
||||
// passes a float the callee never reads AND leaves two integer registers the callee DOES read
|
||||
// unset. That is the dangerous shape, and it was being described as the harmless one.
|
||||
// Split the disagreement by DIRECTION before reporting it, because the two directions are
|
||||
// not two flavours of the same verdict. Declared-above-measured is the documented
|
||||
// lower-bound case and calling through it loads a register nobody reads;
|
||||
// measured-above-declared leaves a register the callee DOES read unset. 81 of CS2's 140
|
||||
// former mismatches were the former, reported as "does not describe this build".
|
||||
if status == model::AbiStatus::Mismatch {
|
||||
let s = sh.expect("a mismatch is only reachable with a measurement");
|
||||
let (verdict, why) = adjudicate_mismatch(&chosen, s, types);
|
||||
status = verdict;
|
||||
note = Some(why.to_string());
|
||||
}
|
||||
// A BARE-NAME claim that the measurement CONTRADICTS is withdrawn, not reported. The gate
|
||||
// admits a bare name only when a measurement exists to adjudicate it — and adjudicating
|
||||
// means rejecting when the answer is no. `CWorldRendererMgr::LockForRead` takes the empty
|
||||
// parameter list of some other class's `LockForRead` and measures four integer arguments:
|
||||
// that is evidence the JOIN is wrong, not that this function's own declaration went stale,
|
||||
// and reporting `mismatch` would attribute a prototype to a function nothing connects it to.
|
||||
// A LOWER-BOUND disagreement is not a contradiction and is kept. 4 on CS2.
|
||||
if matched_by == "bare-name" && status == model::AbiStatus::Mismatch {
|
||||
bump(&format!("{tier}:none"));
|
||||
bump("bare-name:withdrawn");
|
||||
continue;
|
||||
}
|
||||
bump(&format!("status:{}", status.as_str()));
|
||||
bump(&format!("{tier}:resolved"));
|
||||
// Where the reported signature IS the contract, say so: "the engine invokes it this way"
|
||||
// and "somebody declared it this way" are different claims and a consumer weighs them
|
||||
// differently.
|
||||
let matched_by = if chosen.contract {
|
||||
ENGINE_CONTRACT
|
||||
} else {
|
||||
matched_by
|
||||
};
|
||||
functions.insert(
|
||||
name.clone(),
|
||||
model::AbiEntry {
|
||||
tier: tier.to_string(),
|
||||
matched_by: matched_by.to_string(),
|
||||
status,
|
||||
params: Some(chosen.params.clone()),
|
||||
params_complete: chosen.complete.then_some(true),
|
||||
is_const: Some(chosen.is_const),
|
||||
ret: chosen.ret.cloned().or_else(fallback_ret),
|
||||
provenance,
|
||||
derived: sh.cloned(),
|
||||
note,
|
||||
overloads: (cands.len() > 1).then_some(all_sigs),
|
||||
vtable,
|
||||
// Prose belongs to the function record, not to a prototype; the merge attaches it
|
||||
// there and `Rosetta::abi_manifest` joins it back on for the emitters.
|
||||
doc: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Counted from the file rather than tallied as entries are built: the other counts are verdicts,
|
||||
// reached once per name, while this one describes entries that four different branches can create.
|
||||
let n_vtable = functions.values().filter(|e| e.vtable.is_some()).count();
|
||||
if n_vtable > 0 {
|
||||
counts.insert("locator:vtable".to_string(), n_vtable);
|
||||
}
|
||||
|
||||
Ok(model::AbiManifest {
|
||||
meta: model::AbiMeta {
|
||||
game_key: mono.meta.game_key.clone(),
|
||||
source_build: mono.meta.source_build.clone(),
|
||||
counts,
|
||||
},
|
||||
functions,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn shape(int: u8, float: u8) -> model::AbiShape {
|
||||
model::AbiShape {
|
||||
int,
|
||||
float,
|
||||
stack: false,
|
||||
ret: "ret=?".to_string(),
|
||||
}
|
||||
}
|
||||
fn p(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|s| (*s).to_string()).collect()
|
||||
}
|
||||
fn classify_t(t: &str) -> (usize, usize) {
|
||||
classify(t, None)
|
||||
}
|
||||
/// A candidate as a mangled symbol writes one: `this` invisible, nothing authoritative.
|
||||
fn cand(params: &Vec<String>, complete: bool) -> Candidate<'_> {
|
||||
Candidate {
|
||||
params,
|
||||
complete,
|
||||
is_const: false,
|
||||
ret: None,
|
||||
contract: false,
|
||||
}
|
||||
}
|
||||
fn agrees_t(params: &Vec<String>, sh: &model::AbiShape) -> bool {
|
||||
agrees(&cand(params, false), sh, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sysv_classification_is_not_lexical() {
|
||||
// A `Vector` BY VALUE is 3 floats in two SSE registers…
|
||||
assert_eq!(classify_t("Vector"), (0, 2));
|
||||
// …but by reference it is one INTEGER register, whatever it points at. Getting this wrong is
|
||||
// what manufactured most of an early pass's false mismatches.
|
||||
assert_eq!(classify_t("Vector const&"), (1, 0));
|
||||
assert_eq!(classify_t("Vector*"), (1, 0));
|
||||
assert_eq!(classify_t("float"), (0, 1));
|
||||
assert_eq!(classify_t("int"), (1, 0));
|
||||
// A template is classified by its base, not its arguments.
|
||||
assert_eq!(classify_t("CUtlVector<float>"), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_is_invisible_in_the_mangling_so_both_arities_are_accepted() {
|
||||
// `void Foo(int)` declares one parameter; as a MEMBER function the call also passes `this`.
|
||||
// The mangling cannot tell the two apart, so a measured 1 and a measured 2 both agree.
|
||||
assert!(agrees_t(&p(&["int"]), &shape(1, 0)));
|
||||
assert!(agrees_t(&p(&["int"]), &shape(2, 0)));
|
||||
assert!(!agrees_t(&p(&["int"]), &shape(3, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arity_above_the_register_budget_is_compared_capped() {
|
||||
// Only six integer argument registers exist, so a 9-parameter declaration cannot be
|
||||
// distinguished from a 7-parameter one by the footprint alone.
|
||||
let nine = p(&["int"; 9]);
|
||||
assert!(agrees_t(&nine, &shape(6, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_declaration_gets_no_this_allowance() {
|
||||
// The ±1 above exists only because a mangled symbol cannot say whether `this` is passed. A
|
||||
// function-pointer type already names its receiver, so allowing it there would let a declaration
|
||||
// that is short by exactly one argument pass — which is `IScriptVM::CreateVM`, declared with one
|
||||
// and measuring two.
|
||||
let one = p(&["IScriptVM*"]);
|
||||
assert!(agrees(&cand(&one, true), &shape(1, 0), None));
|
||||
assert!(!agrees(&cand(&one, true), &shape(2, 0), None));
|
||||
assert!(agrees(&cand(&one, false), &shape(2, 0), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_most_specific_spelling_wins_when_the_binary_cannot_choose() {
|
||||
// Two sources declaring the same function with the same footprint: one says `void*` where the
|
||||
// other names the type. The measurement separates neither, so the informative one is reported.
|
||||
let vague = p(&["CBaseEntity*", "CTakeDamageInfo*", "void*"]);
|
||||
let named = p(&["CBaseEntity*", "CTakeDamageInfo*", "CTakeDamageResult*"]);
|
||||
let cands = [cand(&vague, true), cand(&named, true)];
|
||||
assert_eq!(most_specific(&cands).params, &named);
|
||||
// A receiver-bearing list outranks one that hides `this`, whatever else it says.
|
||||
let mangled = p(&["CTakeDamageInfo*"]);
|
||||
let cands = [cand(&mangled, false), cand(&vague, true)];
|
||||
assert_eq!(most_specific(&cands).params, &vague);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_engine_contract_is_judged_as_a_lower_bound_not_an_equality() {
|
||||
let io = p(&ENGINE_CONTRACT_PARAMS);
|
||||
let contract = Candidate {
|
||||
contract: true,
|
||||
..cand(&io, true)
|
||||
};
|
||||
// What the engine passes, exactly: the common case, 156 of CS2's 205.
|
||||
assert!(agrees(&contract, &shape(2, 0), None));
|
||||
// A handler that ignores its `InputData_t&`, and a forwarding thunk that reads neither
|
||||
// register. Both are real and neither refutes how the engine invokes them — 49 of the 205.
|
||||
assert!(agrees(&contract, &shape(1, 0), None));
|
||||
assert!(agrees(&contract, &shape(0, 0), None));
|
||||
// An OVER-count is the one direction that refutes it: the callee reads a register the
|
||||
// dispatch never fills, so either the reader invented an argument or this is not a handler.
|
||||
assert!(!agrees(&contract, &shape(3, 0), None));
|
||||
assert!(!agrees(&contract, &shape(2, 1), None));
|
||||
// …and so does an sret return, which the register counts cannot show: it would mean argument 0
|
||||
// is a hidden output pointer and every other argument sits one register along.
|
||||
let byval = model::AbiShape {
|
||||
ret: "ret=byval".to_string(),
|
||||
..shape(2, 0)
|
||||
};
|
||||
assert!(!agrees(&contract, &byval, None));
|
||||
// The same list from a THIRD PARTY gets no such licence — a declaration can go stale, and
|
||||
// catching that is what the manifest is for.
|
||||
assert!(!agrees(&cand(&io, true), &shape(1, 0), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_console_command_contract_carries_a_receiver_only_where_the_form_dispatches_through_one() {
|
||||
// The whole point of keying on the form: a direct registration passes a plain function, so its
|
||||
// contract is the two arguments the engine supplies and nothing else.
|
||||
assert_eq!(
|
||||
concommand_contract("valve-concommand:direct").unwrap(),
|
||||
vec!["CCommandContext*", "CCommand*"]
|
||||
);
|
||||
// The object forms dispatch through a receiver, so they take one more integer register. The
|
||||
// member form's receiver is whatever object the registering constructor was building, which the
|
||||
// binary does not name — `void*` says "a receiver, type unknown" rather than inventing a class.
|
||||
assert_eq!(
|
||||
concommand_contract("valve-concommand:interface").unwrap(),
|
||||
vec!["ICommandCallback*", "CCommandContext*", "CCommand*"]
|
||||
);
|
||||
assert_eq!(
|
||||
concommand_contract("valve-concommand:member").unwrap(),
|
||||
vec!["void*", "CCommandContext*", "CCommand*"]
|
||||
);
|
||||
// A form this code has never measured claims NOTHING — it does not fall back to a guess.
|
||||
assert!(concommand_contract("valve-concommand:something-new").is_none());
|
||||
assert!(concommand_contract("valve-concommand").is_none());
|
||||
// …and no other provenance is mistaken for one, including the prefix as a bare word.
|
||||
assert!(concommand_contract("valve-datadesc").is_none());
|
||||
assert!(concommand_contract("catalogue").is_none());
|
||||
assert!(concommand_contract("valve-concommandering:direct").is_none());
|
||||
|
||||
// Judged as a lower bound like the entity-IO contract, and for the same reason: a handler that
|
||||
// ignores its arguments reads fewer registers, and only an OVER-count refutes the dispatch.
|
||||
let direct = p(&["CCommandContext*", "CCommand*"]);
|
||||
let c = Candidate {
|
||||
contract: true,
|
||||
..cand(&direct, true)
|
||||
};
|
||||
assert!(agrees(&c, &shape(2, 0), None));
|
||||
assert!(agrees(&c, &shape(0, 0), None));
|
||||
// Three integers is what a RECEIVER form measures, and it refutes the direct contract — which
|
||||
// is exactly why the form has to be carried rather than assumed.
|
||||
assert!(!agrees(&c, &shape(3, 0), None));
|
||||
// A console callback returns void, so a by-value return would mean argument 0 is a hidden
|
||||
// output pointer and every other argument has shifted.
|
||||
let byval = model::AbiShape {
|
||||
ret: "ret=byval".to_string(),
|
||||
..shape(2, 0)
|
||||
};
|
||||
assert!(!agrees(&c, &byval, None));
|
||||
}
|
||||
|
||||
/// The verdict AND the note, for one declaration against one measurement.
|
||||
/// CALLS the shipped rule rather than restating it. It used to re-implement `build_manifest`'s
|
||||
/// direction logic, which made the assertions below unfalsifiable: only an edit that changed both
|
||||
/// copies the same wrong way could fail them, and that is the one edit nobody makes by accident.
|
||||
/// The direction is read back out of the shipped note text, so the mapping from direction to prose
|
||||
/// is under test too.
|
||||
fn judge(params: &[&str], sh: &model::AbiShape, complete: bool) -> (model::AbiStatus, String) {
|
||||
let ps = p(params);
|
||||
let c = cand(&ps, complete);
|
||||
if agrees(&c, sh, None) {
|
||||
return (model::AbiStatus::Verified, String::new());
|
||||
}
|
||||
let (status, note) = adjudicate_mismatch(&c, sh, None);
|
||||
let direction = if note.starts_with("measured and declared") {
|
||||
"both"
|
||||
} else if note.starts_with("measured footprint EXCEEDS") {
|
||||
"measured-exceeds"
|
||||
} else if note.starts_with("the declaration passes") {
|
||||
"declared-exceeds"
|
||||
} else {
|
||||
"neither"
|
||||
};
|
||||
(status, direction.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mismatch_in_both_directions_is_not_reported_as_the_harmless_one() {
|
||||
// `FindUseEntity`: declared `(CCSPlayer_UseServices*, float)`, measured `int=3 float=0`. It
|
||||
// passes a float the callee never reads AND leaves two integer registers the callee does read
|
||||
// unset. An either/or test finds the float side first and calls the whole thing benign.
|
||||
assert_eq!(judge(&["void*", "float"], &shape(3, 0), true).1, "both");
|
||||
// The two single-direction cases still read as themselves.
|
||||
assert_eq!(judge(&["void*"], &shape(3, 0), true).1, "measured-exceeds");
|
||||
assert_eq!(
|
||||
judge(&["void*", "void*", "void*"], &shape(1, 0), true).1,
|
||||
"declared-exceeds"
|
||||
);
|
||||
// …and only the OVER-read is a mismatch. The declaration that passes a register nobody reads is
|
||||
// consistent with a footprint that is a lower bound, and calling through it is harmless.
|
||||
assert_eq!(
|
||||
judge(&["void*"], &shape(3, 0), true).0,
|
||||
model::AbiStatus::Mismatch
|
||||
);
|
||||
assert_eq!(
|
||||
judge(&["void*", "float"], &shape(3, 0), true).0,
|
||||
model::AbiStatus::Mismatch
|
||||
);
|
||||
assert_eq!(
|
||||
judge(&["void*", "void*", "void*"], &shape(1, 0), true).0,
|
||||
model::AbiStatus::LowerBound
|
||||
);
|
||||
// …and the invisible `this` is not one of them. `CGameEvent::GetFloat` is declared
|
||||
// `(char const*, float)` from a mangled symbol and measures `int=2 float=0`: the extra integer
|
||||
// register IS the receiver, so the only real disagreement is the float the callee never reads.
|
||||
assert_eq!(
|
||||
judge(&["char const*", "float"], &shape(2, 0), false).1,
|
||||
"declared-exceeds"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_float_disagreement_is_decisive() {
|
||||
// The integer side has the `this` allowance; the float side has none, so a declared float
|
||||
// count that differs from the measurement is a real mismatch.
|
||||
assert!(!agrees_t(&p(&["Vector"]), &shape(1, 0)));
|
||||
assert!(agrees_t(&p(&["Vector"]), &shape(1, 2)));
|
||||
}
|
||||
}
|
||||
840
src/pulse.rs
Normal file
840
src/pulse.rs
Normal file
|
|
@ -0,0 +1,840 @@
|
|||
//! Pulse binding SIGNATURES — recovered from the accessor's own initializer, offline.
|
||||
//!
|
||||
//! [`valvetab`](crate::valvetab) reads the Pulse registry and gets a fully-qualified name, Valve's own
|
||||
//! documentation strings, a call policy — and two code pointers that are NOT the bound function. This
|
||||
//! module reads what those two pointers actually are, and they turn out to be the thing the registry was
|
||||
//! missing: **the typed signature**.
|
||||
//!
|
||||
//! Each is an accessor for a function-local `static` holding a `{count, elements}` vector by value, so
|
||||
//! the fast path is `rax = count | (capacity << 32); rdx = &elements; ret`. `+24` returns the ARGUMENT
|
||||
//! list and `+32` the RETURN list — measured, not assumed: `CBaseEntityAPI::GetAbsOrigin` yields
|
||||
//! `_Target: PVAL_EHANDLE` and `retval: PVAL_VEC3_WORLDSPACE`, and `CLightEntityAPI::SetLightColor`
|
||||
//! yields `_Target: PVAL_EHANDLE, param: PVAL_COLOR_RGB` against an EMPTY return list. Pulse is a typed
|
||||
//! graph VM, so a binding cannot be registered without this; it is the one prototype source in the
|
||||
//! project that is neither 2018-era nor transferred from another game.
|
||||
//!
|
||||
//! **The elements are built at runtime, so they are zero in the file — but the code that builds them is
|
||||
//! not.** Every field is written to a fixed RIP-relative address from a `lea` or an immediate, so a
|
||||
//! constant-propagation pass over the initializer reconstructs the record without a running process.
|
||||
//! That matters beyond convenience: it makes typed signatures available to an OFFLINE derive, and it
|
||||
//! avoids calling 580 functions in a live process to read data the binary already states.
|
||||
//!
|
||||
//! Shape-driven like the table readers: an element is accepted only when its name pointer resolves to a
|
||||
//! plausible identifier in non-executable memory AND its type is a value `PulseValueType_t` actually
|
||||
//! 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};
|
||||
|
||||
/// Highest `PulseValueType_t` enumerator (`PVAL_COUNT`) plus headroom for a build that adds a few. The
|
||||
/// enum is schema-registered, so the DERIVED values are what a caller should validate against — this is
|
||||
/// only the gate that keeps a stale register from being read as a type.
|
||||
const MAX_PVAL: i64 = 64;
|
||||
|
||||
/// Longest plausible parameter name. Names here are C++ parameter identifiers (`_Target`, `pEntity`).
|
||||
const MAX_NAME: usize = 96;
|
||||
|
||||
/// Where the parameter NAME sits inside one element record.
|
||||
const ELEM_NAME: u64 = 8;
|
||||
|
||||
/// Where the element records its type's DESTRUCTOR — a code pointer that is per concrete type rather
|
||||
/// than per binding, and therefore the only thing in the binary that separates one `PVAL_EHANDLE`'s
|
||||
/// entity class from another's. Measured: 990 stores across 80 distinct targets on CS2, each target
|
||||
/// used by exactly one `PulseValueType_t`, and 74 of the 80 are a bare `ret` (the trivially-destructible
|
||||
/// case). See [`PulseParam::type_token`].
|
||||
const ELEM_DTOR: u64 = 0x60;
|
||||
|
||||
/// How far past the accessor's entry the decoder will follow. These are tiny functions — the largest
|
||||
/// observed initializer is under 2 KB — so this only bounds a runaway walk into neighbouring code.
|
||||
const MAX_SPAN: usize = 32 * 1024;
|
||||
|
||||
pub use crate::model::PulseParam;
|
||||
|
||||
/// A binding's full signature: what it takes and what it gives back.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PulseSignature {
|
||||
pub args: Vec<PulseParam>,
|
||||
/// Pulse models returns as named out-parameters, so this is a LIST — usually one `retval`, empty for
|
||||
/// a void binding, and occasionally several.
|
||||
pub returns: Vec<PulseParam>,
|
||||
}
|
||||
|
||||
/// One call the initializer makes, with whatever the pass could establish about its arguments.
|
||||
struct Call {
|
||||
/// The direct target, or `None` for an indirect call.
|
||||
target: Option<u64>,
|
||||
rdi: Option<u64>,
|
||||
rsi: Option<u64>,
|
||||
rdx: Option<u64>,
|
||||
}
|
||||
|
||||
/// What a constant-propagation pass could establish about the initializer.
|
||||
#[derive(Default)]
|
||||
struct Trace {
|
||||
/// Absolute address -> the pointer or immediate stored there.
|
||||
writes: BTreeMap<u64, u64>,
|
||||
/// Call sites, each with the target and its argument registers. A register is present only when it
|
||||
/// was established since the PREVIOUS call, which is what makes `rsi` the type argument of this call
|
||||
/// rather than a leftover from earlier in the initializer.
|
||||
calls: Vec<Call>,
|
||||
/// `(count, elements)` as the accessor returns them on its already-initialised path.
|
||||
ret: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
fn full(r: Register) -> Register {
|
||||
if r.is_gpr() { r.full_register() } else { r }
|
||||
}
|
||||
|
||||
/// Every instruction address reachable from `entry` inside `[entry, entry+code.len())`, in ADDRESS order.
|
||||
///
|
||||
/// One walk, two callers: the descriptor trace and the shim's liveness read need exactly the same thing —
|
||||
/// flow-reachable addresses rather than a linear sweep, so a jump table or an interleaved neighbour cannot
|
||||
/// contribute instructions the function never executes. They differ only in how far they are willing to
|
||||
/// walk, which is the `cap`.
|
||||
///
|
||||
/// `cap` bounds the SET, not the span: a crafted image can present a small span with pathological branch
|
||||
/// density, and this is on the fuzz surface.
|
||||
fn reachable(code: &[u8], entry: u64, cap: usize) -> Vec<u64> {
|
||||
let end = entry.saturating_add(code.len() as u64);
|
||||
let mut seen: HashSet<u64> = HashSet::new();
|
||||
let mut work = vec![entry];
|
||||
let mut insn = Instruction::default();
|
||||
while let Some(at) = work.pop() {
|
||||
if at < entry || at >= end || seen.contains(&at) || seen.len() > cap {
|
||||
continue;
|
||||
}
|
||||
let mut dec =
|
||||
Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE);
|
||||
if !dec.can_decode() {
|
||||
continue;
|
||||
}
|
||||
dec.decode_out(&mut insn);
|
||||
if insn.is_invalid() || insn.len() == 0 {
|
||||
continue;
|
||||
}
|
||||
seen.insert(at);
|
||||
match insn.flow_control() {
|
||||
FlowControl::Return
|
||||
| FlowControl::IndirectBranch
|
||||
| FlowControl::Exception
|
||||
| FlowControl::Interrupt => {}
|
||||
FlowControl::UnconditionalBranch => work.push(insn.near_branch_target()),
|
||||
FlowControl::ConditionalBranch => {
|
||||
work.push(at + insn.len() as u64);
|
||||
work.push(insn.near_branch_target());
|
||||
}
|
||||
_ => work.push(at + insn.len() as u64),
|
||||
}
|
||||
}
|
||||
let mut addrs: Vec<u64> = seen.into_iter().collect();
|
||||
addrs.sort_unstable();
|
||||
addrs
|
||||
}
|
||||
|
||||
/// Constant-propagate through the accessor, recording every fixed-address store, every call's argument
|
||||
/// registers, and the vector the fast path returns.
|
||||
///
|
||||
/// Deliberately a single ADDRESS-ORDER pass rather than a CFG walk: the guard-protected initializer is
|
||||
/// straight-line, and a pass that only ever believes values it computed itself cannot invent one. Every
|
||||
/// instruction it does not model invalidates what it writes.
|
||||
fn trace(img: &CodeImage, entry: u64, seed_rdi: Option<u64>) -> Option<Trace> {
|
||||
let all = img.code_at(entry)?;
|
||||
let code = &all[..all.len().min(MAX_SPAN)];
|
||||
let mut insn = Instruction::default();
|
||||
let addrs = reachable(code, entry, 4000);
|
||||
|
||||
let mut out = Trace::default();
|
||||
let mut regs: HashMap<Register, u64> = HashMap::new();
|
||||
// Tracing a callee with its incoming receiver known is what lets an OUTLINED constructor be read:
|
||||
// the compiler hoists `make an EHANDLE type` into its own function, so the type immediate is inside
|
||||
// the callee rather than at the call site.
|
||||
if let Some(v) = seed_rdi {
|
||||
regs.insert(Register::RDI, v);
|
||||
}
|
||||
for at in addrs {
|
||||
let mut dec =
|
||||
Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE);
|
||||
dec.decode_out(&mut insn);
|
||||
|
||||
// A store to a fixed address: the only way a field of the static record is written.
|
||||
if insn.mnemonic() == Mnemonic::Mov
|
||||
&& insn.op0_kind() == OpKind::Memory
|
||||
&& insn.is_ip_rel_memory_operand()
|
||||
{
|
||||
let dst = insn.ip_rel_memory_address();
|
||||
let val = match insn.op1_kind() {
|
||||
OpKind::Register => regs.get(&full(insn.op1_register())).copied(),
|
||||
OpKind::Immediate32to64 | OpKind::Immediate32 | OpKind::Immediate64 => {
|
||||
Some(insn.immediate(1))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(v) = val {
|
||||
out.writes.insert(dst, v);
|
||||
}
|
||||
}
|
||||
|
||||
match insn.flow_control() {
|
||||
FlowControl::Call | FlowControl::IndirectCall => {
|
||||
out.calls.push(Call {
|
||||
target: (insn.flow_control() == FlowControl::Call)
|
||||
.then(|| insn.near_branch_target()),
|
||||
rdi: regs.get(&Register::RDI).copied(),
|
||||
rsi: regs.get(&Register::RSI).copied(),
|
||||
rdx: regs.get(&Register::RDX).copied(),
|
||||
});
|
||||
for r in CALLER_SAVED {
|
||||
regs.remove(&r);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The already-initialised path returns the vector. The FIRST return reached in address
|
||||
// order is that path: the guard test falls through to it and jumps away to the builder.
|
||||
FlowControl::Return => {
|
||||
if out.ret.is_none() {
|
||||
// The count must have been ESTABLISHED, never defaulted. Substituting 0 for an RAX
|
||||
// this pass could not evaluate turns "the accessor was not readable" into an
|
||||
// affirmative "this binding takes nothing" — a claim, not a gap, and the exact
|
||||
// failure the project's "degrades or stops loudly, never lies" rule forbids. An
|
||||
// accessor that genuinely returns an empty vector zeroes RAX with `xor eax, eax`,
|
||||
// which IS modelled, so honesty here costs no real signature.
|
||||
out.ret = regs.get(&Register::RAX).map(|&rax| {
|
||||
(
|
||||
rax & 0xffff_ffff,
|
||||
regs.get(&Register::RDX).copied().unwrap_or(0),
|
||||
)
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Everything below is the modelled arithmetic. An unmodelled write invalidates its destination,
|
||||
// so a value is only ever believed when this pass computed it.
|
||||
let dst = if insn.op_count() > 0 && insn.op0_kind() == OpKind::Register {
|
||||
Some(full(insn.op0_register()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let src = (insn.op_count() > 1 && insn.op1_kind() == OpKind::Register)
|
||||
.then(|| full(insn.op1_register()));
|
||||
let imm = matches!(
|
||||
insn.op1_kind(),
|
||||
OpKind::Immediate8
|
||||
| OpKind::Immediate8to32
|
||||
| OpKind::Immediate8to64
|
||||
| OpKind::Immediate32
|
||||
| OpKind::Immediate32to64
|
||||
| OpKind::Immediate64
|
||||
)
|
||||
.then(|| insn.immediate(1));
|
||||
|
||||
let Some(d) = dst else { continue };
|
||||
// Only 32- and 64-bit destinations are modelled. A byte or word write leaves the rest of the
|
||||
// register alone, so treating it as the register's whole value would invent one — and these
|
||||
// initializers do write bytes (`mov BYTE PTR [rbp-0x11], 0`). Narrow writes fall through to the
|
||||
// invalidation below, which is the safe direction.
|
||||
if insn.op0_register().size() < 4 {
|
||||
regs.remove(&d);
|
||||
continue;
|
||||
}
|
||||
// A 32-bit write zeroes the upper half, which is exactly how `mov esi, 0xd` reaches RSI.
|
||||
let mask = if insn.op0_register().size() == 4 {
|
||||
0xffff_ffff
|
||||
} else {
|
||||
u64::MAX
|
||||
};
|
||||
let value = match insn.mnemonic() {
|
||||
Mnemonic::Lea if insn.is_ip_rel_memory_operand() => Some(insn.ip_rel_memory_address()),
|
||||
// `lea reg, [base + disp]` — how the initializer walks from one element to the next.
|
||||
Mnemonic::Lea if insn.memory_index() == Register::None => regs
|
||||
.get(&full(insn.memory_base()))
|
||||
.map(|b| b.wrapping_add(insn.memory_displacement64())),
|
||||
Mnemonic::Mov => match (src, imm) {
|
||||
(Some(s), _) => regs.get(&s).copied(),
|
||||
(None, Some(i)) => Some(i),
|
||||
_ => None,
|
||||
},
|
||||
// `xor r, r` is the idiomatic zero; a xor of two different registers is not modelled.
|
||||
Mnemonic::Xor if src == Some(d) => Some(0),
|
||||
// How the initializer walks from one element to the next when the compiler advances a
|
||||
// pointer rather than emitting a fresh `lea` — without this, every element after the first
|
||||
// loses its address and the whole binding drops.
|
||||
Mnemonic::Add => match (src, imm) {
|
||||
(Some(s), _) => regs
|
||||
.get(&d)
|
||||
.zip(regs.get(&s))
|
||||
.map(|(a, b)| a.wrapping_add(*b)),
|
||||
(None, Some(i)) => regs.get(&d).map(|a| a.wrapping_add(i)),
|
||||
_ => None,
|
||||
},
|
||||
Mnemonic::Sub => match (src, imm) {
|
||||
(Some(s), _) => regs
|
||||
.get(&d)
|
||||
.zip(regs.get(&s))
|
||||
.map(|(a, b)| a.wrapping_sub(*b)),
|
||||
(None, Some(i)) => regs.get(&d).map(|a| a.wrapping_sub(i)),
|
||||
_ => None,
|
||||
},
|
||||
Mnemonic::And => match (src, imm) {
|
||||
(Some(s), _) => regs.get(&d).zip(regs.get(&s)).map(|(a, b)| a & b),
|
||||
(None, Some(i)) => regs.get(&d).map(|a| a & i),
|
||||
_ => None,
|
||||
},
|
||||
Mnemonic::Or => match (src, imm) {
|
||||
(Some(s), _) => regs.get(&d).zip(regs.get(&s)).map(|(a, b)| a | b),
|
||||
(None, Some(i)) => regs.get(&d).map(|a| a | i),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
match value {
|
||||
Some(v) => {
|
||||
regs.insert(d, v & mask);
|
||||
}
|
||||
None => {
|
||||
regs.remove(&d);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Is `s` shaped like a C++ parameter name? The gate that separates the record's name slot from every
|
||||
/// other pointer the initializer stores.
|
||||
fn is_param_name(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= MAX_NAME
|
||||
&& s.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
|
||||
&& s.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
|
||||
}
|
||||
|
||||
/// What one accessor's record region says, before the element stride is known.
|
||||
struct Record {
|
||||
t: Trace,
|
||||
count: u64,
|
||||
base: u64,
|
||||
/// Every store of a plausible identifier inside the region. Some are element names; some are not —
|
||||
/// Dota's `CPulseCursorFuncs::TagCursor` stores an `Ed1` at one element's `+0x28`.
|
||||
named: BTreeMap<u64, String>,
|
||||
}
|
||||
|
||||
fn record(img: &CodeImage, accessor: u64) -> Option<Record> {
|
||||
let t = trace(img, accessor, None)?;
|
||||
let (count, base) = t.ret?;
|
||||
if count > 32 || (count > 0 && base == 0) {
|
||||
return None;
|
||||
}
|
||||
let named = t
|
||||
.writes
|
||||
.iter()
|
||||
.filter(|&(a, _)| *a >= base)
|
||||
.filter_map(|(&a, &p)| {
|
||||
(!img.is_code(p))
|
||||
.then(|| img.read_c_string(p))
|
||||
.flatten()
|
||||
.filter(|s| is_param_name(s))
|
||||
.map(|s| (a, s))
|
||||
})
|
||||
.collect();
|
||||
Some(Record {
|
||||
t,
|
||||
count,
|
||||
base,
|
||||
named,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn candidate_strides(r: &Record) -> Vec<u64> {
|
||||
let Some(first) = r.base.checked_add(8) else {
|
||||
return Vec::new();
|
||||
};
|
||||
r.named
|
||||
.keys()
|
||||
.filter(|&&a| a > first)
|
||||
.map(|&a| a - first)
|
||||
// Checked throughout: a crafted image can place a "name" anywhere, so `k * s` is a value the
|
||||
// FILE controls and must not be allowed to wrap into a plausible address.
|
||||
.filter(|&s| {
|
||||
(0..r.count).all(|k| {
|
||||
k.checked_mul(s)
|
||||
.and_then(|o| first.checked_add(o))
|
||||
.is_some_and(|a| r.named.contains_key(&a))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Interpret a record at a known stride.
|
||||
///
|
||||
/// Returns `None` when it does not hold together — an element short of a name or a type drops the WHOLE
|
||||
/// list rather than shipping a partial signature, because a signature missing an argument is worse than
|
||||
/// no signature at all.
|
||||
fn params_at(img: &CodeImage, r: &Record, stride: u64) -> Option<Vec<PulseParam>> {
|
||||
// With no stride every element would resolve to element 0, which reads as N copies of the first
|
||||
// parameter rather than as a failure. A single-element list needs no stride and is unaffected.
|
||||
if r.count > 1 && stride == 0 {
|
||||
return None;
|
||||
}
|
||||
// `base` is whatever the accessor's RDX constant-propagates to — a value the FILE controls, gated
|
||||
// only against zero. Every step from it is checked, including this first one: an unchecked `+ 8`
|
||||
// aborts the overflow-checked build the fuzz harness uses, and `fuzz_pulse` promises fewer
|
||||
// signatures rather than a panic.
|
||||
let first = r.base.checked_add(8)?;
|
||||
(0..r.count)
|
||||
.map(|k| {
|
||||
let at = k.checked_mul(stride).and_then(|o| first.checked_add(o))?;
|
||||
let (ty, type_name) = type_at(img, &r.t, at.checked_add(8)?)?;
|
||||
Some(PulseParam {
|
||||
name: r.named.get(&at)?.clone(),
|
||||
ty,
|
||||
type_name,
|
||||
entity_class: None,
|
||||
// The element's destructor slot. `at` is the NAME, which sits at `ELEM_NAME` into the
|
||||
// element, so the element base is `at - ELEM_NAME` and the token `ELEM_DTOR` beyond it.
|
||||
// Absent rather than zero if the initializer never wrote one — a token of 0 would group
|
||||
// every unwritten parameter together, which is the opposite of what it is for.
|
||||
type_token: at
|
||||
.checked_sub(ELEM_NAME)
|
||||
.and_then(|e| e.checked_add(ELEM_DTOR))
|
||||
.and_then(|a| r.t.writes.get(&a).copied())
|
||||
.filter(|&p| img.is_code(p))
|
||||
.unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every binding's typed signature, read from the accessor pairs `(args, returns)`.
|
||||
///
|
||||
/// The element stride is DERIVED once per image and then applied strictly, rather than re-guessed per
|
||||
/// record. It has to be: the record size is a property of the type, so one image has one answer, while an
|
||||
/// individual record can be ambiguous about it — a two-element list plus one stray identifier admits two
|
||||
/// spacings, and picking the smaller by fiat would read a stray as a parameter name. Taking the value the
|
||||
/// unambiguous records agree on settles those.
|
||||
///
|
||||
/// The winner is the PLURALITY, not a unanimity: requiring every record to agree would let one malformed
|
||||
/// accessor veto an entire library. That is safe in the direction that matters — a record whose own names
|
||||
/// do not sit at the winning spacing fails its gate and drops — but it does mean the vote spread is worth
|
||||
/// looking at, so it is returned and reported rather than reduced to a single number.
|
||||
///
|
||||
/// Returns `(signatures, stride, votes, rivals)`, where `rivals` is the number of records that voted for
|
||||
/// some OTHER spacing. Anything but zero there means the image is not speaking with one voice.
|
||||
pub fn read_all(
|
||||
img: &CodeImage,
|
||||
accessors: &[(u64, u64)],
|
||||
threads: usize,
|
||||
) -> (Vec<Option<PulseSignature>>, u64, usize, usize) {
|
||||
let flat: Vec<u64> = accessors.iter().flat_map(|&(a, b)| [a, b]).collect();
|
||||
let recs = crate::par::parallel_map(&flat, threads, |&a| record(img, a));
|
||||
|
||||
let mut votes: HashMap<u64, usize> = HashMap::new();
|
||||
for r in recs.iter().flatten().filter(|r| r.count > 1) {
|
||||
if let [s] = candidate_strides(r).as_slice() {
|
||||
*votes.entry(*s).or_default() += 1;
|
||||
}
|
||||
}
|
||||
// No record answered unambiguously — a library of single-parameter bindings, or a layout that moved.
|
||||
// Stride 0 is honest about that: the single-element lists still read, and every longer one fails.
|
||||
//
|
||||
// A tie is broken by the SMALLEST stride, and the tie-break is not cosmetic: `max_by_key` over a
|
||||
// HashMap would otherwise pick by hash order, which makes the emitted artifact depend on iteration
|
||||
// order rather than on the binary. This project's artifacts are byte-reproducible; a coin flip here
|
||||
// would quietly end that.
|
||||
let (stride, won) = votes
|
||||
.iter()
|
||||
.max_by_key(|&(s, n)| (*n, std::cmp::Reverse(*s)))
|
||||
.map_or((0, 0), |(&s, &n)| (s, n));
|
||||
let rivals: usize = votes
|
||||
.iter()
|
||||
.filter(|&(&s, _)| s != stride)
|
||||
.map(|(_, n)| n)
|
||||
.sum();
|
||||
|
||||
let sigs = accessors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| {
|
||||
let args = recs[2 * i].as_ref()?;
|
||||
let returns = recs[2 * i + 1].as_ref()?;
|
||||
Some(PulseSignature {
|
||||
args: params_at(img, args, stride)?,
|
||||
returns: params_at(img, returns, stride)?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(sigs, stride, won, rivals)
|
||||
}
|
||||
|
||||
/// The `PulseValueType_t` a `CPulseValueFullType` at `obj` is set to, with the schema type it names.
|
||||
///
|
||||
/// The type is passed in ESI to a setter called on the object. `rsi` is only believed when it was
|
||||
/// established since the previous call, which is what distinguishes the setter from the default
|
||||
/// constructor invoked on the same object first.
|
||||
///
|
||||
/// Where the setter is not at this level the compiler has OUTLINED it — `make an EHANDLE type` becomes
|
||||
/// its own function taking only `this`, so nothing at the call site carries the immediate. Following one
|
||||
/// level with the receiver seeded recovers it, and 92 of 485 CS2 server bindings need exactly that. Two
|
||||
/// callees disagreeing drops the parameter rather than picking, since nothing here can adjudicate.
|
||||
fn type_at(img: &CodeImage, t: &Trace, obj: u64) -> Option<(i32, Option<String>)> {
|
||||
let read = |c: &Call| {
|
||||
(
|
||||
c.rsi.unwrap() as i32,
|
||||
c.rdx
|
||||
.filter(|&p| p != 0 && !img.is_code(p))
|
||||
.and_then(|p| img.read_c_string(p))
|
||||
.filter(|s| !s.is_empty() && s.len() <= MAX_NAME),
|
||||
)
|
||||
};
|
||||
let direct = t
|
||||
.calls
|
||||
.iter()
|
||||
.filter(|c| c.rdi == Some(obj) && c.rsi.is_some_and(valid_pval))
|
||||
.map(read)
|
||||
.next_back();
|
||||
if direct.is_some() {
|
||||
return direct;
|
||||
}
|
||||
|
||||
let mut found: Option<(i32, Option<String>)> = None;
|
||||
for c in t
|
||||
.calls
|
||||
.iter()
|
||||
.filter(|c| c.rdi == Some(obj) && c.rsi.is_none())
|
||||
{
|
||||
let Some(inner) = c.target.and_then(|f| trace(img, f, Some(obj))) else {
|
||||
continue;
|
||||
};
|
||||
let Some(hit) = inner
|
||||
.calls
|
||||
.iter()
|
||||
.filter(|c| c.rdi == Some(obj) && c.rsi.is_some_and(valid_pval))
|
||||
.map(read)
|
||||
.next_back()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match &found {
|
||||
Some(prev) if prev.0 != hit.0 => return None,
|
||||
_ => found = Some(hit),
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// How far past a shim's entry the read-measurement will follow. `.eh_frame_hdr` covers only a fraction of
|
||||
/// these images' functions and none of the shims, so there is no exact extent available; flow-following ends
|
||||
/// at every `ret` regardless, so this only bounds a runaway path.
|
||||
const SHIM_SPAN: u64 = 0x1000;
|
||||
|
||||
/// The seven integer arguments a Pulse invocation shim takes, in SysV order. The seventh is the first
|
||||
/// STACK slot — measured, and the reason the shim's arity cannot be read off `abi_shape`, whose backward
|
||||
/// liveness stops at the registers.
|
||||
const SHIM_SLOTS: [Register; 6] = [
|
||||
Register::RDI,
|
||||
Register::RSI,
|
||||
Register::RDX,
|
||||
Register::RCX,
|
||||
Register::R8,
|
||||
Register::R9,
|
||||
];
|
||||
|
||||
/// 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 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 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,
|
||||
/// Does it read the Pulse host-service context (`rcx`)? That object is VM-owned, so a host cannot
|
||||
/// supply one.
|
||||
pub context: bool,
|
||||
/// Does it read any OTHER slot — `rdi`, `rsi`, `rdx`, `r9`? These are the slots a caller would
|
||||
/// otherwise pass as null, so any read here means it cannot.
|
||||
pub other: bool,
|
||||
}
|
||||
|
||||
impl ShimReads {
|
||||
/// What a host must supply, as the artifact states it.
|
||||
///
|
||||
/// `args-only` is the one that matters: everything such a shim reads is either the argument array a
|
||||
/// caller builds or the game's own entity list, so the remaining slots may be null. That is not a
|
||||
/// deduction — it was validated by calling every eligible binding in both games with a sentinel handle
|
||||
/// (CS2 186 of 193 clean, Dota 211 of 211), and the exceptions are exactly the shims this reports as
|
||||
/// reading another slot.
|
||||
pub fn needs(&self) -> &'static str {
|
||||
if self.context {
|
||||
"pulse-context"
|
||||
} else if self.other {
|
||||
"other-slots"
|
||||
} else if self.sink {
|
||||
"output-sink"
|
||||
} else {
|
||||
"args-only"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an accessor's descriptor region LIVES, and how many elements it holds: `(base, count)`.
|
||||
///
|
||||
/// The signature reader reconstructs the region's CONTENTS by constant-propagating the initialiser,
|
||||
/// because on disk the elements are zeroes — they are written at runtime. That reconstruction is the
|
||||
/// only offline route, and it is also unverified: the multi-library duplicate check reports that CS2
|
||||
/// disagrees with itself on 331 of 419 repeat registrations, and nothing offline can say which account
|
||||
/// is right.
|
||||
///
|
||||
/// A running server can. The region is a plain static, so at `slide + base` a live process holds the
|
||||
/// POPULATED elements, and reading them settles the question against the same build rather than against
|
||||
/// a dump of a different one. This accessor exists for that oracle; the derivation itself never needs it.
|
||||
pub fn record_region(img: &CodeImage, accessor: u64) -> Option<(u64, u64)> {
|
||||
let r = record(img, accessor)?;
|
||||
(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:
|
||||
///
|
||||
/// * `push`/`pop` must NOT update the alias map. The compiler lays the epilogue out BEFORE the
|
||||
/// found-path block, so `pop r13` sits at a lower address than the `mov rax,[r13+0x10]` that reads the
|
||||
/// second argument through a stashed `mov r13, r8` — and letting the pop clear the alias loses the read.
|
||||
/// The same shape cost the ConCommand reader an epoch counter.
|
||||
/// * `xor r, r` / `sub r, r` name the register in BOTH operands and read neither. Counted, they mark an
|
||||
/// argument live that the shim never consumes; `xor edi, edi` alone accounted for 143 false positives.
|
||||
pub fn shim_reads(img: &CodeImage, entry: u64) -> Option<ShimReads> {
|
||||
let all = img.code_at(entry)?;
|
||||
let code = &all[..(all.len() as u64).min(SHIM_SPAN) as usize];
|
||||
let mut insn = Instruction::default();
|
||||
let addrs = reachable(code, entry, 20000);
|
||||
|
||||
let mut live: BTreeMap<Register, bool> = BTreeMap::new();
|
||||
let mut sink = false;
|
||||
let mut fresh: Vec<Register> = SHIM_SLOTS.to_vec();
|
||||
|
||||
for at in addrs {
|
||||
let mut dec =
|
||||
Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE);
|
||||
dec.decode_out(&mut insn);
|
||||
|
||||
// The first stack slot is the output sink. Only `[rbp+0x10]` is ever read — no shim in either
|
||||
// game touches a second — which is what pins the arity at seven.
|
||||
//
|
||||
// The displacement MUST be read as signed. `memory_displacement64` is unsigned, so a local at
|
||||
// `[rbp-0x10]` comes back as `0xffff_ffff_ffff_fff0`, which passes an unsigned `>= 0x10` — and
|
||||
// every shim with a stack local then looks as though it reads the output sink. That mistake
|
||||
// reported 246 sink-readers against a true 201 and hid two bindings whose callability had already
|
||||
// been demonstrated by a live call.
|
||||
if (insn.op0_kind() == OpKind::Memory || insn.op1_kind() == OpKind::Memory)
|
||||
&& insn.memory_base() == Register::RBP
|
||||
&& insn.memory_index() == Register::None
|
||||
&& insn.memory_displacement64() as i64 >= 0x10
|
||||
{
|
||||
sink = true;
|
||||
}
|
||||
let zeroing = matches!(insn.mnemonic(), Mnemonic::Xor | Mnemonic::Sub)
|
||||
&& insn.op0_kind() == OpKind::Register
|
||||
&& insn.op1_kind() == OpKind::Register
|
||||
&& insn.op0_register().full_register() == insn.op1_register().full_register();
|
||||
// A register named inside a MEMORY operand is read even though it is not a register operand.
|
||||
if !zeroing {
|
||||
for r in [insn.memory_base(), insn.memory_index()] {
|
||||
if r != Register::None && r != Register::RIP && fresh.contains(&r.full_register()) {
|
||||
live.insert(r.full_register(), true);
|
||||
}
|
||||
}
|
||||
for i in 0..insn.op_count() {
|
||||
if insn.op_kind(i) != OpKind::Register {
|
||||
continue;
|
||||
}
|
||||
let pure_dst = i == 0
|
||||
&& matches!(
|
||||
insn.mnemonic(),
|
||||
Mnemonic::Mov | Mnemonic::Lea | Mnemonic::Movzx | Mnemonic::Movsxd
|
||||
);
|
||||
let r = insn.op_register(i).full_register();
|
||||
if !pure_dst && fresh.contains(&r) {
|
||||
live.insert(r, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if insn.op_count() > 0
|
||||
&& insn.op0_kind() == OpKind::Register
|
||||
&& !matches!(insn.mnemonic(), Mnemonic::Push | Mnemonic::Pop)
|
||||
{
|
||||
let d = insn.op0_register().full_register();
|
||||
fresh.retain(|&r| r != d);
|
||||
}
|
||||
if insn.flow_control() == FlowControl::Call {
|
||||
for r in CALLER_SAVED {
|
||||
fresh.retain(|&x| x != r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = ShimReads {
|
||||
sink,
|
||||
..Default::default()
|
||||
};
|
||||
for (r, name) in SHIM_SLOTS
|
||||
.iter()
|
||||
.zip(["rdi", "rsi", "rdx", "rcx", "r8", "r9"])
|
||||
{
|
||||
if live.contains_key(r) {
|
||||
out.reads.push(name);
|
||||
slot_need(*r, &mut out);
|
||||
}
|
||||
}
|
||||
if sink {
|
||||
out.reads.push("stack0");
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A `PulseValueType_t` value, `PVAL_VOID` (-1) included.
|
||||
fn valid_pval(v: u64) -> bool {
|
||||
let s = v as i64;
|
||||
(-1..=MAX_PVAL).contains(&s) || (v as i32) == -1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_parameter_name_is_an_identifier_and_nothing_else() {
|
||||
assert!(is_param_name("_Target"));
|
||||
assert!(is_param_name("retval"));
|
||||
assert!(is_param_name("pEntity2"));
|
||||
// The record's other string slots: a description is prose, and a display name has spaces.
|
||||
assert!(!is_param_name("The entity origin (absolute)."));
|
||||
assert!(!is_param_name("Get Abs Origin"));
|
||||
assert!(!is_param_name(""));
|
||||
assert!(!is_param_name("9lives"));
|
||||
}
|
||||
|
||||
fn rec(count: u64, base: u64, at: &[(u64, &str)]) -> Record {
|
||||
Record {
|
||||
t: Trace::default(),
|
||||
count,
|
||||
base,
|
||||
named: at.iter().map(|&(a, n)| (base + a, n.to_string())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stray_string_inside_a_record_offers_a_second_spacing() {
|
||||
// Dota's `CPulseCursorFuncs::TagCursor`: two parameters at the real stride, plus an identifier
|
||||
// the record stores for its own reasons. Both 0x70 and 0xC0 place a name at every element, so
|
||||
// the record ALONE cannot say which is the stride — which is why the answer is taken from the
|
||||
// image, where the unambiguous records agree.
|
||||
let r = rec(
|
||||
2,
|
||||
0x1000,
|
||||
&[(8, "pTagName"), (0x78, "tagValue"), (0xc8, "Ed1")],
|
||||
);
|
||||
assert_eq!(candidate_strides(&r), vec![0x70, 0xc0]);
|
||||
// A record with no stray answers on its own, and that is the vote the image counts.
|
||||
let clean = rec(2, 0x2000, &[(8, "_Target"), (0x78, "param")]);
|
||||
assert_eq!(candidate_strides(&clean), vec![0x70]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spacing_only_counts_when_every_element_lands_on_it() {
|
||||
// Three elements, and the run is broken: nothing places a name at all three positions, so the
|
||||
// record offers no spacing at all rather than a partial one.
|
||||
let r = rec(3, 0x1000, &[(8, "a"), (0x78, "b"), (0x200, "c")]);
|
||||
assert!(candidate_strides(&r).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reports_the_most_restrictive_requirement_a_shim_has() {
|
||||
// Precedence matters: a shim reading both the context and the sink is not "output-sink", because
|
||||
// the context is the one a host cannot supply at all. Ordering it the other way would advertise
|
||||
// a binding as merely needing a sink when it actually needs a live cursor.
|
||||
let ctx = ShimReads {
|
||||
context: true,
|
||||
sink: true,
|
||||
reads: vec!["rcx", "r8", "stack0"],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(ctx.needs(), "pulse-context");
|
||||
let other = ShimReads {
|
||||
other: true,
|
||||
sink: true,
|
||||
reads: vec!["rdi", "r8", "stack0"],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(other.needs(), "other-slots");
|
||||
let sink = ShimReads {
|
||||
sink: 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 {
|
||||
reads: vec!["r8"],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(only.needs(), "args-only");
|
||||
// A shim reading NOTHING is still args-only — a zero-argument binding reads no array either.
|
||||
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
|
||||
assert!(valid_pval(13)); // PVAL_EHANDLE
|
||||
assert!(valid_pval(0xffff_ffff)); // PVAL_VOID, as a 32-bit -1
|
||||
assert!(!valid_pval(0x1000));
|
||||
}
|
||||
}
|
||||
25
src/rtti.rs
25
src/rtti.rs
|
|
@ -10,7 +10,6 @@
|
|||
//! `find_vtable` shape (COL at vftable-8, TypeDescriptor `.?AV<name>@@`).
|
||||
|
||||
use crate::elf::{CodeImage, KindTag};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct VTable {
|
||||
pub slot0: u64, // vaddr of virtual slot index 0
|
||||
|
|
@ -19,11 +18,9 @@ pub struct VTable {
|
|||
|
||||
/// One vtable discovered by the whole-binary sweep — the class inventory row.
|
||||
pub struct ClassVtable {
|
||||
pub mangled: String, // the raw `_ZTS` type name, e.g. "11CBaseEntity"
|
||||
pub name: String, // demangled, e.g. "CBaseEntity"
|
||||
pub vtable_va: u64, // vaddr of slot index 0
|
||||
pub offset_to_top: i64, // 0 for the primary (complete-object) vtable; <0 for sub-object tables
|
||||
pub typeinfo: u64, // vaddr of the Itanium typeinfo struct
|
||||
pub slots: Vec<u64>, // method vaddrs; a method's gamedata offset == its index here
|
||||
pub bases: Vec<BaseClass>, // direct base classes (the is-a graph edges)
|
||||
}
|
||||
|
|
@ -144,10 +141,10 @@ fn demangle_type(mangled: &str) -> String {
|
|||
.unwrap_or_else(|| mangled.to_string())
|
||||
}
|
||||
|
||||
/// If `ti` addresses a valid Itanium typeinfo, return its `(mangled, demangled)` class name.
|
||||
/// If `ti` addresses a valid Itanium typeinfo, return its DEMANGLED class name.
|
||||
/// A typeinfo is `[kind_vtable_ptr][name_ptr][ base-class data … ]`: `+0` points at one of the
|
||||
/// C++ runtime's type_info-kind vtables, `+8` at the `_ZTS` name string.
|
||||
fn typeinfo_name(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Option<(String, String)> {
|
||||
fn typeinfo_name(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Option<String> {
|
||||
// +0 must be one of the three kind vtables. Prefer the symbol-name-derived tag (the only signal that
|
||||
// survives a DYNAMICALLY-linked C++ runtime, where the three kinds all resolve to the same offline
|
||||
// value); else fall back to the in-image value check (statically-linked / stripped builds).
|
||||
|
|
@ -164,7 +161,7 @@ fn typeinfo_name(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Option<(String,
|
|||
if !(c0.is_ascii_digit() || matches!(c0, b'N' | b'I' | b'P' | b'K' | b'S')) {
|
||||
return None;
|
||||
}
|
||||
Some((mangled.clone(), demangle_type(&mangled)))
|
||||
Some(demangle_type(&mangled))
|
||||
}
|
||||
|
||||
/// Direct base classes of the typeinfo at `ti`, dispatched on its exact Itanium kind.
|
||||
|
|
@ -188,7 +185,7 @@ fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass>
|
|||
Some(KindTag::Si) => {
|
||||
// __si_class_type_info: one public, non-virtual base at offset 0; its typeinfo ptr at +16.
|
||||
if let Some(bp) = img.read_ptr(ti.wrapping_add(16))
|
||||
&& let Some((_, name)) = typeinfo_name(img, bp, kinds)
|
||||
&& let Some(name) = typeinfo_name(img, bp, kinds)
|
||||
{
|
||||
return vec![BaseClass {
|
||||
name,
|
||||
|
|
@ -200,7 +197,7 @@ fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass>
|
|||
}
|
||||
Some(KindTag::Vmi) => {
|
||||
// __vmi_class_type_info: flags@+16, base_count@+20, then 16-byte {typeinfo_ptr, offset_flags}.
|
||||
let Some(count) = img.read_u32(ti + 20) else {
|
||||
let Some(count) = img.read_u32(ti.wrapping_add(20)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if count == 0 || count > 128 {
|
||||
|
|
@ -212,7 +209,7 @@ fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass>
|
|||
let Some(bp) = img.read_ptr(e) else {
|
||||
break;
|
||||
};
|
||||
if let Some((_, name)) = typeinfo_name(img, bp, kinds) {
|
||||
if let Some(name) = typeinfo_name(img, bp, kinds) {
|
||||
let of = img.read_i64(e.wrapping_add(8)).unwrap_or(0);
|
||||
bases.push(BaseClass {
|
||||
name,
|
||||
|
|
@ -236,18 +233,16 @@ fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass>
|
|||
pub fn enumerate_vtables(img: &CodeImage, max_slots: usize) -> Vec<ClassVtable> {
|
||||
let kinds = RttiKinds::detect(img);
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for (slot, val) in img.reloc_slots() {
|
||||
if slot < 8 {
|
||||
continue;
|
||||
}
|
||||
let Some((mangled, name)) = typeinfo_name(img, val, &kinds) else {
|
||||
let Some(name) = typeinfo_name(img, val, &kinds) else {
|
||||
continue;
|
||||
};
|
||||
// No de-dup guard: `reloc_slots` iterates a map KEYED by slot vaddr, so every slot — and hence
|
||||
// every `slot + 8` — is already unique. A `seen` set here can never reject a candidate.
|
||||
let vtable_va = slot.wrapping_add(8);
|
||||
if !seen.insert(vtable_va) {
|
||||
continue;
|
||||
}
|
||||
// offset-to-top sits at vtable-16 (just below the typeinfo field): a plain, non-relocated,
|
||||
// pointer-aligned int, 0 for a primary table and a small negative for sub-object tables.
|
||||
let Some(ott) = img.read_i64(slot.wrapping_sub(8)) else {
|
||||
|
|
@ -262,11 +257,9 @@ pub fn enumerate_vtables(img: &CodeImage, max_slots: usize) -> Vec<ClassVtable>
|
|||
}
|
||||
let bases = typeinfo_bases(img, val, &kinds);
|
||||
out.push(ClassVtable {
|
||||
mangled,
|
||||
name,
|
||||
vtable_va,
|
||||
offset_to_top: ott,
|
||||
typeinfo: val,
|
||||
slots,
|
||||
bases,
|
||||
});
|
||||
|
|
|
|||
500
src/schema.rs
500
src/schema.rs
|
|
@ -18,7 +18,7 @@ use crate::elf::CodeImage;
|
|||
use crate::profile::GameProfile;
|
||||
use crate::{live, model};
|
||||
use anyhow::Result;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
/// Byte offsets of the SchemaSystem reflection structs (SchemaClassInfoData_t / SchemaClassFieldData_t /
|
||||
|
|
@ -60,6 +60,10 @@ pub const CURRENT_LAYOUT: SchemaLayout = SchemaLayout {
|
|||
ci_base_count: 41,
|
||||
ci_fields: 48,
|
||||
ci_bases: 56,
|
||||
// Every displacement below is added to a FILE-CONTROLLED pointer, so each use wraps rather than
|
||||
// panicking under the overflow-checked fuzz build. That includes the ones that are 0 today: they are
|
||||
// layout values, revised when Valve reshapes the struct, and "safe because this constant happens to
|
||||
// be zero" is a trap that springs on the revision rather than on the code that introduced it.
|
||||
f_name: 0,
|
||||
f_offset: 16,
|
||||
f_stride: 32,
|
||||
|
|
@ -140,7 +144,6 @@ fn is_type_name(s: &str) -> bool {
|
|||
/// inventory. Sorted by class name.
|
||||
pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for (slot, val) in img.reloc_slots() {
|
||||
if slot < 8 {
|
||||
continue;
|
||||
|
|
@ -152,15 +155,153 @@ pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> {
|
|||
if !is_type_name(&name) {
|
||||
continue;
|
||||
}
|
||||
// No de-dup guard — `reloc_slots` iterates a slot-keyed map, so `slot - 8` is already unique.
|
||||
let base = slot - 8;
|
||||
if !seen.insert(base) {
|
||||
continue;
|
||||
}
|
||||
if let Some(cls) = parse_class(img, base, &name, val) {
|
||||
out.push(cls);
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
// By NAME, then by the record's own address. The walk iterates `reloc_slots()`, which is a HashMap,
|
||||
// so the collection order is hash order; a sort on name alone is stable and therefore leaves ties —
|
||||
// two libraries registering one class — resolved by that hash order, in a byte-reproducible artifact.
|
||||
out.sort_by(|a, b| (&a.name, a.class_info).cmp(&(&b.name, b.class_info)));
|
||||
out
|
||||
}
|
||||
|
||||
/// One registered Source-2 enum recovered from the schema tables — the semantic vocabulary
|
||||
/// (`MoveType_t`, `gear_slot_t`, `DamageTypes_t`) that a raw field offset and an integer width cannot
|
||||
/// supply on their own.
|
||||
pub struct SchemaEnum {
|
||||
pub name: String,
|
||||
/// Underlying integer width in bytes — the binding records it, so a byte-sized enum
|
||||
/// (`MoveType_t`) is distinguishable from a word-sized one (`gear_slot_t`) without inference.
|
||||
pub size: u8,
|
||||
pub align: u8,
|
||||
/// Enumerators in DECLARATION order (names are unique; values are not — aliases like
|
||||
/// `MOVETYPE_LAST` / `MOVETYPE_INVALID` legitimately share one).
|
||||
pub values: Vec<(String, i64)>,
|
||||
}
|
||||
|
||||
// A `CSchemaEnumBinding`, relative to the slot holding its name pointer.
|
||||
const EB_TYPE_NAME: u64 = 0; // char* — the enum's type name (the reloc slot this is found by)
|
||||
const EB_WIDTH: u64 = 16; // u8 size, u8 alignment, u16 flags, u32 enumerator count
|
||||
const EB_VALUES: u64 = 24; // -> the enumerator array
|
||||
const EV_STRIDE: u64 = 32; // one enumerator: char* name, i64 value, then metadata
|
||||
const EV_VALUE: u64 = 8;
|
||||
/// Enumerator-count sanity bound. The largest real CS2 enum is ~100 values; this only has to reject a
|
||||
/// field that isn't a count at all before it drives an allocation.
|
||||
const EB_MAX_VALUES: u32 = 4096;
|
||||
|
||||
/// Enumerate every registered enum in `img`, given the classes [`enumerate_schema`] already recovered.
|
||||
/// Same reloc-driven discovery: an enum binding is found by the slot holding its type-name pointer, then
|
||||
/// accepted only if the width/count word and the enumerator array both read as what they claim to be —
|
||||
/// so a layout change yields fewer enums, never wrong ones. Sorted by name.
|
||||
///
|
||||
/// **A class's FIELD descriptor is byte-compatible with an enum binding**, which is why `classes` is a
|
||||
/// parameter rather than a convenience. `SchemaClassFieldData_t` is `{ name, type, offset, metadataCount,
|
||||
/// metadata }`: read as an enum binding, the name reads as a type name, the low bytes of the offset read
|
||||
/// as a plausible size/alignment, the metadata count reads as an enumerator count, and the metadata array
|
||||
/// — `{ name, data }` pairs — reads as enumerators. Every field carrying exactly one metadata tag at a
|
||||
/// field offset whose low two bytes are both powers of two therefore fits, and the result would be an
|
||||
/// enum that does not exist, named after a member, whose one "value" is the ADDRESS of a documentation
|
||||
/// string and therefore differs between runs of the same build.
|
||||
///
|
||||
/// Two independent structural facts reject them, and both are needed — measured over 1,016 CS2 and 1,490
|
||||
/// Dota candidates, they catch 40 apiece with zero real enums lost, and neither catches all 40 alone:
|
||||
///
|
||||
/// 1. **The record sits inside a class's field array**, at a `F_STRIDE` boundary. That is not a heuristic
|
||||
/// — the SchemaSystem states that this address is that class's Nth field.
|
||||
/// 2. **An enumerator's value is a relocation.** An enum value is a compile-time literal, so it is never
|
||||
/// relocated; a metadata entry's second word is a pointer, so it always is. This is what catches a
|
||||
/// field whose owning class the class walk itself rejected, leaving no array to fall inside.
|
||||
pub fn enumerate_enums(img: &CodeImage, classes: &[SchemaClass]) -> Vec<SchemaEnum> {
|
||||
// The address ranges class field descriptors occupy, sorted so membership is a binary search.
|
||||
let mut spans: Vec<(u64, u64)> = classes
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let fp = img.read_ptr(c.class_info.wrapping_add(CI_FIELDS))?;
|
||||
(fp != 0).then(|| (fp, fp.wrapping_add(F_STRIDE * c.fields.len() as u64)))
|
||||
})
|
||||
.collect();
|
||||
spans.sort_unstable();
|
||||
// Each class owns its own array, so the ranges are disjoint and the last one starting at or before
|
||||
// `a` is the only one that can contain it. If that ever stopped holding, the miss would be a fake
|
||||
// enum surviving rather than a real one dropped — the same direction every other guard here errs in.
|
||||
let in_field_array = |a: u64| {
|
||||
let i = spans.partition_point(|&(s, _)| s <= a);
|
||||
i > 0 && {
|
||||
let (s, e) = spans[i - 1];
|
||||
a < e && (a - s).is_multiple_of(F_STRIDE)
|
||||
}
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (slot, val) in img.reloc_slots() {
|
||||
let Some(name) = img.read_c_string(val) else {
|
||||
continue;
|
||||
};
|
||||
// `reloc_slots` iterates a slot-keyed map, so a per-slot de-dup set can reject nothing; the real
|
||||
// de-dup is by NAME, at the sort/dedup below (a shared enum is registered by several libraries).
|
||||
if !is_type_name(&name) {
|
||||
continue;
|
||||
}
|
||||
let base = slot.wrapping_sub(EB_TYPE_NAME);
|
||||
// Clause 1: the SchemaSystem states this address is a class's field descriptor, so it is one.
|
||||
if in_field_array(base) {
|
||||
continue;
|
||||
}
|
||||
let Some(w) = img.read_ptr(base.wrapping_add(EB_WIDTH)) else {
|
||||
continue;
|
||||
};
|
||||
let (size, align, count) = (w as u8, (w >> 8) as u8, (w >> 32) as u32);
|
||||
if !matches!(size, 1 | 2 | 4 | 8)
|
||||
|| !matches!(align, 1 | 2 | 4 | 8)
|
||||
|| count == 0
|
||||
|| count > EB_MAX_VALUES
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(arr) = img
|
||||
.read_ptr(base.wrapping_add(EB_VALUES))
|
||||
.filter(|&a| a != 0)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Every enumerator must read cleanly; a partial read means this was not an enum binding.
|
||||
let mut values = Vec::with_capacity(count as usize);
|
||||
for i in 0..u64::from(count) {
|
||||
let rec = arr.wrapping_add(i.wrapping_mul(EV_STRIDE));
|
||||
let (Some(n), Some(v)) = (
|
||||
img.read_ptr(rec).and_then(|p| img.read_c_string(p)),
|
||||
img.read_i64(rec.wrapping_add(EV_VALUE)),
|
||||
) else {
|
||||
break;
|
||||
};
|
||||
// Clause 2: a relocated "value" is a pointer, so these are `{ name, data }` metadata
|
||||
// entries and not enumerators. Rejects the whole record — one pointer among the values
|
||||
// means the array is the wrong kind, not that one enumerator is odd.
|
||||
if n.is_empty() || img.is_reloc_slot(rec.wrapping_add(EV_VALUE)) {
|
||||
break;
|
||||
}
|
||||
values.push((n, v));
|
||||
}
|
||||
if values.len() == count as usize {
|
||||
out.push(SchemaEnum {
|
||||
name,
|
||||
size,
|
||||
align,
|
||||
values,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Same reason as `enumerate_schema`, and it matters MORE here because the `dedup_by` below then keeps
|
||||
// whichever row sorted first: with a name-only sort that survivor was picked by hash order. Enums have
|
||||
// no record address on the struct, so the tiebreak is the content that distinguishes two accounts of
|
||||
// one name — width, alignment, then the enumerator list.
|
||||
out.sort_by(|a, b| {
|
||||
(&a.name, a.size, a.align, &a.values).cmp(&(&b.name, b.size, b.align, &b.values))
|
||||
});
|
||||
out.dedup_by(|a, b| a.name == b.name); // one binding per name; libs re-register shared enums
|
||||
out
|
||||
}
|
||||
|
||||
|
|
@ -189,10 +330,13 @@ fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<
|
|||
let mut fields = Vec::with_capacity(field_count as usize);
|
||||
for i in 0..field_count as u64 {
|
||||
let fe = fields_ptr.wrapping_add(i.wrapping_mul(F_STRIDE));
|
||||
let Some(fname) = img.read_ptr(fe + F_NAME).and_then(|p| img.read_c_string(p)) else {
|
||||
let Some(fname) = img
|
||||
.read_ptr(fe.wrapping_add(F_NAME))
|
||||
.and_then(|p| img.read_c_string(p))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let offset = img.read_i32(fe + F_OFFSET).unwrap_or(0);
|
||||
let offset = img.read_i32(fe.wrapping_add(F_OFFSET)).unwrap_or(0);
|
||||
fields.push(SchemaField {
|
||||
name: fname,
|
||||
offset,
|
||||
|
|
@ -205,13 +349,13 @@ fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<
|
|||
if bases_ptr != 0 {
|
||||
for i in 0..base_count as u64 {
|
||||
let be = bases_ptr.wrapping_add(i.wrapping_mul(B_STRIDE));
|
||||
let offset = img.read_u32(be + B_OFFSET).unwrap_or(0);
|
||||
let bcls = img.read_ptr(be + B_CLASS).unwrap_or(0);
|
||||
let offset = img.read_u32(be.wrapping_add(B_OFFSET)).unwrap_or(0);
|
||||
let bcls = img.read_ptr(be.wrapping_add(B_CLASS)).unwrap_or(0);
|
||||
if bcls == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(bn) = img
|
||||
.read_ptr(bcls + CI_NAME)
|
||||
.read_ptr(bcls.wrapping_add(CI_NAME))
|
||||
.and_then(|p| img.read_c_string(p))
|
||||
{
|
||||
bases.push(SchemaBase { name: bn, offset });
|
||||
|
|
@ -233,7 +377,7 @@ fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<
|
|||
// The offline reader above recovers class layouts (names + field offsets) from the static reflection
|
||||
// tables. Field *types* are runtime-resolved (each record's `m_pType` is a null pointer on disk), so
|
||||
// `live_schema` attaches to a running process, reads the types back, and builds the typed
|
||||
// `netvars-<game>.json` (`model::Schema`) directly — no `sdk.json` intermediate.
|
||||
// the artifact's `schema` section (`model::Schema`) directly — no `sdk.json` intermediate.
|
||||
|
||||
/// FNV-1a (32-bit). The Source-2 schema field/class name hash: a field's runtime lookup key is
|
||||
/// `(fnv1a32(class_name) << 32) | fnv1a32(field_name)` (field name keeps its `m_` prefix). Confirmed
|
||||
|
|
@ -261,7 +405,7 @@ fn builtin_size(t: &str) -> i32 {
|
|||
/// Walk a running process's schema across every server-mapped library (`profile.libs`) and build the typed
|
||||
/// netvars (`model::Schema`) DIRECTLY — no `sdk.json` round-trip: field layout is read offline from each
|
||||
/// image, the runtime `m_pType` from the live process. Shared classes (compiled into many libs) de-dupe
|
||||
/// precedence-first (the earlier lib in `libs` wins). This is `netvars-<game>.json` — the shipped SDK
|
||||
/// precedence-first (the earlier lib in `libs` wins). This is the artifact's `schema` section — the shipped SDK
|
||||
/// material (`source2rosetta-gen` renders it on demand).
|
||||
pub(crate) fn live_schema(
|
||||
prof: &GameProfile,
|
||||
|
|
@ -274,6 +418,12 @@ pub(crate) fn live_schema(
|
|||
let mut classes: BTreeMap<String, BTreeMap<String, Field>> = BTreeMap::new();
|
||||
let (mut typed, mut untyped) = (0usize, 0usize);
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut enums: BTreeMap<String, model::EnumDef> = BTreeMap::new();
|
||||
// The schema states each registered class's instance size — the exact half of the layout picture.
|
||||
let mut registered_sizes: BTreeMap<String, usize> = BTreeMap::new();
|
||||
// The base graph the schema already recovers — exported, so a consumer can resolve an inherited
|
||||
// field, and consulted here so the SysV verdict sees inherited members.
|
||||
let mut bases: BTreeMap<String, Vec<model::BaseClass>> = BTreeMap::new();
|
||||
let mut nlibs = 0usize;
|
||||
for &lib in prof.libs {
|
||||
let Ok(img) = crate::locate::load_lib(dir, lib) else {
|
||||
|
|
@ -281,7 +431,23 @@ pub(crate) fn live_schema(
|
|||
};
|
||||
let Some(base) = live.base(lib) else { continue }; // lib not mapped in the process -> skip
|
||||
nlibs += 1;
|
||||
for c in &enumerate_schema(&img) {
|
||||
// ONE class walk per library, shared by both consumers below — the enum walk needs it to tell a
|
||||
// field descriptor from an enum binding, and walking the reflection tables twice per image is
|
||||
// what a large game's memory ceiling notices first.
|
||||
let schema_classes = enumerate_schema(&img);
|
||||
// Enum bindings are static, so they come from the IMAGE — no process read, unlike field types.
|
||||
// First library wins, matching the class precedence: a shared enum has one definition.
|
||||
for e in enumerate_enums(&img, &schema_classes) {
|
||||
enums.entry(e.name).or_insert_with(|| model::EnumDef {
|
||||
size: e.size,
|
||||
values: e
|
||||
.values
|
||||
.into_iter()
|
||||
.map(|(name, value)| model::EnumValue { name, value })
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
for c in &schema_classes {
|
||||
// a shared class already taken from an earlier (higher-precedence) lib — identical layout, skip
|
||||
if !seen.insert(c.name.clone()) {
|
||||
continue;
|
||||
|
|
@ -334,12 +500,54 @@ pub(crate) fn live_schema(
|
|||
},
|
||||
);
|
||||
}
|
||||
if let Ok(sz) = usize::try_from(c.size) {
|
||||
registered_sizes.insert(c.name.clone(), sz);
|
||||
}
|
||||
if !c.bases.is_empty() {
|
||||
bases.insert(
|
||||
c.name.clone(),
|
||||
c.bases
|
||||
.iter()
|
||||
.map(|b| model::BaseClass {
|
||||
name: b.name.clone(),
|
||||
offset: b.offset,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
classes.insert(c.name.clone(), fmap);
|
||||
}
|
||||
}
|
||||
let (types, cal) = derive_type_layouts(&classes, ®istered_sizes, &bases);
|
||||
// A size is only useful to a caller once it reaches the FIELD: `size` was zero for every aggregate,
|
||||
// which reads as "unknown" and is exactly what the layout pass now answers.
|
||||
for fields in classes.values_mut() {
|
||||
for f in fields.values_mut() {
|
||||
if f.size == 0
|
||||
&& let Some(t) = base_type(&f.ty).and_then(|b| types.get(b))
|
||||
{
|
||||
// The field's EXTENT, so a consumer can bound a read: a fixed array spans
|
||||
// element x count. Writing the element size here would understate `char[128]` as 1.
|
||||
f.size = t.size.saturating_mul(array_len(&f.ty));
|
||||
}
|
||||
}
|
||||
}
|
||||
let derived_sizes = classes
|
||||
.values()
|
||||
.flat_map(|c| c.values())
|
||||
.filter(|f| f.size > 0)
|
||||
.count();
|
||||
eprintln!(
|
||||
"typed netvars: {} classes across {nlibs} libs, {typed} typed fields, {untyped} unresolved",
|
||||
classes.len()
|
||||
"typed netvars: {} classes across {nlibs} libs, {typed} typed fields, {untyped} unresolved; \
|
||||
{} enums / {} enumerators; {} type layouts ({derived_sizes}/{} fields sized, \
|
||||
field-gap calibration {}/{} exact)",
|
||||
classes.len(),
|
||||
enums.len(),
|
||||
enums.values().map(|e| e.values.len()).sum::<usize>(),
|
||||
types.len(),
|
||||
typed + untyped,
|
||||
cal.exact,
|
||||
cal.checked
|
||||
);
|
||||
Ok(Schema {
|
||||
meta: SchemaMeta {
|
||||
|
|
@ -347,7 +555,267 @@ pub(crate) fn live_schema(
|
|||
source_build: source_build.to_string(),
|
||||
typed,
|
||||
untyped,
|
||||
enums: enums.len(),
|
||||
types: types.len(),
|
||||
},
|
||||
classes,
|
||||
bases,
|
||||
enums,
|
||||
types,
|
||||
})
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
// Type layouts — what a caller needs to PASS a value, which an offset alone cannot supply
|
||||
// ══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// SysV classification for the engine value types the SchemaSystem does NOT register, and whose size
|
||||
/// alone cannot settle how they travel.
|
||||
///
|
||||
/// At 16 bytes or less an aggregate goes in SSE registers when every member is floating-point and in
|
||||
/// integer registers otherwise, and no derived size distinguishes those two. Above 16 bytes the size
|
||||
/// settles it, so nothing needs declaring. This is therefore the ONE place the deriver declares rather
|
||||
/// than derives, deliberately kept to a closed set of engine primitives — each entry is what the type
|
||||
/// demonstrably IS, not a guess: the math types are plain float aggregates, and everything else here is
|
||||
/// a pointer, a handle or a packed integer.
|
||||
const UNREGISTERED_CLASSES: &[(&str, model::SysvClass)] = &[
|
||||
// All-float aggregates — SSE. `Vector` by value costs TWO SSE registers; by reference, one integer.
|
||||
("Vector", model::SysvClass::Sse),
|
||||
("VectorWS", model::SysvClass::Sse),
|
||||
("Vector2D", model::SysvClass::Sse),
|
||||
("Vector4D", model::SysvClass::Sse),
|
||||
("QAngle", model::SysvClass::Sse),
|
||||
("Quaternion", model::SysvClass::Sse),
|
||||
("RadianEuler", model::SysvClass::Sse),
|
||||
("QuaternionStorage", model::SysvClass::Sse),
|
||||
// Pointers, handles and packed integers — INTEGER.
|
||||
("CUtlString", model::SysvClass::Integer),
|
||||
("CUtlSymbolLarge", model::SysvClass::Integer),
|
||||
("CUtlSymbol", model::SysvClass::Integer),
|
||||
("CGlobalSymbol", model::SysvClass::Integer),
|
||||
("CUtlStringToken", model::SysvClass::Integer),
|
||||
("CHandle", model::SysvClass::Integer),
|
||||
("CEntityHandle", model::SysvClass::Integer),
|
||||
("CStrongHandle", model::SysvClass::Integer),
|
||||
("CWeakHandle", model::SysvClass::Integer),
|
||||
("CGameSoundEventName", model::SysvClass::Integer),
|
||||
("Color", model::SysvClass::Integer),
|
||||
("CTransform", model::SysvClass::Memory), // 32 bytes; stated for clarity, size settles it anyway
|
||||
];
|
||||
|
||||
/// The SysV boundary: an aggregate above this is passed in memory, so its size settles its class.
|
||||
const SYSV_REGISTER_LIMIT: usize = 16;
|
||||
/// A field-gap size is accepted only with this much agreement across observations — the modal gap has to
|
||||
/// dominate, or the "next field" is padding/union noise rather than this field's extent.
|
||||
const GAP_AGREEMENT: f64 = 0.8;
|
||||
/// …and only with at least this many observations, so one lucky class cannot mint a size.
|
||||
const GAP_MIN_OBS: usize = 4;
|
||||
|
||||
/// A builtin's SysV class. The floating types travel in SSE registers, every other builtin in integer
|
||||
/// ones — a property of the ABI, not of Valve's code, which is why it is stated here rather than derived.
|
||||
fn builtin_sysv(t: &str) -> Option<model::SysvClass> {
|
||||
match t {
|
||||
"float32" | "float64" | "double" => Some(model::SysvClass::Sse),
|
||||
"int8" | "uint8" | "char" | "bool" | "int16" | "uint16" | "int32" | "uint32" | "int64"
|
||||
| "uint64" => Some(model::SysvClass::Integer),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// How well the field-gap inference reproduced the sizes that are known exactly — the same free-oracle
|
||||
/// idea as the entity-IO ABI check: the builtins have an independently known size, so running the
|
||||
/// inference over them and comparing is a per-build test of the inference itself, not an assumption.
|
||||
pub struct GapCalibration {
|
||||
pub checked: usize,
|
||||
pub exact: usize,
|
||||
}
|
||||
|
||||
/// The bare type name behind a field's declared type: array suffix stripped, template arguments dropped.
|
||||
/// `None` for a pointer (its size is the pointer's, and it says nothing about the pointee) or a bitfield.
|
||||
fn base_type(ty: &str) -> Option<&str> {
|
||||
let t = ty.trim().split('[').next()?.trim();
|
||||
if t.ends_with('*') || t.starts_with("bitfield") || t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(t.split('<').next()?.trim())
|
||||
}
|
||||
|
||||
/// Every type NAME a declared field type mentions: the outer type plus each template argument, since an
|
||||
/// inner type is a real type a consumer must know — `CUtlLeanVector<CPulseRuntimeMethodArg>` is how the
|
||||
/// element type of a Pulse method's argument list is spelled, and stripping the template arguments loses it.
|
||||
fn mentioned_types(ty: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(b) = base_type(ty) {
|
||||
out.push(b);
|
||||
}
|
||||
// Template arguments, comma-split at depth 1 so a nested template stays with its parent.
|
||||
if let Some(open) = ty.find('<') {
|
||||
let inner = &ty[open + 1..ty.rfind('>').unwrap_or(ty.len())];
|
||||
let (mut depth, mut start) = (0usize, 0usize);
|
||||
for (i, c) in inner.char_indices() {
|
||||
match c {
|
||||
'<' | '(' | '[' => depth += 1,
|
||||
'>' | ')' | ']' => depth = depth.saturating_sub(1),
|
||||
',' if depth == 0 => {
|
||||
out.extend(mentioned_types(&inner[start..i]));
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.extend(mentioned_types(&inner[start..]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The declared array length of a field type (`float32[3]` -> 3), else 1.
|
||||
fn array_len(ty: &str) -> usize {
|
||||
ty.rsplit_once('[')
|
||||
.and_then(|(_, n)| n.strip_suffix(']'))
|
||||
.and_then(|n| n.trim().parse::<usize>().ok())
|
||||
.filter(|&n| n > 0)
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// Is every member of `ty`, inherited members included, a floating-point value? `None` when the answer
|
||||
/// cannot be established — an unknown base, or a member whose own type is not resolvable — because
|
||||
/// "unknown" and "not all float" are different answers and only one of them is safe to act on.
|
||||
fn all_float(
|
||||
ty: &str,
|
||||
classes: &BTreeMap<String, BTreeMap<String, model::Field>>,
|
||||
bases: &BTreeMap<String, Vec<model::BaseClass>>,
|
||||
depth: usize,
|
||||
) -> Option<bool> {
|
||||
if depth > 8 {
|
||||
return None; // pathological or cyclic hierarchy — decline rather than guess
|
||||
}
|
||||
let fields = classes.get(ty)?;
|
||||
for b in bases.get(ty).map(Vec::as_slice).unwrap_or_default() {
|
||||
if !all_float(&b.name, classes, bases, depth + 1)? {
|
||||
return Some(false);
|
||||
}
|
||||
}
|
||||
// A class with no members of its own and no bases tells us nothing about how it travels.
|
||||
if fields.is_empty() && bases.get(ty).is_none_or(Vec::is_empty) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
fields
|
||||
.values()
|
||||
.all(|f| matches!(base_type(&f.ty), Some("float32" | "float64"))),
|
||||
)
|
||||
}
|
||||
|
||||
/// Recover a size and a SysV class for every type the schema's fields refer to.
|
||||
///
|
||||
/// Two independent routes, and which one produced a given answer is recorded rather than blurred:
|
||||
/// a REGISTERED class states its own instance size, and everything else is inferred from the distance to
|
||||
/// the next field — schema fields are laid out in offset order, so that gap IS the field's extent. The
|
||||
/// inference is calibrated on the types whose size is independently known: every primitive
|
||||
/// (`float32`, `int64`, …) comes back exact.
|
||||
pub fn derive_type_layouts(
|
||||
classes: &BTreeMap<String, BTreeMap<String, model::Field>>,
|
||||
registered_sizes: &BTreeMap<String, usize>,
|
||||
bases: &BTreeMap<String, Vec<model::BaseClass>>,
|
||||
) -> (BTreeMap<String, model::TypeLayout>, GapCalibration) {
|
||||
// base type -> observed per-element gap -> how many times it was seen
|
||||
let mut gaps: BTreeMap<&str, BTreeMap<usize, usize>> = BTreeMap::new();
|
||||
for fields in classes.values() {
|
||||
let mut by_off: Vec<(&model::Field, &str)> =
|
||||
fields.values().map(|f| (f, f.ty.as_str())).collect();
|
||||
by_off.sort_by_key(|(f, _)| f.offset);
|
||||
for w in by_off.windows(2) {
|
||||
let (f, ty) = w[0];
|
||||
let gap = w[1].0.offset - f.offset;
|
||||
// A non-positive gap is a union or an overlapping bitfield, not an extent.
|
||||
let (Some(base), true) = (base_type(ty), gap > 0) else {
|
||||
continue;
|
||||
};
|
||||
let n = array_len(ty);
|
||||
if gap as usize % n != 0 {
|
||||
continue; // the gap does not divide into the declared element count — not this field's
|
||||
}
|
||||
*gaps
|
||||
.entry(base)
|
||||
.or_default()
|
||||
.entry(gap as usize / n)
|
||||
.or_default() += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let declared: BTreeMap<&str, model::SysvClass> = UNREGISTERED_CLASSES.iter().copied().collect();
|
||||
let mut out = BTreeMap::new();
|
||||
// Every type any field refers to — a type used only behind a pointer still deserves an entry when
|
||||
// its size is known from the schema.
|
||||
let mut wanted: BTreeSet<&str> = BTreeSet::new();
|
||||
for fields in classes.values() {
|
||||
for f in fields.values() {
|
||||
wanted.extend(mentioned_types(&f.ty));
|
||||
}
|
||||
}
|
||||
wanted.extend(gaps.keys().copied());
|
||||
// Every registered class, whether or not any field happens to name it — the schema states its size, so
|
||||
// withholding the entry would be losing an answer we already hold.
|
||||
wanted.extend(registered_sizes.keys().map(String::as_str));
|
||||
|
||||
let mut cal = GapCalibration {
|
||||
checked: 0,
|
||||
exact: 0,
|
||||
};
|
||||
for ty in wanted {
|
||||
let builtin = builtin_size(ty);
|
||||
// Where a size is known exactly, CHECK the inference against it rather than using the inference.
|
||||
if builtin > 0
|
||||
&& let Some(hist) = gaps.get(ty)
|
||||
&& let Some((&sz, _)) = hist.iter().max_by_key(|&(_, n)| *n)
|
||||
{
|
||||
cal.checked += 1;
|
||||
cal.exact += usize::from(sz == builtin as usize);
|
||||
}
|
||||
let (size, source, obs, agree) = match (builtin, registered_sizes.get(ty)) {
|
||||
// A builtin's size is fixed by the ABI.
|
||||
(b, _) if b > 0 => (b as usize, model::LayoutSource::Declared, None, None),
|
||||
// The schema states a registered class's size — no inference needed.
|
||||
(_, Some(&sz)) => (sz, model::LayoutSource::Schema, None, None),
|
||||
_ => {
|
||||
let Some(hist) = gaps.get(ty) else { continue };
|
||||
let total: usize = hist.values().sum();
|
||||
let (&sz, &n) = hist.iter().max_by_key(|&(_, n)| *n).expect("non-empty");
|
||||
if total < GAP_MIN_OBS || (n as f64) < GAP_AGREEMENT * total as f64 {
|
||||
continue; // too thin or too contested to state a size
|
||||
}
|
||||
(sz, model::LayoutSource::FieldGap, Some(total), Some(n))
|
||||
}
|
||||
};
|
||||
// Above the register limit the size decides. At or below it, the question is whether every member
|
||||
// is floating-point — derivable for a registered class by inspecting its fields, and declared for
|
||||
// the closed set of primitives the schema omits.
|
||||
let sysv = if let Some(c) = builtin_sysv(ty) {
|
||||
c
|
||||
} else if size > SYSV_REGISTER_LIMIT {
|
||||
model::SysvClass::Memory
|
||||
} else if let Some(&c) = declared.get(ty) {
|
||||
c
|
||||
} else {
|
||||
// SSE requires that EVERY member is floating-point — including inherited ones. Judging on
|
||||
// a class's own fields alone calls a type SSE whose base contributes the first eightbyte
|
||||
// (a pointer), which is the difference between passing it in XMM0 and in RDI.
|
||||
match all_float(ty, classes, bases, 0) {
|
||||
Some(true) => model::SysvClass::Sse,
|
||||
Some(false) => model::SysvClass::Integer,
|
||||
None => model::SysvClass::Unknown,
|
||||
}
|
||||
};
|
||||
out.insert(
|
||||
ty.to_string(),
|
||||
model::TypeLayout {
|
||||
size,
|
||||
sysv,
|
||||
source,
|
||||
observations: obs,
|
||||
agreement: agree,
|
||||
},
|
||||
);
|
||||
}
|
||||
(out, cal)
|
||||
}
|
||||
|
|
|
|||
561
src/valvetab.rs
Normal file
561
src/valvetab.rs
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
//! Valve's in-binary NAME tables — the one place a stripped Source-2 module names its own functions.
|
||||
//!
|
||||
//! Two static tables sit in writable data, and they give different things:
|
||||
//!
|
||||
//! - **Entity-IO datadesc** (112-byte stride, in the game library) pairs a NAME with the FUNCTION: the
|
||||
//! C++ handler name (`InputKill`), the map-facing input name (`Kill`), and the handler's address.
|
||||
//! - **Pulse bindings** (80-byte stride, in every module registering Pulse cells or an entity API)
|
||||
//! pairs a fully-qualified `Class::Method` with the author-facing display name and description, two
|
||||
//! metadata words — and two code pointers that are DESCRIPTOR ACCESSORS, not the bound function
|
||||
//! (see [`names`]). It documents the callable surface; it does not locate it.
|
||||
//!
|
||||
//! Both are ground truth from the shipped binary — the names are what Valve compiled in, not a transfer
|
||||
//! from another game and not an inference — so they outrank every other naming source, and extracting
|
||||
//! them belongs beside the RTTI and schema readers rather than in a side-channel: the source travels
|
||||
//! with the binary, so it re-derives on every build for free and needs no genesis input.
|
||||
//!
|
||||
//! Both readers are deliberately shape-driven, not offset-driven: a record is accepted only when its
|
||||
//! name pointer resolves to a plausibly-shaped string AND its function pointer lands in executable
|
||||
//! code. A layout change therefore yields FEWER records, never wrong ones.
|
||||
|
||||
use crate::elf::CodeImage;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Bytes of one Pulse binding record. The scan steps 8 bytes rather than by stride (a table's first
|
||||
/// record is not aligned to any section-relative boundary), so this only bounds the read.
|
||||
const PULSE_STRIDE: usize = 80;
|
||||
/// Bytes of one entity-IO datadesc record — a `typedescription_t`. The array interleaves plain field
|
||||
/// descriptors, inputs and outputs at this one stride; the `Input` name prefix selects the inputs.
|
||||
const DATADESC_STRIDE: usize = 112;
|
||||
|
||||
/// Longest string either table is expected to hold. Descriptions are prose, names are identifiers; a
|
||||
/// pointer that resolves to anything longer is not a record field, so the cap doubles as a validity gate.
|
||||
const MAX_STR: usize = 256;
|
||||
|
||||
/// One Pulse scripting binding, as the module registers it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PulseBinding {
|
||||
/// Fully-qualified `Class::Method`.
|
||||
pub name: String,
|
||||
/// The author-facing label Valve shows in the Pulse graph editor ("Get Abs Origin").
|
||||
pub display: Option<String>,
|
||||
/// The author-facing documentation string ("The entity origin (absolute).").
|
||||
pub description: Option<String>,
|
||||
/// Accessor returning the binding's static descriptor — a lazy-init singleton, NOT the bound
|
||||
/// function. Useful as the anchor a runtime walks to reach the descriptor; useless as a locator.
|
||||
pub descriptor: u64,
|
||||
/// A second accessor of the same shape, for the binding's argument descriptor.
|
||||
pub arg_descriptor: u64,
|
||||
/// The binding's own INVOCATION shim — the one code pointer in the record that is an entry point
|
||||
/// rather than a descriptor accessor. Zero when the slot holds no executable code (8 of 485 on CS2).
|
||||
///
|
||||
/// Measured as a fixed-signature marshalling stub: seven integer arguments returning int, where the
|
||||
/// fifth is an array of pointers to the argument values (element *k* at `+8+8k`) and the seventh is an
|
||||
/// output sink read by exactly the bindings that declare a return.
|
||||
///
|
||||
/// It IS emitted, and the measurement above is not the reason to trust it — the live oracle is. The
|
||||
/// address and its measured read-set ship as `surfaces.pulse[].shim` / `.call`; `verify_pulse_shims`
|
||||
/// actually CALLS every `args-only` shim on a running server each build (CS2 186 of 193 clean, Dota
|
||||
/// 211 of 211); and `GameProfile::min_pulse_callable` floors the population that survives. A locator
|
||||
/// nobody has exercised would be a claim — this one is exercised every derive.
|
||||
pub shim: u64,
|
||||
pub flags: PulseFlags,
|
||||
}
|
||||
|
||||
/// The metadata words a Pulse binding carries, decoded.
|
||||
///
|
||||
/// Pulse is Source 2's TYPED graph VM, so a binding cannot be registered without the engine knowing how
|
||||
/// it may be called — and it records that as data. Each decoded flag below is named for what it was
|
||||
/// measured to separate across the 1,217 CS2 bindings, not for what its field is presumed to mean:
|
||||
/// every one of them partitions the table almost perfectly along a naming convention that is
|
||||
/// independent of the bytes.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub struct PulseFlags {
|
||||
/// Free-function binding on a library class — no receiver. Set for every `CPulse*lib` / `*Funcs`
|
||||
/// binding and, being the complement of `instance`, never together with it.
|
||||
pub library: bool,
|
||||
/// Binding requires an entity receiver. Separates the `*API::` classes from everything else
|
||||
/// exactly: 345 of 353 `*API::` bindings set it, and 0 of the other 864 do.
|
||||
pub instance: bool,
|
||||
/// Binding writes state — the const-correctness bit. Across the entity APIs it is set on 100% of
|
||||
/// `Set`/`Add`/`Remove`/`Destroy`/`Turn`/`Toggle`/`Play`/`Stop` bindings and on ~0% of
|
||||
/// `Get`/`Is`/`Has`/`Find` ones.
|
||||
pub mutating: bool,
|
||||
/// Binding may suspend the calling cursor rather than returning within the frame — every record
|
||||
/// carrying it is a `Wait` / `Yield` / `Pause` / timer-`Start` / long-running-sequence binding.
|
||||
pub blocking: bool,
|
||||
/// The two undecoded words as read, so a consumer can re-derive meaning if a later build repurposes
|
||||
/// a bit rather than silently inheriting today's reading.
|
||||
pub raw: (u32, u32),
|
||||
}
|
||||
|
||||
impl PulseFlags {
|
||||
// Both words hold their booleans one-per-BYTE rather than packed one-per-bit, which is what a
|
||||
// plain `bool` struct member compiles to — so the flags are read as byte tests, not masks.
|
||||
fn decode(w0: u32, w1: u32) -> Self {
|
||||
PulseFlags {
|
||||
library: w0 & 0xff != 0,
|
||||
mutating: w0 >> 8 & 0xff != 0,
|
||||
instance: w0 >> 16 & 0xff != 0,
|
||||
blocking: w1 >> 3 & 1 != 0,
|
||||
raw: (w0, w1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The byte offset a FIELD descriptor records its member at. Established against the SchemaSystem at
|
||||
/// **601/601** on records genuinely inside a datadesc array — the qualification that matters, because a
|
||||
/// sweep of all writable data mostly finds the schema's OWN field tables, which carry a name pointer and
|
||||
/// an offset too and therefore match themselves.
|
||||
const FIELD_OFFSET_SLOT: u64 = 8;
|
||||
|
||||
/// One entity-IO input handler: the C++ method name, the input name a map fires, and the handler.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DatadescInput {
|
||||
/// The C++ handler name, e.g. `InputKill`. Not class-qualified BY THE RECORD — the descriptor
|
||||
/// carries no owning class. [`datadesc_arrays`] recovers it from the array instead; see `class`.
|
||||
pub handler: String,
|
||||
/// The class that owns this handler, where the array it sits in identified one. `InputEnable` is a
|
||||
/// distinct handler on 48 classes, and this is what tells them apart.
|
||||
pub class: Option<String>,
|
||||
/// The entity-IO input name a map or another entity fires, e.g. `Kill`.
|
||||
pub io_name: String,
|
||||
pub func: u64,
|
||||
}
|
||||
|
||||
/// One entity-IO output: an event an entity fires, and the member holding its subscriber list.
|
||||
///
|
||||
/// The mirror of [`DatadescInput`] in the same array — an input is something you SEND an entity, an output
|
||||
/// is something it TELLS you, which is what a mod hooks to react to gameplay. Carries a member OFFSET
|
||||
/// rather than a function pointer: an output is data on the instance, not code.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DatadescOutput {
|
||||
/// The member holding the output, e.g. `m_OnStartTouch`.
|
||||
pub member: String,
|
||||
/// The entity-IO name a map wires to, e.g. `OnStartTouch`. Usually the member minus `m_`, but NOT
|
||||
/// reliably so (`m_OnBombExplode` fires `BombExplode`), which is why both are kept.
|
||||
pub output: String,
|
||||
/// Byte offset of the member within its entity.
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
/// Every entity-IO output the game library declares.
|
||||
///
|
||||
/// Shares the array and the stride with [`datadesc_inputs`]; the discriminator is structural rather than
|
||||
/// lexical — an output has NO handler at `+40` (measured: zero for all 226 CS2 outputs, non-zero for every
|
||||
/// input), because it is a subscriber list rather than a function.
|
||||
pub fn datadesc_outputs(img: &CodeImage) -> Vec<DatadescOutput> {
|
||||
let mut out = Vec::new();
|
||||
scan_records(img, DATADESC_STRIDE, |at| {
|
||||
let Some(member) = img.read_ptr(at).and_then(|p| table_string(img, p)) else {
|
||||
return;
|
||||
};
|
||||
// Valve's output convention, the counterpart of the `Input` prefix the input reader keys on.
|
||||
if !member.starts_with("m_On") || member.len() <= 4 {
|
||||
return;
|
||||
}
|
||||
let (Some(output), Some(handler)) = (
|
||||
img.read_ptr(at + 24).and_then(|p| table_string(img, p)),
|
||||
img.read_ptr(at + 40),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
// A handler here means this is an INPUT record that happens to be named `m_On…`.
|
||||
if handler != 0 {
|
||||
return;
|
||||
}
|
||||
let Some(offset) = img.read_u32(at + 8).filter(|&o| o > 0 && o < (1 << 20)) else {
|
||||
return;
|
||||
};
|
||||
out.push(DatadescOutput {
|
||||
member,
|
||||
output,
|
||||
offset,
|
||||
});
|
||||
});
|
||||
out.sort_by(|a, b| (&a.output, &a.member, a.offset).cmp(&(&b.output, &b.member, b.offset)));
|
||||
out.dedup_by(|a, b| a.member == b.member && a.output == b.output && a.offset == b.offset);
|
||||
out
|
||||
}
|
||||
|
||||
/// One entity-factory record: the classname a map spawns by, and the C++ class it constructs.
|
||||
///
|
||||
/// Carries no function pointer — this is the vocabulary a level designer writes (`func_door`) bound to
|
||||
/// the class the schema describes (`CBaseDoor`), which is the join a consumer needs to spawn or identify
|
||||
/// an entity by name. It is NOT a naming source.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EntityClass {
|
||||
/// The map-facing classname, e.g. `func_door`.
|
||||
pub classname: String,
|
||||
/// The C++ class it constructs, e.g. `CBaseDoor` — a schema-registered class.
|
||||
pub class: String,
|
||||
}
|
||||
|
||||
/// Every entity classname the game library binds to a class.
|
||||
///
|
||||
/// Two adjacent string pointers, gated on their SHAPES being complementary: a map classname is
|
||||
/// lowercase-with-underscores by Valve's own convention, and a C++ class name starts uppercase. That
|
||||
/// asymmetry is what separates these records from the many other adjacent string pairs in `.data`.
|
||||
pub fn entity_classes(img: &CodeImage) -> Vec<EntityClass> {
|
||||
let map_name = |s: &str| {
|
||||
s.len() >= 3
|
||||
&& s.starts_with(|c: char| c.is_ascii_lowercase())
|
||||
&& s.bytes()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
|
||||
};
|
||||
// Source's universal class prefix: a capital `C` followed by another capital. Plain
|
||||
// uppercase-first is far too loose — `.data` is full of adjacent string pairs, and it accepted
|
||||
// `chicken_server` -> `GameSessionManifest_server`, two unrelated neighbours.
|
||||
let cpp_name = |s: &str| {
|
||||
s.len() >= 3
|
||||
&& s.starts_with('C')
|
||||
&& s.as_bytes()[1].is_ascii_uppercase()
|
||||
&& s.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashMap::new();
|
||||
scan_records(img, 16, |at| {
|
||||
let (Some(classname), Some(class)) = (
|
||||
img.read_ptr(at).and_then(|p| table_string(img, p)),
|
||||
img.read_ptr(at + 8).and_then(|p| table_string(img, p)),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
if !map_name(&classname) || !cpp_name(&class) {
|
||||
return;
|
||||
}
|
||||
// One binding per classname; a duplicate that disagrees is dropped rather than guessed at.
|
||||
match seen.entry(classname.clone()) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => {
|
||||
if *e.get() != class {
|
||||
out.retain(|x: &EntityClass| x.classname != classname);
|
||||
}
|
||||
}
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(class.clone());
|
||||
out.push(EntityClass { classname, class });
|
||||
}
|
||||
}
|
||||
});
|
||||
out.sort_by(|a, b| a.classname.cmp(&b.classname));
|
||||
out
|
||||
}
|
||||
|
||||
/// A name the binary vouches for, at an address it can be trusted to LOCATE.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ValveName {
|
||||
pub name: String,
|
||||
pub addr: u64,
|
||||
}
|
||||
|
||||
/// Read the NUL-terminated string at `vaddr`, rejecting anything that is not a short printable-ASCII
|
||||
/// run. Every table field this validates is a C identifier or a UI string, so a pointer that lands on
|
||||
/// binary data fails here rather than being carried forward as a garbage name.
|
||||
fn table_string(img: &CodeImage, vaddr: u64) -> Option<String> {
|
||||
if vaddr == 0 {
|
||||
return None;
|
||||
}
|
||||
let s = img.read_c_string(vaddr)?;
|
||||
(!s.is_empty() && s.len() <= MAX_STR && s.bytes().all(|c| (0x20..0x7f).contains(&c)))
|
||||
.then_some(s)
|
||||
}
|
||||
|
||||
/// Is `s` a fully-qualified `Class::Method` of C identifiers? The Pulse table's defining shape — and a
|
||||
/// strong enough gate on its own that a false positive would have to be a deliberately-planted string
|
||||
/// followed by two code pointers.
|
||||
fn is_qualified(s: &str) -> bool {
|
||||
let Some((class, method)) = s.split_once("::") else {
|
||||
return false;
|
||||
};
|
||||
let ident = |p: &str| {
|
||||
let mut c = p.chars();
|
||||
c.next()
|
||||
.is_some_and(|f| f.is_ascii_alphabetic() || f == '_')
|
||||
&& c.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
|
||||
};
|
||||
ident(class) && ident(method)
|
||||
}
|
||||
|
||||
/// Walk every writable data section in 8-byte steps, calling `f` with each candidate record address.
|
||||
/// `stride` reserves the record's own length so a reader never runs off the end of its section.
|
||||
fn scan_records(img: &CodeImage, stride: usize, mut f: impl FnMut(u64)) {
|
||||
for (va, len) in img.data_blocks() {
|
||||
let Some(last) = len.checked_sub(stride) else {
|
||||
continue;
|
||||
};
|
||||
for off in (0..=last).step_by(8) {
|
||||
f(va + off as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every Pulse binding `img` registers.
|
||||
pub fn pulse_bindings(img: &CodeImage) -> Vec<PulseBinding> {
|
||||
let mut out = Vec::new();
|
||||
scan_records(img, PULSE_STRIDE, |at| {
|
||||
let Some(name) = img.read_ptr(at).and_then(|p| table_string(img, p)) else {
|
||||
return;
|
||||
};
|
||||
if !is_qualified(&name) {
|
||||
return;
|
||||
}
|
||||
// Both code pointers are required: one alone also matches a plain `{name, …, fnptr}` pair,
|
||||
// whereas a second in the very next slot is specific to a binding record.
|
||||
let (Some(descriptor), Some(arg_descriptor)) =
|
||||
(img.read_ptr(at + 24), img.read_ptr(at + 32))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !img.is_code(descriptor) || !img.is_code(arg_descriptor) {
|
||||
return;
|
||||
}
|
||||
out.push(PulseBinding {
|
||||
name,
|
||||
display: img.read_ptr(at + 8).and_then(|p| table_string(img, p)),
|
||||
description: img.read_ptr(at + 16).and_then(|p| table_string(img, p)),
|
||||
descriptor,
|
||||
arg_descriptor,
|
||||
shim: img
|
||||
.read_ptr(at + 72)
|
||||
.filter(|&p| img.is_code(p))
|
||||
.unwrap_or(0),
|
||||
flags: PulseFlags::decode(
|
||||
img.read_u32(at + 56).unwrap_or(0),
|
||||
img.read_u32(at + 60).unwrap_or(0),
|
||||
),
|
||||
});
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Every entity-IO input handler `img` declares.
|
||||
pub fn datadesc_inputs(img: &CodeImage) -> Vec<DatadescInput> {
|
||||
let mut out = Vec::new();
|
||||
scan_records(img, DATADESC_STRIDE, |at| {
|
||||
let Some(handler) = img.read_ptr(at).and_then(|p| table_string(img, p)) else {
|
||||
return;
|
||||
};
|
||||
// `Input` + at least one more character: the naming convention Valve's entity-IO macros emit,
|
||||
// and what separates the input records from the field and output descriptors sharing the array.
|
||||
if !handler.starts_with("Input") || handler.len() <= 5 {
|
||||
return;
|
||||
}
|
||||
let (Some(io_name), Some(func)) = (
|
||||
img.read_ptr(at + 24).and_then(|p| table_string(img, p)),
|
||||
img.read_ptr(at + 40),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
if !img.is_code(func) {
|
||||
return;
|
||||
}
|
||||
out.push(DatadescInput {
|
||||
handler,
|
||||
class: None,
|
||||
io_name,
|
||||
func,
|
||||
});
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// One datadesc array: the FIELD descriptors that identify its owning class, and the input handlers
|
||||
/// that class owns.
|
||||
pub struct DatadescArray {
|
||||
/// `(member name, byte offset)` for each field descriptor, which the SchemaSystem states
|
||||
/// independently — the fingerprint that names the array's class.
|
||||
pub fields: Vec<(String, i32)>,
|
||||
pub inputs: Vec<DatadescInput>,
|
||||
}
|
||||
|
||||
/// Segment the datadesc into ARRAYS, so an input handler can be attributed to the class that owns it.
|
||||
///
|
||||
/// The record carries no owning class, which is why `names` has to drop a handler name the table gives
|
||||
/// more than one address. But the array does: it also holds FIELD descriptors, and a field is a
|
||||
/// `(member, offset)` pair the SchemaSystem states from a completely different table. The class whose
|
||||
/// schema contains EVERY pair in an array owns the array, and therefore owns its handlers.
|
||||
///
|
||||
/// An array is found from a confirmed input outward — a record with a name at `+0` continues it — so a
|
||||
/// run is anchored on something already known to be a datadesc record rather than on a shape guess.
|
||||
pub fn datadesc_arrays(img: &CodeImage) -> Vec<DatadescArray> {
|
||||
let named = |at: u64| img.read_ptr(at).and_then(|p| table_string(img, p));
|
||||
let input_at = |at: u64| -> Option<DatadescInput> {
|
||||
let handler = named(at).filter(|h| h.starts_with("Input") && h.len() > 5)?;
|
||||
let io_name = img.read_ptr(at + 24).and_then(|p| table_string(img, p))?;
|
||||
let func = img.read_ptr(at + 40).filter(|&f| img.is_code(f))?;
|
||||
Some(DatadescInput {
|
||||
handler,
|
||||
class: None,
|
||||
io_name,
|
||||
func,
|
||||
})
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut claimed: Vec<(u64, u64)> = Vec::new();
|
||||
for (va, len) in img.data_blocks() {
|
||||
let Some(last) = len.checked_sub(DATADESC_STRIDE) else {
|
||||
continue;
|
||||
};
|
||||
let end = va + last as u64;
|
||||
let mut at = va;
|
||||
while at <= end {
|
||||
if input_at(at).is_none() {
|
||||
at += 8;
|
||||
continue;
|
||||
}
|
||||
if claimed.iter().any(|&(s, e)| at >= s && at <= e) {
|
||||
at += DATADESC_STRIDE as u64;
|
||||
continue;
|
||||
}
|
||||
let stride = DATADESC_STRIDE as u64;
|
||||
let (mut lo, mut hi) = (at, at);
|
||||
while lo >= va + stride && named(lo - stride).is_some() {
|
||||
lo -= stride;
|
||||
}
|
||||
while hi + stride <= end && named(hi + stride).is_some() {
|
||||
hi += stride;
|
||||
}
|
||||
claimed.push((lo, hi));
|
||||
let (mut fields, mut inputs) = (Vec::new(), Vec::new());
|
||||
let mut cur = lo;
|
||||
while cur <= hi {
|
||||
if let Some(i) = input_at(cur) {
|
||||
inputs.push(i);
|
||||
} else if let Some(n) = named(cur).filter(|n| n.starts_with("m_") && n.len() > 3)
|
||||
&& let Some(o) = img.read_u32(cur + FIELD_OFFSET_SLOT)
|
||||
{
|
||||
fields.push((n, o as i32));
|
||||
}
|
||||
cur += stride;
|
||||
}
|
||||
if !inputs.is_empty() {
|
||||
out.push(DatadescArray { fields, inputs });
|
||||
}
|
||||
at = hi + stride;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The names the tables can be trusted to LOCATE, with AMBIGUOUS ones dropped.
|
||||
///
|
||||
/// **Only the entity-IO datadesc qualifies.** The Pulse table's two code pointers are both descriptor
|
||||
/// accessors, not the bound function: every one of the 485 CS2 `libserver` bindings measures the same
|
||||
/// empty footprint (`int=0 float=0`), and they disassemble to a lazy-init singleton that returns a
|
||||
/// pointer to a static descriptor. Folding a Pulse name would ship `CBaseEntityAPI::GetAbsOrigin`
|
||||
/// pointing at a zero-argument accessor instead of the getter — a locator that resolves, passes live
|
||||
/// validation as executable code, and is still the wrong function. The bindings are shipped as a
|
||||
/// registry instead, where the address is labelled for what it is.
|
||||
///
|
||||
/// A name is ambiguous when the table gives it more than one address in this image: `InputEnable` is a
|
||||
/// distinct handler on each of 48 classes and the record carries no owning class to tell them apart, so
|
||||
/// picking one is a coin flip. The whole name is dropped instead. Returns `(names, dropped)`.
|
||||
pub fn names(inputs: &[DatadescInput]) -> (Vec<ValveName>, usize) {
|
||||
// A handler whose array identified its class is keyed by the QUALIFIED name, which is what makes it
|
||||
// unambiguous: `InputEnable` on 48 classes is 48 distinct names once each carries its own. Only the
|
||||
// ones still unqualified can collide, and those still drop rather than guess.
|
||||
let qualified: Vec<String> = inputs
|
||||
.iter()
|
||||
.map(|i| match &i.class {
|
||||
Some(c) => format!("{c}::{}", i.handler),
|
||||
None => i.handler.clone(),
|
||||
})
|
||||
.collect();
|
||||
let mut by_name: HashMap<&str, Vec<u64>> = HashMap::new();
|
||||
for (i, q) in inputs.iter().zip(&qualified) {
|
||||
let e = by_name.entry(q.as_str()).or_default();
|
||||
if !e.contains(&i.func) {
|
||||
e.push(i.func);
|
||||
}
|
||||
}
|
||||
let dropped = by_name.values().filter(|a| a.len() > 1).count();
|
||||
let mut out: Vec<ValveName> = by_name
|
||||
.into_iter()
|
||||
.filter(|(_, a)| a.len() == 1)
|
||||
.map(|(name, a)| ValveName {
|
||||
name: name.to_string(),
|
||||
addr: a[0],
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|x, y| x.name.cmp(&y.name)); // deterministic fold order
|
||||
(out, dropped)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn qualified_names_are_two_c_identifiers() {
|
||||
assert!(is_qualified("CBaseEntityAPI::GetAbsOrigin"));
|
||||
assert!(is_qualified("_Priv::_m0"));
|
||||
assert!(!is_qualified("CBaseEntityAPI")); // unqualified
|
||||
assert!(!is_qualified("::GetAbsOrigin")); // empty class
|
||||
assert!(!is_qualified("CFoo::9Bar")); // method starts with a digit
|
||||
assert!(!is_qualified("CFoo<int>::Bar")); // a demangled template, not a table string
|
||||
assert!(!is_qualified("Get Abs Origin")); // the display name, not the qualified one
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_class_qualified_handler_is_no_longer_ambiguous() {
|
||||
// The same handler NAME on two classes is a collision only while both are unqualified. Once the
|
||||
// array's field descriptors identify the owner, they are two distinct names and BOTH survive —
|
||||
// which is the whole point of reading the array rather than the record.
|
||||
let q = |handler: &str, class: &str, func| DatadescInput {
|
||||
handler: handler.into(),
|
||||
class: Some(class.into()),
|
||||
io_name: "Enable".into(),
|
||||
func,
|
||||
};
|
||||
let (names, dropped) = names(&[
|
||||
q("InputEnable", "CBaseDoor", 0x100),
|
||||
q("InputEnable", "CBaseTrigger", 0x200),
|
||||
]);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(names.len(), 2);
|
||||
assert_eq!(names[0].name, "CBaseDoor::InputEnable");
|
||||
assert_eq!(names[1].name, "CBaseTrigger::InputEnable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_handler_name_at_several_addresses_is_dropped_not_guessed() {
|
||||
let input = |handler: &str, io: &str, func| DatadescInput {
|
||||
handler: handler.into(),
|
||||
class: None,
|
||||
io_name: io.into(),
|
||||
func,
|
||||
};
|
||||
let (names, dropped) = names(&[
|
||||
input("InputKill", "Kill", 0x100),
|
||||
// The same handler NAME on two classes — the record carries no owning class, so neither
|
||||
// address can be claimed for the bare name.
|
||||
input("InputEnable", "Enable", 0x200),
|
||||
input("InputEnable", "Enable", 0x300),
|
||||
// The same handler serving two inputs is ONE function, not an ambiguity.
|
||||
input("InputToggle", "Toggle", 0x400),
|
||||
input("InputToggle", "ToggleAlias", 0x400),
|
||||
]);
|
||||
assert_eq!(dropped, 1);
|
||||
let got: Vec<(&str, u64)> = names.iter().map(|n| (n.name.as_str(), n.addr)).collect();
|
||||
assert_eq!(got, [("InputKill", 0x100), ("InputToggle", 0x400)]); // sorted, no InputEnable
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_decode_per_byte_not_per_bit() {
|
||||
// A library getter: library byte set, mutating and instance clear.
|
||||
let lib_get = PulseFlags::decode(0x00_00_01, 0);
|
||||
assert!(lib_get.library && !lib_get.mutating && !lib_get.instance);
|
||||
// An entity setter: instance + mutating, library clear.
|
||||
let api_set = PulseFlags::decode(0x01_01_00, 0);
|
||||
assert!(api_set.instance && api_set.mutating && !api_set.library);
|
||||
// An entity getter: instance only.
|
||||
let api_get = PulseFlags::decode(0x01_00_00, 0);
|
||||
assert!(api_get.instance && !api_get.mutating);
|
||||
// The yield bit lives in the second word.
|
||||
assert!(PulseFlags::decode(0x00_01_00, 0x08).blocking);
|
||||
assert!(!PulseFlags::decode(0x00_01_00, 0x20).blocking);
|
||||
// The raw words survive decoding so a later build can be re-read.
|
||||
assert_eq!(api_set.raw, (0x01_01_00, 0));
|
||||
}
|
||||
}
|
||||
558
src/vscript.rs
Normal file
558
src/vscript.rs
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
//! The VScript binding registry — the fourth surface a Source-2 module documents about itself, and the
|
||||
//! only one that states a function's PARAMETER NAMES.
|
||||
//!
|
||||
//! Valve exposes a subset of the C++ surface to script (Lua in Dota's custom games, and a smaller set in
|
||||
//! CS2). Every exposed method is registered with the script VM through a descriptor carrying its
|
||||
//! script-facing name, its C++ name, an English description, a return type, and a pointer to the
|
||||
//! implementation. That is a locator AND a prototype AND documentation, all stated by Valve, which makes
|
||||
//! it the same kind of find as the Pulse registry and the console-command registration.
|
||||
//!
|
||||
//! # Why this is not a table walk
|
||||
//!
|
||||
//! The obvious route — find a static array of descriptors and read it — does not work, and the reason is
|
||||
//! worth stating because it costs a day to rediscover. The descriptors are built at RUNTIME: a scan of
|
||||
//! Dota's `libserver.so` finds 2,268,664 `R_X86_64_RELATIVE` relocations and **not one** points at a
|
||||
//! description string. On disk the descriptor array is zeroes.
|
||||
//!
|
||||
//! What is static is the CODE that fills it in, and every field is a constant in the instruction stream.
|
||||
//! This is the same shape the Pulse parameter records turned out to have, and the same answer applies:
|
||||
//! constant-propagate through the initialiser rather than read the table.
|
||||
//!
|
||||
//! ```text
|
||||
//! movq xmm0, [rip+slot] ; the script-facing name, via a relocated .data.rel.ro slot
|
||||
//! lea rdx, [rip+"Script_TakeDamage"]
|
||||
//! pinsrq xmm0, rdx, 1 ; pack both names into one 16-byte store
|
||||
//! lea rsi, [rip+"Applies damage to this entity."]
|
||||
//! lea rax, [rax+rax*4] ; index * 5
|
||||
//! shl rax, 4 ; * 16 -> stride 80
|
||||
//! add rax, [rbx+0x28] ; base = owning class descriptor's function array
|
||||
//! mov [rax+0x30], rbx ; owner
|
||||
//! mov [rax+0x10], rsi ; description
|
||||
//! movups [rax], xmm0 ; +0x00 script name, +0x08 C++ name
|
||||
//! mov [rax+0x18], r11w ; return type
|
||||
//! ```
|
||||
//!
|
||||
//! # The record
|
||||
//!
|
||||
//! **80 bytes**, derived rather than assumed — the `lea r,[r+r*4]` / `shl r,4` pair states it in the
|
||||
//! instruction stream, so a stride change is a decode failure rather than silent corruption.
|
||||
//!
|
||||
//! | offset | field |
|
||||
//! |---|---|
|
||||
//! | `+0x00` | script-facing name (`TakeDamage`) |
|
||||
//! | `+0x08` | C++ binding name (`Script_TakeDamage`) |
|
||||
//! | `+0x10` | Valve's English description |
|
||||
//! | `+0x18` | return type, a `u16` |
|
||||
//! | `+0x28` | a name string — the return value's, where one is given |
|
||||
//! | `+0x30` | the owning class descriptor |
|
||||
//! | `+0x38` | the marshalling thunk, SHARED by every binding of the same shape |
|
||||
//! | `+0x40` | pointer-to-member: the implementation |
|
||||
//! | `+0x48` | a `u32` count |
|
||||
//!
|
||||
//! `+0x40` is an Itanium pointer-to-member, which is convenient rather than awkward: a non-virtual
|
||||
//! member is a plain address and a virtual one is `slot * 8 + 1`. Those are exactly the two locator
|
||||
//! forms the rest of this crate already emits, so a VScript binding lands in `gamedata` as either a
|
||||
//! signature or a vtable offset with no new concept.
|
||||
//!
|
||||
//! Do not confuse `+0x38` with `+0x40`. The thunk at `+0x38` is a compiler-generated trampoline shared
|
||||
//! across every binding with the same signature; folding it would ship dozens of distinct names all
|
||||
//! pointing at one address. That is the same mistake the Pulse `+24`/`+32` accessors invite, and it is
|
||||
//! caught here the same way — by the sharing itself, since a real implementation is referenced once.
|
||||
//!
|
||||
//! # Shape-driven, so a layout change yields FEWER bindings and never wrong ones
|
||||
//!
|
||||
//! Nothing here is anchored on an address, a symbol or a fixed offset into the image. A record is
|
||||
//! recognised by what the initialiser DOES — a 16-byte store of two plausible name strings, a
|
||||
//! description or nothing at `+0x10`, a small return type, and a `+0x40` that is either executable code
|
||||
//! or a small odd integer. A build that reshapes the descriptor fails those tests and produces nothing,
|
||||
//! which the release floor then catches.
|
||||
|
||||
use crate::elf::CodeImage;
|
||||
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
/// The record stride, in bytes. Stated by the initialiser's own `idx*5 << 4`; kept as a constant only to
|
||||
/// validate what is decoded.
|
||||
///
|
||||
/// `pub(crate)` because the LIVE class walk in `produce` steps the same records and must step them by
|
||||
/// the same number. Unlike the Pulse element stride this one is NOT derived by consensus — the
|
||||
/// initialiser states it in the instruction stream, so there is nothing to vote on — and a build that
|
||||
/// changes it shows up as decoded records failing validation here, not as a mis-strided live walk.
|
||||
pub(crate) const STRIDE: i64 = 80;
|
||||
|
||||
/// Field displacements within the record.
|
||||
const F_NAME: i64 = 0x00;
|
||||
const F_CPP: i64 = 0x08;
|
||||
const F_DESC: i64 = 0x10;
|
||||
const F_RET: i64 = 0x18;
|
||||
const F_IMPL: i64 = 0x40;
|
||||
|
||||
/// Longest accepted name/description, so a mis-decoded pointer into the middle of a blob cannot produce a
|
||||
/// megabyte "name".
|
||||
const MAX_NAME: usize = 128;
|
||||
const MAX_DESC: usize = 512;
|
||||
|
||||
/// `ScriptDataType_t`, DERIVED by joining recovered bindings against Valve's own published VScript dump
|
||||
/// rather than assumed from Source's historical ordering.
|
||||
///
|
||||
/// The distinction matters, and the first attempt at this table is the reason it is spelled out. Two
|
||||
/// anchors were available by inspection — a binding returning `float` stores `1`, one returning `int`
|
||||
/// stores `5` — and they fit Source 1's long-standing `FIELD_*` ordering, in which `5` is `BOOLEAN`. That
|
||||
/// reading was WRONG: joined against 389 bindings whose return type Valve states, `5` is `int` and `6` is
|
||||
/// `bool`. Two points are enough to fit a plausible table and not enough to check one.
|
||||
///
|
||||
/// Agreement on the derived table is total where a comparison is meaningful. The apparent disagreements
|
||||
/// are Valve naming a SEMANTIC type over the same ABI type: `5` also covers `modifierpriority` and
|
||||
/// `UnitFilterResult` (enums, which are ints), and `31` also covers `CDOTA_BaseNPC` and `CBaseEntity`
|
||||
/// (entity handles, which are handles).
|
||||
///
|
||||
/// The raw word ships beside the decoded name regardless — the rule `flags_raw` already follows — so a
|
||||
/// build that renumbers this can be re-read rather than silently mislabelled.
|
||||
const RET_TYPES: [(u16, &str); 13] = [
|
||||
(0, "void"),
|
||||
(1, "float"),
|
||||
(3, "Vector"),
|
||||
(5, "int"),
|
||||
(6, "bool"),
|
||||
(13, "ehandle"),
|
||||
(14, "Vector"),
|
||||
(29, "unknown"),
|
||||
(30, "string"),
|
||||
(31, "handle"),
|
||||
(32, "table"),
|
||||
(37, "uint"),
|
||||
(39, "QAngle"),
|
||||
];
|
||||
|
||||
/// Decode a return-type word, or `None` when the value is outside what is corroborated.
|
||||
pub fn ret_type_name(raw: u16) -> Option<&'static str> {
|
||||
RET_TYPES.iter().find(|(v, _)| *v == raw).map(|(_, n)| *n)
|
||||
}
|
||||
|
||||
/// Where a binding's implementation lives, decoded from the pointer-to-member at `+0x40`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Impl {
|
||||
/// A non-virtual member: the address itself.
|
||||
Addr(u64),
|
||||
/// A virtual member: `(pmf - 1) / 8` is the vtable slot index.
|
||||
Slot(u64),
|
||||
}
|
||||
|
||||
/// One registered VScript binding.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VScriptFunc {
|
||||
/// The script-facing name a Lua author calls (`TakeDamage`).
|
||||
pub name: String,
|
||||
/// The C++ binding name (`Script_TakeDamage`). Often but not always the script name with a prefix.
|
||||
pub cpp_name: String,
|
||||
/// Valve's own English description, where the registration supplies one.
|
||||
pub description: Option<String>,
|
||||
/// The return type as stored, undecoded.
|
||||
pub ret_raw: u16,
|
||||
/// The return type decoded, or `None` if the value is outside the corroborated set.
|
||||
pub ret: Option<&'static str>,
|
||||
/// The implementation, as an address or a vtable slot.
|
||||
pub imp: Option<Impl>,
|
||||
}
|
||||
|
||||
/// What a register provably holds — CONSTANTS only, which is where this parts company with `concmd`'s
|
||||
/// tracker.
|
||||
///
|
||||
/// There is no symbolic-base variant here and none is needed: a store is credited to a record through
|
||||
/// `recid`, propagated across `mov rD,rS`, rather than through a `(register, epoch)` pair. That is why
|
||||
/// the base survives being copied between registers, and why this tracker needs no epoch counter.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum V {
|
||||
Unknown,
|
||||
Const(u64),
|
||||
}
|
||||
|
||||
impl V {
|
||||
fn konst(self) -> Option<u64> {
|
||||
match self {
|
||||
V::Const(c) => Some(c),
|
||||
V::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 64-bit parent register as a slot index. Thin wrapper over [`crate::abi::gp_slot`] — the mapping is a
|
||||
/// fixed SysV fact, and this file only narrows it to the `u8` its `[_; 16]` arrays index by.
|
||||
fn gpr(r: Register) -> Option<u8> {
|
||||
crate::abi::gp_slot(r).map(|s| s as u8)
|
||||
}
|
||||
|
||||
fn xmm(r: Register) -> Option<u8> {
|
||||
r.is_xmm()
|
||||
.then(|| (r as usize - Register::XMM0 as usize) as u8)
|
||||
.filter(|i| *i < 16)
|
||||
}
|
||||
|
||||
/// A field of a particular record: which record, and the displacement within it.
|
||||
type Slot = (u32, i64);
|
||||
|
||||
/// Read a NUL-terminated string, rejecting anything that is not plausibly a name.
|
||||
fn text(img: &CodeImage, va: u64, max: usize) -> Option<String> {
|
||||
let s = img.read_c_string(va)?;
|
||||
if s.is_empty() || s.len() > max {
|
||||
return None;
|
||||
}
|
||||
s.chars()
|
||||
.all(|c| c.is_ascii_graphic() || c == ' ')
|
||||
.then_some(s)
|
||||
}
|
||||
|
||||
/// An identifier-shaped string — what a script-facing or C++ name must look like. Deliberately strict:
|
||||
/// a mis-decoded pointer usually lands on prose or a path, and both fail this.
|
||||
fn ident(img: &CodeImage, va: u64) -> Option<String> {
|
||||
let s = text(img, va, MAX_NAME)?;
|
||||
let ok = s
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
|
||||
&& s.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
|
||||
ok.then_some(s)
|
||||
}
|
||||
|
||||
/// Decode the pointer-to-member at `+0x40`.
|
||||
///
|
||||
/// The two forms are distinguished by the low bit, per the Itanium ABI. Both are validated: an address
|
||||
/// has to land in executable code, and a slot index has to be small enough to be a real vtable position.
|
||||
/// Anything else means the field is not what this reader thinks it is, and yields `None` rather than a
|
||||
/// confident wrong locator.
|
||||
fn decode_pmf(img: &CodeImage, pmf: u64) -> Option<Impl> {
|
||||
if pmf == 0 {
|
||||
return None;
|
||||
}
|
||||
if pmf & 1 == 1 {
|
||||
let slot = (pmf - 1) / 8;
|
||||
// 2048 is the same ceiling `rtti` reads vtables to; past it this is not a slot index.
|
||||
(slot < 2048 && (pmf - 1).is_multiple_of(8)).then_some(Impl::Slot(slot))
|
||||
} else {
|
||||
img.is_code(pmf).then_some(Impl::Addr(pmf))
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover every VScript binding the image registers.
|
||||
///
|
||||
/// One pass over the candidate functions, tracking what each register and XMM half provably holds and
|
||||
/// collecting stores to record-relative slots. A group of stores is accepted as a binding only if it
|
||||
/// presents the full shape, so partial or coincidental matches are dropped rather than guessed at.
|
||||
pub fn vscript_functions(img: &CodeImage) -> Vec<VScriptFunc> {
|
||||
let entries = crate::locate::function_entries(img);
|
||||
|
||||
let mut out: Vec<VScriptFunc> = Vec::new();
|
||||
let mut insn = Instruction::default();
|
||||
|
||||
for (i, &start) in entries.iter().enumerate() {
|
||||
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
|
||||
let Some(code) = img.code_range(start, end) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut val = [V::Unknown; 16];
|
||||
// Which registers currently hold a RECORD BASE, and which hold the half-built `idx*80` on the way
|
||||
// to one. This is the structural anchor: a store is only collected when its base was computed by
|
||||
// the initialiser's own `idx*5 << 4 + [class+0x28]`. Without it the pass collects any struct with
|
||||
// two string pointers at +0x00/+0x08, and `libserver` has at least one other table of that shape
|
||||
// (the network field serialisers) which then contributes records whose "name" is a netvar.
|
||||
let mut scaled = [false; 16];
|
||||
// Which RECORD each register currently points at, not merely whether it points at one. Keying on
|
||||
// identity rather than on (register, epoch) is what lets a base survive `mov rcx,rax` — the
|
||||
// compiler routinely copies the base and then reuses the original for something else, writing
|
||||
// half a record through each. Keyed by register, those two halves land in different groups and
|
||||
// neither is complete.
|
||||
let mut recid: [Option<u32>; 16] = [None; 16];
|
||||
let mut next_rec: u32 = 0;
|
||||
// Each XMM tracked as its two 64-bit halves, which is the only way the packed name store is
|
||||
// readable: both names reach the record through one 16-byte write.
|
||||
let mut xr = [(V::Unknown, V::Unknown); 16];
|
||||
let mut stores: HashMap<Slot, u64> = HashMap::new();
|
||||
|
||||
let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE);
|
||||
while dec.can_decode() {
|
||||
dec.decode_out(&mut insn);
|
||||
|
||||
if insn.flow_control() == FlowControl::Call {
|
||||
// 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;
|
||||
}
|
||||
xr = [(V::Unknown, V::Unknown); 16];
|
||||
// MEASURED DEAD END, recorded so it is not re-attempted: treating `rax` as a speculative
|
||||
// record base after every call — on the theory that some registrations allocate a record
|
||||
// and fill it through the returned pointer — reintroduces precisely the network-field
|
||||
// serialisers the record-base anchor exists to reject (`CBaseEntity`/`m_fFlags`,
|
||||
// `CNetworkOriginCellCoordQuantizedVector`/`m_cellX`, the `*ChangedCompat` callbacks) and
|
||||
// recovers no additional binding. The structure built through a call's return here is the
|
||||
// CLASS descriptor, not a function record: its `+0x00`/`+0x08` hold the class name twice.
|
||||
continue;
|
||||
}
|
||||
|
||||
match insn.mnemonic() {
|
||||
// `lea r,[rip+d]` — a string or global address. `lea rD,[rS+rS*4]` is something else
|
||||
// entirely: the first half of the record-base computation, `idx * 5`.
|
||||
Mnemonic::Lea => {
|
||||
if let Some(d) = gpr(insn.op0_register()) {
|
||||
let times_five = insn.memory_index() != Register::None
|
||||
&& insn.memory_base() == insn.memory_index()
|
||||
&& insn.memory_index_scale() == 4
|
||||
&& insn.memory_displacement64() == 0;
|
||||
val[d as usize] = if insn.is_ip_rel_memory_operand() {
|
||||
V::Const(insn.ip_rel_memory_address())
|
||||
} else {
|
||||
V::Unknown
|
||||
};
|
||||
scaled[d as usize] = times_five;
|
||||
recid[d as usize] = None;
|
||||
}
|
||||
}
|
||||
|
||||
// `xor rD,rD` is the zeroing idiom, not an arithmetic unknown. It matters more here than
|
||||
// it looks: a `void` binding sets its return type with `xor r11d,r11d` and then stores
|
||||
// `r11w`, so treating this as an unknown loses every void-returning binding — which on
|
||||
// Dota is most of them.
|
||||
Mnemonic::Xor => {
|
||||
if let (Some(d), Some(s)) = (gpr(insn.op0_register()), gpr(insn.op1_register()))
|
||||
{
|
||||
val[d as usize] = if d == s { V::Const(0) } else { V::Unknown };
|
||||
scaled[d as usize] = false;
|
||||
recid[d as usize] = None;
|
||||
}
|
||||
}
|
||||
|
||||
// `shl rD,4` completes `idx * 80`. Any other shift of a scaled register means this is
|
||||
// not the idiom and the candidate is dropped.
|
||||
Mnemonic::Shl => {
|
||||
if let Some(d) = gpr(insn.op0_register()) {
|
||||
let keep = scaled[d as usize]
|
||||
&& insn.op1_kind() == OpKind::Immediate8
|
||||
&& insn.immediate8() == 4;
|
||||
val[d as usize] = V::Unknown;
|
||||
scaled[d as usize] = keep;
|
||||
recid[d as usize] = None;
|
||||
}
|
||||
}
|
||||
|
||||
// `add rD,[class+0x28]` turns `idx * 80` into the record's own address. From here every
|
||||
// store through `rD` is a field of one binding.
|
||||
Mnemonic::Add => {
|
||||
if let Some(d) = gpr(insn.op0_register()) {
|
||||
let base = scaled[d as usize] && insn.op1_kind() == OpKind::Memory;
|
||||
val[d as usize] = V::Unknown;
|
||||
scaled[d as usize] = false;
|
||||
recid[d as usize] = base.then(|| {
|
||||
next_rec += 1;
|
||||
next_rec
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// `movq xmm,[rip+slot]` loads a RELOCATED pointer — the script-facing name arrives this
|
||||
// way rather than as a `lea`, and reading it needs the relocation applied, which
|
||||
// `read_ptr` does. `movq xmm,r64` and the reverse also appear.
|
||||
Mnemonic::Movq | Mnemonic::Movd => {
|
||||
if let Some(x) = xmm(insn.op0_register()) {
|
||||
let lo = if insn.op1_kind() == OpKind::Memory {
|
||||
if insn.is_ip_rel_memory_operand() {
|
||||
img.read_ptr(insn.ip_rel_memory_address())
|
||||
.map_or(V::Unknown, V::Const)
|
||||
} else {
|
||||
V::Unknown
|
||||
}
|
||||
} else if let Some(s) = gpr(insn.op1_register()) {
|
||||
val[s as usize]
|
||||
} else {
|
||||
V::Unknown
|
||||
};
|
||||
// `movq` zeroes the upper half; that matters because the high name is inserted
|
||||
// afterwards and must not inherit a stale value.
|
||||
xr[x as usize] = (lo, V::Const(0));
|
||||
}
|
||||
}
|
||||
|
||||
// `pinsrq xmm,r64,1` — the second name packed into the high half.
|
||||
Mnemonic::Pinsrq => {
|
||||
if let (Some(x), Some(s)) = (xmm(insn.op0_register()), gpr(insn.op1_register()))
|
||||
&& insn.op2_kind() == OpKind::Immediate8
|
||||
{
|
||||
let v = val[s as usize];
|
||||
if insn.immediate8() == 1 {
|
||||
xr[x as usize].1 = v;
|
||||
} else {
|
||||
xr[x as usize].0 = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `movddup xmm,[rip+slot]` — ONE pointer written into both halves. This is the form the
|
||||
// compiler picks when the script-facing name and the C++ name are the SAME string, which
|
||||
// is the common case: only the bindings that need a distinct C++ name (usually a
|
||||
// `Script_`-prefixed wrapper) load two pointers. Missing this mnemonic costs roughly
|
||||
// four fifths of the registry on Dota, so it is not an edge case.
|
||||
Mnemonic::Movddup => {
|
||||
if let Some(x) = xmm(insn.op0_register()) {
|
||||
let v = if insn.is_ip_rel_memory_operand() {
|
||||
img.read_ptr(insn.ip_rel_memory_address())
|
||||
.map_or(V::Unknown, V::Const)
|
||||
} else {
|
||||
V::Unknown
|
||||
};
|
||||
xr[x as usize] = (v, v);
|
||||
}
|
||||
}
|
||||
|
||||
// `punpcklqdq x0,x1` — the same pack, reached the other way.
|
||||
Mnemonic::Punpcklqdq => {
|
||||
if let (Some(a), Some(b)) = (xmm(insn.op0_register()), xmm(insn.op1_register()))
|
||||
{
|
||||
xr[a as usize] = (xr[a as usize].0, xr[b as usize].0);
|
||||
}
|
||||
}
|
||||
|
||||
// The 16-byte store that lands both names.
|
||||
Mnemonic::Movups | Mnemonic::Movaps | Mnemonic::Movdqu | Mnemonic::Movdqa => {
|
||||
if insn.op0_kind() == OpKind::Memory
|
||||
&& let Some(x) = xmm(insn.op1_register())
|
||||
&& let Some(b) = gpr(insn.memory_base())
|
||||
&& insn.memory_index() == Register::None
|
||||
&& let Some(rec) = recid[b as usize]
|
||||
{
|
||||
let d = insn.memory_displacement64() as i64;
|
||||
if let Some(v) = xr[x as usize].0.konst() {
|
||||
stores.insert((rec, d), v);
|
||||
}
|
||||
if let Some(v) = xr[x as usize].1.konst() {
|
||||
stores.insert((rec, d + 8), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Mnemonic::Mov => {
|
||||
// Store to a record-relative slot: `mov [base+d], reg` or `mov [base+d], imm`.
|
||||
if insn.op0_kind() == OpKind::Memory
|
||||
&& insn.memory_index() == Register::None
|
||||
&& let Some(b) = gpr(insn.memory_base())
|
||||
&& let Some(rec) = recid[b as usize]
|
||||
{
|
||||
let d = insn.memory_displacement64() as i64;
|
||||
let v = match insn.op1_kind() {
|
||||
// A 16-bit store carries the return type. The tracker follows full
|
||||
// registers, so `mov [rec+0x18], r11w` reads back through `r11`.
|
||||
OpKind::Register => {
|
||||
gpr(insn.op1_register()).and_then(|s| val[s as usize].konst())
|
||||
}
|
||||
OpKind::Immediate8 | OpKind::Immediate16 | OpKind::Immediate32 => {
|
||||
Some(insn.immediate32to64() as u64)
|
||||
}
|
||||
OpKind::Immediate32to64 | OpKind::Immediate8to64 => {
|
||||
Some(insn.immediate64())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(v) = v {
|
||||
stores.insert((rec, d), v);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Register-to-register and immediate loads feed the tracker.
|
||||
if let Some(d) = gpr(insn.op0_register()) {
|
||||
// A plain `mov rD,rS` carries the RECORD IDENTITY across, not just the value.
|
||||
// This is the whole reason identity is tracked instead of a per-register flag:
|
||||
// the compiler routinely computes the base in one register, copies it to a
|
||||
// second, and then reuses the first — writing half the record through each.
|
||||
// Without this the second half is attributed to no record and the binding is
|
||||
// lost. `CBaseEntity::AddNewModifier` is the case that exposed it.
|
||||
recid[d as usize] = match insn.op1_kind() {
|
||||
OpKind::Register => {
|
||||
gpr(insn.op1_register()).and_then(|s| recid[s as usize])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
scaled[d as usize] = false;
|
||||
val[d as usize] = match insn.op1_kind() {
|
||||
OpKind::Register => {
|
||||
gpr(insn.op1_register()).map_or(V::Unknown, |s| val[s as usize])
|
||||
}
|
||||
OpKind::Immediate8
|
||||
| OpKind::Immediate16
|
||||
| OpKind::Immediate32
|
||||
| OpKind::Immediate32to64
|
||||
| OpKind::Immediate8to64 => V::Const(insn.immediate64()),
|
||||
_ => V::Unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
// Any other write invalidates the destination, so a stale constant cannot be
|
||||
// attributed to a record it never reached.
|
||||
if let Some(d) = gpr(insn.op0_register()) {
|
||||
val[d as usize] = V::Unknown;
|
||||
scaled[d as usize] = false;
|
||||
recid[d as usize] = None;
|
||||
}
|
||||
if let Some(x) = xmm(insn.op0_register()) {
|
||||
xr[x as usize] = (V::Unknown, V::Unknown);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group the collected stores by the record they were written to, then keep the groups that
|
||||
// present the full binding shape.
|
||||
// BTreeMap, and it is not a style choice: the emit order below decides which row survives the
|
||||
// `(name, cpp_name)` dedup at the end, so a HashMap made that a hash-order coin flip in a
|
||||
// byte-reproducible artifact. Record ids are minted in ascending address order by the `Add` arm,
|
||||
// so ordering by id is the natural reading order and changes nothing outside a tie.
|
||||
let mut groups: BTreeMap<u32, HashMap<i64, u64>> = BTreeMap::new();
|
||||
for ((rec, d), v) in stores {
|
||||
groups.entry(rec).or_default().insert(d, v);
|
||||
}
|
||||
for g in groups.values() {
|
||||
// The initialiser writes the name pair at the record's own `+0`, so a group without both is
|
||||
// not a binding — a partial match on some other structure, or a record whose construction
|
||||
// the tracker only saw half of.
|
||||
let (Some(&n), Some(&c)) = (g.get(&F_NAME), g.get(&F_CPP)) else {
|
||||
continue;
|
||||
};
|
||||
let (Some(name), Some(cpp_name)) = (ident(img, n), ident(img, c)) else {
|
||||
continue;
|
||||
};
|
||||
let ret_raw = g.get(&F_RET).copied().unwrap_or(u64::MAX);
|
||||
if ret_raw > u16::MAX as u64 {
|
||||
continue;
|
||||
}
|
||||
let ret_raw = ret_raw as u16;
|
||||
// The implementation is recorded when present but NOT required. It is written at the top of
|
||||
// the initialiser's next loop iteration, so whether it lands in this record's group depends
|
||||
// on which register the compiler happened to reuse — a binding whose fields are otherwise
|
||||
// complete must not be dropped over a scheduling accident. (The shared marshalling thunk at
|
||||
// `+0x38` is read past for the same reason and no longer recorded: nothing consumed it, and
|
||||
// the record-layout table in this module's header is where that offset is documented.)
|
||||
//
|
||||
// An earlier revision did require both, on the reasoning that `libserver` holds another table
|
||||
// of similar stride (the network field serialisers) whose records carry no code pointer. That
|
||||
// was treating a symptom: the record-base anchor above rejects those structurally, because
|
||||
// they are not built by `idx*5 << 4 + [class+0x28]`. Requiring the pair on top of that cost
|
||||
// real bindings — `CBaseEntity::EmitSound` among them — for no additional safety.
|
||||
out.push(VScriptFunc {
|
||||
name,
|
||||
cpp_name,
|
||||
description: g.get(&F_DESC).and_then(|&v| text(img, v, MAX_DESC)),
|
||||
ret_raw,
|
||||
ret: ret_type_name(ret_raw),
|
||||
imp: g.get(&F_IMPL).and_then(|&v| decode_pmf(img, v)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
out.sort_by(|a, b| (&a.name, &a.cpp_name).cmp(&(&b.name, &b.cpp_name)));
|
||||
out.dedup_by(|a, b| a.name == b.name && a.cpp_name == b.cpp_name);
|
||||
out
|
||||
}
|
||||
50
src/xref.rs
50
src/xref.rs
|
|
@ -13,56 +13,45 @@
|
|||
//! 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 {
|
||||
pub fn build(img: &CodeImage) -> Self {
|
||||
// Reliable gameplay entries (vtable slots + fn-pointers via relocations, plus call targets),
|
||||
// then add the eh_frame starts (the runtime tail). Union = coverage of the whole binary.
|
||||
let mut entries = crate::locate::candidate_entries(img);
|
||||
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
|
||||
entries.sort_unstable();
|
||||
entries.dedup();
|
||||
let entries = crate::locate::function_entries(img);
|
||||
|
||||
// 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() {
|
||||
|
|
@ -70,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`.
|
||||
|
|
@ -114,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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue