read what the binary says about itself: names, signatures, prototypes; gen v2
All checks were successful
CI / lint (push) Successful in 17s
CI / fuzz (push) Successful in 1m52s
CI / test (push) Successful in 24s

This commit is contained in:
Kamal Tufekcic 2026-07-29 20:09:21 +03:00
commit c458b4cb50
34 changed files with 58363 additions and 192 deletions

View file

@ -33,7 +33,7 @@ jobs:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6.0.2
- name: Clear artifacts from any earlier run - name: Clear artifacts from any earlier run
run: rm -rf fuzz/artifacts 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 run: bash fuzz.sh 30 2
- name: Fail on any crash / timeout / OOM - name: Fail on any crash / timeout / OOM
run: | run: |

View file

@ -86,9 +86,6 @@ jobs:
- name: Produce — validate the contribution live (no model fold; the build is unchanged) - name: Produce — validate the contribution live (no model fold; the build is unchanged)
run: | 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" ln -sfn "$GAME_DIR" "work/$BUILDID-$PATCH"
./target/release/source2rosetta --game "$GAME" produce \ ./target/release/source2rosetta --game "$GAME" produce \
--seed "seed-$GAME.patched.json" \ --seed "seed-$GAME.patched.json" \
@ -100,9 +97,6 @@ jobs:
cp "seed-$GAME.patched.json" "dist/seed-$GAME.json" cp "seed-$GAME.patched.json" "dist/seed-$GAME.json"
- name: Drop the re-folded model, compress the seed - 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: | run: |
rm -f "dist/model-$GAME.json" rm -f "dist/model-$GAME.json"
gzip -6 "dist/seed-$GAME.json" gzip -6 "dist/seed-$GAME.json"

View file

@ -23,6 +23,7 @@ jobs:
STEAM_APPS: /home/cs2/.steam/SteamApps STEAM_APPS: /home/cs2/.steam/SteamApps
STEAM_USER: source2rosetta STEAM_USER: source2rosetta
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
OVERRIDE_DIR: /home/cs2/rosetta-override
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6.0.2
@ -34,9 +35,6 @@ jobs:
*) echo "unknown game '$GAME' (expected cs2 or dota2)"; exit 1 ;; *) echo "unknown game '$GAME' (expected cs2 or dota2)"; exit 1 ;;
esac esac
echo "APPID=$APPID" >> "$GITHUB_ENV" 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 steamcmd +login "$STEAM_USER" +app_update "$APPID" +quit
- name: Resolve the game paths + the new buildid - name: Resolve the game paths + the new buildid
@ -52,24 +50,29 @@ jobs:
- name: Build the deriver - name: Build the deriver
run: cargo build --release 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: | run: |
mkdir -p in dist work mkdir -p in dist work
curl -fsSL -o in/model.gz "$RELEASE_BASE/$GAME-latest/model-$GAME.json.gz" OVR="$OVERRIDE_DIR/$GAME"
gunzip -c in/model.gz > "in/model-$GAME.json" for stem in "model-$GAME.json" "seed-$GAME.json"; do
curl -fsSL -o in/seed.gz "$RELEASE_BASE/$GAME-latest/seed-$GAME.json.gz" if [ -f "$OVR/$stem" ]; then
gunzip -c in/seed.gz > "in/seed-$GAME.json" 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 - name: Produce — derive + validate-live + typed netvars + fold model N -> N+1
run: | 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" ln -sfn "$GAME_DIR" "work/$BUILDID"
./target/release/source2rosetta --game "$GAME" produce \ ./target/release/source2rosetta --game "$GAME" produce \
--seed "in/seed-$GAME.json" \ --seed "in/seed-$GAME.json" \
--corpus-model "in/model-$GAME.json" \ --corpus-model "in/model-$GAME.json" \
--prototypes mappings/prototypes.json \
--ehandle-classes mappings/ehandle-classes.json \
--target "work/$BUILDID" \ --target "work/$BUILDID" \
--game-dir "$GAME_DIR" \ --game-dir "$GAME_DIR" \
--version "$GAME-$BUILDID-0" \ --version "$GAME-$BUILDID-0" \
@ -104,3 +107,9 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
override: true override: true
release-notes: "Rolling ${{ env.GAME }} gamedata — always the newest build (currently ${{ env.BUILDID }}). Stable URL; assets overwritten each update." 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"

View file

@ -52,6 +52,24 @@ The catalogues and dictionaries that give the derived offsets/signatures their n
[hazedumper](https://github.com/frk1/hazedumper) (frk1). [hazedumper](https://github.com/frk1/hazedumper) (frk1).
- **macOS symbol ground-truth** — [dota-2-symbols](https://github.com/a2x/dota-2-symbols) (a2x): symbolicated - **macOS symbol ground-truth** — [dota-2-symbols](https://github.com/a2x/dota-2-symbols) (a2x): symbolicated
Dota builds that anchor cross-game name transfer. 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), - **Dota / Deadlock gamedata** — [McDota](https://github.com/LWSS/McDota) (LWSS),
[dota2dumped](https://github.com/ikhsanprasetyo/dota2dumped) & [Dota2Cheat](https://github.com/ikhsanprasetyo/Dota2Cheat) [dota2dumped](https://github.com/ikhsanprasetyo/dota2dumped) & [Dota2Cheat](https://github.com/ikhsanprasetyo/Dota2Cheat)
(ikhsanprasetyo), [D2VDump](https://github.com/ModDota/D2VDump) (ModDota), (ikhsanprasetyo), [D2VDump](https://github.com/ModDota/D2VDump) (ModDota),

2
Cargo.lock generated
View file

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

350
README.md
View file

@ -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. 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 ```sh
# always the newest build R=https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest # always the newest build
curl -fsSLO https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest/gamedata-cs2.json curl -fsSLO $R/gamedata-cs2.json # WHERE functions are — signatures + vtable offsets
curl -fsSLO https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest/netvars-cs2.json curl -fsSLO $R/netvars-cs2.json # field offsets and types
curl -fsSLO $R/abi-cs2.json # HOW to call them — parameter and return types, re-judged per build
``` ```
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. Also published: `bindings-<game>.json` (the callable surface the binary declares about itself — Pulse
bindings, entity IO, console commands) and `manifest.json` (which build you got). The
[artifacts section](#artifacts-schemas--output-formats) covers all of them.
The output is framework-neutral; `source2rosetta-gen` renders it into whatever your stack speaks — the gamedata into your framework's locator format, and `abi-<game>.json` into **typed call sites** for the same functions. The deriver behind it is a standalone Rust tool — you only need that if you're self-hosting the pipeline or adding a game.
## Docs ## Docs
@ -27,10 +32,10 @@ Ballpark from a recent build, on a 16-core desktop. These move build-to-build
| | derived functions | typed schema | model | one-time distill | | | 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 | | **CS2** | ~1,125 `core` + ~2,620 `high_confidence`, plus ~4,375 `experimental` name guesses | ~1,900 classes / ~12,300 fields | ~48 MB (a few MB gzipped) | ~15 min |
| **Dota 2** | ~1,900 `core` + ~1,000 `high_confidence`, plus ~6,100 `experimental` | ~2,960 classes / ~17,700 fields | ~570 MB | ~1 hr | | **Dota 2** | ~1,930 `core` + ~2,450 `high_confidence`, plus ~5,950 `experimental` | ~2,960 classes / ~17,700 fields | ~570 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. A full run live-validates what it ships and reports **0 dropped** on both games — for CS2 that is ~2,610 signatures and ~1,120 vtable offsets checked against a running server. Distilling the model is a one-time cost; after that each build's re-derive is minutes of compute, and the half hour in the headline is the whole loop: notice, update, derive, validate, publish.
--- ---
@ -42,7 +47,7 @@ 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. 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. 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 | | you want | use |
|---|---| |---|---|
@ -54,49 +59,163 @@ Follow `-latest` to adopt updates as they land, or pin a buildid tag to adopt th
--- ---
## How it works — read → derive → validate → emit ## 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` ### 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. All Source-2 `.so` files link at vaddr 0, so a runtime address is simply `load_base + file_vaddr`.
### 3. Validate — against a live server, not a spec ### 2. Derive from the binary's own reflection
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: Valve compiles two reflection systems into every module, and both are read directly.
**RTTI** gives the type hierarchy and vtable layout: which classes exist, what they inherit, and the ordering of every vtable. Where a slot is read *directly* — the fold's offset locators and the experimental band — a slot index is a fact, not a guess.
**SchemaSystem** gives class → field metadata. Be precise about what is static here: **name and offset are in the file; the TYPE is not.** A field's type pointer is a null placeholder on disk and is populated only at runtime, which is why typed netvars require a live process and why an offline run ships no `netvars-<game>.json` at all.
### 3. Names Valve ships in the binary — three sources, and only two locate
Every Source-2 module names some of its own functions. This is ground truth from the shipped binary — not inference, not cross-game transfer — so it outranks every derived name, and it re-derives on every build with no input to maintain and nothing to bootstrap: the source travels with the binary.
| source | what it pairs | locates? |
|---|---|---|
| **entity-IO datadesc** | the C++ handler name (`InputKill`) with the handler's address | **yes** |
| **console-command registration** | the command name (`bot_add`) with its callback | **yes** |
| **Pulse binding registry** | a qualified `Class::Method` with display name, description, call policy and a full typed signature | **no** |
**The Pulse registry does not locate anything, and the mistake is instructive.** Its records carry two code pointers, which an early pass folded as locators for a headline `+782` names. They are *descriptor accessors* — every CS2 `libserver` binding measures the same empty `int=0 float=0` footprint, and they disassemble to a lazy-init singleton that returns a static vector. Folding them would have shipped `CBaseEntityAPI::GetAbsOrigin` pointing at a zero-argument accessor: a locator that resolves, passes live validation as executable code, and is still the wrong function. The honest yield from that table is zero locators — and a complete typed API surface, which ships separately as `bindings-<game>.json`.
**The datadesc handlers are class-qualified** by joining each record's array against the SchemaSystem: a datadesc array also carries field descriptors, and a `(member, offset)` pair is something the schema states from an entirely different table, so the class whose schema satisfies every pair in the array owns it. 653 of 715 CS2 handlers qualify this way. This is what makes `InputEnable` — a distinct handler on 48 classes — nameable at all; unqualified names that resolve to several addresses are still **dropped, not guessed**. Note for consumers: qualification **renamed 343 shipped keys** (`InputActivateSkybox``CAmbientGeneric::InputActivateSkybox`).
**Console commands are read from the registration call, not a registry walk.** CS2 registers through a handle-based `ConCommandRef` whose registry lives in tier0, so there is no static `ConCommand` object to scan for and nothing in the file points at a command name. The registration *call* still does — an ordinary call from a static initializer whose name, handler, description and flags are all constants in the instruction stream. source2rosetta tracks what each argument register provably holds and reads the vector at every call: **784 commands across 20 of the 22 libraries on CS2, 855 on Dota** (the two misses are libraries whose registrar the shape test does not find). The registrar is identified by what it *does* — it opens by writing the invalid-handle sentinel into its `ConCommandRef` — never by address and never by ranking call sites, so a library with three commands is as readable as one with three hundred.
Because name and handler come from one instruction sequence, Valve's own published command dump checks the result: **746 of 746 descriptions agree exactly, with no disagreements**, 742 of 780 distinct names appear in that dump, and 12 flag bits are matched to the flag names it prints. Three further flag bits occur and are deliberately left unnamed.
**Typed Pulse signatures, read offline.** A binding's record does not carry its signature; the accessors beside it return the parameter and return vectors, which are built at runtime and therefore zero in the file. The *code* that builds them is not, and every field is written to a fixed address from a `lea` or an immediate — so constant-propagating through the initializer recovers the full typed signature with no running process: **999/999 CS2 records and 859/859 Dota**, with parameter names, `PulseValueType_t` types, and the schema type a value refers to where the binding names one. Cross-checked against Valve's published metadata dump: **435 of 437** CS2 server bindings agree exactly, **293 of 294** on Dota (every difference a global-event binding, where Valve documents the same vector as an out-param).
### 4. Locate across builds — for everything Valve doesn't name
A stripped non-virtual function has no slot and no symbol, so it has to be *found*.
**The fingerprint** is a recompilation-invariant statistical description of a function: bounded-CFG structural counts, an 18-bucket mnemonic-class histogram, a 32-bucket sketch of the printable `.rodata` strings it references, and one hop of call-graph context (distinct callees plus an aggregate of each callee's own instructions, calls and branches) — **63 dimensions**, deliberately abstracted statistics and never raw bytes.
To be exact about what this is *not*: **no trained model, no machine learning, no learned or weighted metric, no embedding network.** Matching is nearest-history under a plain unweighted **L1** distance, accepted within a fixed threshold. The per-game "model" is a bundle of derived *facts* — vtable-alignment hops, reference-fingerprint windows, ABI-shape consensus, slot timelines — not a network.
**A signature resolves by one of three outcomes, and the artifact says which:**
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 `abi-<game>.json` joins *declared* prototypes to that measurement and judges each one. See [the manifest](#abi-gamejson--declared-prototypes-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; - 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; - 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 ### 7. Confidence tiers — nothing vanishes silently
The output is a per-game **monolith** in which every catalogue entry is accounted for, sorted into four tiers:
| tier | meaning | | tier | meaning |
|---|---| |---|---|
| `core` | derived and, in a full run, **live-validated** — the load-bearing gamedata | | `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) | | `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** | | `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 reason (`sig-drifted`, `offset-low-conf`, …) and no locator | | `unresolved` | catalogued but not confidently produced this build, with a closed-vocabulary reason (`sig-drifted`, `offset-low-conf`, `unresolved`, `abi-drift`) and no locator |
A function that couldn't be derived this build shows up as `unresolved` with a reason — it never just disappears. A function that couldn't be derived this build shows up as `unresolved` with a reason — it never just disappears.
### 5. Emit **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.
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)). ---
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 |
| typed netvars | fraction of fields typed | a runtime type-layout reshape resolves every field "untyped" and would otherwise ship a typeless schema at exit 0 |
| schema enums | enum count | read by shape like the class table, so a reshape yields zero rather than wrong |
| live validation | pass rate, above a minimum sample | |
### `validated` is three-valued
`true` / `false` / `null`, and `null` is not a synonym for failure. Three cases ship `null` legitimately: the library is not mapped in the vanilla server, the class is not a vtable class (an engine special or a carried member offset), or the entry has no locator to check. An **offline run ships the whole monolith `null`** — absence of validation, not failed validation. Treat `null` as "not checked here", never as "checked and passed".
### The standing oracles
A dozen free, mostly two-sided checks run on **every** derive and are reported. Two-sided means the two halves are read from different places by readers that don't know about each other, so agreement is evidence and disagreement is a defect:
- **entity-IO ABI** — hundreds of independent handlers against one declared prototype: CS2 715/715, Dota 624/624.
- **console-command ABI** — the callback *form* comes from the registration site, the *arity* from a liveness pass over a different function body: CS2 784/784, Dota 855/855.
- **console-command distinctness** — 784 commands → 783 distinct handlers (855 → 854 on Dota). Catches a layout change that would start returning a shared dispatch thunk: still resolving, still validating, still wrong.
- **Pulse receiver cross-check** — the registry's policy flag vs the independently recovered parameter list: 999/999 and 859/859 registrations.
- **entity-output ↔ schema join** — 226/226 CS2, 186/187 Dota.
- **EHANDLE class grouping** — Valve's naming vs the binary's destructor addresses: 0 of 44 CS2 / 41 Dota groups carry two classes.
- **Pulse element stride** — derived by consensus per image, unanimous across six libraries in both games.
- **live schema oracle** — offline layout vs the running process: 852/852 CS2, 1,912/1,912 Dota.
- **field-gap size calibration**, the semantic call sweep, and a 500-iteration live fuzz.
One of these found a real defect on its first run: 34 of 715 handlers measured float arguments a `void(ptr, ref)` cannot have, which traced to the ABI reader treating a `call` as fall-through so a callee's *return* propagated backwards as a phantom argument.
### What is NOT gated
Stated because "we check things" is worthless without a boundary. There is **no** floor on the validate-live drop rate, the live-fuzz fault rate, the schema class count, or the RTTI class / base-graph size. A regression in any of those is reported, not refused.
**One oracle is currently reporting.** The Pulse registry is keyed by qualified name, so a binding registered by several modules keeps one row, and every duplicate is compared against the row already present. On the current builds **CS2 flags 331 of 419 repeat registrations and Dota 271 of 359** as disagreeing — so for those names `bindings-<game>.json` carries one module's account of the signature, not a merged one. It is flagged on every run and is not yet resolved; if you consume `params`/`returns` for a multiply-registered binding, know that.
--- ---
@ -113,21 +232,23 @@ cargo build --release # → ./target/release/source2roset
cargo build --release -p source2rosetta-core # → ./target/release/source2rosetta-gen 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 …`. `--game <cs2|dota2>` is a global flag (default `cs2`), given before the subcommand: `source2rosetta --game dota2 produce …`.
| command | one line | | 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.** | | `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. | | `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. | | `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). | | `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. | | `classify-change` | `--prev`/`--new``skip` / `normal` / `shift` + the exact % of function bodies that changed, comparing with position-dependent bytes masked so a pure layout shift reads as unchanged. An **operator primitive** — nothing in the shipped pipeline invokes it; the poller dispatches a derive on any buildid change. |
| `filter-corpus` | Collapse runs of code-identical builds to one representative, label each transition `normal`/`shift`, and segment the timeline into toolchain eras. Writes the selection manifest the distill reads. | | `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 ### 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 ```sh
# OFFLINE — derive gamedata + roll the model forward. No server, fully deterministic. # OFFLINE — derive gamedata + roll the model forward. No server, fully deterministic.
@ -147,18 +268,31 @@ Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/re
--out-dir out --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`. - `--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.)
- `--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.** - `--game-dir <install>` — must be the **`game/` subtree** of the install, the same directory layout the dedicated server is launched from.
- 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). - `--seed <bundle>` — one file bundling every derive input. The loose equivalent is `--catalogue <file>` plus the optional `--promotable` / `--candidates` / `--full-names` / `--extra-offsets` / `--extra-sigs`, all defaulting to empty — **so a brand-new game needs only a catalogue to start deriving.**
- Corpus signal — exactly one of `--corpus-model <model.json>` (the normal path: forward-derive from the model + target binary, rolling the model N→N+1 as a sidecar) or `--corpus <dir>` (fingerprint raw build binaries on the fly).
Model-based derives are **forward-only**: the model describes history up to its newest build, so pointing one at an *older* target is not supported.
The launched server writes its log to `TMPDIR/<token>-produce.log`, one file per game, and binds a **fixed port** — so two games cannot be produced concurrently on one host without changing it.
--- ---
## Fork it & distill your own model ## 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. ### What a fork inherits, and what it must supply
- **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.
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 ```sh
# Distill a corpus into a model (streaming, bounded RAM even over Dota's ~1k builds). # Distill a corpus into a model (streaming, bounded RAM even over Dota's ~1k builds).
@ -168,11 +302,17 @@ Nothing is hosted — fork it, `cargo build --release`, and point it at a build
--out model-cs2.json --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 ### 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 ```sh
./target/release/source2rosetta --game cs2 fold-model \ ./target/release/source2rosetta --game cs2 fold-model \
@ -182,36 +322,44 @@ Once a model exists you never need the corpus again. `fold-model` rolls it forwa
--out model-cs2.next.json --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) ### 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: 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. 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 ### 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 ## 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 | | 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 | | `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 | | `abi-<game>.json` | The **prototype manifest** — declared parameter/return types, each judged against the footprint measured in this build | always |
| `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`) | | `bindings-<game>.json` | The **declared callable surface** — what the binary says about itself: Pulse bindings, entity IO, entity classnames, console commands | always |
| `netvars-<game>.json` | The **typed schema** — every SchemaSystem class → field → offset/type, plus the base graph and type layouts | full (`--game-dir`) runs only |
| `model-<game>.json` | The **per-game model** — the distilled facts derivation reads instead of the corpus | when the run folds an existing model |
| `manifest.json` | Volatile release metadata: `{ version, artifacts: [...] }` | always | | `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 other artifacts carry no timestamp, so they are **byte-reproducible** — the same build in yields the same JSON out.
### `gamedata-<game>.json` — the monolith ### `gamedata-<game>.json` — the monolith
@ -226,50 +374,102 @@ Wall-clock and other volatile metadata live only in `manifest.json`; the monolit
} }
``` ```
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: A **`MonoEntry`** is a locator flattened to the top level, plus its grading and — where the address resolved offline — its measured `abi` footprint inline. A virtual method ships as a bare integer `offset` (its RTTI vtable slot index); a non-virtual function as a `signature` object with the `library` it scans and a space-hex `linux` pattern with `?` wildcards. By deriver convention an entry carries one or the other; the readers handle the rare both-present case deterministically.
```jsonc `reason` on an unresolved entry comes from a closed vocabulary: `sig-drifted`, `offset-low-conf`, `unresolved`, `abi-drift`.
{
"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)
}
```
`validated: false` entries stay in the file for transparency but are **dropped by every renderer**. Console-command handlers ship under the key **`ConCommand::<name>`**. The prefix says what the entry *is* — the handler bound to that command — rather than claiming a C++ symbol; `ent_fire`'s real method name appears nowhere in the binary.
> 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. ### `abi-<game>.json` — declared prototypes, judged against this build
Gamedata says *where* a function is; it never says what it takes. Types cannot be recovered from a stripped binary, so they come from declarations — and a declaration must be checked before anything calls through it, because a stale one produces a call that resolves, validates, and loads the wrong registers.
**The verdict is the product**, and there are six:
| verdict | meaning |
|---|---|
| `verified` | declared arity matches the footprint measured in *this* build |
| `lower-bound` | the declaration passes registers the callee never reads, and contradicts it in no register class — safe to call, but not the same claim as an exact match |
| `mismatch` | the callee reads a register the declaration does not mention — **do not call through it** |
| `return-only` | a return type is known and no parameter list, so there is no arity claim to check |
| `unverified` | nothing to check it against |
| `ambiguous` | several signatures on offer and no measurement to separate them |
Types come from three places, and `provenance` says which: a source declaration; the engine's own **dispatch contract**; or, for a return with neither, the measured register class (written `ret=…` so it can never be mistaken for a declared type — the measured class is wrong about known-void functions roughly seven times in eight).
**Two dispatch contracts exist**, and both are stronger than any header because nobody has to have written the function down for the way the engine invokes it to be known: an entity-IO handler is invoked through `void(CEntityInstance*, InputData_t&)`; a console-command handler through the command-context and command pair, plus a receiver where the registration dispatches through an object. Which of the three callback forms a command uses is recorded at the registration site, so the contract is keyed on it rather than assumed. A contract is judged as a **lower bound** — it describes how the function is *invoked*, so only an over-count refutes it.
In the artifact, parameters are spelled as **pointers** (`CCommandContext*`, `CCommand*`), not as the C++ reference types.
`source2rosetta-gen --abi … --format <framework>` turns this into **call sites**: typed C# fields for CounterStrikeSharp, a C++ typedef header for Metamod plugins, an `[AddressKey]` interface for ModSharp, and runtime type descriptors for Swiftly and Plugify. Both `verified` and `lower-bound` entries with a settled receiver are emitted, and every output marks the lower-bound ones.
### `bindings-<game>.json` — the declared callable surface
What the binary *says about itself*, as opposed to what the derivation *infers about it*. Kept out of the monolith deliberately: `gamedata` answers "where is this function", this answers "what may I do with it, and how". **Five sections:**
- `pulse` — bindings keyed by 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, and the recovered typed signature.
- `entity_inputs` — the map-facing input name, the C++ handler, its owning class where the schema join qualified it, and the handler's **address** (these also ship as gamedata).
- `entity_outputs` — the events an entity fires and where the subscriber list lives on the instance.
- `entity_classes` — map classname → the C++ class it constructs (`func_door``CBaseDoor`). Names to names, no addresses.
- `commands` — console commands with description, decoded flags, the raw flags word, the callback form, the measured ABI shape and the handler **address** (these also ship as gamedata).
**`descriptor` on a Pulse binding is NOT a locator** — see [above](#3-names-valve-ships-in-the-binary--three-sources-and-only-two-locate). Any Pulse count is a count of *registrations*, not distinct bindings.
### `netvars-<game>.json` — the typed schema ### `netvars-<game>.json` — the typed schema
```jsonc Every SchemaSystem class → field → offset and type, plus two sections that are easy to miss and load-bearing:
{
"meta": { "game_key", "source_build", "typed", "untyped" },
"classes": { "<class>": { "<field>": { "offset", "type", "kind", "size", "name_hash" } } }
}
```
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. - `bases` — the class base graph. Without it an inherited field is unresolvable.
- `types` — per-type size and SysV register class, needed to compute a by-value argument's register cost.
Of a field's attributes, `type` and `kind` are read from the **live process**; `size` and the name hash are derived offline.
### `model-<game>.json` — the per-game model ### `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 ### 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` takes the monolith (`--from`), the schema (`--netvars`) or the manifest (`--abi`) and renders the matching format. The **input** chooses what is rendered; the `--format` id chooses for whom.
**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)**. 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 `model` format emits a tier-selected `Gamedata` with no `meta`, so its output cannot be fed back in via `--from`.
---
## Provenance
This is a publishable tool, so where declarations and names come from is a hard boundary, not a footnote.
**Harvested:** Valve-published artifacts (the shipped binaries themselves, Valve's own metadata and command dumps, symbolicated older macOS builds) and legitimately licensed open-source projects, credited in [ATTRIBUTIONS.md](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 ship no netvars and no `validated` state**, 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).
- **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 ## 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 ## License
[AGPL-3.0](LICENSE). Built on a decade of community work — see **[ATTRIBUTIONS.md](ATTRIBUTIONS.md)** first. AGPL-3.0. See [LICENSE](LICENSE).

View file

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

View file

@ -2,7 +2,7 @@
Render a published [source2rosetta](../../README.md) gamedata release into whatever format your framework 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 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 validating it on a live server — and publishes a small set of JSON files per game. `source2rosetta-gen` turns those into
CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, or a typed C# SDK, locally, in a second. CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, or a typed C# SDK, locally, in a second.
It's deliberately tiny: it links only `source2rosetta-core` (serde + the format emitters) — **no** ELF reader, It's deliberately tiny: it links only `source2rosetta-core` (serde + the format emitters) — **no** ELF reader,
@ -23,10 +23,19 @@ does **not** build it — use `-p source2rosetta-core` or `--workspace`.)
## Use it ## Use it
Download the two artifacts for your game from the release page: Three of the published artifacts are `gen` inputs, one per `--` flag:
- `gamedata-<game>.json` — the derived gamedata (function signatures + vtable offsets), tiered by confidence. - `gamedata-<game>.json` (`--from`) — the derived gamedata (function signatures + vtable offsets), tiered by confidence.
- `netvars-<game>.json` — the typed schema (every class's field offsets + runtime types). - `netvars-<game>.json` (`--netvars`) — the typed schema (field offsets + runtime types, plus the class base
graph and per-type sizes).
- `abi-<game>.json` (`--abi`) — declared parameter and return types, each re-judged against the footprint
measured in that build. This is what a function TAKES, as opposed to where it is. See
[Call shapes](#call-shapes----abi-abi-gamejson).
`bindings-<game>.json` ships beside them and `gen` does **not** render it — it is not locator data. It is
what the binary declares about itself, in five sections: Pulse bindings (display name, description, call
policy, and each binding's typed signature), entity-IO inputs and outputs, map-classname → C++ class, and
console commands. Plain JSON, readable as-is.
Then point `gen` at whichever you need and pick a `--format`. Output goes to `--out`, or stdout if omitted. Then point `gen` at whichever you need and pick a `--format`. Output goes to `--out`, or stdout if omitted.
@ -51,15 +60,69 @@ source2rosetta-gen --netvars netvars-cs2.json --format netvars --out netvars.jso
| `--format` | needs | output | | `--format` | needs | output |
|---|---|---| |---|---|---|
| `cssharp` *(default)* | `--from` | CounterStrikeSharp combined gamedata (a commented, sectioned file) | | `cssharp` *(default)* | `--from` | CounterStrikeSharp combined gamedata **JSONC**: banner comments mean a strict JSON parser will reject it |
| `metamod` | `--from` | Metamod:Source / SourceMod gamedata VDF (`.games.txt`) | | `metamod` | `--from` | Metamod:Source / SourceMod gamedata VDF (`.games.txt`) |
| `modsharp` | `--from` | ModSharp gamedata JSON | | `modsharp` | `--from` | ModSharp gamedata JSON |
| `swiftly` | `--from` | Swiftly gamedata JSON | | `swiftly` | `--from` | Swiftly gamedata JSON **signature entries only**; vtable-offset entries are omitted, because that framework takes offsets through a separate file |
| `plugify` | `--from` | Plugify gamedata JSON | | `plugify` | `--from` | Plugify gamedata JSON |
| `model` | `--from` | the canonical model, re-serialized (format-neutral) | | `model` | `--from` | the selected tiers flattened to one name → locator map (format-neutral; not a re-readable monolith) |
| `cs-sdk` | `--netvars` | typed C# SDK — `static class` per schema class, `const int` field offsets tagged with their type | | `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 } }` | | `netvars` | `--netvars` | flat schema map, `{ class: { field: offset } }` |
### Call shapes — `--abi abi-<game>.json`
The same framework ids, a different input: `--abi` renders **how to call** a function rather than where it
is. The input picks the family, so `--abi … --format cssharp` emits typed call sites while
`--from … --format cssharp` emits the gamedata those calls resolve through.
All five framework ids work here, exactly as they do for `--from`:
```sh
source2rosetta-gen --abi abi-cs2.json --format cssharp --out RosettaFunctions.cs
source2rosetta-gen --abi abi-cs2.json --format metamod --out rosetta_prototypes.h
source2rosetta-gen --abi abi-cs2.json --format modsharp --out RosettaCalls.cs
source2rosetta-gen --abi abi-cs2.json --format swiftly --out prototypes.json
source2rosetta-gen --abi abi-cs2.json --format plugify --out prototypes.json
```
| `--format` | output |
|---|---|
| `cssharp` | C# `MemoryFunction*` fields (signature) / `VirtualFunction*` factories (vtable slot) |
| `metamod` | C++ header of `using X_t = RET (*)(…)`, plus an `X_vtidx` constant for a slot — Metamod plugins are C++ and take the **declared** types verbatim |
| `modsharp` | C# `[AddressKey]` interface for its Roslyn generator (signature) + a vtable-dispatch class (slot) |
| `swiftly` | JSON per-function type descriptors (`{"args":"ppf","ret":"v","call":"address"}`) |
| `plugify` | JSON runtime type arrays (`{"paramTypes":["pointer","string","float"],"retType":"void"}`) |
Source for the two C# targets and for C++ because their type lists are **compile-time**; data for Swiftly
and Plugify because theirs are resolved at runtime.
**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 a LIVE-derived manifest's call sites are vtable-located, so binding them all through the
signature path would look up keys that live in the gamedata's `offsets` section and never in its
`signatures` one.
An **offline**-derived manifest emits none through the vtable path at all: a slot is recorded only 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. Same artifact shape, fewer vtable call sites — worth knowing
before diffing two manifests produced different ways.
**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.
Only functions the deriver could stand behind are emitted: `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 (prose in the generated source, `ret_declared` / `retDeclared` in the data), and the value is
documented as the raw return register rather than a typed result.
## Confidence tier ## Confidence tier
The gamedata formats (the `--from` ones) take a `--tier`, cumulative and defaulting to `high_confidence`: The gamedata formats (the `--from` ones) take a `--tier`, cumulative and defaulting to `high_confidence`:

View file

@ -15,6 +15,7 @@ use std::path::PathBuf;
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
name = "source2rosetta-gen", name = "source2rosetta-gen",
version,
about = "Render a source2rosetta monolith into a framework gamedata format" about = "Render a source2rosetta monolith into a framework gamedata format"
)] )]
struct Cli { struct Cli {
@ -24,9 +25,17 @@ struct Cli {
/// The typed `netvars-<game>.json` (for a SCHEMA --format: cs-sdk/netvars). /// The typed `netvars-<game>.json` (for a SCHEMA --format: cs-sdk/netvars).
#[arg(long)] #[arg(long)]
netvars: Option<PathBuf>, netvars: Option<PathBuf>,
/// The prototype manifest `abi-<game>.json` — renders CALL SHAPES (declared parameter and return
/// types, verified against the build) instead of locators. Reuses the framework --format ids: the
/// INPUT chooses what is rendered, so `--abi … --format cssharp` emits typed call sites while
/// `--from … --format cssharp` emits the gamedata those calls resolve through.
#[arg(long)]
abi: Option<PathBuf>,
/// Output format. GAMEDATA (needs --from): cssharp | metamod | modsharp | swiftly | plugify | model. /// 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 /// SCHEMA (needs --netvars): cs-sdk (typed C# SDK) | netvars (flat offset map). ABI (needs --abi):
/// `//`-bannered CS# combined file; metamod also covers SourceMod (the VDF `.games.txt`). /// cssharp | metamod | modsharp | swiftly | plugify. cssharp = the `//`-bannered CS# combined file;
/// the metamod gamedata format also covers SourceMod (the VDF `.games.txt`), while its ABI format is
/// a C++ header, because Metamod plugins are C++ and declare prototypes in source.
#[arg(long, default_value = "cssharp")] #[arg(long, default_value = "cssharp")]
format: String, format: String,
/// Confidence tier for a gamedata format (cumulative): core | high_confidence | experimental. Defaults to /// Confidence tier for a gamedata format (cumulative): core | high_confidence | experimental. Defaults to
@ -42,7 +51,18 @@ fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let fmt = cli.format.as_str(); let fmt = cli.format.as_str();
let text = if render::SCHEMA_FORMAT_IDS.contains(&fmt) { // The INPUT selects the emitter family, which is why `cssharp` can name three different outputs.
let text = if let Some(path) = cli.abi.as_ref() {
let man: model::AbiManifest = serde_json::from_str(&std::fs::read_to_string(path)?)
.with_context(|| format!("parse abi manifest json {}", path.display()))?;
let e = render::abi_by_id(fmt).with_context(|| {
format!(
"unknown ABI --format `{fmt}` (known: {})",
render::ABI_FORMAT_IDS.join(" | ")
)
})?;
e.render(&man)
} else if render::SCHEMA_FORMAT_IDS.contains(&fmt) {
// schema formats render the typed netvars (class -> field -> offset/type), not the gamedata monolith. // schema formats render the typed netvars (class -> field -> offset/type), not the gamedata monolith.
let path = cli.netvars.as_ref().context( let path = cli.netvars.as_ref().context(
"a schema --format (cs-sdk | netvars) requires --netvars <netvars-<game>.json>", "a schema --format (cs-sdk | netvars) requires --netvars <netvars-<game>.json>",

View file

@ -154,11 +154,20 @@ impl Gamedata {
// =========================================================================================== // ===========================================================================================
/// A monolith entry's confidence tier — its finer label within a section, serialized as kebab strings /// A monolith entry's confidence tier — its finer label within a section, serialized as kebab strings
/// (`core`, `self-named`, `dict-exact`, `contextual`, `corroborated`, `high`, `medium`, `low`). /// (`core`, `valve-table`, `self-named`, `dict-exact`, `contextual`, `corroborated`, `high`, `medium`,
/// `low`). Declared most-confident first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum Tier { pub enum Tier {
Core, Core,
/// Named by a table Valve compiled into the binary being derived — today the entity-IO datadesc,
/// whose records pair a handler name with the handler itself. The only naming source that is neither
/// inferred nor transferred from another build, so it outranks every tier below it: the binary is
/// vouching for its own function names. (It is still not [`Tier::Core`], which additionally means
/// cross-build history — fingerprint verification and drift detection — that one build cannot supply.
/// The Pulse binding table names far more functions but does not LOCATE them; it ships as its own
/// registry, see [`Bindings`].)
ValveTable,
SelfNamed, SelfNamed,
/// Dictionary-corroborated by the FOLD (an exact hit in the harvested name catalogue). /// Dictionary-corroborated by the FOLD (an exact hit in the harvested name catalogue).
DictExact, DictExact,
@ -200,7 +209,7 @@ pub struct Provenance {
pub by_value: Option<bool>, pub by_value: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ret_class: Option<String>, pub ret_class: Option<String>,
/// "catalogue" | "source2rosetta-nameext" | "contribution:<date>" | … /// `"catalogue"` | `"source2rosetta-nameext"` | `"contribution:<date>"` | …
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>, pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@ -230,6 +239,7 @@ impl Tier {
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Tier::Core => "core", Tier::Core => "core",
Tier::ValveTable => "valve-table",
Tier::SelfNamed => "self-named", Tier::SelfNamed => "self-named",
Tier::DictExact => "dict-exact", Tier::DictExact => "dict-exact",
Tier::Contextual => "contextual", Tier::Contextual => "contextual",
@ -246,6 +256,7 @@ impl Tier {
pub fn from_id(s: &str) -> Option<Tier> { pub fn from_id(s: &str) -> Option<Tier> {
Some(match s { Some(match s {
"core" => Tier::Core, "core" => Tier::Core,
"valve-table" => Tier::ValveTable,
"self-named" => Tier::SelfNamed, "self-named" => Tier::SelfNamed,
"dict-exact" => Tier::DictExact, "dict-exact" => Tier::DictExact,
"contextual" => Tier::Contextual, "contextual" => Tier::Contextual,
@ -289,12 +300,42 @@ pub struct MonoEntry {
/// experimental offsets only: the vtable class the slot lives on (a reader's eyeball check). /// experimental offsets only: the vtable class the slot lives on (a reader's eyeball check).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<String>, pub class: Option<String>,
/// The argument footprint read out of THIS build's machine code — see [`AbiShape`]. Absent when the
/// function's address wasn't resolvable offline.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub abi: Option<AbiShape>,
pub provenance: Provenance, pub provenance: Provenance,
/// Live-validation verdict: `Some(true)` passed, `Some(false)` dropped confident-bad, `None` unvalidated. /// Live-validation verdict: `Some(true)` passed, `Some(false)` dropped confident-bad, `None` unvalidated.
#[serde(default)] #[serde(default)]
pub validated: Option<bool>, pub validated: Option<bool>,
} }
/// A function's SysV-AMD64 argument footprint, read out of the target binary rather than declared: how many
/// integer and float registers it takes as inputs, whether arguments also spill to the stack, and how it
/// returns. This is what makes a *declared* prototype checkable — a declaration whose arity contradicts the
/// footprint does not describe this build, and calling through it would load the wrong registers.
///
/// A lower bound, never an over-count (a forwarding thunk reads no argument register of its own), so a
/// disagreement is worth review rather than an automatic rejection.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AbiShape {
/// Integer/pointer arguments, including an implicit `this`. Caps at 6 — the SysV register budget.
pub int: u8,
/// Floating arguments (XMM0..7).
pub float: u8,
/// Arguments also arrive on the stack: the real arity exceeds the register budget, so a caller filling
/// only registers is wrong.
#[serde(default, skip_serializing_if = "is_false")]
pub stack: bool,
/// Return class token: `ret=void` | `ret=int` | `ret=float` | `ret=byval` | `ret=?` (undetermined).
/// `ret=byval` is the sret case — UNSAFE to blind-call, since the caller must pass an output buffer.
pub ret: String,
}
fn is_false(b: &bool) -> bool {
!*b
}
/// A catalogued function the derivation could not confidently produce — kept in-file (never a shipped /// A catalogued function the derivation could not confidently produce — kept in-file (never a shipped
/// locator) so the monolith is the complete catalogue picture. /// locator) so the monolith is the complete catalogue picture.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
@ -395,6 +436,372 @@ impl Monolith {
} }
} }
// ===========================================================================================
// The binding registry — the shipped `bindings-<game>.json`. What the binary DECLARES about its own
// callable surface, as opposed to what the derivation infers about it: Valve registers every Pulse
// binding and entity-IO input with a name, author-facing documentation, and call metadata, and this is
// that data read back out. Kept OUT of the monolith on purpose — the monolith answers "where is this
// function", this answers "what may I do with it", and only the first belongs in a gamedata file.
// ===========================================================================================
/// How a Pulse binding is invoked — the receiver question, which decides whether a caller needs an
/// entity at all. Mutually exclusive by construction (the two flag bytes are never set together).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BindingKind {
/// A free function on a Pulse library class (`CPulseMathlib::Sin`) — no receiver.
Library,
/// A method on an entity API (`CBaseEntityAPI::GetAbsOrigin`) — needs an instance receiver.
Instance,
/// A Pulse cell's own entry point (`CPulseCell_Step_DebugLog::Run`) — invoked by the VM as it walks
/// a graph, not called by a graph author.
Cell,
}
/// The call metadata Valve records for a binding — a typed graph VM cannot register a binding without
/// knowing how it may be invoked, so this is the engine's own policy, read back rather than reasoned out.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct CallPolicy {
pub kind: BindingKind,
/// The binding writes state. The const-correctness axis: a caller that only observes can be run
/// where a mutation may not be.
pub mutates: bool,
/// The binding may suspend the calling cursor instead of completing within the frame.
pub blocking: bool,
/// The two metadata words exactly as read, so a consumer can re-derive meaning if a later build
/// repurposes a bit rather than silently inheriting this decoding.
pub raw: [u32; 2],
}
/// One Pulse binding: where it is, what Valve calls it, and how it may be invoked.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Binding {
/// The module registering it — `server`, `pulse_system`, …
pub library: String,
/// The author-facing label ("Get Abs Origin").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display: Option<String>,
/// The author-facing documentation ("The entity origin (absolute).").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub policy: CallPolicy,
/// The binding's declared parameters, in order, recovered from the descriptor accessor's own
/// initializer. Pulse is a TYPED graph VM, so this is the engine's own statement of how the
/// binding is called — not a transfer from another game and not a 2018-era declaration.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub params: Vec<PulseParam>,
/// The values it hands back. Pulse models returns as named out-parameters, so this is a LIST:
/// usually one `retval`, empty for a void binding, occasionally several.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub returns: Vec<PulseParam>,
/// Whether the signature above was recovered at all. An empty `params` on an untyped binding means
/// "not read"; on a typed one it means "takes nothing", and only this field separates them.
pub typed: bool,
/// Address of the binding's DESCRIPTOR ACCESSOR in this build — a lazy-init singleton returning the
/// static descriptor, not the bound function. It is the anchor a runtime walks to reach the
/// descriptor (and, through it, the real entry point); it is NOT a locator for the named method, and
/// no shipped gamedata entry points at it.
pub descriptor: String,
}
/// One Pulse parameter or return value, as the binding declares it.
///
/// Defined here rather than beside the reader so the deriver-free core crate can carry it: `gen` emits
/// these, and nothing about the shape depends on how it was read out of the binary.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PulseParam {
/// The author-facing parameter name — `_Target` for the receiver, `retval` for a return.
pub name: String,
/// The `PulseValueType_t` enumerator, as the binary states it. Join it to the `enums` section of
/// `netvars-<game>.json` for the spelling; the raw value is kept because that is the fact.
#[serde(rename = "type")]
pub ty: i32,
/// The schema type the value refers to, where the binding NAMES one — which enum a
/// `PVAL_SCHEMA_ENUM` is, which struct an opaque handle wraps. Absent for the self-describing
/// types, and absent for a `PVAL_EHANDLE`'s entity class, which the initializer does not state —
/// see `entity_class` for where that comes from instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub type_name: Option<String>,
/// The entity class behind a `PVAL_EHANDLE` — `func_mover`, `basemodelentity`. NOT read from the
/// initializer, which never states it: it is Valve's own naming (`mappings/ehandle-classes.json`,
/// harvested from the published metadata) propagated across the parameters this build proves are
/// the same type. See [`PulseParam::type_token`] for what proves it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entity_class: Option<String>,
/// An opaque per-build token identifying the parameter's CONCRETE type: the address of that type's
/// destructor, which the initializer stores beside the name. Two parameters carry the same token
/// exactly when they are the same type — including the entity class behind a `PVAL_EHANDLE`, which
/// nothing else in the binary distinguishes.
///
/// NOT serialized, deliberately. An address is meaningless outside the build it was read from, so
/// shipping it would invite a consumer to key on something that moves every release. It exists to
/// carry the grouping from the reader to the stage that names it, and no further.
#[serde(skip)]
pub type_token: u64,
}
/// One entity-IO input handler: the name a map fires, and the C++ method that answers it.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EntityInput {
/// The input name entity IO addresses — `Kill`, `Enable`, `SetSpeed`.
pub input: String,
/// The class that owns this handler, recovered from the FIELD descriptors sharing its datadesc
/// array: a field is a `(member, offset)` pair the SchemaSystem states independently, so the class
/// whose schema contains every pair in the array owns it. Absent where the array's fingerprint fit
/// several classes or none — `InputEnable` is a distinct handler on 48 classes and guessing between
/// them is the thing this exists to stop.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
/// The C++ handler — `InputKill`. NOT class-qualified: the record carries no owning class, which is
/// also why the same handler name legitimately appears at many addresses here.
pub handler: String,
pub library: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub abi: Option<AbiShape>,
pub addr: String,
}
/// One registered console command: the name a server operator or a mod types, and the function that
/// answers it. Unlike the Pulse registry this carries a real locator — the handler comes from the same
/// registration call as the name, not from a descriptor accessor beside it.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ConsoleCommand {
/// The console-facing name, exactly as Valve compiled it — `bot_add`, `+bugvoice`.
pub name: String,
pub library: String,
/// Valve's own help text; absent when the registration passes none.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
/// The flag bits whose meaning is measured against Valve's published dump.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<String>,
/// The raw flags word, kept beside the decoding so a build that repurposes a bit can be re-read
/// instead of silently mis-labelled — the same rule the Pulse policy word follows.
pub flags_raw: String,
/// How the registration passed its callback: `direct`, `interface` or `member`. Says how much
/// indirection produced the address, which is the honest measure of how far this is from the
/// instruction stream.
pub form: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub abi: Option<AbiShape>,
pub addr: String,
}
/// One entity-IO output: an event an entity fires, and where its subscriber list lives on the instance.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EntityOutput {
/// The entity-IO name a map or a mod wires to, e.g. `OnStartTouch`.
pub output: String,
/// The member holding it, e.g. `m_OnStartTouch` — usually the name with `m_` prepended, but not
/// reliably (`BombExplode` lives on `m_OnBombExplode`), so both ship.
pub member: String,
pub library: String,
/// Byte offset of the member within its entity — an output is data, not a function, so this is the
/// locator. (`CEntityIOOutput` is 24 bytes; see the `types` section of the schema artifact.)
pub offset: u32,
/// The class the member lives on, recovered by joining `(member, offset)` against the SchemaSystem.
/// Without it an offset is unusable wherever a name repeats: `OnBreak` exists at three different
/// offsets on three different classes, and reading the wrong one runs ~1 KB past the intended member
/// on a live entity. `None` only when the schema does not describe the member.
///
/// NB the member's TYPE is not carried here: the offline schema reader recovers names and offsets but
/// not types (those are runtime-resolved), and a handful of outputs are not the plain 24-byte
/// `CEntityIOOutput` — `CLogicCase::m_OnCase` is `CEntityIOOutput[32]`. Join `class` + `member`
/// against `netvars-<game>.json` for the type before striding one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
}
/// The declared callable surface for one build — the shipped `bindings-<game>.json`.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Bindings {
pub meta: BindingsMeta,
/// Pulse bindings, keyed by their fully-qualified `Class::Method`.
pub pulse: BTreeMap<String, Binding>,
/// Entity-IO inputs, as a LIST: the handler name is not unique (one `InputEnable` per class), so
/// there is no honest key to map them by.
pub entity_inputs: Vec<EntityInput>,
/// Entity-IO outputs — the events an entity fires, as a LIST for the same reason as the inputs.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entity_outputs: Vec<EntityOutput>,
/// Map classname -> the C++ class it constructs (`func_door` -> `CBaseDoor`). The join between the
/// vocabulary a level designer writes and the classes `netvars-<game>.json` describes. No addresses:
/// the factory record binds names to names, not to a function.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub entity_classes: BTreeMap<String, String>,
/// Console commands, as a LIST: a handful of names are registered by more than one library, so
/// there is no honest key to map them by either.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<ConsoleCommand>,
}
/// The binding registry's intrinsic identity (no wall-clock field, same rationale as [`MonoMeta`]).
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct BindingsMeta {
pub game_key: String,
pub source_build: String,
pub pulse: usize,
/// Of those, how many carry a recovered typed signature.
#[serde(default)]
pub pulse_typed: usize,
pub entity_inputs: usize,
#[serde(default)]
pub entity_outputs: usize,
#[serde(default)]
pub entity_classes: usize,
#[serde(default)]
pub commands: usize,
}
impl Bindings {
pub fn is_empty(&self) -> bool {
self.pulse.is_empty()
&& self.entity_inputs.is_empty()
&& self.entity_outputs.is_empty()
&& self.entity_classes.is_empty()
&& self.commands.is_empty()
}
}
// ===========================================================================================
// The prototype manifest — the shipped `abi-<game>.json`. What a function TAKES, which the gamedata
// deliberately does not answer: a locator says where a function is, a prototype says how to call it, and
// the two have different sources and different lifetimes. Declarations are static and human-sourced;
// the VERDICT on each one is re-measured against every build.
// ===========================================================================================
/// The verdict on a declared prototype, judged against the footprint measured in THIS build.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AbiStatus {
/// The declared arity matches the measured footprint — safe to call through.
Verified,
/// The callee reads a register the declaration does not mention. The declaration does not describe
/// this build and MUST NOT be called through: such a call resolves, passes live validation, and then
/// leaves a register the callee reads unset.
Mismatch,
/// The declaration passes registers the callee never reads, and contradicts it in no register class.
///
/// NOT a mismatch, and separating the two is the point. The measured footprint is a documented LOWER
/// bound — a callee that ignores an argument, a forwarding thunk that reads none of its own, an
/// empty virtual override — so measuring FEWER arguments than declared is expected behaviour rather
/// than evidence against the declaration. `CBaseEntity::SetAbsAngles` is the shape: declared
/// `(this, float, float, float)`, measured `int=0 float=3`, every float agreeing exactly and only
/// the unread `this` differing. Calling through one merely loads a register nobody reads, which is
/// the opposite of the failure `Mismatch` names. 81 of CS2's 140 former mismatches are this.
LowerBound,
/// No measurement available to check it against.
Unverified,
/// Overloads the measurement could not separate.
Ambiguous,
/// A return type was declared and a parameter list was not, so there is no arity claim for the
/// binary to confirm or refute. `CALL_VIRTUAL(RET, …)` sites are the source: they say what comes
/// back and pass VALUES rather than types, so nothing about the signature can be read off them.
ReturnOnly,
}
impl AbiStatus {
pub fn as_str(self) -> &'static str {
match self {
AbiStatus::Verified => "verified",
AbiStatus::Mismatch => "mismatch",
AbiStatus::LowerBound => "lower-bound",
AbiStatus::Unverified => "unverified",
AbiStatus::Ambiguous => "ambiguous",
AbiStatus::ReturnOnly => "return-only",
}
}
}
/// One function's declared prototype and the verdict this build gives it.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct AbiEntry {
/// Which shipped tier the function is in.
pub tier: String,
/// `exact` — the declaration names this function; `bare-name` — it names the same METHOD on some
/// class, claimed only because exactly one declaration bears that name and a measurement could
/// adjudicate.
pub matched_by: String,
pub status: AbiStatus,
/// Declared parameter TYPES — the thing a machine-derived arity cannot supply.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<Vec<String>>,
/// Set when `params` is the FULL register-visible argument list, receiver included, because the
/// declaration was a function-pointer type rather than a mangled symbol. Absent means `this` is not
/// in the list and a caller has to supply it — the convention every Itanium-derived entry uses.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params_complete: Option<bool>,
#[serde(rename = "const", default, skip_serializing_if = "Option::is_none")]
pub is_const: Option<bool>,
/// A DECLARED return type where one exists, else the measured register class — a far weaker
/// statement (see [`AbiShape`]), and one that is not evidence about the declared type.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ret: Option<String>,
/// Omitted on an `ambiguous` verdict, where no declaration was chosen.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub provenance: Vec<String>,
/// The measured SysV footprint the verdict was reached against.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub derived: Option<AbiShape>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
/// Every signature the declarations offered, whenever there was more than one — whether the
/// measurement went on to pick between them (see `note`) or could not, which is `ambiguous`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overloads: Option<Vec<Vec<String>>>,
/// The vtable slot this build's shipped locator resolves through, present exactly when the locator
/// is a vtable offset that LIVE VALIDATION confirmed.
///
/// It states something no measurement can: a slot is only reachable through an object, so the
/// function HAS a receiver even where the footprint cannot see one. That case is not marginal —
/// a getter that returns a constant never reads `this`, and backward liveness reads that as no
/// argument at all, so `CBaseDoor::GetDataDescMap` measures `int=0` while genuinely taking one.
///
/// Live validation is part of the condition rather than a separate check, because it is what rules
/// out the one way an `offset` locator can fail to be a vtable slot: a carried MEMBER offset under a
/// name the deriver could not classify, which the live oracle reports as `Unknown`/`Oob` instead of
/// `Live`. An offline derive has no such evidence and therefore states no `vtable` at all —
/// a smaller emittable set, never a guessed receiver.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vtable: Option<i64>,
}
impl AbiEntry {
/// An entry with only the fields every verdict carries — the base for functional-update construction.
pub fn blank() -> Self {
Self {
tier: String::new(),
matched_by: String::new(),
status: AbiStatus::Unverified,
params: None,
params_complete: None,
is_const: None,
ret: None,
provenance: Vec::new(),
derived: None,
note: None,
overloads: None,
vtable: None,
}
}
}
/// The prototype manifest's identity plus its verdict tally.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct AbiMeta {
pub game_key: String,
pub source_build: String,
/// Verdict tally — `status:verified`, `core:resolved`, `high_confidence:none`, …
pub counts: BTreeMap<String, usize>,
}
/// The shipped `abi-<game>.json`.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct AbiManifest {
pub meta: AbiMeta,
pub functions: BTreeMap<String, AbiEntry>,
}
/// One typed schema field (the field NAME is the map key). `offset` is static; `ty`/`kind`/`size` are /// One typed schema field (the field NAME is the map key). `offset` is static; `ty`/`kind`/`size` are
/// runtime-resolved (empty/zero when derived offline without a live process). /// runtime-resolved (empty/zero when derived offline without a live process).
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
@ -422,12 +829,99 @@ pub enum FieldKind {
FixedArray, FixedArray,
} }
/// A direct base class and the `this`-adjustment to reach it. The schema records these, and a consumer
/// needs them for the artifact's most basic question: `CCSPlayerPawn.m_iHealth` is not a field OF
/// `CCSPlayerPawn` — it is inherited, and only the base chain says where to look.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct BaseClass {
pub name: String,
pub offset: u32,
}
/// How a type travels when passed BY VALUE under the SysV-AMD64 convention — the fact a caller needs
/// that a field offset cannot supply.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SysvClass {
/// Travels in integer registers: pointers, handles, and small aggregates with any non-float member.
Integer,
/// Travels in SSE registers — an aggregate of 16 bytes or less whose members are all floating-point.
/// `Vector` (3 floats) is the case that bites: by value it costs TWO SSE registers, by reference one
/// integer register.
Sse,
/// Larger than 16 bytes, so it is passed in memory (effectively by reference) and returned through a
/// hidden pointer. Size alone settles this one.
Memory,
/// Size known but composition unknown, or size unknown — a caller must not guess.
Unknown,
}
/// Where a type's size came from — the honesty axis, since the two routes carry different confidence.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LayoutSource {
/// The SchemaSystem states the class's instance size outright.
Schema,
/// Inferred from the distance to the next field across many classes — exact for every primitive whose
/// size is independently known, but an inference nonetheless.
FieldGap,
/// Declared in the deriver, for the closed set of engine primitives the schema does not register.
Declared,
}
/// What a consumer needs to pass or hold a value of some type: how big it is, and how it travels.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct TypeLayout {
pub size: usize,
pub sysv: SysvClass,
pub source: LayoutSource,
/// How many field observations backed a `field-gap` size, and how many agreed — omitted for the other
/// sources, where the size is stated rather than inferred.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observations: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agreement: Option<usize>,
}
/// One enumerator: the name and the value it stands for.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EnumValue {
pub name: String,
pub value: i64,
}
/// A registered enum — the semantic vocabulary behind an integer field. `m_MoveType = 2` is only
/// meaningful as `MOVETYPE_WALK`, and a register footprint can never recover that.
///
/// Unlike a field's TYPE, this is static data: the SchemaSystem records enum bindings in the binary, so
/// these are read from the image rather than from the running process.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EnumDef {
/// Underlying integer width in bytes — 1 for `MoveType_t`, 4 for `gear_slot_t`. Recorded by the
/// binding, so the distinction is read rather than assumed.
pub size: u8,
/// Enumerators in DECLARATION order. A list, not a map: names are unique but values are not, since
/// aliases (`MOVETYPE_LAST` / `MOVETYPE_INVALID`) legitimately share one.
pub values: Vec<EnumValue>,
}
/// The typed schema — the shipped `netvars-<game>.json`. Merges field offsets with runtime types: /// The typed schema — the shipped `netvars-<game>.json`. Merges field offsets with runtime types:
/// class -> field -> [`Field`]. /// class -> field -> [`Field`], plus the enum vocabulary those fields refer to.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Schema { pub struct Schema {
pub meta: SchemaMeta, pub meta: SchemaMeta,
pub classes: BTreeMap<String, BTreeMap<String, Field>>, pub classes: BTreeMap<String, BTreeMap<String, Field>>,
/// Direct base classes per class. `classes` holds each class's OWN fields only, so resolving an
/// inherited member means walking this.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bases: BTreeMap<String, Vec<BaseClass>>,
/// Registered enums by name. Empty on an older artifact that predates the enum walk.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub enums: BTreeMap<String, EnumDef>,
/// Size + SysV class for every type the fields above refer to — what a caller needs in order to pass
/// one, which neither an offset nor a register footprint can supply.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub types: BTreeMap<String, TypeLayout>,
} }
/// The typed schema's intrinsic identity (no wall-clock field, same rationale as [`MonoMeta`]). /// The typed schema's intrinsic identity (no wall-clock field, same rationale as [`MonoMeta`]).
@ -437,6 +931,12 @@ pub struct SchemaMeta {
pub source_build: String, pub source_build: String,
pub typed: usize, pub typed: usize,
pub untyped: usize, pub untyped: usize,
/// Registered enums recovered (0 on an artifact that predates the enum walk).
#[serde(default)]
pub enums: usize,
/// Types with a recovered size + SysV class.
#[serde(default)]
pub types: usize,
} }
#[cfg(test)] #[cfg(test)]
@ -451,6 +951,7 @@ mod monolith_tests {
offset: Some(158), offset: Some(158),
}, },
class: None, class: None,
abi: None,
provenance: Provenance { provenance: Provenance {
source: Some("catalogue".into()), source: Some("catalogue".into()),
..Provenance::with_tier(Tier::Core) ..Provenance::with_tier(Tier::Core)
@ -474,6 +975,7 @@ mod monolith_tests {
offset: Some(40), offset: Some(40),
}, },
class: Some("CFoo".into()), class: Some("CFoo".into()),
abi: None,
provenance: Provenance { provenance: Provenance {
confidence: Some("low".into()), confidence: Some("low".into()),
self_named: Some(false), self_named: Some(false),
@ -521,6 +1023,7 @@ mod monolith_tests {
offset: None, offset: None,
}, },
class: None, class: None,
abi: None,
provenance: Provenance { provenance: Provenance {
source: Some("catalogue".into()), source: Some("catalogue".into()),
..Provenance::with_tier(Tier::Core) ..Provenance::with_tier(Tier::Core)

File diff suppressed because it is too large Load diff

12
fuzz.sh
View file

@ -3,7 +3,9 @@
# #
# Fans all offline-derivation fuzz targets out across the box with GNU parallel (each target getting # 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 # 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: # Usage:
# ./fuzz.sh [seconds] [workers] run for `seconds` (default 60) with `workers`/target (default 3), # ./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 CRATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # source2rosetta/ — cargo fuzz runs from here
cd "$CRATE_DIR" 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" FUZZ_ROOT="$CRATE_DIR/fuzz"
LOG_BASE="$FUZZ_ROOT/logs" LOG_BASE="$FUZZ_ROOT/logs"
# Ignore iced_x86's intentional one-time 'static decoder-table allocation (see lsan_suppressions.txt); # 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. # 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" export LSAN_OPTIONS="suppressions=$FUZZ_ROOT/lsan_suppressions.txt"
STATS_ONLY=false STATS_ONLY=false

View file

@ -65,3 +65,24 @@ path = "fuzz_targets/fuzz_xref.rs"
test = false test = false
doc = false doc = false
bench = 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

View file

@ -0,0 +1,45 @@
#![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.
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);
}
// 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");
});

View file

@ -0,0 +1,34 @@
#![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);
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");
}
}
});

View file

@ -11,6 +11,16 @@ fuzz_target!(|data: &[u8]| {
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else { let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
return; return;
}; };
// 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.
for e in schema::enumerate_enums(&img) {
let _ = (e.name.len(), e.size, e.align);
for (n, v) in &e.values {
let _ = (n.len(), *v);
}
}
for c in schema::enumerate_schema(&img) { for c in schema::enumerate_schema(&img) {
let _ = c.primary_base(); let _ = c.primary_base();
for f in &c.fields { for f in &c.fields {

View 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"
);
}
});

View file

@ -11,12 +11,20 @@
use std::fs; use std::fs;
use std::path::Path; 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] = &[ const TARGETS: &[&str] = &[
"fuzz_elf", "fuzz_elf",
"fuzz_schema", "fuzz_schema",
"fuzz_rtti", "fuzz_rtti",
"fuzz_sig_abi", "fuzz_sig_abi",
"fuzz_xref", "fuzz_xref",
"fuzz_valvetab",
"fuzz_pulse",
"fuzz_concmd",
]; ];
fn w16(v: &mut [u8], o: usize, x: u16) { fn w16(v: &mut [u8], o: usize, x: u16) {

View file

@ -5,5 +5,5 @@
# one-time allocation as a leak on the first input that decodes an instruction of that encoding family. # 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. # 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 leak:iced_x86

File diff suppressed because it is too large Load diff

50538
mappings/prototypes.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,10 @@
//! (a rebuild doesn't change which arguments a function takes) and moves precisely when the prototype //! (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. //! 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 //! 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 //! 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 //! only inside a jump-table (indirect-branch) case isn't followed, so it can be missed. Both stay
@ -24,6 +28,10 @@
//! diff's `int==0` low-confidence bucket also absorbs the thunk case. `int_args` is the OBSERVABLE //! 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`); //! 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. //! that too is stable per function, so the cross-build diff still works.
//!
//! 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 crate::elf::CodeImage;
use iced_x86::{ use iced_x86::{
@ -36,6 +44,8 @@ use std::collections::HashMap;
/// bitmask over these 14 slots is a function's live-in argument set. /// bitmask over these 14 slots is a function's live-in argument set.
const N_INT: usize = 6; const N_INT: usize = 6;
const N_XMM: usize = 8; 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 /// 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 /// whether it also loads arguments off the stack (a 7th+ integer / 9th+ float argument, or a large
@ -54,8 +64,15 @@ pub struct AbiShape {
/// footprint: a change here (int↔float↔by-value) is a prototype change the arg counts alone miss, and /// 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 /// `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 /// 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 /// (the `CSwapTeams::GetDisplayString` sret trap).
/// `Unknown` when the return path doesn't decode — so it only ever adds a signal, never a false one. ///
/// 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)] #[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Debug)]
pub enum RetClass { pub enum RetClass {
/// No decodable return path (a forwarding thunk / tail call / undecoded) — no signal. /// No decodable return path (a forwarding thunk / tail call / undecoded) — no signal.
@ -290,6 +307,19 @@ fn insn_effect(factory: &mut InstructionInfoFactory, insn: &Instruction) -> (u16
use_m &= !(1 << slot); use_m &= !(1 << slot);
def_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) (use_m, def_m, stack)
} }
@ -456,7 +486,16 @@ fn decode_region(img: &CodeImage, entry: u64) -> Option<(Vec<Insn>, bool, bool)>
let next = start + insn.len() as u64; let next = start + insn.len() as u64;
let mut succ = Vec::new(); let mut succ = Vec::new();
match insn.flow_control() { 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 => { FlowControl::UnconditionalBranch => {
let t = insn.near_branch_target(); let t = insn.near_branch_target();
if in_span(t) { if in_span(t) {
@ -470,7 +509,9 @@ fn decode_region(img: &CodeImage, entry: u64) -> Option<(Vec<Insn>, bool, bool)>
succ.push(t); 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 { for &s in &succ {
if !recs.contains_key(&s) { if !recs.contains_key(&s) {
@ -689,6 +730,40 @@ mod tests {
assert_eq!(shape_of(&[0xF2, 0x0F, 0x51, 0xD9, 0xC3]).key(), (0, 2)); 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 --- // --- return class ---
#[test] #[test]

483
src/concmd.rs Normal file
View file

@ -0,0 +1,483 @@
//! 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.
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;
/// Caller-saved under SysV: a call destroys any constant we were tracking in these. The `this` a
/// constructor threads through its registrations is callee-saved (rbx, r12-r15), so it survives — which
/// is what makes the member-callback form readable at all.
const CLOBBER: [usize; 9] = [0, RCX, RDX, RSI, RDI, R8, R9, 10, 11];
/// 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"),
];
/// 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.
fn gpr(r: Register) -> Option<u8> {
let f = r.full_register();
f.is_gpr64()
.then(|| (f as usize - Register::RAX as usize) 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,
}
}
}
/// 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 {
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.
fn cmd_name(img: &CodeImage, va: u64) -> Option<String> {
let s = img.read_c_string(va)?;
let ok = !s.is_empty()
&& s.len() <= MAX_NAME
&& s.bytes()
.all(|c| c.is_ascii_graphic() && c != b'"' && c != b'%');
ok.then_some(s)
}
/// Every console command `img` registers.
pub fn console_commands(img: &CodeImage) -> Vec<ConsoleCommand> {
let mut entries = crate::locate::candidate_entries(img);
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
entries.sort_unstable();
entries.dedup();
// 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 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<[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 *is_reg
.entry(t)
.or_insert_with(|| inits_invalid_handle(img, t))
{
found.push(val);
}
for c in CLOBBER {
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()) {
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()) {
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()))
{
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())
{
val[d as usize] = V::Unknown;
epoch[d as usize] = epoch[d as usize].saturating_add(1);
}
}
}
}
}
if !found.is_empty() {
let stores = std::sync::Arc::new(stores);
sites.extend(found.into_iter().map(|args| Site {
args,
stores: stores.clone(),
}));
}
}
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
}
#[cfg(test)]
mod tests {
use super::*;
#[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 a_command_name_is_an_identifier_not_prose() {
// The gate is applied to a resolved string, so exercise it through the same predicate the
// reader uses by checking the shape rules it encodes.
let ok = |s: &str| {
!s.is_empty()
&& s.len() <= MAX_NAME
&& s.bytes()
.all(|c| c.is_ascii_graphic() && c != b'"' && c != b'%')
};
assert!(ok("bot_add"));
assert!(ok("+bugvoice")); // an on/off pair is a real command name
assert!(!ok("")); // an empty string is not a name
assert!(!ok("Adds a bot matching the given criteria.")); // a description
assert!(!ok("%s: no varname specified\n")); // a format string
assert!(!ok(&"x".repeat(MAX_NAME + 1)));
}
}

View file

@ -325,6 +325,25 @@ impl CodeImage {
.collect() .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. /// 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. /// a large set of real function entry addresses obtained without disassembling anything.
pub fn code_pointer_targets(&self) -> Vec<u64> { pub fn code_pointer_targets(&self) -> Vec<u64> {

View file

@ -30,16 +30,20 @@ pub mod profile;
// ---- low-level engine (implementation detail; `pub` only for the fuzz harness, not a stable surface) ---- // ---- low-level engine (implementation detail; `pub` only for the fuzz harness, not a stable surface) ----
pub mod abi; pub mod abi;
pub mod concmd;
pub mod elf; pub mod elf;
pub mod emit; pub mod emit;
pub mod fingerprint; pub mod fingerprint;
pub mod live; pub mod live;
pub mod locate; pub mod locate;
pub mod par; pub mod par;
pub mod prototypes;
pub mod pulse;
pub mod rtti; pub mod rtti;
pub mod schema; pub mod schema;
pub mod sig; pub mod sig;
pub mod taxonomy; pub mod taxonomy;
pub mod valvetab;
pub mod xref; pub mod xref;
// The canonical model + emitters live in the deriver-free `source2rosetta-core` crate; re-export them so // The canonical model + emitters live in the deriver-free `source2rosetta-core` crate; re-export them so

View file

@ -72,7 +72,7 @@ enum Cmd {
#[arg(long)] #[arg(long)]
keep: bool, keep: bool,
/// With --gamedata, also run the LIVE fuzzer against this same server for N randomized probes /// 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). Runs against the server `produce` already launched; there is no separate command for it.
#[arg(long, default_value_t = 500)] #[arg(long, default_value_t = 500)]
fuzz_iterations: usize, fuzz_iterations: usize,
}, },
@ -129,6 +129,16 @@ enum Cmd {
/// Multilib non-virtual names to fold as sigs — `{lib: [{name,addr}]}`; `make_sig` runs per lib. /// Multilib non-virtual names to fold as sigs — `{lib: [{name,addr}]}`; `make_sig` runs per lib.
#[arg(long)] #[arg(long)]
extra_sigs: Option<PathBuf>, extra_sigs: Option<PathBuf>,
/// Declared C++ prototypes (`mappings/prototypes.json`) to judge against this build's measured
/// register footprints. Emits `abi-<game>.json`. Static repo input — omit to skip the manifest.
#[arg(long)]
prototypes: 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 /// 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. /// `core` sigs use a separate fixed budget — this flag does not widen those.
#[arg(long, default_value_t = 400)] #[arg(long, default_value_t = 400)]
@ -356,6 +366,8 @@ fn main() -> Result<()> {
full_names, full_names,
extra_offsets, extra_offsets,
extra_sigs, extra_sigs,
prototypes,
ehandle_classes,
sig_cap, sig_cap,
version, version,
out_dir, out_dir,
@ -393,6 +405,8 @@ fn main() -> Result<()> {
full_names: inputs.full_names.as_deref(), full_names: inputs.full_names.as_deref(),
extra_offsets: inputs.extra_offsets.as_deref(), extra_offsets: inputs.extra_offsets.as_deref(),
extra_sigs: inputs.extra_sigs.as_deref(), extra_sigs: inputs.extra_sigs.as_deref(),
prototypes: prototypes.as_deref(),
ehandle_classes: ehandle_classes.as_deref(),
sig_cap, sig_cap,
version: &version, version: &version,
out_dir: &out_dir, out_dir: &out_dir,

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
//! CI orchestration + the LIVE half of the engine. `produce` runs the whole per-game build in one //! CI orchestration + the LIVE half of the engine. `produce` runs the whole per-game build in one
//! long-running command (derive → fold → validate-live → sdk → fold-model), assembling the 3-file monolith //! long-running command (derive → fold → validate-live → typed netvars → fold-model), assembling the monolith
//! artifact set; `classify-change` and `filter-corpus` are the CI *branch* primitives (is this buildid worth //! artifact set; `classify-change` and `filter-corpus` are the CI *branch* primitives (is this buildid worth
//! a release? which corpus builds are code-distinct?). Everything that attaches to and drives a RUNNING //! a release? which corpus builds are code-distinct?). Everything that attaches to and drives a RUNNING
//! server lives here, not in `pipeline`: the semantic oracle (`run_live_oracle`, pawn probing, `fuzz_live_run`), //! server lives here, not in `pipeline`: the semantic oracle (`run_live_oracle`, pawn probing, `fuzz_live_run`),
@ -10,9 +10,9 @@ use crate::elf::CodeImage;
use crate::locate::{find_file, load_lib}; use crate::locate::{find_file, load_lib};
use crate::par::{default_threads, parallel_map}; use crate::par::{default_threads, parallel_map};
use crate::pipeline::{ use crate::pipeline::{
ClassScope, CorpusModel, CorpusSource, FoldArgs, GdMap, annotate_validation, build_date, ClassScope, CorpusModel, CorpusSource, FoldArgs, Folded, GdMap, annotate_validation,
build_gamedata_cmd, find_builds, fold_model_cmd, gamedata, label_of, lib_filename, load_model, build_date, build_gamedata_cmd, find_builds, fold_model_cmd, gamedata, label_of, lib_filename,
read_gamedata_str, load_model, read_gamedata_str,
}; };
use crate::profile::{self, GameProfile}; use crate::profile::{self, GameProfile};
use crate::sig::Pattern; use crate::sig::Pattern;
@ -100,6 +100,12 @@ pub struct ProduceArgs<'a> {
pub full_names: Option<&'a Path>, pub full_names: Option<&'a Path>,
pub extra_offsets: Option<&'a Path>, pub extra_offsets: Option<&'a Path>,
pub extra_sigs: Option<&'a Path>, pub extra_sigs: Option<&'a Path>,
/// Declared prototypes to JUDGE against this build's measured footprints -> `abi-<game>.json`.
/// Static repo input, not rolling state, so it is passed as a path rather than fetched.
pub prototypes: Option<&'a Path>,
/// Valve's `PVAL_EHANDLE` entity-class naming (`mappings/ehandle-classes.json`) — a static repo
/// input the bindings artifact is enriched with. Optional.
pub ehandle_classes: Option<&'a Path>,
pub sig_cap: usize, pub sig_cap: usize,
pub version: &'a str, pub version: &'a str,
pub out_dir: &'a Path, pub out_dir: &'a Path,
@ -130,6 +136,8 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
full_names, full_names,
extra_offsets, extra_offsets,
extra_sigs, extra_sigs,
prototypes,
ehandle_classes,
sig_cap, sig_cap,
version, version,
out_dir, out_dir,
@ -144,7 +152,7 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
let p = |name: &str| out_dir.join(name); let p = |name: &str| out_dir.join(name);
let token = prof.token; let token = prof.token;
// Parse the corpus model ONCE (2.9 GB for Dota): the derive borrows it below, and the sidecar fold in // Parse the corpus model ONCE (~571 MB for Dota): the derive borrows it below, and the sidecar fold in
// step 4 consumes the same instance — no second parse. A `--corpus` (genesis) run has no model (its model // step 4 consumes the same instance — no second parse. A `--corpus` (genesis) run has no model (its model
// is distilled by `corpus-model`); a `--corpus-model` run rolls that model N to N+1 in the fold. // is distilled by `corpus-model`); a `--corpus-model` run rolls that model N to N+1 in the fold.
let cmodel: Option<CorpusModel> = match corpus_model { let cmodel: Option<CorpusModel> = match corpus_model {
@ -160,7 +168,11 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
// 1. derive + fold (offline, in memory) -> the monolith + its CS# render (the string live validate checks) // 1. derive + fold (offline, in memory) -> the monolith + its CS# render (the string live validate checks)
eprintln!("\n===== derive + fold (offline) ====="); eprintln!("\n===== derive + fold (offline) =====");
let derived = gamedata(prof, catalogue, source, target)?; let derived = gamedata(prof, catalogue, source, target)?;
let (mut mono, cssharp) = build_gamedata_cmd( let Folded {
mut mono,
cssharp,
bindings,
} = build_gamedata_cmd(
prof, prof,
FoldArgs { FoldArgs {
build, build,
@ -170,11 +182,13 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
core: &derived.core, core: &derived.core,
flagged: &derived.flagged, flagged: &derived.flagged,
unverified: &derived.unverified, unverified: &derived.unverified,
abi: &derived.abi,
sig_cap, sig_cap,
version, version,
full_names, full_names,
extra_offsets, extra_offsets,
extra_sigs, extra_sigs,
ehandle_classes,
source_build: &label_of(target), source_build: &label_of(target),
}, },
)?; )?;
@ -213,6 +227,15 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
typed_frac * 100.0, typed_frac * 100.0,
NETVARS_MIN_TYPED * 100.0 NETVARS_MIN_TYPED * 100.0
); );
// The enum table is read by shape like the class table, so a Valve reshape yields zero
// enums rather than wrong ones — safe, but silent. See GameProfile::min_schema_enums.
ensure!(
nv.meta.enums >= prof.min_schema_enums,
"recovered only {} schema enums (floor {}) — the SchemaSystem enum-binding layout \
likely moved; refusing to ship a schema with its enum vocabulary missing",
nv.meta.enums,
prof.min_schema_enums
);
netvars = Some(nv); netvars = Some(nv);
Ok(()) Ok(())
})(); })();
@ -242,6 +265,57 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
); );
artifacts.push(nv_name); artifacts.push(nv_name);
} }
// The declared callable surface. Gated PER TABLE, not on the sum: each is matched by its own record
// shape, so Valve reshaping one collapses that one alone — and a summed floor stays satisfied by the
// tables that still work. See GameProfile::min_pulse_bindings.
for (what, got, floor) in [
(
"Pulse bindings",
bindings.meta.pulse,
prof.min_pulse_bindings,
),
(
"typed Pulse signatures",
bindings.meta.pulse_typed,
prof.min_pulse_typed,
),
(
"entity-IO records",
bindings.meta.entity_inputs + bindings.meta.entity_outputs,
prof.min_entity_io,
),
(
"entity classnames",
bindings.meta.entity_classes,
prof.min_entity_classes,
),
(
"console commands",
bindings.meta.commands,
prof.min_commands,
),
] {
ensure!(
got >= floor,
"read only {got} {what} from Valve's in-binary tables (floor {floor}) — that table's layout \
likely moved; refusing to ship a release whose declared surface silently collapsed"
);
}
if !bindings.is_empty() {
let bd_name = format!("bindings-{token}.json");
std::fs::write(p(&bd_name), serde_json::to_string_pretty(&bindings)?)
.with_context(|| format!("write {bd_name}"))?;
eprintln!(
" binding registry -> {bd_name}: {} Pulse bindings ({} typed), {} entity-IO inputs, {} outputs, {} entity classnames, {} console commands",
bindings.meta.pulse,
bindings.meta.pulse_typed,
bindings.meta.entity_inputs,
bindings.meta.entity_outputs,
bindings.meta.entity_classes,
bindings.meta.commands
);
artifacts.push(bd_name);
}
// 4. sidecar: fold model N -> N+1 (offline), emitted when a --corpus-model was the source. The derive has // 4. sidecar: fold model N -> N+1 (offline), emitted when a --corpus-model was the source. The derive has
// returned, so its read-only borrow of the model is done — the fold consumes the same instance by value. // returned, so its read-only borrow of the model is done — the fold consumes the same instance by value.
@ -252,6 +326,28 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
artifacts.push(model_name); artifacts.push(model_name);
} }
// The prototype manifest: the declared parameter types, each judged against the footprint measured
// in THIS build. Emitted beside the gamedata because the two answer different questions — where a
// function is, and how to call it — and a consumer needs both to make a call at all.
if let Some(pp) = prototypes {
let man = crate::prototypes::build_manifest(pp, &mono, netvars.as_ref().map(|n| &n.types))?;
let ab_name = format!("abi-{token}.json");
std::fs::write(p(&ab_name), serde_json::to_string_pretty(&man)?)
.with_context(|| format!("write {ab_name}"))?;
let n = |k: &str| man.meta.counts.get(k).copied().unwrap_or(0);
eprintln!(
" prototype manifest -> {ab_name}: {} entries ({} verified, {} mismatch, {} unverified, \
{} return-only, {} ambiguous)",
man.functions.len(),
n("status:verified"),
n("status:mismatch"),
n("status:unverified"),
n("status:return-only"),
n("core:overloaded") + n("high_confidence:overloaded")
);
artifacts.push(ab_name);
}
// 5. the interop manifest. // 5. the interop manifest.
let manifest = json!({ "version": version, "artifacts": artifacts }); let manifest = json!({ "version": version, "artifacts": artifacts });
std::fs::write(p("manifest.json"), serde_json::to_string_pretty(&manifest)?)?; std::fs::write(p("manifest.json"), serde_json::to_string_pretty(&manifest)?)?;
@ -758,7 +854,7 @@ const ORACLE_MIN_SAMPLE: u32 = 25;
/// wholesale type-record reshape, not on the odd unresolved field. /// wholesale type-record reshape, not on the odd unresolved field.
const NETVARS_MIN_TYPED: f64 = 0.5; const NETVARS_MIN_TYPED: f64 = 0.5;
/// The live-fuzzing loop against an ALREADY-ATTACHED server — shared by the standalone `fuzz-live` /// The live-fuzzing loop against an ALREADY-ATTACHED server — shared by the standalone
/// command and the `integration-test` harness (which owns the server, so no separate launch and no fixed /// command and the `integration-test` harness (which owns the server, so no separate launch and no fixed
/// wall-clock: it runs exactly `iterations` probes and stops). /// wall-clock: it runs exactly `iterations` probes and stops).
fn fuzz_live_run( fn fuzz_live_run(
@ -1018,6 +1114,10 @@ pub(crate) fn run_live_oracle(
_ => None, _ => None,
}; };
// The derived gamedata, parsed ONCE: the CALL test below needs THIS build's IsPlayerPawn slot, and
// the validate stage needs the whole document.
let doc = gamedata.map(read_gamedata_str).transpose()?;
eprintln!("\n=== read-only oracle on the owned process ==="); eprintln!("\n=== read-only oracle on the owned process ===");
let mut verdicts: Vec<(&str, OracleCounts)> = Vec::new(); let mut verdicts: Vec<(&str, OracleCounts)> = Vec::new();
verdicts.push(("schema-layout", verify_live_cmd(prof, pid, build, lib)?)); verdicts.push(("schema-layout", verify_live_cmd(prof, pid, build, lib)?));
@ -1036,12 +1136,37 @@ pub(crate) fn run_live_oracle(
// not `?`-propagate past produce's fail-fast and abort the release. Same treatment as // not `?`-propagate past produce's fail-fast and abort the release. Same treatment as
// `callable_method_sweep` below. `(|| -> Option ...)()` lets one unreadable access bail the probe. // `callable_method_sweep` below. `(|| -> Option ...)()` lets one unreadable access bail the probe.
println!("\n=== CALL test (ptrace injection — the thing read-only can't do) ==="); println!("\n=== CALL test (ptrace injection — the thing read-only can't do) ===");
let is_player_pawn = pa.is_player_pawn_slot; // The slot THIS build derived, not the constant frozen in the profile. The two agree today, but
// the catalogue shows this slot taking four distinct values in nine months, and a stale index
// does not fail loudly — it ptrace-CALLS whatever function now occupies it, on the same live
// process this run then reads typed netvars from and fuzzes 500 times. The frozen value survives
// only as a fallback for a run with no rendered gamedata to consult.
let is_player_pawn = doc
.as_ref()
.and_then(|d| d.get("CBaseEntity::IsPlayerPawn"))
.and_then(|e| render::entry_from_value(e).offset)
.and_then(|o| u64::try_from(o).ok())
.unwrap_or(pa.is_player_pawn_slot);
if is_player_pawn != pa.is_player_pawn_slot {
eprintln!(
" NOTE derived IsPlayerPawn slot {is_player_pawn} differs from the profile's frozen \
{} using the derived one; update GameProfile::is_player_pawn_slot",
pa.is_player_pawn_slot
);
}
let probed = (|| -> Option<()> { let probed = (|| -> Option<()> {
let hp = live.read_i32(pawn + health).ok()?; let hp = live.read_i32(pawn + health).ok()?;
println!("alive pawn {pawn:#014x}, live m_iHealth = {hp}"); println!("alive pawn {pawn:#014x}, live m_iHealth = {hp}");
let vtable_ptr = live.read_u64(pawn).ok()?; let vtable_ptr = live.read_u64(pawn).ok()?;
let func = live.read_u64(vtable_ptr + is_player_pawn * 8).ok()?; let func = live.read_u64(vtable_ptr + is_player_pawn * 8).ok()?;
// Same gate the other two `call_remote` sites apply: never inject a call to something that
// is not executable code in the live process.
if !live.is_exec(func) {
println!(
" slot {is_player_pawn} does not point at live executable code — skipping"
);
return None;
}
println!( println!(
"calling IsPlayerPawn (gamedata vtable offset {is_player_pawn}, fn {func:#x}) on the live pawn..." "calling IsPlayerPawn (gamedata vtable offset {is_player_pawn}, fn {func:#x}) on the live pawn..."
); );
@ -1067,11 +1192,11 @@ pub(crate) fn run_live_oracle(
} }
} }
let live_result = if let Some(gd) = gamedata { let live_result = if gamedata.is_some() {
println!("\n=== validate-live: derived gamedata vs the running server ==="); println!("\n=== validate-live: derived gamedata vs the running server ===");
// Parse the monolith's CS# render (passed in-memory, no `gamedata.json`) once — it feeds sig/offset // Parsed once, above — it feeds the CALL test's slot, sig/offset validation, and the pawn
// validation AND the pawn sweep/fuzz below. // sweep/fuzz below.
let doc = read_gamedata_str(gd)?; let doc = doc.expect("parsed above whenever `gamedata` is Some");
let (kept, entry_verdicts) = validate_live_cmd(prof, pid, build, &doc)?; let (kept, entry_verdicts) = validate_live_cmd(prof, pid, build, &doc)?;
// The semantic sweep + live fuzz operate on a live pawn; pawn-less games stop at sig validation. // The semantic sweep + live fuzz operate on a live pawn; pawn-less games stop at sig validation.
if let Some(PawnContext { if let Some(PawnContext {
@ -1154,17 +1279,23 @@ pub(crate) fn launch_bots_server(
bots: u32, bots: u32,
) -> Result<OwnedServer> { ) -> Result<OwnedServer> {
let bindir = game.join("bin/linuxsteamrt64"); let bindir = game.join("bin/linuxsteamrt64");
let exe = bindir.join(prof.executable);
ensure!( ensure!(
exe.exists(), bindir.join(prof.executable).exists(),
"{} server executable `{}` not found at {}", "{} server executable `{}` not found at {}",
prof.display_name, prof.display_name,
prof.executable, prof.executable,
exe.display() bindir.join(prof.executable).display()
); );
// ABSOLUTE from here on. `current_dir` below is applied in the CHILD before `exec`, so a relative
// `--game-dir` would have the program path re-resolved from inside `bindir` and fail to spawn —
// after the check above had just found the file, which is the worst shape for a guard to have.
let bindir = bindir
.canonicalize()
.with_context(|| format!("resolve {}", bindir.display()))?;
let exe = bindir.join(prof.executable);
// Live-oracle readiness anchor (via the shared resolve_ready_anchor). A game with // Live-oracle readiness anchor (via the shared resolve_ready_anchor). A game with
// a player pawn waits for an ALIVE pawn; a pawn-less game (Dota) waits for a live gamerules proxy = map // a player pawn waits for an ALIVE pawn; a pawn-less game (Dota) waits for a live gamerules proxy = map
// loaded + libserver ready, which is all produce's live stages (validate-live + sdk) need. // loaded + libserver ready, which is all produce's live stages (validate-live + typed netvars) need.
let img = load_lib(build, lib)?; let img = load_lib(build, lib)?;
let (ready_vt, pawn_health) = resolve_ready_anchor(prof, &img)?; let (ready_vt, pawn_health) = resolve_ready_anchor(prof, &img)?;
let logpath = std::env::temp_dir().join(format!("{}-produce.log", prof.token)); let logpath = std::env::temp_dir().join(format!("{}-produce.log", prof.token));

View file

@ -83,6 +83,29 @@ pub struct GameProfile {
/// from is comparing different objects. A raise is a re-distill, not a config tweak — change it and the /// from is comparing different objects. A raise is a re-distill, not a config tweak — change it and the
/// model together. /// model together.
pub max_vtable_slots: usize, 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,
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,
pub min_schema_enums: usize,
/// Output game-key the game-keyed emitters use (Metamod `Games { <key> {..} }`, Plugify `{ "<key>": {..} }`). /// Output game-key the game-keyed emitters use (Metamod `Games { <key> {..} }`, Plugify `{ "<key>": {..} }`).
pub game_key: &'static str, pub game_key: &'static str,
/// The `--game` CLI token / per-release filename suffix (`cs2`, `dota2`) — distinct from `game_key` (the /// The `--game` CLI token / per-release filename suffix (`cs2`, `dota2`) — distinct from `game_key` (the
@ -108,7 +131,7 @@ pub struct GameProfile {
pub soft_serializer: &'static [&'static str], pub soft_serializer: &'static [&'static str],
/// Method-name prefixes for a this-only blind-callable boolean query — the live call-smoke-test gate. /// Method-name prefixes for a this-only blind-callable boolean query — the live call-smoke-test gate.
pub query_prefixes: &'static [&'static str], 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`. /// field-by-field (the ones mods actually read). CS2 gameplay fields on the generic `CBaseEntity`.
pub spotlight_fields: &'static [(&'static str, &'static [&'static str])], pub spotlight_fields: &'static [(&'static str, &'static [&'static str])],
/// Human-readable game name for the shipped gamedata banner. /// Human-readable game name for the shipped gamedata banner.
@ -159,6 +182,13 @@ pub const CS2: GameProfile = GameProfile {
"libvscript.so", "libvscript.so",
], ],
max_vtable_slots: 2048, 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_entity_io: 400,
min_entity_classes: 200,
min_commands: 400,
min_schema_enums: 250,
game_key: "csgo", game_key: "csgo",
token: "cs2", token: "cs2",
executable: "cs2", executable: "cs2",
@ -255,6 +285,13 @@ pub const DOTA: GameProfile = GameProfile {
"libvscript.so", "libvscript.so",
], ],
max_vtable_slots: 2048, 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_entity_io: 300,
min_entity_classes: 1000,
min_commands: 400,
min_schema_enums: 350,
game_key: "dota", game_key: "dota",
token: "dota2", token: "dota2",
executable: "dota2", // bin/linuxsteamrt64/dota2 executable: "dota2", // bin/linuxsteamrt64/dota2

911
src/prototypes.rs Normal file
View file

@ -0,0 +1,911 @@
//! 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 the deriver stamps on a name it read out of Valve's entity-IO datadesc. Kept in step
/// with `pipeline::VALVE_DATADESC` — the two halves of one fact: which names the datadesc named, and
/// what the engine's dispatch contract therefore says about them.
const VALVE_DATADESC: &str = "valve-datadesc";
/// What the manifest calls a prototype that came from how the ENGINE invokes the function rather than
/// from anyone's declaration of 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&"];
/// The provenance prefix a console-command handler ships under, `:<form>`-suffixed. Kept in step with
/// `pipeline::VALVE_CONCOMMAND`.
const VALVE_CONCOMMAND: &str = "valve-concommand";
/// 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(),
)
}
/// 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>>,
) -> 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());
if decls.is_empty() && !is_contract {
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_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());
}
// 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);
let matched_by = if exact.is_some() {
"exact"
} 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 (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;
// Only an over-read refutes the declaration. `both` stays a mismatch: a class where the
// callee reads more is unsafe regardless of another class where it reads fewer.
if declared_over && !measured_over {
status = model::AbiStatus::LowerBound;
}
note = Some(
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",
}
.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,
},
);
}
}
// 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.
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 (i, f) = footprint(c.params, None);
let i = if complete {
i
} else {
(i..=i + 1)
.min_by_key(|d| d.abs_diff(sh.int as usize))
.unwrap()
};
let (i, f) = (i.min(6), f.min(8));
let over = sh.int as usize > i || sh.float as usize > f;
let under = i > sh.int as usize || f > sh.float as usize;
(
if under && !over {
model::AbiStatus::LowerBound
} else {
model::AbiStatus::Mismatch
},
match (over, under) {
(true, true) => "both",
(true, false) => "measured-exceeds",
_ => "declared-exceeds",
}
.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)));
}
}

616
src/pulse.rs Normal file
View file

@ -0,0 +1,616 @@
//! 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.
use crate::elf::CodeImage;
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
use std::collections::{BTreeMap, HashMap};
/// 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)>,
}
/// Registers whose value a call destroys. Anything else the pass cannot evaluate is invalidated as the
/// instruction that writes it is seen, so the default is always "unknown" rather than "stale".
const CALLER_SAVED: [Register; 9] = [
Register::RAX,
Register::RCX,
Register::RDX,
Register::RSI,
Register::RDI,
Register::R8,
Register::R9,
Register::R10,
Register::R11,
];
fn full(r: Register) -> Register {
if r.is_gpr() { r.full_register() } else { r }
}
/// 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 code = img.code_at(entry)?;
let cap = code.len().min(MAX_SPAN);
let in_span = |t: u64| t >= entry && ((t - entry) as usize) < cap;
// Reachable instruction addresses, then walked in address order.
let mut seen: HashMap<u64, usize> = HashMap::new();
let mut work = vec![entry];
let mut insn = Instruction::default();
while let Some(at) = work.pop() {
if seen.contains_key(&at) || !in_span(at) || seen.len() > 4000 {
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, insn.len());
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.keys().copied().collect();
addrs.sort_unstable();
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,
})
}
/// Every CODE pointer an accessor's initializer stores into its record region, with the region base:
/// `(base, [(address written, code address written)])`.
///
/// A DIAGNOSTIC, and deliberately not part of any shipped artifact. The parameter records carry a
/// function pointer whose ROLE is not established — the record reader already has to look at these in
/// order to reject them as parameter names, so exposing them costs nothing and lets that question be
/// settled against evidence collected elsewhere (a runtime call-edge trace) rather than guessed. Nothing
/// here interprets them; they are raw measurements.
pub fn code_stores(img: &CodeImage, accessor: u64) -> Option<(u64, Vec<(u64, u64)>)> {
let r = record(img, accessor)?;
let stores =
r.t.writes
.iter()
.filter(|&(a, _)| *a >= r.base)
.filter(|&(_, p)| img.is_code(*p))
.map(|(&a, &p)| (a, p))
.collect();
Some((r.base, stores))
}
/// The spacings at which this record's `count` names could sit, given that element 0's name is at
/// `base + 8` and the array is contiguous. Usually one; a record carrying a second identifier-shaped
/// string of its own offers more, which is why the stride is settled per IMAGE and not per record.
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
}
/// 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 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));
}
}

View file

@ -10,7 +10,6 @@
//! `find_vtable` shape (COL at vftable-8, TypeDescriptor `.?AV<name>@@`). //! `find_vtable` shape (COL at vftable-8, TypeDescriptor `.?AV<name>@@`).
use crate::elf::{CodeImage, KindTag}; use crate::elf::{CodeImage, KindTag};
use std::collections::HashSet;
pub struct VTable { pub struct VTable {
pub slot0: u64, // vaddr of virtual slot index 0 pub slot0: u64, // vaddr of virtual slot index 0
@ -236,7 +235,6 @@ fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass>
pub fn enumerate_vtables(img: &CodeImage, max_slots: usize) -> Vec<ClassVtable> { pub fn enumerate_vtables(img: &CodeImage, max_slots: usize) -> Vec<ClassVtable> {
let kinds = RttiKinds::detect(img); let kinds = RttiKinds::detect(img);
let mut out = Vec::new(); let mut out = Vec::new();
let mut seen = HashSet::new();
for (slot, val) in img.reloc_slots() { for (slot, val) in img.reloc_slots() {
if slot < 8 { if slot < 8 {
continue; continue;
@ -244,10 +242,9 @@ pub fn enumerate_vtables(img: &CodeImage, max_slots: usize) -> Vec<ClassVtable>
let Some((mangled, name)) = typeinfo_name(img, val, &kinds) else { let Some((mangled, name)) = typeinfo_name(img, val, &kinds) else {
continue; 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); 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, // 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. // 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 { let Some(ott) = img.read_i64(slot.wrapping_sub(8)) else {

View file

@ -18,7 +18,7 @@ use crate::elf::CodeImage;
use crate::profile::GameProfile; use crate::profile::GameProfile;
use crate::{live, model}; use crate::{live, model};
use anyhow::Result; use anyhow::Result;
use std::collections::{BTreeMap, HashSet}; use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::Path; use std::path::Path;
/// Byte offsets of the SchemaSystem reflection structs (SchemaClassInfoData_t / SchemaClassFieldData_t / /// Byte offsets of the SchemaSystem reflection structs (SchemaClassInfoData_t / SchemaClassFieldData_t /
@ -140,7 +140,6 @@ fn is_type_name(s: &str) -> bool {
/// inventory. Sorted by class name. /// inventory. Sorted by class name.
pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> { pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> {
let mut out = Vec::new(); let mut out = Vec::new();
let mut seen = HashSet::new();
for (slot, val) in img.reloc_slots() { for (slot, val) in img.reloc_slots() {
if slot < 8 { if slot < 8 {
continue; continue;
@ -152,10 +151,8 @@ pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> {
if !is_type_name(&name) { if !is_type_name(&name) {
continue; continue;
} }
// No de-dup guard — `reloc_slots` iterates a slot-keyed map, so `slot - 8` is already unique.
let base = slot - 8; let base = slot - 8;
if !seen.insert(base) {
continue;
}
if let Some(cls) = parse_class(img, base, &name, val) { if let Some(cls) = parse_class(img, base, &name, val) {
out.push(cls); out.push(cls);
} }
@ -164,6 +161,92 @@ pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> {
out 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`, alongside [`enumerate_schema`]'s classes. 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.
pub fn enumerate_enums(img: &CodeImage) -> Vec<SchemaEnum> {
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);
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;
};
if n.is_empty() {
break;
}
values.push((n, v));
}
if values.len() == count as usize {
out.push(SchemaEnum {
name,
size,
align,
values,
});
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out.dedup_by(|a, b| a.name == b.name); // one binding per name; libs re-register shared enums
out
}
fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<SchemaClass> { fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<SchemaClass> {
let size = img.read_i32(base.wrapping_add(CI_SIZE))?; let size = img.read_i32(base.wrapping_add(CI_SIZE))?;
if size <= 0 || size >= (1 << 23) { if size <= 0 || size >= (1 << 23) {
@ -274,6 +357,12 @@ pub(crate) fn live_schema(
let mut classes: BTreeMap<String, BTreeMap<String, Field>> = BTreeMap::new(); let mut classes: BTreeMap<String, BTreeMap<String, Field>> = BTreeMap::new();
let (mut typed, mut untyped) = (0usize, 0usize); let (mut typed, mut untyped) = (0usize, 0usize);
let mut seen: HashSet<String> = HashSet::new(); 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; let mut nlibs = 0usize;
for &lib in prof.libs { for &lib in prof.libs {
let Ok(img) = crate::locate::load_lib(dir, lib) else { let Ok(img) = crate::locate::load_lib(dir, lib) else {
@ -281,6 +370,18 @@ pub(crate) fn live_schema(
}; };
let Some(base) = live.base(lib) else { continue }; // lib not mapped in the process -> skip let Some(base) = live.base(lib) else { continue }; // lib not mapped in the process -> skip
nlibs += 1; nlibs += 1;
// 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) {
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 &enumerate_schema(&img) { for c in &enumerate_schema(&img) {
// a shared class already taken from an earlier (higher-precedence) lib — identical layout, skip // a shared class already taken from an earlier (higher-precedence) lib — identical layout, skip
if !seen.insert(c.name.clone()) { if !seen.insert(c.name.clone()) {
@ -334,12 +435,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); classes.insert(c.name.clone(), fmap);
} }
} }
let (types, cal) = derive_type_layouts(&classes, &registered_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!( eprintln!(
"typed netvars: {} classes across {nlibs} libs, {typed} typed fields, {untyped} unresolved", "typed netvars: {} classes across {nlibs} libs, {typed} typed fields, {untyped} unresolved; \
classes.len() {} 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 { Ok(Schema {
meta: SchemaMeta { meta: SchemaMeta {
@ -347,7 +490,267 @@ pub(crate) fn live_schema(
source_build: source_build.to_string(), source_build: source_build.to_string(),
typed, typed,
untyped, untyped,
enums: enums.len(),
types: types.len(),
}, },
classes, 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)
}

544
src/valvetab.rs Normal file
View file

@ -0,0 +1,544 @@
//! 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,
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,
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));
}
}