Compare commits
7 commits
gen-v2.1.0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bac78eb8fa | ||
|
|
43e37679ed | ||
|
|
36441687ff | ||
|
|
a9665e55f9 | ||
|
|
22ab973f0c | ||
|
|
fb652c39ae | ||
|
|
3410a79b6a |
31 changed files with 30876 additions and 1085 deletions
|
|
@ -72,4 +72,4 @@ jobs:
|
||||||
release-dir: dist
|
release-dir: dist
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
override: true
|
override: true
|
||||||
release-notes: "`source2rosetta-gen` ${{ github.ref_name }} — renders a published gamedata release into your framework's format: CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, or a typed C# SDK. Download it, `chmod +x`, and point it at the `gamedata-<game>.json` / `netvars-<game>.json` from a per-game release (`cs2-latest`, `dota2-latest`). Usage: see `crates/source2rosetta-core/README.md`. Linux x86-64."
|
release-notes: "`source2rosetta-gen` ${{ github.ref_name }} — renders a published release into your framework's format: CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, a typed C# SDK, or the Dota script API. A `--format` names WHO the output is for and writes every file that consumer reads, so `--out` is a directory. Download it, `chmod +x`, and point it at the `rosetta-<game>.json` from a per-game release (`cs2-latest`, `dota2-latest`): `source2rosetta-gen --from rosetta-cs2.json --format cssharp --out ./out`. Usage: see `crates/source2rosetta-core/README.md`. Linux x86-64."
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,9 @@ jobs:
|
||||||
runs-on: s2-runner
|
runs-on: s2-runner
|
||||||
env:
|
env:
|
||||||
GAME: ${{ github.event.inputs.game }}
|
GAME: ${{ github.event.inputs.game }}
|
||||||
STEAM_APPS: /home/cs2/.steam/SteamApps
|
|
||||||
STEAM_USER: source2rosetta
|
STEAM_USER: source2rosetta
|
||||||
|
STEAM_HOME_ANON: /home/cs2
|
||||||
|
STEAM_HOME_AUTH: /home/cs2/steam-auth
|
||||||
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
|
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
|
||||||
OVERRIDE_DIR: /home/cs2/rosetta-override
|
OVERRIDE_DIR: /home/cs2/rosetta-override
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -30,12 +31,36 @@ jobs:
|
||||||
- name: Update the install to the current build
|
- name: Update the install to the current build
|
||||||
run: |
|
run: |
|
||||||
case "$GAME" in
|
case "$GAME" in
|
||||||
cs2) APPID=730 ;;
|
cs2) APPID=730; LOGIN=anonymous; STEAM_HOME="$STEAM_HOME_ANON" ;;
|
||||||
dota2) APPID=570 ;;
|
dota2) APPID=570; LOGIN="$STEAM_USER"; STEAM_HOME="$STEAM_HOME_AUTH" ;;
|
||||||
*) echo "unknown game '$GAME' (expected cs2 or dota2)"; exit 1 ;;
|
*) echo "unknown game '$GAME' (expected cs2 or dota2)"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
echo "APPID=$APPID" >> "$GITHUB_ENV"
|
env HOME="$STEAM_HOME" steamcmd +login "$LOGIN" +app_update "$APPID" +quit
|
||||||
steamcmd +login "$STEAM_USER" +app_update "$APPID" +quit
|
|
||||||
|
STEAM_APPS=""; SEEN=""; BUILD=""
|
||||||
|
for cand in "$STEAM_HOME/Steam/steamapps" "$STEAM_HOME/.steam/steam/steamapps" \
|
||||||
|
"$STEAM_HOME/.steam/SteamApps"; do
|
||||||
|
m="$cand/appmanifest_$APPID.acf"
|
||||||
|
[ -f "$m" ] || continue
|
||||||
|
# The same tree reached twice through a symlink is ONE tree, not a disagreement.
|
||||||
|
key=$(stat -Lc '%d:%i' "$m")
|
||||||
|
case " $SEEN " in *" $key "*) continue ;; esac
|
||||||
|
SEEN="$SEEN $key"
|
||||||
|
b=$(grep -oP '"buildid"[[:space:]]+"\K[0-9]+' "$m")
|
||||||
|
echo " candidate $cand -> buildid $b"
|
||||||
|
if [ -z "$STEAM_APPS" ]; then
|
||||||
|
STEAM_APPS="$cand"; BUILD="$b"
|
||||||
|
elif [ "$b" != "$BUILD" ]; then
|
||||||
|
echo "::error::two Steam app trees under $STEAM_HOME disagree — $STEAM_APPS says" \
|
||||||
|
"$BUILD, $cand says $b. One is stale; deriving from it would publish gamedata for" \
|
||||||
|
"a build nothing is running. Remove the stale tree or symlink it to the live one."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ -n "$STEAM_APPS" ] || {
|
||||||
|
echo "::error::no appmanifest_$APPID.acf under $STEAM_HOME — did the update run?"; exit 1; }
|
||||||
|
echo "using $STEAM_APPS (buildid $BUILD)"
|
||||||
|
{ echo "APPID=$APPID"; echo "STEAM_APPS=$STEAM_APPS"; } >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Resolve the game paths + the new buildid
|
- name: Resolve the game paths + the new buildid
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -72,6 +97,7 @@ jobs:
|
||||||
--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 \
|
--prototypes mappings/prototypes.json \
|
||||||
|
--semantics "mappings/semantics-$GAME.json" \
|
||||||
--ehandle-classes mappings/ehandle-classes.json \
|
--ehandle-classes mappings/ehandle-classes.json \
|
||||||
--target "work/$BUILDID" \
|
--target "work/$BUILDID" \
|
||||||
--game-dir "$GAME_DIR" \
|
--game-dir "$GAME_DIR" \
|
||||||
|
|
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -248,7 +248,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "source2rosetta-core"
|
name = "source2rosetta-core"
|
||||||
version = "2.1.0"
|
version = "3.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
|
|
||||||
409
README.md
409
README.md
|
|
@ -8,17 +8,16 @@ Here it takes **about half an hour, with nobody involved.** A timer notices the
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
R=https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest # always the newest build
|
R=https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest # always the newest build
|
||||||
curl -fsSLO $R/gamedata-cs2.json # WHERE functions are — signatures + vtable offsets
|
curl -fsSLO $R/rosetta-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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Also published: `bindings-<game>.json` (the callable surface the binary declares about itself — Pulse
|
**One file per game.** One record per function: where it is, what its machine code was measured to take, what
|
||||||
bindings with a callable shim, entity IO, console commands, ConVars) and `manifest.json` (which build you
|
a declaration says it takes, what the binary declares may be done with it, and what it does in plain language
|
||||||
got). The
|
— plus the typed schema, and the surfaces that are not function-keyed (the Pulse registry, entity outputs,
|
||||||
[artifacts section](#artifacts-schemas--output-formats) covers all of them.
|
classnames, ConVars). `manifest.json` says which build you got. The
|
||||||
|
[artifacts section](#artifacts-schemas--output-formats) covers the shape.
|
||||||
|
|
||||||
The output is framework-neutral; `source2rosetta-gen` renders it into whatever your stack speaks — 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.
|
The output is framework-neutral; `source2rosetta-gen` renders it into whatever your stack speaks — one command writes both your framework's locator format and **typed call sites** for the same functions. The deriver behind it is a standalone Rust tool — you only need that if you're self-hosting the pipeline or adding a game.
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
|
|
@ -29,16 +28,18 @@ The output is framework-neutral; `source2rosetta-gen` renders it into whatever y
|
||||||
|
|
||||||
## Results
|
## Results
|
||||||
|
|
||||||
Ballpark from a recent build, on a 16-core desktop. These move build-to-build — treat them as orders of magnitude, not guarantees.
|
Measured on CS2 build `24537688` and Dota 2 build `24541331`, on a 16-core desktop. These move build-to-build — treat them as orders of magnitude, not guarantees.
|
||||||
|
|
||||||
| | derived functions | declared surface | typed prototypes | typed schema | model | one-time distill |
|
| | derived functions | declared surface | typed prototypes | typed schema | model | one-time distill |
|
||||||
|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| **CS2** | ~1,125 `core` + ~2,620 `high_confidence`, plus ~4,375 `experimental` name guesses | 580 Pulse bindings (127 host-callable), 784 commands, **1,551 ConVars**, 715 entity inputs / 226 outputs, 474 classnames | ~2,055 `verified` + ~80 `lower-bound`, 55 `mismatch` | ~1,900 classes / ~12,300 fields | ~48 MB (a few MB gzipped) | ~15 min |
|
| **CS2** | 1,086 `core` + 2,894 `high_confidence`, plus 4,374 `experimental` name guesses | **300 VScript bindings (247 located)**, 580 Pulse bindings (127 host-callable), 784 commands, 1,551 ConVars, 715 entity inputs / 226 outputs, 474 classnames | 2,158 `verified` + 92 `lower-bound`, 84 `mismatch`, 261 `return-only` | 1,899 classes / 12,331 fields | ~46 MB (a few MB gzipped) | ~15 min |
|
||||||
| **Dota 2** | ~1,930 `core` + ~2,450 `high_confidence`, plus ~5,950 `experimental` | 500 Pulse bindings (99 host-callable), 855 commands, **1,170 ConVars**, 624 entity inputs / 187 outputs, 3,528 classnames | ~3,635 `verified` + ~100 `lower-bound`, 44 `mismatch` | ~2,960 classes / ~17,700 fields | ~570 MB | ~1 hr |
|
| **Dota 2** | 1,097 `core` + 4,047 `high_confidence`, plus 5,817 `experimental` | **1,841 VScript bindings (1,599 located)**, 500 Pulse bindings (99 host-callable), 855 commands, 1,171 ConVars, 624 entity inputs / 187 outputs, 3,528 classnames | 2,837 `verified` + 99 `lower-bound`, 45 `mismatch`, 1,594 `return-only` | 2,962 classes / 17,695 fields | ~735 MB | ~1 hr |
|
||||||
|
|
||||||
**Declared surface** is what the binary states about itself, and it is a different kind of fact from the rest: no inference, no cross-build chaining, no confidence tier. The *host-callable* count is the subset of Pulse bindings invocable with an argument array alone — verified by calling each one on a live server of both games.
|
**Declared surface** is what the binary states about itself, and it is a different kind of fact from the rest: no inference, no cross-build chaining, no confidence tier. Two counts in it are subsets worth reading precisely. *Host-callable* is the Pulse bindings invocable with an argument array alone — verified by calling each on a live server of both games. *Located* is the VScript bindings whose implementation folds onto a function record as a real locator; the rest are documented but not addressable, and a C++ name registered at two addresses is dropped rather than guessed.
|
||||||
|
|
||||||
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.
|
The VScript surface is the newest and it moves the `high_confidence` count more than anything else has: **+247 on CS2 and +1,599 on Dota**, every one a name Valve states in the binary alongside a declared return type. On Dota that is a 65% increase in the named surface, and it reaches gameplay verbs no other source in this project locates — `AddNewModifier`, `AddItemByName`, `CastAbilityOnTarget` and `ChangeTeam` are all absent from every tier of the previous release.
|
||||||
|
|
||||||
|
A full run live-validates what it ships and reports **0 dropped** on both games — for CS2 that is 3,972 entries carrying 2,848 signatures and 1,129 vtable offsets, all checked against a running server (Dota: 5,138 entries, 4,309 signatures, 829 offsets). Distilling the model is a one-time cost; after that each build's re-derive is minutes of compute, and the half hour in the headline is the whole loop: notice, update, derive, validate, publish.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -54,8 +55,8 @@ A CS2 update never rebuilds Dota, and vice versa. Two rules keep it honest: ever
|
||||||
|
|
||||||
| you want | use |
|
| you want | use |
|
||||||
|---|---|
|
|---|---|
|
||||||
| the newest build, always | `…/releases/download/cs2-latest/gamedata-cs2.json` |
|
| the newest build, always | `…/releases/download/cs2-latest/rosetta-cs2.json` |
|
||||||
| a specific build, pinned | `…/releases/download/cs2-<buildid>-0/gamedata-cs2.json` |
|
| a specific build, pinned | `…/releases/download/cs2-<buildid>-0/rosetta-cs2.json` |
|
||||||
| to know what you got | `manifest.json` — carries `version = <game>-<buildid>-<patch>` |
|
| to know what you got | `manifest.json` — carries `version = <game>-<buildid>-<patch>` |
|
||||||
|
|
||||||
Follow `-latest` to adopt updates as they land, or pin a buildid tag to adopt them deliberately; old snapshots stay up either way. Whichever you choose, **check the manifest's build id against the server you're actually running** before loading — that is what stops stale offsets meeting a changed binary. (`patch` counts rebuilds on the same binary, e.g. a merged contribution.)
|
Follow `-latest` to adopt updates as they land, or pin a buildid tag to adopt them deliberately; old snapshots stay up either way. Whichever you choose, **check the manifest's build id against the server you're actually running** before loading — that is what stops stale offsets meeting a changed binary. (`patch` counts rebuilds on the same binary, e.g. a merged contribution.)
|
||||||
|
|
@ -66,14 +67,16 @@ Follow `-latest` to adopt updates as they land, or pin a buildid tag to adopt th
|
||||||
|
|
||||||
The artifacts answer four different questions, and most useful work joins two or more of them:
|
The artifacts answer four different questions, and most useful work joins two or more of them:
|
||||||
|
|
||||||
- **`gamedata-<game>.json` — where the code is.** Every entry is a hook point or a call target: a byte signature or an RTTI vtable slot, tiered and, for `core`/`high_confidence`, checked against a running server.
|
- **`functions` — where the code is.** Every record is a hook point or a call target: a byte signature or an RTTI vtable slot, tiered and, for `core`/`high_confidence`, checked against a running server.
|
||||||
- **`netvars-<game>.json` — what the state is.** Field offsets and types for every SchemaSystem class, plus the base graph, the enum tables and per-type sizes. This is the half that needs no hooking at all: a great deal of gameplay is readable and writable as plain memory.
|
- **`schema` — what the state is.** Field offsets and types for every SchemaSystem class, plus the base graph, the enum tables and per-type sizes. This is the half that needs no hooking at all: a great deal of gameplay is readable and writable as plain memory.
|
||||||
- **`abi-<game>.json` — whether it is safe to call.** A declared prototype joined to the register footprint measured in *this* build, with a verdict per function. `verified` and `lower-bound` are callable; `mismatch` says the prototype in circulation is wrong for this binary.
|
- **`prototype`, on each record — whether it is safe to call.** A declared prototype joined to the register footprint measured in *this* build, with a verdict per function. `verified` and `lower-bound` are callable; `mismatch` says the prototype in circulation is wrong for this binary.
|
||||||
- **`bindings-<game>.json` — what the binary declares about itself.** Console commands and ConVars with decoded flags, entity inputs and outputs, map classname → C++ class, and the typed Pulse registry with a callable entry point per binding.
|
- **`bindings` and `surfaces` — what the binary declares about itself.** Console commands and ConVars with decoded flags, entity inputs and outputs, map classname → C++ class, the typed Pulse registry with a callable entry point per binding, and the VScript registry — the surface Valve exposes to Lua, each entry pairing a script-facing name with a C++ name, an English description and a declared return type.
|
||||||
|
|
||||||
Two of those are newer than the rest and worth calling out, because they change what a plugin can do:
|
Three of those are newer than the rest and worth calling out, because they change what a plugin can do:
|
||||||
|
|
||||||
**ConVars ship with their flags.** 1,551 on CS2 across four libraries, 781 in Dota's `libserver` — with `cheat`, `replicated`, `archive` and `notify` decoded, and the raw word beside them. The names are not the point: a consumer finds a convar by name at runtime with no gamedata at all. The *flags* are, because they are engine-declared authority. A host that wants to say "this module may change gameplay settings but not cheat-protected ones" can key that on what the engine itself declares instead of maintaining an allowlist by hand.
|
**The VScript registry closes the biggest gap in the Dota surface.** 1,841 bindings on Dota and 300 on CS2, each pairing the name a script author types with the C++ name, Valve's own English description, and a declared return type — and 1,599 / 247 of them fold into the gamedata as real locators. It is the only source here that supplies gameplay VERBS on Dota: `AddNewModifier`, `AddItemByName`, `CastAbilityOnTarget`, `ChangeTeam`, `ModifyGold` and `AddExperience` are absent from every tier of the previous release and present now, which is why the [Dota section below](#dota-2) reads differently from how it did. They are script-facing wrappers rather than the underlying methods, and for a caller that is a feature: the wrapper's argument shape is the one Valve declared for a content author to use safely, and the wrapper is what the engine itself invokes.
|
||||||
|
|
||||||
|
**ConVars ship with their flags.** 1,551 on CS2 across four libraries, 782 in Dota's `libserver` — with `cheat`, `replicated`, `archive` and `notify` decoded, and the raw word beside them. The names are not the point: a consumer finds a convar by name at runtime with no gamedata at all. The *flags* are, because they are engine-declared authority. A host that wants to say "this module may change gameplay settings but not cheat-protected ones" can key that on what the engine itself declares instead of maintaining an allowlist by hand.
|
||||||
|
|
||||||
**Most of the Pulse surface is callable.** Each binding carries a `shim` address and a `call.needs` verdict; the `args-only` tier — roughly 110 on CS2, 82 on Dota within `libserver` — is invocable with an argument array and nothing else, through Valve's own marshalling, which enforces the binding's declared types. Those are *actions* (teleport, ignite, change team, start a mover, spawn a template), which is the half no field write can do; reading state remains the schema's job and is better served there.
|
**Most of the Pulse surface is callable.** Each binding carries a `shim` address and a `call.needs` verdict; the `args-only` tier — roughly 110 on CS2, 82 on Dota within `libserver` — is invocable with an argument array and nothing else, through Valve's own marshalling, which enforces the binding's declared types. Those are *actions* (teleport, ignite, change team, start a mover, spawn a template), which is the half no field write can do; reading state remains the schema's job and is better served there.
|
||||||
|
|
||||||
|
|
@ -95,7 +98,9 @@ What you do not get: `TryPlayerMove`, `WalkMove`, `Accelerate` and `TracePlayerB
|
||||||
|
|
||||||
#### Combat, damage and tracing
|
#### Combat, damage and tracing
|
||||||
|
|
||||||
`CBaseEntity::TakeDamage` is the funnel and `CCSPlayerPawn::OnTakeDamage_Alive` the player-specific override, but the interesting part is that you do not need a constructor to build a damage packet: `CTakeDamageInfo` is laid out completely — 22 fields over 280 bytes — and `CTakeDamageResult` (15 fields) tells you what the engine actually did, including `m_flPreModifiedDamage` beside `m_flDamageDealt` and a `m_bWasDamageSuppressed` flag. `DamageTypes_t`, `HitGroup_t` and the 21-flag `TakeDamageFlags_t` (`DFLAG_PREVENT_DEATH`, `DFLAG_IGNORE_ARMOR`, …) give you the switchboard. Two ABI notes: `CBaseEntity::DispatchTraceAttack` and `CBaseEntity::Event_Killed` are `verified` — and `Event_Killed` measures as the CS2-shaped `(CCSPlayerPawn*, CTakeDamageResult*)`, not the Source-1 `CTakeDamageInfo const&` everyone assumes — while `abi:CBaseEntity::TakeDamage` is tier `core` but verdict **`unverified`**: the declaration was never checked against this build. Build the struct by offsets and prefer the verified entry points.
|
`CBaseEntity::TakeDamage` is the funnel and `CCSPlayerPawn::OnTakeDamage_Alive` the player-specific override, but the interesting part is that you do not need a constructor to build a damage packet: `CTakeDamageInfo` is laid out completely — 22 fields over 280 bytes — and `CTakeDamageResult` (15 fields) tells you what the engine actually did, including `m_flPreModifiedDamage` beside `m_flDamageDealt` and a `m_bWasDamageSuppressed` flag. `DamageTypes_t`, `HitGroup_t` and the 21-flag `TakeDamageFlags_t` (`DFLAG_PREVENT_DEATH`, `DFLAG_IGNORE_ARMOR`, …) give you the switchboard. Two prototype notes, and the second is the sharpest example in this file of why the locator and the prototype are separate facts. `CBaseEntity::Event_Killed` is `verified` and measures as the CS2-shaped `(CCSPlayerPawn*, CTakeDamageResult*)`, not the Source-1 `CTakeDamageInfo const&` everyone assumes. And `CBaseEntity::TakeDamage` — the funnel itself — is tier `core` with `validated: true`, and its prototype verdict is **`mismatch`**: the circulated declaration `(CTakeDamageInfo&)` accounts for two integer registers and this build's callee reads **three**. The address is right and hooking it is fine; *calling through that declaration* would load the wrong registers. Build the struct by offsets and prefer the verified entry points.
|
||||||
|
|
||||||
|
**Do not use `CBaseEntity::DispatchTraceAttack`. Earlier revisions of this section recommended it, and it is mislocated** — the entry resolves to `CLogicRelay::Trigger`, which is a different function entirely. It is the clearest example in this file of why a locator that passes every check can still be wrong, so it is worth reading rather than just avoiding: its shipped pattern is a bare compiler prologue with no distinguishing content, so it is unique in the library by luck rather than by identity; the address holds real executable code, so live validation passed it; and `Trigger(hActivator, hCaller)` on a relay measures the same `int=3, ret=int` footprint as the declared `(CBaseEntity*, CTakeDamageInfo*, CTakeDamageResult*)`, so the ABI check called it `verified`. Three independent guards, none of which is an identity check. What caught it was **Valve's VScript registry naming that same address `Trigger`, with the description "Triggers the logic_relay"** — and the disassembly agreeing, every offset it touches being a named `CLogicRelay` field (`m_OnTrigger` at `+0x7a0`, `m_bDisabled`, `m_bPassthoughCaller`). Found 2026-08-01 by the [alias grouping](#functions--one-record-each), which is what made two sources' accounts of one address comparable at all.
|
||||||
|
|
||||||
Weapon rebalancing is entirely schema work. `CCSWeaponBaseVData` is 84 fields over 2,216 bytes: damage, headshot multiplier, armour ratio, penetration, range falloff, cycle time, seven per-stance inaccuracy terms, four recoil terms, the spray-recovery transition bullets, price, kill award — and `m_nRecoilSeed`/`m_nSpreadSeed`, the per-weapon RNG seeds that generate CS2's deterministic spray patterns. Reach it with `FindWeaponVDataByName` (`verified`); there is no member offset from a weapon entity to its VData.
|
Weapon rebalancing is entirely schema work. `CCSWeaponBaseVData` is 84 fields over 2,216 bytes: damage, headshot multiplier, armour ratio, penetration, range falloff, cycle time, seven per-stance inaccuracy terms, four recoil terms, the spray-recovery transition bullets, price, kill award — and `m_nRecoilSeed`/`m_nSpreadSeed`, the per-weapon RNG seeds that generate CS2's deterministic spray patterns. Reach it with `FindWeaponVDataByName` (`verified`); there is no member offset from a weapon entity to its VData.
|
||||||
|
|
||||||
|
|
@ -109,7 +114,7 @@ Two things make this domain unusually workable. The entire combat pipeline lands
|
||||||
|
|
||||||
`CCSPlayerPawn::m_pBot` is a typed `CCSBot*` on the pawn, and `CCSBot` is 140 typed fields over 24,088 bytes: current enemy, visible parts, goal position, path index, heard noise, panic and hurry timers, stuck state with a velocity ring buffer, radio timestamps, and the whole aim model (`m_lookPitch`/`m_lookYaw` with velocities, `m_aimError`, `m_aimFocus`, the reaction-queue indices). Every `CountdownTimer` is itself a schema class, so timers are readable *and* writable. A live bot-brain inspector, a custom difficulty curve, or an anti-stuck watchdog all need zero hooking.
|
`CCSPlayerPawn::m_pBot` is a typed `CCSBot*` on the pawn, and `CCSBot` is 140 typed fields over 24,088 bytes: current enemy, visible parts, goal position, path index, heard noise, panic and hurry timers, stuck state with a velocity ring buffer, radio timestamps, and the whole aim model (`m_lookPitch`/`m_lookYaw` with velocities, `m_aimError`, `m_aimFocus`, the reaction-queue indices). Every `CountdownTimer` is itself a schema class, so timers are readable *and* writable. A live bot-brain inspector, a custom difficulty curve, or an anti-stuck watchdog all need zero hooking.
|
||||||
|
|
||||||
The aim and behaviour functions — `UpdateLookAngles`, `UpdateReactionQueue`, `BendLineOfSight`, `FindMostDangerousThreat`, plus the direct verbs `SetBotEnemy`, `SetState`, `Panic`, `Blind`, `Retreat` — are `core` or `high_confidence` byte signatures, but **none has a declared prototype**: `abi-cs2.json` has no entry for any `CCSBot::` method. You get an address and a register count.
|
The aim and behaviour functions — `UpdateLookAngles`, `UpdateReactionQueue`, `BendLineOfSight`, `FindMostDangerousThreat`, plus the direct verbs `SetBotEnemy`, `SetState`, `Panic`, `Blind`, `Retreat` — are `core` or `high_confidence` byte signatures, but **none has a declared prototype**: not one `CCSBot::` method carries one. You get an address and a register count.
|
||||||
|
|
||||||
The state machine is more limited than it first looks. All 16 behaviour states appear as `core` entries and a `OnEnter=0 / OnUpdate=1 / OnExit=2 / GetName=3` slot convention is visible across them. But those offset-form entries carry no class binding — `IdleState::OnEnter`, `HideState::OnEnter` and `PickupHostageState::OnEnter` are all bare "slot 0" — so resolving a specific state's vtable needs a class pointer the artifacts do not supply. Eleven of the state methods (including `CCSBot::SetState` and eight `OnUpdate` slots) ship as byte signatures instead, and those you can resolve directly.
|
The state machine is more limited than it first looks. All 16 behaviour states appear as `core` entries and a `OnEnter=0 / OnUpdate=1 / OnExit=2 / GetName=3` slot convention is visible across them. But those offset-form entries carry no class binding — `IdleState::OnEnter`, `HideState::OnEnter` and `PickupHostageState::OnEnter` are all bare "slot 0" — so resolving a specific state's vtable needs a class pointer the artifacts do not supply. Eleven of the state methods (including `CCSBot::SetState` and eight `OnUpdate` slots) ship as byte signatures instead, and those you can resolve directly.
|
||||||
|
|
||||||
|
|
@ -141,7 +146,7 @@ The lowest-risk half of this domain needs no signatures: `env_shake`, `env_fade`
|
||||||
|
|
||||||
`UTIL_DispatchEffect`/`UTIL_DispatchEffectFilter` are `core`, `verified`, params-complete, and their one non-trivial argument — `CEffectData` — is fully mapped (20 fields, 112 bytes, SysV `memory`). That pairing is the ideal case these artifacts exist to produce.
|
`UTIL_DispatchEffect`/`UTIL_DispatchEffectFilter` are `core`, `verified`, params-complete, and their one non-trivial argument — `CEffectData` — is fully mapped (20 fields, 112 bytes, SysV `memory`). That pairing is the ideal case these artifacts exist to produce.
|
||||||
|
|
||||||
Recipient filters are the gap in this domain, and several otherwise-attractive routes run through them. The filtered dispatchers (`UTIL_DispatchParticleEffectFilter_Position`/`_Attachment`, `UTIL_SayTextFilter`, `UTIL_SayText2Filter`, `SoundOpGameSystem::StartSoundEventString`) are real and verified — but the *only* recipient-filter symbol anywhere in `gamedata-cs2.json` is `CRecipientFilter::AddAllPlayers`, whose ABI entry is `unverified` with an empty parameter list. There is no `AddRecipient`, no per-team filter, no single-user filter. You get "dispatch to everyone" and a filter-shaped hole you must fill from your own framework. The genuinely per-client route that *is* covered is the `point_soundevent` entity: `StartSoundOnSingleClient` targets one player index and fires an `m_onSoundFinished` output when the sound ends.
|
Recipient filters are the gap in this domain, and several otherwise-attractive routes run through them. The filtered dispatchers (`UTIL_DispatchParticleEffectFilter_Position`/`_Attachment`, `UTIL_SayTextFilter`, `UTIL_SayText2Filter`, `SoundOpGameSystem::StartSoundEventString`) are real and verified — but the *only* recipient-filter symbol anywhere in the catalogue is `CRecipientFilter::AddAllPlayers`, whose ABI entry is `unverified` with an empty parameter list. There is no `AddRecipient`, no per-team filter, no single-user filter. You get "dispatch to everyone" and a filter-shaped hole you must fill from your own framework. The genuinely per-client route that *is* covered is the `point_soundevent` entity: `StartSoundOnSingleClient` targets one player index and fires an `m_onSoundFinished` output when the sound ends.
|
||||||
|
|
||||||
The sound-operator system itself is unusually complete — start-by-string, the 11-argument raw start, set-param-string, and stop-with-filter are all `core` with `verified`, params-complete prototypes from a single provenance. The exception is flagged loudly: `SoundOpGameSystem::StopSoundEvent` is verdict `mismatch` (measured footprint exceeds declared), so use `StopSoundEventFilter`.
|
The sound-operator system itself is unusually complete — start-by-string, the 11-argument raw start, set-param-string, and stop-with-filter are all `core` with `verified`, params-complete prototypes from a single provenance. The exception is flagged loudly: `SoundOpGameSystem::StopSoundEvent` is verdict `mismatch` (measured footprint exceeds declared), so use `StopSoundEventFilter`.
|
||||||
|
|
||||||
|
|
@ -165,9 +170,11 @@ Because command flags are decoded, the client-reachable attack surface is exactl
|
||||||
|
|
||||||
### Dota 2
|
### Dota 2
|
||||||
|
|
||||||
Dota's surface is materially larger, and the difference is structural rather than incidental: **3,528 registered entity classnames against CS2's 474**, and 2,958 schema classes / 17,668 fields against 1,899 / 12,330. The reason is that in Dota every ability and every item is a networked entity with its own class — 2,155 `CDOTA_Ability*` classnames (795 of them `special_bonus_*` talents, 1,360 regular abilities), 660 `CDOTA_Item*`, 231 unit types, 130 heroes. What that buys is identification: given any script name a mod author types, you get the exact C++ class. What it does not buy is per-ability hooking — only a minority of those classes carry fields or functions of their own; the shared bases (`CDOTABaseAbility` 54 fields, `CDOTA_Item` 63, `CDOTA_BaseNPC` 269) are where the data lives.
|
Dota's surface is materially larger, and the difference is structural rather than incidental: **3,528 registered entity classnames against CS2's 474**, and 2,962 schema classes / 17,695 fields against 1,899 / 12,331. The reason is that in Dota every ability and every item is a networked entity with its own class — 2,155 `CDOTA_Ability*` classnames (795 of them `special_bonus_*` talents, 1,360 regular abilities), 660 `CDOTA_Item*`, 231 unit types, 130 heroes. What that buys is identification: given any script name a mod author types, you get the exact C++ class. What it does not buy is per-ability hooking — only a minority of those classes carry fields or functions of their own; the shared bases (`CDOTABaseAbility` 54 fields, `CDOTA_Item` 63, `CDOTA_BaseNPC` 269) are where the data lives.
|
||||||
|
|
||||||
The shape of Dota's coverage is also different from CS2's. Its `core` tier is bigger but narrower: 919 `CModifierFactory<…>` entries and several hundred game-system factories account for most of it. The classic gameplay verbs a Dota modder expects — cast, apply damage, issue an order, add a modifier — are **not in it**. Dota's strength here is observation and schema; CS2's is invocation.
|
The shape of Dota's coverage is also different from CS2's. Its `core` tier is narrow: 919 `CModifierFactory<…>` entries and several hundred game-system factories account for most of it, and the classic gameplay verbs a Dota modder expects are not in *that* tier.
|
||||||
|
|
||||||
|
**They are in `high_confidence`, via the VScript registry, and that is recent.** `AddNewModifier`, `CastAbilityOnTarget`, `AddItemByName`, `ChangeTeam`, `ModifyGold`, `AddExperience` — cast, apply, give, pay, add a modifier — all fold as located names with a Valve-declared return. Earlier releases of this file said Dota's strength was observation and CS2's was invocation; that is no longer the split. What separates them now is that CS2's verbs are mostly native methods while Dota's are mostly script wrappers, which mainly changes which argument shapes you get for free.
|
||||||
|
|
||||||
#### Custom game rules
|
#### Custom game rules
|
||||||
|
|
||||||
|
|
@ -181,7 +188,9 @@ The shape of Dota's coverage is also different from CS2's. Its `core` tier is bi
|
||||||
|
|
||||||
The vocabulary is complete too: `modifierfunction` has all 409 `MODIFIER_PROPERTY_*`/`MODIFIER_EVENT_*` values, `modifierstate` all 65 states, and `CDOTA_BaseNPC::m_nUnitState64` is a `uint64` — one read decodes stunned/silenced/rooted/hexed/disarmed/magic-immune for any unit. `CDOTA_Buff` is 38 fields including `m_hScriptScope`, the handle back into the Lua object.
|
The vocabulary is complete too: `modifierfunction` has all 409 `MODIFIER_PROPERTY_*`/`MODIFIER_EVENT_*` values, `modifierstate` all 65 states, and `CDOTA_BaseNPC::m_nUnitState64` is a `uint64` — one read decodes stunned/silenced/rooted/hexed/disarmed/magic-immune for any unit. `CDOTA_Buff` is 38 fields including `m_hScriptScope`, the handle back into the Lua object.
|
||||||
|
|
||||||
The wall: there is no `AddNewModifier` or `RemoveModifierByName` at any tier, and `CDOTA_ModifierManager` exposes only 7 of its 904 bytes — no vector of active buffs. You can hook creation and read a buff you hold; you cannot enumerate a unit's modifiers or apply one, except through the debug command `dota_modifier_test <entityindex> <modifiername> <duration>`, whose handler is `verified`.
|
The wall used to be that nothing could APPLY one. **The VScript registry breaks it:** `AddNewModifier` folds as a `high_confidence` locator with a Valve-declared return, alongside the rest of the script-facing verb set — `AddItem`, `AddItemByName`, `CastAbilityOnTarget`, `CastAbilityNoTarget`, `ChangeTeam`, `AddSpeechBubble`. None of these existed at any tier before it landed. They are the script WRAPPERS rather than the underlying C++ methods, which is the right target anyway: their argument shapes are the ones Valve declared for a content author to call safely.
|
||||||
|
|
||||||
|
What is still missing is the read side of the same surface. `RemoveModifierByName` is not in the registry either, and `CDOTA_ModifierManager` still exposes only 7 of its 904 bytes — no vector of active buffs. So you can hook creation, read a buff you hold, and now apply one; you still cannot enumerate a unit's modifiers, except through the debug command `dota_modifier_test <entityindex> <modifiername> <duration>`, whose handler is `verified`.
|
||||||
|
|
||||||
#### Match telemetry
|
#### Match telemetry
|
||||||
|
|
||||||
|
|
@ -189,7 +198,7 @@ The wall: there is no `AddNewModifier` or `RemoveModifierByName` at any tier, an
|
||||||
|
|
||||||
The engine also publishes things people normally reconstruct from replays: `m_fCreepDistanceSafe`/`Mid`/`Off` are per-team lane-equilibrium floats refreshed on their own timer, `m_flAvailableLaneGold` is a lane gold pool, and `CDOTAGameRules::m_hEnemyCreepsInBase` is literally a handle vector of creeps in your base. `CDOTA_NeutralSpawner` records `m_iStackingCreditPlayerID` and a per-team `m_bSeenClearedByTeam`, so camp analytics needs no heuristics. Every creep death carries `m_flTimeOfDeath`, `m_vWsKillOrigin` and `m_vWsKillDirection`.
|
The engine also publishes things people normally reconstruct from replays: `m_fCreepDistanceSafe`/`Mid`/`Off` are per-team lane-equilibrium floats refreshed on their own timer, `m_flAvailableLaneGold` is a lane gold pool, and `CDOTAGameRules::m_hEnemyCreepsInBase` is literally a handle vector of creeps in your base. `CDOTA_NeutralSpawner` records `m_iStackingCreditPlayerID` and a per-team `m_bSeenClearedByTeam`, so camp analytics needs no heuristics. Every creep death carries `m_flTimeOfDeath`, `m_vWsKillOrigin` and `m_vWsKillDirection`.
|
||||||
|
|
||||||
Reading is the strong direction. There is **no gold or XP mutator** at any usable tier (the only match is `CDOTATurboGameMode::FilterModifyGold`, experimental), so a plugin writes the ledger or tunes the passive knobs.
|
Reading was the strong direction until the VScript registry landed; the economy is now writable. `ModifyGold`, `ModifyGoldFiltered` ("gives this hero some gold, using the gold filter"), `SetGold`, `SpendGold` and `AddExperience` are all located, as are the passive knobs — `SetGoldPerTick`, `SetGoldTickTime`, `SetStartingGold`, `SetLoseGoldOnDeath`, `SetMinimumGoldBounty` / `SetMaximumGoldBounty`. Better than a mutator, the FILTERS are exposed too: `SetModifyGoldFilter`, `SetModifyExperienceFilter` and `SetExecuteOrderFilter` install a script callback the engine consults, which is interception rather than a race with the engine's own bookkeeping. Only the experimental `CDOTATurboGameMode::FilterModifyGold` was reachable before.
|
||||||
|
|
||||||
#### Map, encounters and scripting
|
#### Map, encounters and scripting
|
||||||
|
|
||||||
|
|
@ -211,13 +220,13 @@ A flat offset dump cannot do any of the following, and each one is a real failur
|
||||||
|
|
||||||
**`bases` is also the only place multiple inheritance is expressed.** Treating a `CEconEntity` as `IHasAttributes` requires adding 3,136 bytes; for `CChicken` it is 3,728. In Dota, 16 ability classes carry a second base at +2144 — `CDOTA_Ability_Morphling_Waveform` and friends inherit `CHorizontalMotionController` there, `CDOTA_Ability_DataDriven` inherits `CDOTA_ActionRunner`. A naive `(Base*)ptr` cast at any of these sites corrupts memory silently.
|
**`bases` is also the only place multiple inheritance is expressed.** Treating a `CEconEntity` as `IHasAttributes` requires adding 3,136 bytes; for `CChicken` it is 3,728. In Dota, 16 ability classes carry a second base at +2144 — `CDOTA_Ability_Morphling_Waveform` and friends inherit `CHorizontalMotionController` there, `CDOTA_Ability_DataDriven` inherits `CDOTA_ActionRunner`. A naive `(Base*)ptr` cast at any of these sites corrupts memory silently.
|
||||||
|
|
||||||
**`enums` recovers field width, not just readability.** 812 CS2 fields report `size: 0`; the enum's own size is what makes them decodable. `CBaseEntity::m_MoveType`, `m_nPreviouslySetMoveType` and `m_nActualMoveType` sit at 1491/1492/1493 and are only three consecutive `u8`s because `MoveType_t` is one byte wide. Beyond that, 555 CS2 enums / 743 Dota give you the legal-value tables — damage-type bitmasks, hit groups, observer modes, and on Dota the entire gameplay vocabulary.
|
**`enums` recovers field width, not just readability.** 812 CS2 fields report `size: 0`; the enum's own size is what makes them decodable. `CBaseEntity::m_MoveType`, `m_nPreviouslySetMoveType` and `m_nActualMoveType` sit at 1491/1492/1493 and are only three consecutive `u8`s because `MoveType_t` is one byte wide. Beyond that, 524 CS2 enums / 710 Dota give you the legal-value tables — damage-type bitmasks, hit groups, observer modes, and on Dota the entire gameplay vocabulary.
|
||||||
|
|
||||||
**`types` gives size and SysV class.** Size turns every generated accessor into a bounds check (12,330/12,330 CS2 fields pass). SysV class is what stops a struct-return call from corrupting the stack: a 12-byte `Vector` comes back in XMM registers (`sse`), a 48-byte `matrix3x4_t` through a hidden pointer (`memory`). That is what makes `CBaseEntity::GetEyePosition` callable correctly.
|
**`types` gives size and SysV class.** Size turns every generated accessor into a bounds check (12,331/12,331 CS2 fields pass). SysV class is what stops a struct-return call from corrupting the stack: a 12-byte `Vector` comes back in XMM registers (`sse`), a 48-byte `matrix3x4_t` through a hidden pointer (`memory`). That is what makes `CBaseEntity::GetEyePosition` callable correctly.
|
||||||
|
|
||||||
**A field's `name_hash` is stable across builds *and* across games.** 10,363 `Class::field` pairs exist in both artifacts; all 10,363 have identical hashes, and 2,589 of them sit at different offsets. So ship one hash-keyed table of the fields your plugin touches and bind offsets per build and per game at load. A hash that vanishes means a rename; a hash that moves means a rebind.
|
**A field's `name_hash` is stable across builds *and* across games.** 10,363 `Class::field` pairs exist in both artifacts; all 10,363 have identical hashes, and 2,589 of them sit at different offsets. So ship one hash-keyed table of the fields your plugin touches and bind offsets per build and per game at load. A hash that vanishes means a rename; a hash that moves means a rebind.
|
||||||
|
|
||||||
**A checked prototype is worth more than a declared one, and the verdict is the product.** `verified` (2,054 CS2 / 3,633 Dota) means declared arity matches the footprint measured in this build. `lower-bound` (82/99) means the declaration passes registers the callee never reads — compatible, but not the same claim. **`mismatch` (55/44) is the most immediately useful of the six**: it names community-circulated prototypes that are wrong for this binary and will load the wrong registers. `ambiguous` lists the surviving overloads for you to separate; `return-only` gives a return type and no arity claim; `unverified` means nothing checked it.
|
**A checked prototype is worth more than a declared one, and the verdict is the product.** `verified` (2,158 CS2 / 2,837 Dota) means declared arity matches the footprint measured in this build. `lower-bound` (92/99) means the declaration passes registers the callee never reads — compatible, but not the same claim. **`mismatch` (84/45) is the most immediately useful of the six**: it names community-circulated prototypes that are wrong for this binary and will load the wrong registers. `ambiguous` lists the surviving overloads for you to separate; `return-only` gives a return type and no arity claim; `unverified` means nothing checked it.
|
||||||
|
|
||||||
Two structural cross-checks come for free: all **226 CS2 entity outputs agree exactly with netvars** on class, member and byte offset, independently derived; and for all 759 CS2 commands present in both files, the dispatch form in `bindings` agrees with the prototype in `abi` — 674 `direct`, 81 `member` (extra leading `this`), 4 `interface`, zero disagreements. Hooking a member-form command with the free-function signature shifts every argument by one, and nothing in the command's name tells you which it is.
|
Two structural cross-checks come for free: all **226 CS2 entity outputs agree exactly with netvars** on class, member and byte offset, independently derived; and for all 759 CS2 commands present in both files, the dispatch form in `bindings` agrees with the prototype in `abi` — 674 `direct`, 81 `member` (extra leading `this`), 4 `interface`, zero disagreements. Hooking a member-form command with the free-function signature shifts every argument by one, and nothing in the command's name tells you which it is.
|
||||||
|
|
||||||
|
|
@ -225,13 +234,13 @@ Two structural cross-checks come for free: all **226 CS2 entity outputs agree ex
|
||||||
|
|
||||||
### The experimental band — read this before using any of it
|
### The experimental band — read this before using any of it
|
||||||
|
|
||||||
`experimental` is 4,374 entries on CS2 and 5,947 on Dota, and it is a different kind of artifact from everything above.
|
`experimental` is 4,374 entries on CS2 and 5,817 on Dota, and it is a different kind of artifact from everything above.
|
||||||
|
|
||||||
**Resolvable locator. Unverified name. Never live-validated.** Every entry has `validated: null`, `corroboration: bare` (one source, nothing independently agreed) and `self_named: false`. What is real is the *locator* — an RTTI class plus vtable slot, or a byte signature — and the *measured register footprint*, which every entry carries. What is a guess is the label. 270 CS2 / 357 Dota entries carry `collision: true` (another guessed name resolved to the same target) and 42 / 150 carry `dead_weight: true` (the target is a stub).
|
**Resolvable locator. Unverified name. Never live-validated.** Every entry has `validated: null`, `corroboration: bare` (one source, nothing independently agreed) and `self_named: false`. What is real is the *locator* — an RTTI class plus vtable slot, or a byte signature — and the *measured register footprint*, which every entry carries. What is a guess is the label. 270 CS2 / 355 Dota entries carry `collision: true` (another guessed name resolved to the same target) and 42 / 130 carry `dead_weight: true` (the target is a stub).
|
||||||
|
|
||||||
The two games' bands are not the same product. CS2's is 3,230 vtable locators across 914 RTTI classes plus 1,144 byte signatures across 21 libraries — and **zero in `libserver`**. It is engine infrastructure: `CPhysicsBody`, `CVPhys2World`, `CEngineServer`, `CServerSideClient`, `CNetChan`, `CCvar`, `CSchemaSystem`. If the names are right, that is a whole telemetry, physics and cvar surface — `CNetChan::GetAvgLatency` at slot 11 measures `{int:1, ret=float}`, which is at least the shape of a `float GetX() const`. If they are wrong, you have called a numbered slot with the wrong idea of what it does. Anyone hunting there for an unnamed `CCSPlayerPawn` method will not find it.
|
The two games' bands are not the same product. CS2's is 3,230 vtable locators across 914 RTTI classes plus 1,144 byte signatures across 21 libraries — and **zero in `libserver`**. It is engine infrastructure: `CPhysicsBody`, `CVPhys2World`, `CEngineServer`, `CServerSideClient`, `CNetChan`, `CCvar`, `CSchemaSystem`. If the names are right, that is a whole telemetry, physics and cvar surface — `CNetChan::GetAvgLatency` at slot 11 measures `{int:1, ret=float}`, which is at least the shape of a `float GetX() const`. If they are wrong, you have called a numbered slot with the wrong idea of what it does. Anyone hunting there for an unnamed `CCSPlayerPawn` method will not find it.
|
||||||
|
|
||||||
Dota's band *does* reach gameplay: 1,402 byte signatures in `libserver`, roughly 350 of them DOTA-named — `CDOTAGameRules::KillCreeps`, `CDOTATurboGameMode::FilterModifyGold`, `CDOTA_Ability_*::OnSpellStart`. If those names are right it is a gold mine for custom-game work. Treat every one as a hypothesis.
|
Dota's band *does* reach gameplay: 2,040 byte signatures in `libserver`, roughly 350 of them DOTA-named — `CDOTAGameRules::KillCreeps`, `CDOTATurboGameMode::FilterModifyGold`, `CDOTA_Ability_*::OnSpellStart`. If those names are right it is a gold mine for custom-game work. Treat every one as a hypothesis.
|
||||||
|
|
||||||
One sub-band is self-checking, which makes it usable on different terms: the `CNetMessagePB<id, MessageType, (SignonGroup_t)g, …>` template instantiations bake a wire id, a protobuf class name, a signon group and a reliability flag into the mangled name. Unlike a bare `CFoo::Bar` guess, that is structured data you can falsify against live traffic in one command (`net_listallmessages`, `net_messageinfo`). Note that for Dota the *authoritative* message-id source is not this band at all — it is the schema enums `EDotaUserMessages`, `EBaseUserMessages` and `EDotaClientMessages`, which are deterministic. Use those for ids and the templates as corroboration.
|
One sub-band is self-checking, which makes it usable on different terms: the `CNetMessagePB<id, MessageType, (SignonGroup_t)g, …>` template instantiations bake a wire id, a protobuf class name, a signon group and a reliability flag into the mangled name. Unlike a bare `CFoo::Bar` guess, that is structured data you can falsify against live traffic in one command (`net_listallmessages`, `net_messageinfo`). Note that for Dota the *authoritative* message-id source is not this band at all — it is the schema enums `EDotaUserMessages`, `EBaseUserMessages` and `EDotaClientMessages`, which are deterministic. Use those for ids and the templates as corroboration.
|
||||||
|
|
||||||
|
|
@ -274,6 +283,15 @@ ELF parsing is by hand. Three things make a stripped binary readable at all:
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
**"Which function" is an attribution, and it is only as good as the entry list.** Nearest-entry-at-or-below is
|
||||||
|
how an instruction is credited to a function, and CS2 strips `.eh_frame` from the game code, so a
|
||||||
|
`[entry, next_entry)` range routinely spans a real function plus unindexed neighbours and inherits their
|
||||||
|
strings. Anchor RECOVERY — using an anchor to locate a function that no byte-signature found — therefore
|
||||||
|
applies the same test anchor DERIVATION does: every instruction that loads the string must fall inside the
|
||||||
|
candidate's own flow-reachable code, walked from its entry. On CS2 that rejects 38 of 476 recoveries; on
|
||||||
|
Dota, whose FDEs span the whole `.text`, it rejects 4. The asymmetry is the point — the test fires where the
|
||||||
|
attribution is genuinely weak and stays quiet where it is not.
|
||||||
|
|
||||||
All Source-2 `.so` files link at vaddr 0, so a runtime address is simply `load_base + file_vaddr`.
|
All Source-2 `.so` files link at vaddr 0, so a runtime address is simply `load_base + file_vaddr`.
|
||||||
|
|
||||||
### 2. Derive from the binary's own reflection
|
### 2. Derive from the binary's own reflection
|
||||||
|
|
@ -282,9 +300,9 @@ Valve compiles two reflection systems into every module, and both are read direc
|
||||||
|
|
||||||
**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.
|
**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.
|
**SchemaSystem** gives class → field metadata. Be precise about what is static here: **name and offset are in the file; the TYPE is not.** A field's type pointer is a null placeholder on disk and is populated only at runtime, which is why the typed schema requires a live process and why an offline run states `"schema": null` instead.
|
||||||
|
|
||||||
### 3. Names Valve ships in the binary — three sources, and only two locate
|
### 3. Names Valve ships in the binary — four sources, and only three locate
|
||||||
|
|
||||||
Every Source-2 module names some of its own functions. This is ground truth from the shipped binary — not inference, not cross-game transfer — so it outranks every derived name, and it re-derives on every build with no input to maintain and nothing to bootstrap: the source travels with the binary.
|
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.
|
||||||
|
|
||||||
|
|
@ -292,13 +310,26 @@ Every Source-2 module names some of its own functions. This is ground truth from
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **entity-IO datadesc** | the C++ handler name (`InputKill`) with the handler's address | **yes** |
|
| **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** |
|
| **console-command registration** | the command name (`bot_add`) with its callback | **yes** |
|
||||||
|
| **VScript binding registry** | a script-facing name (`TakeDamage`) with a C++ name, an English description, a return type and the implementation | **yes** |
|
||||||
| **Pulse binding registry** | a qualified `Class::Method` with display name, description, call policy, a full typed signature and an invocation shim | **not as a C++ symbol** |
|
| **Pulse binding registry** | a qualified `Class::Method` with display name, description, call policy, a full typed signature and an invocation shim | **not as a C++ symbol** |
|
||||||
|
|
||||||
|
**The VScript registry is the fourth, and it is the only one that states a RETURN TYPE.** Valve exposes a subset of the C++ surface to script — Lua in Dota's custom games, a smaller set in CS2 — and each exposed method is registered with a descriptor carrying both names, Valve's own prose, a `ScriptDataType_t` return and a pointer to the implementation. That is a locator, a prototype and documentation in one record.
|
||||||
|
|
||||||
|
It is not a table walk, for the same reason the Pulse signatures are not: **the descriptors are built at runtime and are zeroes on disk.** A scan of Dota's `libserver.so` finds 2,268,664 `R_X86_64_RELATIVE` relocations and not one points at a description string. What is static is the code that fills them in, so the same answer applies — constant-propagate the initialiser rather than read the table. Three distinct registration forms are recovered: a packed pair of name pointers, a single string duplicated when both names are the same, and a record base copied between registers mid-construction.
|
||||||
|
|
||||||
|
The return-type decoding is DERIVED rather than assumed. An early reading fitted two observations to Source 1's historical `FIELD_*` ordering and was wrong; joined against 389 bindings whose return type Valve's published dump states, `5` is `int` and `6` is `bool`. The raw word ships beside the decoding regardless.
|
||||||
|
|
||||||
|
**It is very nearly disjoint from everything else here, and the exceptions are measured rather than assumed.** On Dota not one of the 1,652 implementations shares an address with an existing catalogue entry, and no name is shared either — a `Script_TakeDamage` is a script-facing WRAPPER, a different function from the `TakeDamage` it wraps. **On CS2 twelve are not wrappers**: `SetAbsOrigin`, `SetAbsVelocity`, `ScriptSetAbsAngles`, `Script_SetModelScale` and eight others are bound straight to the native method, so they land on an address the catalogue already names. That is a fact about the two games' bindings rather than a defect — where a native signature is already script-callable, Valve binds it directly — and both names now ship, each [declaring the other as an alias](#functions--one-record-each) instead of appearing as two unrelated functions.
|
||||||
|
|
||||||
|
An earlier revision of this section generalised the Dota measurement to both games and said the surface was disjoint outright. It is not, and the alias field is how a consumer sees where.
|
||||||
|
|
||||||
|
A C++ name registered at more than one address is a different case and is still dropped rather than guessed (2 of 1,650 on Dota, 0 on CS2), the same rule the ambiguous datadesc handlers follow.
|
||||||
|
|
||||||
**Two of the Pulse record's three code pointers do not locate anything, and the mistake is instructive.** An early pass folded the pair at `+24`/`+32` as locators for a headline `+782` names. They are *descriptor accessors* — every CS2 `libserver` binding measures the same empty `int=0 float=0` footprint, and they disassemble to a lazy-init singleton returning a static vector. Folding them would have shipped `CBaseEntityAPI::GetAbsOrigin` pointing at a zero-argument accessor: a locator that resolves, passes live validation as executable code, and is still the wrong function.
|
**Two of the Pulse record's three code pointers do not locate anything, and the mistake is instructive.** An early pass folded the pair at `+24`/`+32` as locators for a headline `+782` names. They are *descriptor accessors* — every CS2 `libserver` binding measures the same empty `int=0 float=0` footprint, and they disassemble to a lazy-init singleton returning a static vector. Folding them would have shipped `CBaseEntityAPI::GetAbsOrigin` pointing at a zero-argument accessor: a locator that resolves, passes live validation as executable code, and is still the wrong function.
|
||||||
|
|
||||||
**The third pointer, at `+72`, is a real entry point** — one per binding, never shared. It is not the bound C++ method and cannot be folded as one (that method's address is genuinely unrecoverable offline; the shim dispatches indirectly). It is a fixed-signature marshalling stub, and calling it *invokes the binding*. That is verified by doing it: `SetRenderAlpha` and `SetRenderColor` were called on a live CS2 server and moved the entity's `m_clrRender`, and a `SetRenderColor` on Dota set the RGB bytes while leaving the alpha byte untouched — proof that dispatch honours the binding's DECLARED `PulseValueType_t`, so a caller cannot smuggle a mistyped argument past it. Every eligible binding is re-checked on each derive by calling it with a sentinel handle (see [the standing oracles](#the-standing-oracles)).
|
**The third pointer, at `+72`, is a real entry point** — one per binding, never shared. It is not the bound C++ method and cannot be folded as one (that method's address is genuinely unrecoverable offline; the shim dispatches indirectly). It is a fixed-signature marshalling stub, and calling it *invokes the binding*. That is verified by doing it: `SetRenderAlpha` and `SetRenderColor` were called on a live CS2 server and moved the entity's `m_clrRender`, and a `SetRenderColor` on Dota set the RGB bytes while leaving the alpha byte untouched — proof that dispatch honours the binding's DECLARED `PulseValueType_t`, so a caller cannot smuggle a mistyped argument past it. Every eligible binding is re-checked on each derive by calling it with a sentinel handle (see [the standing oracles](#the-standing-oracles)).
|
||||||
|
|
||||||
So the honest yield from this table is **zero C++ symbol locators, a complete typed API surface, and a callable entry point per binding** — all of which ship in `bindings-<game>.json`, with `call.needs` stating what a host must supply for each.
|
So the honest yield from this table is **zero C++ symbol locators, a complete typed API surface, and a callable entry point per binding** — all of which ship under `surfaces.pulse`, with `call.needs` stating what a host must supply for each.
|
||||||
|
|
||||||
**The datadesc handlers are class-qualified** by joining each record's array against the SchemaSystem: a datadesc array also carries field descriptors, and a `(member, offset)` pair is something the schema states from an entirely different table, so the class whose schema satisfies every pair in the array owns it. 653 of 715 CS2 handlers qualify this way. This is what makes `InputEnable` — a distinct handler on 48 classes — nameable at all; unqualified names that resolve to several addresses are still **dropped, not guessed**. Note for consumers: qualification **renamed 343 shipped keys** (`InputActivateSkybox` → `CAmbientGeneric::InputActivateSkybox`).
|
**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`).
|
||||||
|
|
||||||
|
|
@ -334,7 +365,7 @@ So each function's observable **SysV-AMD64 shape** (which argument registers are
|
||||||
|
|
||||||
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.
|
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).
|
Types cannot be recovered from a stripped binary, so each record's `prototype` joins a *declared* prototype to that measurement and judges it. See [the verdicts](#prototype--declared-types-judged-against-this-build).
|
||||||
|
|
||||||
### 6. Validate — against a live server, not a spec
|
### 6. Validate — against a live server, not a spec
|
||||||
|
|
||||||
|
|
@ -362,9 +393,19 @@ The contract is blunt: **"degrades or stops loudly, never lies."** An entry live
|
||||||
| `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` | 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 |
|
| `high_confidence` | names folded in as verified offsets/sigs — Valve's own in-binary sources (`valve-table` provenance, ground truth) first, then macOS ground-truth transfer, dictionary-exact, and gated extrapolation |
|
||||||
| `experimental` | the least-filtered band — every graded name guess, each with a **resolvable locator** but an **unverified name**. **Never live-validated.** |
|
| `experimental` | the least-filtered band — every graded name guess, each with a **resolvable locator** but an **unverified name**. **Never live-validated.** |
|
||||||
| `unresolved` | catalogued but not confidently produced this build, with a closed-vocabulary reason (`sig-drifted`, `offset-low-conf`, `unresolved`, `abi-drift`) and no locator |
|
| `unresolved` | catalogued but not confidently produced this build, with a closed-vocabulary reason (`sig-drifted`, `offset-low-conf`, `unresolved`, `abi-drift`, `name-contradicted`) and no locator |
|
||||||
|
|
||||||
A function that couldn't be derived this build shows up as `unresolved` with a reason — it never just disappears.
|
A function that couldn't be derived this build shows up as `unresolved` with a reason — it never just
|
||||||
|
disappears, and that is now a checked property rather than a habit: after every pass, whatever the catalogue
|
||||||
|
named and no locator or flag accounts for is swept into `unresolved` with a reason naming which of the three
|
||||||
|
things went wrong. Measured on the current builds, `core + high_confidence + experimental + unresolved`
|
||||||
|
accounts for **1,601 of 1,601 CS2 catalogue names and 2,131 of 2,131 on Dota**.
|
||||||
|
|
||||||
|
The reason matters as much as the presence, because most of what the sweep catches was never a function:
|
||||||
|
86 CS2 names and 193 Dota ones carry only an `offset` variant (a raw member offset this tool does not
|
||||||
|
derive) or name no library to search — the catalogue is harvested from other people's dumps, and a dumper's
|
||||||
|
own JSON keys (`build_number`, `dwEntityList`, `attack`) arrive looking exactly like function names.
|
||||||
|
Reporting those as "functions we failed to locate" would trade one silence for a louder untruth.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
|
|
@ -387,14 +428,20 @@ A `produce` run **aborts rather than publish** a collapsed artifact. Each surfac
|
||||||
| 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 |
|
| console commands | recovered-command count | the registrar is found by SHAPE, so a reworked constructor yields **zero** commands rather than wrong ones — correct, and invisible without this |
|
||||||
| host-callable Pulse shims | count of `call.needs == "args-only"` | the registry can read perfectly and every signature recover perfectly while a codegen change makes each shim appear to read another argument — retiring the one callable tier without failing anything |
|
| host-callable Pulse shims | count of `call.needs == "args-only"` | the registry can read perfectly and every signature recover perfectly while a codegen change makes each shim appear to read another argument — retiring the one callable tier without failing anything |
|
||||||
| ConVars | recovered-convar count | found by a DIFFERENT test than commands — convergence of registrar wrappers on a shared core, not a sentinel in the callee — so it can collapse while commands keep working |
|
| ConVars | recovered-convar count | found by a DIFFERENT test than commands — convergence of registrar wrappers on a shared core, not a sentinel in the callee — so it can collapse while commands keep working |
|
||||||
|
| VScript bindings | recovered-binding count | a THIRD identification test again: a record base computed by the initialiser's own `idx*5 << 4 + [class+0x28]`. The floor is set deliberately loose because the reader recovers three distinct registration forms, and losing any ONE of them would still clear a tight floor while quietly dropping a third of the surface |
|
||||||
|
| VScript owning classes | attributed-binding count, **full runs only** | the one floor an offline run skips rather than fails, because zero is correct there by construction — the class is live-only. It is separate from the row above because it fails in the opposite direction: that floor guards the offline READER against a Valve reshape, this one guards the LIVE WALK, and the walk breaking leaves every binding recovered, described and located with no class on any of them. `class` is what `gen`'s `moddota` format groups by, so the release would clear every other gate while both renderers emitted nothing |
|
||||||
| typed netvars | fraction of fields typed | a runtime type-layout reshape resolves every field "untyped" and would otherwise ship a typeless schema at exit 0 |
|
| typed netvars | fraction of fields typed | a runtime type-layout reshape resolves every field "untyped" and would otherwise ship a typeless schema at exit 0 |
|
||||||
| schema enums | enum count | read by shape like the class table, so a reshape yields zero rather than wrong |
|
| schema 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 | |
|
| schema classes | class count | the largest table, and the one every other schema claim rests on — the `schema` section itself, the entity-output and datadesc joins, the derived type layouts, and half the identity check's conjunction. Nothing else covers it: the enum floor passes at zero classes, and the live oracle's own class gate is SKIPPED below its minimum sample, which is the collapse range |
|
||||||
|
| derived functions | `core` + `high_confidence` count | the headline product, and the one place a pass-RATE gate cannot help: the live oracle scores entries that reached the gamedata document, and a signature that failed to resolve never enters it — so a derive emitting forty functions instead of four thousand passes at 100% |
|
||||||
|
| live validation | pass rate, above a minimum sample | covers the derived locators too, not only the Valve-table oracles: the stage judging the primary product used to print its tally and discard it |
|
||||||
|
|
||||||
### `validated` is three-valued
|
### `validated` is three-valued
|
||||||
|
|
||||||
`true` / `false` / `null`, and `null` is not a synonym for failure. Three cases ship `null` legitimately: the library is not mapped in the vanilla server, the class is not a vtable class (an engine special or a carried member offset), or the entry has no locator to check. An **offline run ships the whole monolith `null`** — absence of validation, not failed validation. Treat `null` as "not checked here", never as "checked and passed".
|
`true` / `false` / `null`, and `null` is not a synonym for failure. Three cases ship `null` legitimately: the library is not mapped in the vanilla server, the class is not a vtable class (an engine special or a carried member offset), or the entry has no locator to check. An **offline run ships the whole monolith `null`** — absence of validation, not failed validation. Treat `null` as "not checked here", never as "checked and passed".
|
||||||
|
|
||||||
|
**`true` means every locator the entry carries was checked, not the first one found.** An entry may hold a signature *and* a vtable offset — five CS2 `core` entries do — and each is a separate claim about the running server. Both are validated; the entry is dropped if either is confidently bad; and if any half went unchecked the whole entry degrades to `null` rather than letting the checked half vouch for the other. That last rule is the one that matters to a hooking consumer: those offsets ship into ModSharp's `VFuncs` and Metamod's `Offsets`, where a wrong slot is what crashes a plugin.
|
||||||
|
|
||||||
### The standing oracles
|
### 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:
|
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:
|
||||||
|
|
@ -406,17 +453,41 @@ A dozen free, mostly two-sided checks run on **every** derive and are reported.
|
||||||
- **entity-output ↔ schema join** — 226/226 CS2, 186/187 Dota.
|
- **entity-output ↔ schema join** — 226/226 CS2, 186/187 Dota.
|
||||||
- **EHANDLE class grouping** — Valve's naming vs the binary's destructor addresses: 0 of 44 CS2 / 41 Dota groups carry two classes.
|
- **EHANDLE class grouping** — Valve's naming vs the binary's destructor addresses: 0 of 44 CS2 / 41 Dota groups carry two classes.
|
||||||
- **Pulse element stride** — derived by consensus per image, unanimous across six libraries in both games.
|
- **Pulse element stride** — derived by consensus per image, unanimous across six libraries in both games.
|
||||||
- **live schema oracle** — offline layout vs the running process: 852/852 CS2, 1,912/1,912 Dota.
|
- **live schema oracle** — offline layout vs the running process: 852/852 CS2, 1,916/1,916 Dota. Note the population: this reads `libserver` alone, where the release floor counts the union across every mapped library (1,899 / 2,962). Two different numbers for two different questions.
|
||||||
- **Pulse shim invocation** — the only *behavioural* oracle here: every binding the artifact calls `args-only` is invoked on the live server with a sentinel entity handle, which the engine's own resolve rejects before touching anything. CS2 **67/67 clean**. It verifies a claim the artifact makes rather than a value it reports, and it is safe to run in CI precisely because the sentinel path mutates nothing — every argument slot the measurement calls unused is passed as null, so a slot that is actually used faults, and a fault is caught and the thread restored.
|
- **Pulse shim invocation** — the only *behavioural* oracle here: every binding the artifact calls `args-only` is invoked on the live server with a sentinel entity handle, which the engine's own resolve rejects before touching anything. CS2 **67/67 clean**. It verifies a claim the artifact makes rather than a value it reports, and it is safe to run in CI precisely because the sentinel path mutates nothing — every argument slot the measurement calls unused is passed as null, so a slot that is actually used faults, and a fault is caught and the thread restored.
|
||||||
|
- **Pulse descriptors, against the live ones** — the reconstruction check. A binding's typed signature is *constant-propagated out of an initialiser*, not read from data: the elements are written at runtime and are zeroes on disk. So the shipped `params` were, until this landed, an unverified inference. The oracle reads what the running server actually holds and compares: **383/383 on `libserver` and 155/155 on `libpulse_system`, with returns 139/139, zero disagreements.** The trick is that the regions are lazy-init singletons a normal match never populates — a standard game executes no Pulse graph — so the oracle *calls the accessor first*. Those are the same `+24`/`+32` accessors the fold refuses to treat as locators: nullary, `int=0`, body builds a static once. Worthless as locators, and exactly what makes this check possible.
|
||||||
- **field-gap size calibration**, the semantic call sweep, and a 500-iteration live fuzz.
|
- **field-gap size calibration**, the semantic call sweep, and a 500-iteration live fuzz.
|
||||||
|
|
||||||
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.
|
**One of these is not like the others, and it is the newest: the IDENTITY check.** Every oracle above verifies that a locator *resolves* — that the address is real, the slot is real, the declaration matches the measurement. None of them asks whether it resolves to the **right function**, and a wrong locator passes all of them: the address holds real code, so live validation confirms it; the pattern is unique in its library, so the scan is clean; and if the impostor happens to take the same number of arguments, the ABI verdict reads `verified`.
|
||||||
|
|
||||||
|
So one check asks the other question, from two things the binary states about an address and a name that is a `Class::Method`:
|
||||||
|
|
||||||
|
- **Valve's VScript registry names the address something else.** Ground truth — the binary naming its own function.
|
||||||
|
- **The code operates on a different class.** A `CFoo::` method reaches its object through `this`, so every `this + N` it touches must satisfy `N < sizeof(CFoo)`, and the SchemaSystem states that size offline.
|
||||||
|
|
||||||
|
**Both must hold, and the conjunction is the whole design.** Either alone rejects good entries, measured rather than supposed: a dozen CS2 bindings are bound *straight* to the native method instead of through a script wrapper, so `SetAbsOrigin` and `CBaseEntity::SetAbsOrigin` legitimately share an address (as do `ScriptSetSize` and `CBaseModelEntity::SetCollisionBounds`, whose names do not even resemble each other); and separately, six entries reach past their class because their NAME carries the wrong prefix while the locator is fine — four `CPathMover::` entries that are really `CFuncMover` setters, two `CBasePlayerController::` that are really `CCSPlayerController`. All eight of those still ship.
|
||||||
|
|
||||||
|
Across the ~3,980 CS2 entries that resolved before it ran, the conjunction fires **once**, and that one had shipped in a release: `CBaseEntity::DispatchTraceAttack` resolved to `CLogicRelay::Trigger`. It now ships as `name-contradicted` instead of as a locator. Because n=1, it refuses the entry rather than failing the release.
|
||||||
|
|
||||||
|
**It runs where the model LEARNS, not only where the artifact is written**, and that placement is the point. The same locate step feeds the incremental fold and the distill, so a check applied only at emit time would leave the model recording the impostor's fingerprint — and the strict fingerprint check would then *confirm* the wrong address on the next build. That is exactly how this entry survived: the model had learned the decoy, so the guard that should have caught it vouched for it instead.
|
||||||
|
|
||||||
|
That sentence described the design before it described the code. The fold-path call passed the library's FILE name to a map keyed by its SHORT one, so the lookup missed, the `?` returned, and the check silently passed on every call — while the line four below it applied the very normalisation the lookup omitted. It was dead on the one path the paragraph above says matters most, and the shipped model carried **eight** fingerprint observations plus a consensus ABI for `DispatchTraceAttack` — the wrong function's, `Trigger(hActivator, hCaller)`'s `int=3`. Re-distilling with the check live removes exactly that one name and nothing else.
|
||||||
|
|
||||||
|
Worth stating precisely, because the honest version is less dramatic than it sounds: the contamination was **latent, never active**. The emit-time check rejected the entry regardless of what the model held, so a derive against the contaminated model and one against the clean model produce byte-identical artifacts. What the dead guard cost was not a wrong locator today but a loaded one tomorrow — had the conjunction's other half ever drifted (Valve renames the binding, the class size moves), those eight observations were sitting ready to confirm the decoy as correct.
|
||||||
|
|
||||||
|
The `this`-tracking is deliberately conservative — a register stops holding `this` on any write that is not a move from another register already holding it, all caller-saved registers drop across a `call`, and a path merge keeps only what holds on both paths — so the error direction is a missed contradiction, never a false accusation.
|
||||||
|
|
||||||
|
Two of these have found real defects. The entity-IO ABI check, on its first run: 34 of 715 handlers measured float arguments a `void(ptr, ref)` cannot have, which traced to the ABI reader treating a `call` as fall-through so a callee's *return* propagated backwards as a phantom argument. And the descriptor oracle settled a question the multi-library duplicate check had been reporting with no way to resolve — for every binding it can read, the two libraries' accounts are identical and both match the live descriptor, so the reported disagreement is not in the parameter or return lists.
|
||||||
|
|
||||||
### What is NOT gated
|
### 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.
|
Stated because "we check things" is worthless without a boundary. There is **no** floor on the live-fuzz fault rate or the RTTI class / base-graph size. A regression in either is reported, not refused.
|
||||||
|
|
||||||
**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.
|
The list used to be longer. The validate-live drop rate, the schema class count and the derived function count were all ungated and are now floored — see the table above. They are called out here rather than quietly removed because the gap they left was structural rather than an oversight of three numbers: **every gate measured what was emitted, never what failed to be emitted**, so a collapsed derive scored 100% on a shrinking denominator.
|
||||||
|
|
||||||
|
**One oracle is currently reporting.** The Pulse registry is keyed by qualified name, so a binding registered by several modules keeps one row, and every duplicate is compared against the row already present. On the current builds **CS2 flags 331 of 419 repeat registrations and Dota 271 of 359** as disagreeing — so for those names `surfaces.pulse` carries one module's account of the signature, not a merged one. It is flagged on every run and is not yet resolved; if you consume `params`/`returns` for a multiply-registered binding, know that.
|
||||||
|
|
||||||
|
*Which* module's account it carries is now the highest-precedence one, matching the rule `GameProfile::libs` states and every sibling table in that fold already followed — the earlier library wins, with one exception: a later library that recovered a typed signature replaces an earlier one that did not, since precedence must not cost information. It previously kept the LAST registration read, so 224 of 580 CS2 rows shipped another module's account of a name libserver also registers — and, because the live shim and descriptor oracles run against the server image, the rows that got verified were not the rows that got shipped.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -444,7 +515,7 @@ cargo build --release -p source2rosetta-core # → ./target/release/source2roset
|
||||||
| `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`). |
|
| `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. An **operator primitive** — nothing in the shipped pipeline invokes it; the poller dispatches a derive on any buildid change. |
|
| `classify-change` | `--prev`/`--new` → `skip` / `normal` / `shift` + the exact % of function bodies that changed, comparing with position-dependent bytes masked so a pure layout shift reads as unchanged. Enumerates functions over relocation code-pointers ∪ decoded call targets ∪ `.eh_frame` starts — the FDE list alone covers ~12% of a CS2 binary, since Valve strips unwind info from the game code and leaves it only for the statically-linked runtime tail. An **operator primitive** — nothing in the shipped pipeline invokes it; the poller dispatches a derive on any buildid change. |
|
||||||
| `filter-corpus` | Collapse runs of code-identical builds to one representative, label each transition `normal`/`shift`, and segment the timeline into toolchain eras. Writes an **advisory** selection manifest; the distill does not read it (see [corpus curation](#getting-the-corpus-only-to-bootstrap-a-model)). |
|
| `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
|
||||||
|
|
@ -456,6 +527,8 @@ Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/re
|
||||||
./target/release/source2rosetta --game cs2 produce \
|
./target/release/source2rosetta --game cs2 produce \
|
||||||
--seed mappings/seed-cs2.json \
|
--seed mappings/seed-cs2.json \
|
||||||
--corpus-model model-cs2.json \
|
--corpus-model model-cs2.json \
|
||||||
|
--prototypes mappings/prototypes.json \
|
||||||
|
--semantics mappings/semantics-cs2.json \
|
||||||
--target <build-dir> \
|
--target <build-dir> \
|
||||||
--out-dir out
|
--out-dir out
|
||||||
|
|
||||||
|
|
@ -464,6 +537,8 @@ Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/re
|
||||||
./target/release/source2rosetta --game cs2 produce \
|
./target/release/source2rosetta --game cs2 produce \
|
||||||
--seed mappings/seed-cs2.json \
|
--seed mappings/seed-cs2.json \
|
||||||
--corpus-model model-cs2.json \
|
--corpus-model model-cs2.json \
|
||||||
|
--prototypes mappings/prototypes.json \
|
||||||
|
--semantics mappings/semantics-cs2.json \
|
||||||
--target <build-dir> \
|
--target <build-dir> \
|
||||||
--game-dir <cs2-install> \
|
--game-dir <cs2-install> \
|
||||||
--out-dir out
|
--out-dir out
|
||||||
|
|
@ -471,7 +546,7 @@ Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/re
|
||||||
|
|
||||||
- `--target <dir>` — the build **directory** to derive from; `produce` requires a directory and its libraries are searched by name. (Other subcommands accept a bare `.so` as well, which is how `classify-change --prev` is used.)
|
- `--target <dir>` — the build **directory** to derive from; `produce` requires a directory and its libraries are searched by name. (Other subcommands accept a bare `.so` as well, which is how `classify-change --prev` is used.)
|
||||||
- `--game-dir <install>` — must be the **`game/` subtree** of the install, the same directory layout the dedicated server is launched from.
|
- `--game-dir <install>` — must be the **`game/` subtree** of the install, the same directory layout the dedicated server is launched from.
|
||||||
- `--seed <bundle>` — one file bundling every derive input. The loose equivalent is `--catalogue <file>` plus the optional `--promotable` / `--candidates` / `--full-names` / `--extra-offsets` / `--extra-sigs`, all defaulting to empty — **so a brand-new game needs only a catalogue to start deriving.**
|
- `--seed <bundle>` — one file bundling every derive input. The loose equivalent is `--catalogue <file>` plus the optional `--promotable` / `--candidates` / `--full-names` / `--extra-offsets` / `--extra-sigs`, all defaulting to empty — **so a brand-new game needs only a catalogue to start deriving.** The two forms are mutually exclusive and the CLI says so: `--seed` supplies all six, so passing one alongside it is a usage error rather than an input silently dropped.
|
||||||
- Corpus signal — exactly one of `--corpus-model <model.json>` (the normal path: forward-derive from the model + target binary, rolling the model N→N+1 as a sidecar) or `--corpus <dir>` (fingerprint raw build binaries on the fly).
|
- 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.
|
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.
|
||||||
|
|
@ -553,42 +628,107 @@ A full `produce` run writes a self-contained release set per game into `--out-di
|
||||||
|
|
||||||
| 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 |
|
| `rosetta-<game>.json` | **The release** — one record per function, plus the typed schema and the surfaces that are not function-keyed | always |
|
||||||
| `abi-<game>.json` | The **prototype manifest** — declared parameter/return types, each judged against the footprint measured in this build | always |
|
|
||||||
| `bindings-<game>.json` | The **declared callable surface** — what the binary says about itself: Pulse bindings (with a callable shim), entity IO, entity classnames, console commands, ConVars | 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 |
|
| `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 other artifacts carry no timestamp, so they are **byte-reproducible** — the same build in yields the same JSON out.
|
Wall-clock and other volatile metadata live only in `manifest.json`; the release artifact carries no
|
||||||
|
timestamp, so it is **byte-reproducible** — the same build in yields the same JSON out. Verified rather
|
||||||
|
than asserted: two consecutive derives over identical inputs produce byte-identical `rosetta-<game>.json`,
|
||||||
|
`model-<game>.json` and `manifest.json`.
|
||||||
|
|
||||||
### `gamedata-<game>.json` — the monolith
|
Reproducibility is a property that has to be defended at every tie-break, not only at the obvious ones. A
|
||||||
|
`HashMap` iterated to build an output is the usual culprit, but so is a *stable* sort on a key that repeats:
|
||||||
|
two libraries registering the same schema class, or one class registering a script name twice, leave the
|
||||||
|
survivor of the dedup decided by hash order. Those are sorted on a total key now.
|
||||||
|
|
||||||
|
### `rosetta-<game>.json`
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"meta": { "game_key", "game", "source_build", "version",
|
"meta": { "game_key", "game", "source_build", "version", "counts", "alias_groups",
|
||||||
"counts": { "core", "high_confidence", "experimental", "unresolved" } },
|
"aliased_names", "merged", "joined" },
|
||||||
"core": { "<fn name>": <MonoEntry>, ... },
|
"functions": { "<fn name>": <FunctionRecord>, ... },
|
||||||
"high_confidence": { "<fn name>": <MonoEntry>, ... },
|
"unresolved": { "<fn name>": { "reason", "detail" }, ... },
|
||||||
"experimental": { "<fn name>": <MonoEntry>, ... },
|
"schema": { "classes", "bases", "enums", "types", "meta" }, // null on an offline build
|
||||||
"unresolved": { "<fn name>": { "reason", "detail" }, ... }
|
"surfaces": { "pulse", "entity_outputs", "entity_classes", "convars", "unjoined" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
**One record per function is the whole point.** Where a function is, what its machine code was measured to
|
||||||
|
take, what a declaration says it takes, what the binary declares may be done with it, and what it means are
|
||||||
|
five different KINDS of fact about one thing — separately derived, separately trustworthy, and useless apart.
|
||||||
|
A consumer answering "may I call this, and how" needed four files open at once to find out.
|
||||||
|
|
||||||
Two further keys appear where they were established, and both are part of locating rather than decoration:
|
What is NOT function-keyed stays whole under `surfaces`: a Pulse binding names no C++ function, an entity
|
||||||
|
output is a member rather than a method, a classname binds one name to another, and a ConVar is
|
||||||
|
configuration rather than code.
|
||||||
|
|
||||||
- **`class`** — for an `offset` entry, the class whose vtable the slot was measured on. A slot index alone locates nothing, since it only means anything relative to a particular vtable. Taken from the class the derivation actually chained the offset through, never parsed out of the entry name: a base-declared method routinely sits in a derived class's vtable, so those are different facts and only the measured one locates.
|
### `functions` — one record each
|
||||||
- **`anchors`** — distinctive string literals the function references, each unique to it within its library. Not a third locator competing with the sig-XOR-offset pair, but a supplement with a *different failure mode*: a byte signature is a snapshot of one build's codegen, while a string survives a recompile that moves instructions. Emitted alongside the signature, never instead of it.
|
|
||||||
|
|
||||||
`reason` on an unresolved entry comes from a closed vocabulary: `sig-drifted`, `offset-low-conf`, `unresolved`, `abi-drift`.
|
A **`FunctionRecord`** opens with its `tier` (`core` / `high_confidence` / `experimental`) and the locator
|
||||||
|
flattened to the top level. A virtual method ships as a bare integer `offset` (its RTTI vtable slot index); a
|
||||||
|
non-virtual function as a `signature` object with the `library` it scans and a space-hex `linux` pattern with
|
||||||
|
`?` wildcards. Usually one or the other — but **a record may carry both**, and a handful of `core` CS2 records
|
||||||
|
do, so a consumer (and the live oracle) has to judge every locator a record holds rather than the first it
|
||||||
|
finds.
|
||||||
|
|
||||||
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.
|
Two further locator keys appear where they were established:
|
||||||
|
|
||||||
### `abi-<game>.json` — declared prototypes, judged against this build
|
- **`class`** — for an `offset` entry, the class whose vtable the slot was measured on. A slot index alone
|
||||||
|
locates nothing, since it only means anything relative to a particular vtable. It is the record's own name
|
||||||
|
class, stated explicitly: the deriver keys its slot timelines and alignment hops by that class and chains
|
||||||
|
through it, so "the class chained through" and "the class in the name" are one fact. Directly folded
|
||||||
|
offsets (the multilib ground-truth path) omit it and leave the consumer to split the name.
|
||||||
|
- **`anchors`** — distinctive string literals the function references, each unique to it within its library.
|
||||||
|
Not a locator competing with the signature and the offset, but a supplement with a *different failure
|
||||||
|
mode*: a byte signature is a snapshot of one build's codegen, while a string survives a recompile that
|
||||||
|
moves instructions. Emitted alongside the signature, never instead of it.
|
||||||
|
|
||||||
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.
|
Then, each labelled by where it came from:
|
||||||
|
|
||||||
|
| field | what it is |
|
||||||
|
|---|---|
|
||||||
|
| `measured` | the SysV argument footprint read out of THIS build's machine code — a lower bound, never an over-count |
|
||||||
|
| `validated` | the live-validation verdict, **three-valued** — see [below](#validated-is-three-valued) |
|
||||||
|
| `aliases` | the other shipped names for this same function |
|
||||||
|
| `provenance` | how the name and locator were arrived at, and at what confidence |
|
||||||
|
| `prototype` | the DECLARED parameter and return types, and this build's verdict on them — see [the verdicts](#prototype--declared-types-judged-against-this-build) |
|
||||||
|
| `bindings` | what the binary itself declares may be done with this function — see [below](#bindings--what-the-binary-declares) |
|
||||||
|
| `description` | what it does, in plain language, and whether that text is derived or generated |
|
||||||
|
|
||||||
|
**`aliases` — the other shipped names for the same function, and the one key that is about the RELEASE rather
|
||||||
|
than the locator.** Several names on one function is normal here and not a defect: the catalogue is assembled
|
||||||
|
from independent sources that spell the same function differently, so `CreateEntityByName`,
|
||||||
|
`UTIL::CreateEntityByName`, `CBaseEntity::CreateEntityByName` and `CGameEntitySystem::CreateEntityByName` are
|
||||||
|
four names on one address. Dropping three of them would break whichever spelling a given consumer's code
|
||||||
|
already uses, so all four ship and each one names the other three. On CS2 that is **80 functions covering 172
|
||||||
|
names, 4.3% of the resolved surface**; on Dota it is a single group, because that catalogue is far less of a
|
||||||
|
merge.
|
||||||
|
|
||||||
|
Read it before treating a tier count as a function count. `counts.core + counts.high_confidence` = 3,980 is
|
||||||
|
exactly right about NAMES and describes **3,888 distinct functions**. And read it before hooking: two names on
|
||||||
|
one address detoured independently is one trampoline chain claimed twice.
|
||||||
|
|
||||||
|
Grouping is by locator identity across `core` + `high_confidence` — a shipped pattern is generated at the
|
||||||
|
resolved address and confirmed unique in its library, so an identical `(library, pattern)` pair cannot be two
|
||||||
|
functions, and a vtable entry keys on `(class, slot)`. **Two limits, both deliberate.** `experimental` is
|
||||||
|
excluded, because a shared target between two unverified guesses is not evidence of a shared meaning (that
|
||||||
|
band states the converse relation — one name guessed at several addresses — through `provenance.collision`).
|
||||||
|
And a bare `offset` with no `class` is never grouped: it names no vtable, so two of them carrying slot 3 are
|
||||||
|
not evidence of anything. Grouping those anyway would assert 1,080 CS2 names to be aliases of each other in 70
|
||||||
|
fictitious groups. So an entry with no `aliases` means *none was derivable*, which for a bare slot is not the
|
||||||
|
same as *none exists*.
|
||||||
|
|
||||||
|
Console-command handlers are keyed **`ConCommand::<name>`**. The prefix says what the entry *is* — the handler
|
||||||
|
bound to that command — rather than claiming a C++ symbol; `ent_fire`'s real method name appears nowhere in
|
||||||
|
the binary.
|
||||||
|
|
||||||
|
### `prototype` — declared types, judged against this build
|
||||||
|
|
||||||
|
A locator says *where* a function is; it never says what it takes. Types cannot be recovered from a stripped
|
||||||
|
binary, so they come from declarations — and a declaration must be checked before anything calls through it,
|
||||||
|
because a stale one produces a call that resolves, validates, and loads the wrong registers.
|
||||||
|
|
||||||
**The verdict is the product**, and there are six:
|
**The verdict is the product**, and there are six:
|
||||||
|
|
||||||
|
|
@ -601,41 +741,87 @@ Gamedata says *where* a function is; it never says what it takes. Types cannot b
|
||||||
| `unverified` | nothing to check it against |
|
| `unverified` | nothing to check it against |
|
||||||
| `ambiguous` | several signatures on offer and no measurement to separate them |
|
| `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).
|
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.
|
**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.
|
Parameters are spelled as **pointers** (`CCommandContext*`, `CCommand*`), not as the C++ reference types.
|
||||||
|
`vtable` is present exactly when the locator is a vtable offset that live validation confirmed: a slot is only
|
||||||
|
reachable through an object, so it states a receiver no measurement can see.
|
||||||
|
|
||||||
`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` — what the binary declares
|
||||||
|
|
||||||
### `bindings-<game>.json` — the declared callable surface
|
A LIST, because one function can be several — and because a `kind` tells a consumer which it got:
|
||||||
|
|
||||||
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". **Six sections:**
|
- `entity-input` — the map-facing input name this handler answers, and the class the datadesc join qualified.
|
||||||
|
- `command` — the console name, Valve's help text, decoded flags with the raw word beside them, and which of
|
||||||
|
the three callback forms the registration used.
|
||||||
|
- `vscript` — the script-facing name a content author types, the owning class, Valve's own description, and
|
||||||
|
the declared return type.
|
||||||
|
|
||||||
- `pulse` — bindings keyed by qualified `Class::Method`, each with Valve's display name and description, a decoded call policy (receiver kind, mutating, blocking) with the raw words beside it, the recovered typed signature, and — where the record carries one — a `shim` address plus a `call` block saying how to invoke it. `call.needs` is the field to read:
|
**A row is attached only when the record cannot contradict it.** A name is unique only within a module:
|
||||||
- `args-only` — callable with an argument array and nothing else; the other argument slots may be null. **Validated by calling every such binding on a live server of both games.**
|
`AddOutput` is registered in three libraries as three different functions, and `cl_particles_dumplist` in two,
|
||||||
- `output-sink` — it returns a value, and writes through a register-file object a host does not have. Read the state through `netvars-<game>.json` instead; the Pulse getters are redundant with schema fields.
|
while the catalogue holds one entry under each name. Asserting every registration onto that one record would
|
||||||
- `pulse-context` — needs a live `CPulseExecCursor` or graph instance. Not host-callable.
|
be a claim about code it does not locate — so a row joins on matching library (or onto a vtable-located record,
|
||||||
- `other-slots` — reads an argument whose role is not established (the `CPulseCell_*` family, which are graph *node* implementations rather than API bindings). Not host-callable.
|
which names no library to contradict), and the rest are stated under `surfaces.unjoined`.
|
||||||
|
|
||||||
`call.reads` carries the measured slot list beside the decoding, so a build that changes the contract is re-readable rather than silently mis-labelled.
|
### `schema` — the typed schema, and it is LIVE-ONLY
|
||||||
- `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).
|
|
||||||
- `convars` — the configuration half of the console surface: name, help text, decoded flags, the raw flags word, and the ConVar object's address. Emitted for the **metadata**, not as a locator — a consumer finds a convar by name at runtime with no gamedata at all, so the name alone would add nothing. The flags are the payload: `cheat`, `replicated`, `archive`, `notify` are engine-*declared* authority, which is what lets a host decide what a plugin may change without a hand-maintained allowlist. Decoded by a **convar-specific** bit table, not the command one — see [Known limitations](#known-limitations).
|
|
||||||
|
|
||||||
**`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
|
|
||||||
|
|
||||||
Every SchemaSystem class → field → offset and type, plus two sections that are easy to miss and load-bearing:
|
Every SchemaSystem class → field → offset and type, plus two sections that are easy to miss and load-bearing:
|
||||||
|
`bases`, the class base graph, without which an inherited field is unresolvable; and `types`, per-type size
|
||||||
|
and SysV register class, needed to compute a by-value argument's register cost. Of a field's attributes,
|
||||||
|
`type` and `kind` are read from the **live process**; `size` and the name hash are derived offline.
|
||||||
|
|
||||||
- `bases` — the class base graph. Without it an inherited field is unresolvable.
|
**An offline build states `"schema": null`** — an explicit null, not an absent key, because absence would be
|
||||||
- `types` — per-type size and SysV register class, needed to compute a by-value argument's register cost.
|
indistinguishable from a build that resolved zero classes.
|
||||||
|
|
||||||
Of a field's attributes, `type` and `kind` are read from the **live process**; `size` and the name hash are derived offline.
|
### `surfaces` — what is not function-keyed
|
||||||
|
|
||||||
|
- `pulse` — bindings keyed by qualified `Class::Method`, each with Valve's display name and description, a
|
||||||
|
decoded call policy (receiver kind, mutating, blocking) with the raw words beside it, the recovered typed
|
||||||
|
signature, and — where the record carries one — a `shim` address plus a `call` block saying how to invoke
|
||||||
|
it. `call.needs` is the field to read:
|
||||||
|
- `args-only` — callable with an argument array and nothing else; the other argument slots may be null.
|
||||||
|
**Validated by calling every such binding on a live server of both games.**
|
||||||
|
- `output-sink` — it returns a value, and writes through a register-file object a host does not have. Read
|
||||||
|
the state through the `schema` section instead; the Pulse getters are redundant with schema fields.
|
||||||
|
- `pulse-context` — needs a live `CPulseExecCursor` or graph instance. Not host-callable.
|
||||||
|
- `other-slots` — reads an argument whose role is not established (the `CPulseCell_*` family, which are
|
||||||
|
graph *node* implementations rather than API bindings). Not host-callable.
|
||||||
|
|
||||||
|
`call.reads` carries the measured slot list beside the decoding, so a build that changes the contract is
|
||||||
|
re-readable rather than silently mis-labelled.
|
||||||
|
- `entity_outputs` — the events an entity fires and where the subscriber list lives on the instance.
|
||||||
|
- `entity_classes` — map classname → the C++ class it constructs (`func_door` → `CBaseDoor`). Names to names,
|
||||||
|
no addresses.
|
||||||
|
- `convars` — the configuration half of the console surface: name, help text, decoded flags, the raw flags
|
||||||
|
word, and the ConVar object's address. Emitted for the **metadata**, not as a locator — a consumer finds a
|
||||||
|
convar by name at runtime with no gamedata at all, so the name alone would add nothing. The flags are the
|
||||||
|
payload: `cheat`, `replicated`, `archive`, `notify` are engine-*declared* authority, which is what lets a
|
||||||
|
host decide what a plugin may change without a hand-maintained allowlist. Decoded by a **convar-specific**
|
||||||
|
bit table, not the command one — see [Known limitations](#known-limitations).
|
||||||
|
- `unjoined` — declared rows that belong to no function record here, split by kind, each keeping its own
|
||||||
|
`library` and `addr`. Two things put a row here and they differ in kind: **nothing located it** (the
|
||||||
|
implementation did not resolve, or an ambiguous handler name was dropped rather than guessed), or **the name
|
||||||
|
belongs to another module's function**, as above. Keeping them is what makes the fold lossless — every row
|
||||||
|
the deriver read is either on the function it describes or stated here.
|
||||||
|
|
||||||
|
`unresolved` sits beside `functions` rather than inside it: those names have no locator, so they are not
|
||||||
|
function records. `reason` comes from a closed vocabulary: `sig-drifted`, `offset-low-conf`, `unresolved`,
|
||||||
|
`abi-drift`, `name-contradicted`.
|
||||||
|
|
||||||
|
**`name-contradicted` is the newest and means something different from the rest.** The others say a locator was
|
||||||
|
not *found*; this one says a locator **was** found and the binary proves it is a different function, so it was
|
||||||
|
refused. The entry needs a new locator rather than another build — see [the identity check](#the-standing-oracles).
|
||||||
|
|
||||||
### `model-<game>.json` — the per-game model
|
### `model-<game>.json` — the per-game model
|
||||||
|
|
||||||
|
|
@ -643,16 +829,43 @@ The distilled facts derivation reads instead of the corpus. Not a consumer artif
|
||||||
|
|
||||||
### Rendering — the `gen` binary
|
### Rendering — the `gen` binary
|
||||||
|
|
||||||
`source2rosetta-gen` takes the monolith (`--from`), the schema (`--netvars`) or the manifest (`--abi`) and renders the matching format. The **input** chooses what is rendered; the `--format` id chooses for whom.
|
`source2rosetta-gen --from rosetta-<game>.json --format <who-it-is-for> --out <dir>` writes the files that
|
||||||
|
consumer reads. A framework gets **two**: the gamedata its loader resolves through, and the typed call sites
|
||||||
|
that go through it — `cssharp`, `metamod` (also SourceMod's VDF), `modsharp`, `swiftly`, `plugify`. Beside
|
||||||
|
them, `cs-sdk` / `netvars` render the schema, and `moddota` renders the VScript surface for the Dota
|
||||||
|
custom-game ecosystem — both shapes it publishes, `api.json` and `api.d.ts` — grouping members by owning class
|
||||||
|
with Valve's own description as the doc comment.
|
||||||
|
|
||||||
|
Every published artifact renders all nine, because a release is always derived against a running server. It
|
||||||
|
is only a **locally derived OFFLINE artifact** that renders fewer: the schema is live-only and so is the
|
||||||
|
owning class `moddota` groups by, so those three have nothing to work from and `gen` says so rather than
|
||||||
|
writing an empty file.
|
||||||
|
|
||||||
|
**The artifact states its own game and the output follows it**, so there is no `--game` flag. Two formats are
|
||||||
|
game-keyed and both key by the game DIRECTORY, which is what `meta.game_key` holds: Metamod's
|
||||||
|
`Games { csgo | dota }` section, which a plugin looks up by the engine's own `GetGameDir()`, and Plugify's
|
||||||
|
top-level key, matched against the `S2SDK_GAME_NAME` its plugin was built with. Two consumers cannot run on
|
||||||
|
Dota 2 at all — CounterStrikeSharp and Swiftly both resolve their binaries out of `<dir>/csgo/bin/` — and
|
||||||
|
`gen` declines those rather than write a file that can never load, with `--force` to override.
|
||||||
|
|
||||||
|
**Every call site carries its description into the generated source**, as a C# XML doc comment, a C++ header
|
||||||
|
comment, or a field in the data formats — so hovering `CBaseEntity_SetParent` in an editor says what it does
|
||||||
|
instead of only what it takes. Each one states **whose sentence it is**: Valve's own, read from a registry in
|
||||||
|
the binary, or this project's reading of the build. Valve's always wins where both exist, and a generated one
|
||||||
|
only ever fills a gap. On the rendered surface that is 2,073 CS2 and 2,326 Dota call sites, 717 / 774 of them
|
||||||
|
in Valve's own words.
|
||||||
|
|
||||||
Two things to know before you diff outputs:
|
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 default `cssharp` gamedata output is **JSONC** — it carries comment banners, so a strict JSON parser
|
||||||
- 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.
|
will reject it.
|
||||||
- The `model` format emits a tier-selected `Gamedata` with no `meta`, so its output cannot be fed back in via `--from`.
|
- The `swiftly` gamedata format emits **signature entries only**; vtable-offset entries are omitted (that is
|
||||||
- The `modsharp` format now emits a **`refs`** block where one was derived — `refs.strings` for string anchors and `refs.vtable` for an offset entry's class, both ModSharp's own keys. An entry with anchors and no byte pattern is emitted as `refs`-only, which is how several of ModSharp's own hand-written entries are written; if you diff against an older render, those rows are additions rather than changes.
|
over a thousand entries on CS2), because that framework takes offsets through a separate file.
|
||||||
|
- The `modsharp` format emits a **`refs`** block where one was derived — `refs.strings` for string anchors and
|
||||||
|
`refs.vtable` for an offset entry's class, both ModSharp's own keys. An entry with anchors and no byte
|
||||||
|
pattern is emitted as `refs`-only, which is how several of ModSharp's own hand-written entries are written.
|
||||||
|
|
||||||
---
|
See [source2rosetta-gen](crates/source2rosetta-core/README.md) for the full format table.
|
||||||
|
|
||||||
## Provenance
|
## Provenance
|
||||||
|
|
||||||
|
|
@ -673,7 +886,7 @@ This is a publishable tool, so where declarations and names come from is a hard
|
||||||
- **Linux x86-64 only.** SysV register classification, `/proc`-based validation and ptrace are all platform-specific.
|
- **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.
|
- **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.
|
- **`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.
|
- **Offline runs state `"schema": null` and no `validated` verdicts**, because field types and validation both require a running process.
|
||||||
- **Two games cannot be produced concurrently** on one host (fixed server port).
|
- **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).
|
- **The Pulse duplicate-registration disagreement is open** — see [What is NOT gated](#what-is-not-gated).
|
||||||
- **ConVar flag bits are decoded by a convar-specific table, and three bits are unnamed.** FCVAR is not one flag space across object types — decoding convars with the *command* table mislabels bit 0 on 185 Dota and 56 CS2 convars with a name Valve's own dumps give to none of them. The convar table was derived against both games' published dumps (1,939 convars pooled) keeping only bits that hold at 100% precision; bits 0, 1 and 2 are set often and match nothing cleanly, so they stay unnamed and survive in `flags_raw`.
|
- **ConVar flag bits are decoded by a convar-specific table, and three bits are unnamed.** FCVAR is not one flag space across object types — decoding convars with the *command* table mislabels bit 0 on 185 Dota and 56 CS2 convars with a name Valve's own dumps give to none of them. The convar table was derived against both games' published dumps (1,939 convars pooled) keeping only bits that hold at 100% precision; bits 0, 1 and 2 are set often and match nothing cleanly, so they stay unnamed and survive in `flags_raw`.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "source2rosetta-core"
|
name = "source2rosetta-core"
|
||||||
version = "2.1.0"
|
version = "3.0.2"
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
# source2rosetta-gen
|
# source2rosetta-gen
|
||||||
|
|
||||||
Render a published [source2rosetta](../../README.md) gamedata release into whatever format your framework
|
Render a published [source2rosetta](../../README.md) release into whatever format your framework reads.
|
||||||
reads. `source2rosetta` does the hard part — deriving CS2 / Dota 2 gamedata from the stripped engine and
|
`source2rosetta` does the hard part — deriving CS2 / Dota 2 gamedata from the stripped engine and
|
||||||
validating it on a live server — and publishes a small set of JSON files per game. `source2rosetta-gen` turns those into
|
validating it on a live server — and publishes **one file per game**, `rosetta-<game>.json`.
|
||||||
CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, or a typed C# SDK, locally, in a second.
|
`source2rosetta-gen` turns that into CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, a
|
||||||
|
typed C# SDK, or the Dota script API, locally, in a second.
|
||||||
|
|
||||||
It's deliberately tiny: it links only `source2rosetta-core` (serde + the format emitters) — **no** ELF reader,
|
It's deliberately tiny: it links only `source2rosetta-core` (serde + the format emitters) — **no** ELF reader,
|
||||||
no disassembler, no ptrace. So a consumer who "just wants the files" downloads one release + this small binary
|
no disassembler, no ptrace. So a consumer who "just wants the files" downloads one release + this small
|
||||||
and generates exactly what they need, instead of every format being pre-baked into the release.
|
binary and generates exactly what they need, instead of every format being pre-baked into the release.
|
||||||
|
|
||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
|
|
@ -23,109 +24,140 @@ does **not** build it — use `-p source2rosetta-core` or `--workspace`.)
|
||||||
|
|
||||||
## Use it
|
## Use it
|
||||||
|
|
||||||
Three of the published artifacts are `gen` inputs, one per `--` flag:
|
One input, one flag. `--format` says **who the output is for**; `--out` is a **directory**, because most
|
||||||
|
formats write more than one file.
|
||||||
- `gamedata-<game>.json` (`--from`) — the derived gamedata (function signatures + vtable offsets), tiered by confidence.
|
|
||||||
- `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.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# CounterStrikeSharp combined gamedata (the default)
|
R=https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest
|
||||||
source2rosetta-gen --from gamedata-cs2.json --format cssharp --out gamedata.json
|
curl -fsSLO $R/rosetta-cs2.json
|
||||||
|
|
||||||
# Metamod / SourceMod gamedata VDF (one .games.txt)
|
# CounterStrikeSharp: the combined gamedata + typed call sites for the same functions
|
||||||
source2rosetta-gen --from gamedata-cs2.json --format metamod --out csgo.games.txt
|
source2rosetta-gen --from rosetta-cs2.json --format cssharp --out ./csharp
|
||||||
|
|
||||||
# Swiftly / ModSharp / Plugify gamedata
|
# Metamod:Source / SourceMod: the gamedata VDF + a C++ prototype header
|
||||||
source2rosetta-gen --from gamedata-cs2.json --format swiftly --out gamedata.json
|
source2rosetta-gen --from rosetta-cs2.json --format metamod --out ./mm
|
||||||
|
|
||||||
# Typed C# SDK from the schema — one `static class` per engine class, `const` field offsets + types
|
# A typed C# SDK from the schema — one `static class` per engine class, `const` offsets + types
|
||||||
source2rosetta-gen --netvars netvars-cs2.json --format cs-sdk --out Schema.cs
|
source2rosetta-gen --from rosetta-cs2.json --format cs-sdk --out ./sdk
|
||||||
|
|
||||||
# Flat netvar offset map (class -> field -> offset)
|
# The Dota script API: ModDota's dota-data shape AND the TypeScript declarations
|
||||||
source2rosetta-gen --netvars netvars-cs2.json --format netvars --out netvars.json
|
source2rosetta-gen --from rosetta-dota2.json --format moddota --out ./dota
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each run prints what it wrote.
|
||||||
|
|
||||||
## Formats
|
## Formats
|
||||||
|
|
||||||
| `--format` | needs | output |
|
**A framework gets two files, and it needs both.** The gamedata says *where* a function is; the call sites say
|
||||||
|
*how to call it*. They were separate inputs when the release was four files; one artifact makes them one
|
||||||
|
command.
|
||||||
|
|
||||||
|
| `--format` | writes | notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `cssharp` *(default)* | `--from` | CounterStrikeSharp combined gamedata — **JSONC**: banner comments mean a strict JSON parser will reject it |
|
| `cssharp` *(default)* | `gamedata.json` + `RosettaFunctions.cs` | the gamedata is **JSONC** — banner comments, so a strict JSON parser will reject it. The `.cs` is `MemoryFunction*` fields / `VirtualFunction*` factories |
|
||||||
| `metamod` | `--from` | Metamod:Source / SourceMod gamedata VDF (`.games.txt`) |
|
| `metamod` | `<game_key>.games.txt` + `rosetta_prototypes.h` | the VDF also covers SourceMod. Metamod plugins are C++, so the prototypes are a header of `using X_t = RET (*)(…)` plus an `X_vtidx` constant per slot |
|
||||||
| `modsharp` | `--from` | ModSharp gamedata JSON |
|
| `modsharp` | `gamedata.json` + `RosettaCalls.cs` | `[AddressKey]` interface for its Roslyn generator, plus a vtable-dispatch class |
|
||||||
| `swiftly` | `--from` | Swiftly gamedata JSON — **signature entries only**; vtable-offset entries are omitted, because that framework takes offsets through a separate file |
|
| `swiftly` | `gamedata.json` + `prototypes.json` | **signature entries only** in the gamedata; that framework takes offsets through a separate file |
|
||||||
| `plugify` | `--from` | Plugify gamedata JSON |
|
| `plugify` | `gamedata.json` + `prototypes.json` | runtime type arrays (`{"paramTypes":["pointer","string"],"retType":"void"}`) |
|
||||||
| `model` | `--from` | the selected tiers flattened to one name → locator map (format-neutral; not a re-readable monolith) |
|
| `cs-sdk` | `Schema.cs` | typed C# SDK: `static class` per schema class, `const int` field offsets tagged with their type, plus the engine's own enums at their real width |
|
||||||
| `cs-sdk` | `--netvars` | typed C# SDK — `static class` per schema class, `const int` field offsets tagged with their type |
|
| `netvars` | `netvars.json` | flat schema map, `{ class: { field: offset } }` |
|
||||||
| `netvars` | `--netvars` | flat schema map, `{ class: { field: offset } }` |
|
| `moddota` | `api.json` + `api.d.ts` | the VScript API in ModDota `dota-data`'s shape (their toolchain renders from it), plus TypeScript declarations for authors using the published packages as-is — one `interface` per class, Valve's own description as the doc comment |
|
||||||
|
| `flat` | `gamedata-flat.json` | the selected tiers as one name → locator map, format-neutral |
|
||||||
|
|
||||||
### Call shapes — `--abi abi-<game>.json`
|
**Every published artifact renders every format on this list.** The releases are always derived against a
|
||||||
|
running server, so nothing here is conditional on how the artifact was made. (If you derive your own, that
|
||||||
|
changes — see [below](#if-you-derived-the-artifact-yourself).)
|
||||||
|
|
||||||
The same framework ids, a different input: `--abi` renders **how to call** a function rather than where it
|
### Which games a format covers
|
||||||
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`:
|
**The artifact states its own game** (`meta.game_key`, `csgo` or `dota`) and the output follows it — there is
|
||||||
|
no `--game` flag, because a second place to state one fact is a second place for it to be wrong.
|
||||||
|
|
||||||
```sh
|
Two formats are **game-keyed**, and for both the key is the game DIRECTORY the server runs out of, which is
|
||||||
source2rosetta-gen --abi abi-cs2.json --format cssharp --out RosettaFunctions.cs
|
what `game_key` already holds:
|
||||||
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 |
|
- `metamod` writes `Games { <game_key> { … } }`. The consuming plugin looks that section up by the engine's
|
||||||
|---|---|
|
own `GetGameDir()` — see [cs2kz-metamod's reader][kz] — so `dota` is what a Dota 2 plugin will look for.
|
||||||
| `cssharp` | C# `MemoryFunction*` fields (signature) / `VirtualFunction*` factories (vtable slot) |
|
Metamod takes Dota 2 as a first-class SDK target ([`dota.json`][mm], `define: DOTA`, `source2: true`).
|
||||||
| `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 |
|
- `plugify` writes `{ "<game_key>": { … } }`, matched against the `S2SDK_GAME_NAME` its s2sdk plugin was
|
||||||
| `modsharp` | C# `[AddressKey]` interface for its Roslyn generator (signature) + a vtable-dispatch class (slot) |
|
BUILT with (default `csgo`).
|
||||||
| `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
|
The rest are game-neutral in shape: `modsharp` and `cssharp` carry no game key at all (flat, keyed only by
|
||||||
and Plugify because theirs are resolved at runtime.
|
platform), and `flat` / `cs-sdk` / `netvars` / `moddota` are plain data.
|
||||||
|
|
||||||
**The two locator forms are not interchangeable, and every output distinguishes them.** A signature
|
**Two consumers cannot run on Dota 2 at all**, and `gen` declines rather than write a file that can never
|
||||||
resolves to one address; a vtable slot is entered through the object, so the framework reaches it by a
|
load: CounterStrikeSharp resolves its binaries out of `<dir>/csgo/bin/`, and Swiftly initialises against the
|
||||||
different call entirely — `VirtualFunctionVoid(instance, slot)` rather than `GameData.GetSignature(key)`,
|
`csgo` game directory. `--force` renders anyway. It is a warning rather than a rule on purpose — that is a
|
||||||
`GetVFuncIndex` rather than `GetAddress`, `(*(void***)self)[idx]` rather than a scanned pointer. Roughly
|
claim about somebody else's project, read out of their source at one point in time, and projects add games.
|
||||||
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
|
**ModSharp is CS2-first but not excluded.** Its own paths are hardcoded (`../../csgo/steam.inf`), yet its
|
||||||
live validation has confirmed it is really a vtable slot and not a carried member offset, so an offline
|
gamedata carries no game key whatsoever — flat `Addresses` / `VFuncs`, platform-keyed — so the file rendered
|
||||||
run states no slot rather than guess one. Same artifact shape, fewer vtable call sites — worth knowing
|
here is the same one whatever game the build targets.
|
||||||
before diffing two manifests produced different ways.
|
|
||||||
|
|
||||||
**The receiver is always in the type list.** Where the declaration came from an Itanium-mangled symbol
|
[kz]: https://github.com/KZGlobalTeam/cs2kz-metamod/blob/dev/src/utils/gameconfig.cpp
|
||||||
`this` is invisible, so it is prepended, spelled from the function's own class (`CBaseEntity*`, not
|
[mm]: https://github.com/alliedmodders/hl2sdk-manifests/blob/master/manifests/dota.json
|
||||||
`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
|
### Descriptions
|
||||||
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
|
**Every call site is emitted with a sentence saying what the function is FOR**, wherever that target's
|
||||||
live-validated vtable slot proves it, or the measurement independently agrees), and every parameter
|
readers hover: a C# XML `<summary>`, so IntelliSense shows it; a comment above the C++ typedef; a
|
||||||
mappable onto an ABI class. A function whose return **nobody
|
`description` field in the data formats. The prototype keeps a home of its own — a `<remarks>` in C#, the
|
||||||
declared** is still emitted — otherwise Dota would lose 2,304 of its 3,732 call sites — but it is marked as such in every
|
identity line in the header — so nothing is lost to make room. That is every emittable call site: 2,073 on
|
||||||
output (prose in the generated source, `ret_declared` / `retDeclared` in the data), and the value is
|
CS2, 2,326 on Dota.
|
||||||
documented as the raw return register rather than a typed result.
|
|
||||||
|
**Each one says whose sentence it is, and that is not decoration.** Some are Valve's own, read out of a
|
||||||
|
registry in the binary (717 CS2 / 774 Dota); the rest are this project's reading of the build, and the two
|
||||||
|
carry very different weight. So every generated source prints the origin beside the text, the data formats
|
||||||
|
carry it as an id (`valve` / `derived` / `generated`), and `moddota` — which deliberately mirrors a shape the
|
||||||
|
Dota ecosystem already publishes — keeps ours under keys of our own name rather than in the `description`
|
||||||
|
field their toolchain renders as Valve's word.
|
||||||
|
|
||||||
|
**Valve's text always wins.** Where the binary documents a function, that is what ships; a generated
|
||||||
|
description only ever fills a gap, so the two can never disagree in an output. Between Valve's own two
|
||||||
|
registries the script one wins: a console registration's help text documents the COMMAND an operator types,
|
||||||
|
while a script binding documents the function. In the `.d.ts`, a member Valve documents reads exactly as it
|
||||||
|
did before — bare, the way the published types do — and only a gap Valve left is filled and marked.
|
||||||
|
|
||||||
|
`cs-sdk` and `netvars` render the schema, which is classes and field offsets. There are no functions in them
|
||||||
|
to describe, so they carry none.
|
||||||
|
|
||||||
|
### What the call sites will and won't emit
|
||||||
|
|
||||||
|
Only functions the deriver could stand behind: `status: verified` **or `lower-bound`** (the declaration passes
|
||||||
|
registers the callee never reads and contradicts it in none — safe to call, and marked as such in every
|
||||||
|
output), a receiver settled by evidence (the declaration names it, a live-validated vtable slot proves it, or
|
||||||
|
the measurement independently agrees), and every parameter mappable onto an ABI class. A function whose return
|
||||||
|
**nobody declared** is still emitted — otherwise Dota would lose 2,304 of its 3,732 call sites — but it is
|
||||||
|
marked as such in every output (`ret_declared` / `retDeclared` in the data formats, prose in the generated
|
||||||
|
source), and the value is documented as the raw return register rather than a typed result.
|
||||||
|
|
||||||
|
**The two locator forms are not interchangeable, and every output distinguishes them.** A signature resolves to
|
||||||
|
one address; a vtable slot is entered through the object, so the framework reaches it by a different call
|
||||||
|
entirely — `VirtualFunctionVoid(instance, slot)` rather than `GameData.GetSignature(key)`, `GetVFuncIndex`
|
||||||
|
rather than `GetAddress`, `(*(void***)self)[idx]` rather than a scanned pointer. Roughly a quarter of the
|
||||||
|
call sites are vtable-located.
|
||||||
|
|
||||||
|
**A few entries carry BOTH**, which is worth knowing if you consume the gamedata rather than these call
|
||||||
|
sites: `model::Entry` allows it and five CS2 `core` entries use it. Both locators are live-validated
|
||||||
|
independently, and `validated: true` means both passed — an entry whose signature checked out but whose slot
|
||||||
|
could not be reached ships `null`, never `true`. The call-site emitters here pick one form per function, so
|
||||||
|
this only affects what you read out of the artifact directly.
|
||||||
|
|
||||||
|
**The receiver is always in the type list.** Where the declaration came from an Itanium-mangled symbol `this`
|
||||||
|
is invisible, so it is prepended, spelled from the function's own class (`CBaseEntity*`, not `void*`) and
|
||||||
|
marked `[this]` in the C++ header. It is a real register in the call frame — leaving it out shifts every
|
||||||
|
argument by one.
|
||||||
|
|
||||||
|
**Two things `moddota` states honestly rather than guesses.** Parameters are declared `...args: any[]`,
|
||||||
|
because the registry does not carry them — types appear only inside Valve's prose descriptions, inconsistently,
|
||||||
|
in about a fifth of entries. It is visibly ugly on purpose: nobody should mistake these for complete
|
||||||
|
declarations, and inventing plausible arity would emit declarations that lie rather than abstain. And
|
||||||
|
`available` is always `server`, because a dedicated server never maps `libclient`, so this derivation cannot
|
||||||
|
see the client side at all.
|
||||||
|
|
||||||
## Confidence tier
|
## Confidence tier
|
||||||
|
|
||||||
The gamedata formats (the `--from` ones) take a `--tier`, cumulative and defaulting to `high_confidence`:
|
The locator half takes a `--tier`, cumulative and defaulting to `high_confidence`:
|
||||||
|
|
||||||
| `--tier` | includes |
|
| `--tier` | includes |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -135,10 +167,40 @@ The gamedata formats (the `--from` ones) take a `--tier`, cumulative and default
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# only the rock-solid set:
|
# only the rock-solid set:
|
||||||
source2rosetta-gen --from gamedata-cs2.json --format cssharp --tier core --out gamedata.json
|
source2rosetta-gen --from rosetta-cs2.json --format cssharp --tier core --out ./csharp
|
||||||
```
|
```
|
||||||
|
|
||||||
The schema formats (`cs-sdk`, `netvars`) ignore `--tier`.
|
The schema and script-API formats ignore `--tier`.
|
||||||
|
|
||||||
|
## What is in the artifact that `gen` does not render
|
||||||
|
|
||||||
|
`rosetta-<game>.json` is plain JSON and readable as-is, and it carries more than these formats consume: the
|
||||||
|
Pulse binding registry (typed signatures, call policy, and a callable shim per binding), entity outputs,
|
||||||
|
map classname → C++ class, ConVars with decoded flags, and the declared rows that belong to no function here
|
||||||
|
(`surfaces.unjoined`). Those have no loader to render them into — read them directly. The per-function
|
||||||
|
descriptions are in there too, and they DO reach the outputs, but only for the ~2,000 functions that become
|
||||||
|
call sites; the artifact describes about twice as many. See the [artifacts section](../../README.md#artifacts-schemas--output-formats).
|
||||||
|
|
||||||
|
## If you derived the artifact yourself
|
||||||
|
|
||||||
|
Skip this if you downloaded the artifact — it is about `source2rosetta produce`, not about `gen`.
|
||||||
|
|
||||||
|
The releases are derived against a **running server**, which is where two whole surfaces come from. Run
|
||||||
|
`produce` without `--game-dir` and it derives what it can offline, honestly, and states the rest as absent —
|
||||||
|
so `gen` gets an artifact that is real but smaller, and three things follow:
|
||||||
|
|
||||||
|
- **`cs-sdk`, `netvars` and `moddota` have nothing to render**, and say so rather than writing an empty file.
|
||||||
|
The schema's field TYPES are runtime-resolved, and a VScript binding's owning class is reached through a
|
||||||
|
register loaded from memory, so neither is readable from the file alone. All three group by one or the
|
||||||
|
other.
|
||||||
|
- **No call site is reached through a vtable.** A slot is only recorded once live validation has confirmed it
|
||||||
|
is really a vtable slot and not a carried member offset, so an offline run states no slot rather than guess
|
||||||
|
one. Roughly a quarter of a live manifest's call sites are vtable-located; an offline one has none. Same
|
||||||
|
shape, fewer entries — worth knowing before diffing two outputs made different ways.
|
||||||
|
- **Nothing has been checked against a running server.** `validated` is `null` on every record rather than a
|
||||||
|
verdict, and the locator half drops only what live validation confidently REJECTED — so an offline artifact
|
||||||
|
keeps entries a live one would have thrown out. The tiers still mean what they mean; they just have not
|
||||||
|
been tested.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,65 @@
|
||||||
//! `source2rosetta-gen` — the standalone generator. Reads the published monolith (`gamedata-<game>.json`
|
//! `source2rosetta-gen` — the standalone generator. Reads the published `rosetta-<game>.json` and writes
|
||||||
//! from `source2rosetta produce`) and renders it into any framework's gamedata format at a chosen confidence
|
//! the files one consumer needs: a framework's gamedata plus the typed call sites that resolve through it,
|
||||||
//! tier.
|
//! a typed schema SDK, or the script API the Dota ecosystem publishes.
|
||||||
//!
|
//!
|
||||||
//! It touches only the `model` + `render` layers — no ELF reader, no ptrace, no disassembler — so a consumer
|
//! It touches only the `model` + `render` layers — no ELF reader, no ptrace, no disassembler — so a consumer
|
||||||
//! who "just wants the files" downloads one monolith + this small, rarely-changing binary and generates
|
//! who "just wants the files" downloads one artifact and this small, rarely-changing binary and generates
|
||||||
//! whatever their framework needs locally, instead of every format being pre-baked into releases. It lives in
|
//! whatever their framework needs locally, instead of every format being pre-baked into releases. It lives in
|
||||||
//! the `source2rosetta-core` crate (serde-only), so it stays genuinely lean.
|
//! the `source2rosetta-core` crate (serde-only), so it stays genuinely lean.
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use source2rosetta_core::{model, render};
|
use source2rosetta_core::{model, render};
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "source2rosetta-gen",
|
name = "source2rosetta-gen",
|
||||||
version,
|
version,
|
||||||
about = "Render a source2rosetta monolith into a framework gamedata format"
|
about = "Render a source2rosetta release into the files your framework reads"
|
||||||
)]
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// The monolith `gamedata-<game>.json` (for a GAMEDATA --format: cssharp/metamod/modsharp/swiftly/plugify/model).
|
/// The published `rosetta-<game>.json` — one artifact holding every surface.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
from: Option<PathBuf>,
|
from: PathBuf,
|
||||||
/// The typed `netvars-<game>.json` (for a SCHEMA --format: cs-sdk/netvars).
|
/// Who the output is for, and it writes every file that consumer reads. FRAMEWORKS get a gamedata
|
||||||
#[arg(long)]
|
/// file and the typed call sites that resolve through it: cssharp | metamod (also SourceMod's VDF) |
|
||||||
netvars: Option<PathBuf>,
|
/// modsharp | swiftly | plugify. SCHEMA: cs-sdk (typed C# SDK) | netvars (flat offset map). SCRIPT
|
||||||
/// The prototype manifest `abi-<game>.json` — renders CALL SHAPES (declared parameter and return
|
/// API: moddota, which writes both shapes that ecosystem publishes (`api.json` + `api.d.ts`). Plus
|
||||||
/// types, verified against the build) instead of locators. Reuses the framework --format ids: the
|
/// `flat`, a format-neutral name -> locator map.
|
||||||
/// 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.
|
|
||||||
/// SCHEMA (needs --netvars): cs-sdk (typed C# SDK) | netvars (flat offset map). ABI (needs --abi):
|
|
||||||
/// 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 the locator half, cumulative: core | high_confidence | experimental. Defaults to
|
||||||
/// `high_confidence` (core + the promoted names). Ignored by schema formats.
|
/// `high_confidence` (core + the promoted names). Ignored by the schema and script-API formats.
|
||||||
#[arg(long, default_value = "high_confidence")]
|
#[arg(long, default_value = "high_confidence")]
|
||||||
tier: String,
|
tier: String,
|
||||||
/// Write here (default: stdout).
|
/// Directory to write into (default: the working directory). A format writes more than one file, so
|
||||||
|
/// this names a DIRECTORY rather than a file — the names are the ones each framework expects.
|
||||||
|
#[arg(long, default_value = ".")]
|
||||||
|
out: PathBuf,
|
||||||
|
/// Render even where the consumer is not known to run on this artifact's game. What that claim rests
|
||||||
|
/// on is in `render::GAME_SUPPORT`; it is read out of somebody else's source and they do add games,
|
||||||
|
/// so it declines by default rather than refusing outright.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
out: Option<PathBuf>,
|
force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One file to write: the name the consuming framework expects, and what goes in it.
|
||||||
|
struct Out {
|
||||||
|
name: String,
|
||||||
|
text: String,
|
||||||
|
/// What this file is, for the line printed after writing it.
|
||||||
|
what: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let fmt = cli.format.as_str();
|
let fmt = cli.format.as_str();
|
||||||
|
|
||||||
// The INPUT selects the emitter family, which is why `cssharp` can name three different outputs.
|
let text = std::fs::read_to_string(&cli.from)
|
||||||
let text = if let Some(path) = cli.abi.as_ref() {
|
.with_context(|| format!("read {}", cli.from.display()))?;
|
||||||
let man: model::AbiManifest = serde_json::from_str(&std::fs::read_to_string(path)?)
|
let r: model::Rosetta = serde_json::from_str(&text)
|
||||||
.with_context(|| format!("parse abi manifest json {}", path.display()))?;
|
.with_context(|| format!("parse {} as a rosetta artifact", cli.from.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.
|
|
||||||
let path = cli.netvars.as_ref().context(
|
|
||||||
"a schema --format (cs-sdk | netvars) requires --netvars <netvars-<game>.json>",
|
|
||||||
)?;
|
|
||||||
let schema: model::Schema = serde_json::from_str(&std::fs::read_to_string(path)?)
|
|
||||||
.with_context(|| format!("parse netvars json {}", path.display()))?;
|
|
||||||
render::schema_by_id(fmt)
|
|
||||||
.expect("known schema format")
|
|
||||||
.render(&schema)
|
|
||||||
} else {
|
|
||||||
let path = cli
|
|
||||||
.from
|
|
||||||
.as_ref()
|
|
||||||
.context("a gamedata --format requires --from <gamedata-<game>.json>")?;
|
|
||||||
let tier = model::TierSelect::from_id(&cli.tier).with_context(|| {
|
let tier = model::TierSelect::from_id(&cli.tier).with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"unknown --tier {:?} (want one of: {})",
|
"unknown --tier {:?} (want one of: {})",
|
||||||
|
|
@ -84,24 +67,145 @@ fn main() -> Result<()> {
|
||||||
model::TIER_IDS.join(" | ")
|
model::TIER_IDS.join(" | ")
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let mono: model::Monolith = serde_json::from_str(&std::fs::read_to_string(path)?)
|
|
||||||
.with_context(|| format!("parse monolith json {}", path.display()))?;
|
// Checked before anything is rendered: a file that cannot load on the game it was made for is worse
|
||||||
match fmt {
|
// than no file, and the one thing worse than that is one written silently.
|
||||||
// cssharp is the bannered combined file (guaranteed + extrapolated sections), not a flat map.
|
if let Some(mismatch) = render::game_mismatch(fmt, &r.meta.game_key) {
|
||||||
"cssharp" => render::render_monolith_cssharp(&mono, tier),
|
if !cli.force {
|
||||||
f @ ("metamod" | "modsharp" | "swiftly" | "plugify" | "model") => render::by_id(f)
|
bail!("{mismatch}\n Pass --force to render it anyway.");
|
||||||
.expect("known flat format")
|
|
||||||
.render(&mono.select(tier)),
|
|
||||||
other => bail!(
|
|
||||||
"unknown --format {other:?} (gamedata: cssharp|metamod|modsharp|swiftly|plugify|model; \
|
|
||||||
schema: cs-sdk|netvars)"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
eprintln!("warning: {mismatch}\n Rendering anyway (--force).");
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputs = match fmt {
|
||||||
|
// A framework gets the pair: WHERE the functions are, and HOW to call them. They were two
|
||||||
|
// invocations against two files; one artifact makes them one command, and a consumer who has the
|
||||||
|
// locators without the call sites has half of what it takes to make a call.
|
||||||
|
f @ ("cssharp" | "metamod" | "modsharp" | "swiftly" | "plugify") => {
|
||||||
|
let gd = match f {
|
||||||
|
// cssharp is the bannered combined file (guaranteed + extrapolated sections), not a flat map.
|
||||||
|
"cssharp" => render::render_monolith_cssharp(&r.to_monolith(), tier),
|
||||||
|
_ => render::by_id(f)
|
||||||
|
.expect("known flat format")
|
||||||
|
.render(&r.gamedata(tier)),
|
||||||
|
};
|
||||||
|
let calls = render::abi_by_id(f)
|
||||||
|
.expect("known abi format")
|
||||||
|
.render(&r.abi_manifest());
|
||||||
|
let (gd_name, calls_name) = match f {
|
||||||
|
"cssharp" => ("gamedata.json".into(), "RosettaFunctions.cs"),
|
||||||
|
"metamod" => (
|
||||||
|
format!("{}.games.txt", r.meta.game_key),
|
||||||
|
"rosetta_prototypes.h",
|
||||||
|
),
|
||||||
|
"modsharp" => ("gamedata.json".into(), "RosettaCalls.cs"),
|
||||||
|
_ => ("gamedata.json".into(), "prototypes.json"),
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
Out {
|
||||||
|
name: gd_name,
|
||||||
|
text: gd,
|
||||||
|
what: "locators",
|
||||||
|
},
|
||||||
|
Out {
|
||||||
|
name: calls_name.into(),
|
||||||
|
text: calls,
|
||||||
|
what: "typed call sites",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"flat" => vec![Out {
|
||||||
|
name: "gamedata-flat.json".into(),
|
||||||
|
text: render::by_id("model")
|
||||||
|
.expect("known flat format")
|
||||||
|
.render(&r.gamedata(tier)),
|
||||||
|
what: "locators, format-neutral",
|
||||||
|
}],
|
||||||
|
f @ ("cs-sdk" | "netvars") => {
|
||||||
|
// Field types are runtime-resolved, so an offline build states no schema at all. Saying so is
|
||||||
|
// better than writing an empty SDK that compiles and describes nothing.
|
||||||
|
let schema = r.typed_schema().context(
|
||||||
|
"this artifact's `schema` is null, so the schema formats have nothing to render — field \
|
||||||
|
TYPES are resolved at runtime and are not in the file. Every PUBLISHED artifact carries \
|
||||||
|
one, so this is a local OFFLINE derive: re-run `produce` with --game-dir, or take the \
|
||||||
|
artifact from a release.",
|
||||||
|
)?;
|
||||||
|
let text = render::schema_by_id(f)
|
||||||
|
.expect("known schema format")
|
||||||
|
.render(&schema);
|
||||||
|
vec![Out {
|
||||||
|
name: if f == "cs-sdk" {
|
||||||
|
"Schema.cs".into()
|
||||||
|
} else {
|
||||||
|
"netvars.json".into()
|
||||||
|
},
|
||||||
|
text,
|
||||||
|
what: "typed schema",
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
// One consumer, two files, for the same reason a framework gets two: the ModDota ecosystem's
|
||||||
|
// toolchain renders from the JSON, while an author working against the published packages reads
|
||||||
|
// the declarations. Emitting one of them is answering half the question.
|
||||||
|
"moddota" => {
|
||||||
|
let vscript = r.vscript();
|
||||||
|
// Both shapes group members by owning class, and the class is only readable from a running
|
||||||
|
// server — so an offline artifact renders nothing, and an empty file would look like an
|
||||||
|
// answer rather than a missing input. The two ways of having nothing to render are worth
|
||||||
|
// telling apart: a game with no script VM at all is not a build that was run offline.
|
||||||
|
if vscript.is_empty() {
|
||||||
|
bail!(
|
||||||
|
"this artifact carries no VScript bindings at all, so there is no script API to \
|
||||||
|
render. Only Dota 2 and CS2 expose one — check you passed the right artifact."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if vscript.iter().all(|v| v.class.is_none()) {
|
||||||
|
bail!(
|
||||||
|
"this artifact has {} VScript bindings and no owning class on any of them, and both \
|
||||||
|
shapes group members BY class. The owning class is only readable from a running \
|
||||||
|
server, and every PUBLISHED artifact carries it, so this is a local OFFLINE derive: \
|
||||||
|
re-run `produce` with --game-dir, or take the artifact from a release.",
|
||||||
|
vscript.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let schema = r.typed_schema();
|
||||||
|
let render_as = |id: &str| {
|
||||||
|
render::bindings_by_id(id)
|
||||||
|
.expect("known bindings format")
|
||||||
|
.render(&vscript, schema.as_ref())
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
Out {
|
||||||
|
name: "api.json".into(),
|
||||||
|
text: render_as("api-json"),
|
||||||
|
what: "script API, ModDota `dota-data` shape",
|
||||||
|
},
|
||||||
|
Out {
|
||||||
|
name: "api.d.ts".into(),
|
||||||
|
text: render_as("dts"),
|
||||||
|
what: "script API, TypeScript declarations",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
other => bail!(
|
||||||
|
"unknown --format {other:?} (want one of: {})",
|
||||||
|
render::FORMAT_IDS.join(" | "),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
match cli.out {
|
write_all(&cli.out, &outputs)
|
||||||
Some(p) => std::fs::write(&p, text).with_context(|| format!("write {}", p.display()))?,
|
}
|
||||||
None => println!("{text}"),
|
|
||||||
|
fn write_all(dir: &Path, outputs: &[Out]) -> Result<()> {
|
||||||
|
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||||
|
for o in outputs {
|
||||||
|
let p = dir.join(&o.name);
|
||||||
|
std::fs::write(&p, &o.text).with_context(|| format!("write {}", p.display()))?;
|
||||||
|
println!(
|
||||||
|
"{} ({}, {} KB)",
|
||||||
|
p.display(),
|
||||||
|
o.what,
|
||||||
|
o.text.len().div_ceil(1024)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,8 +3,11 @@
|
||||||
//! derivation never changes. This module depends only on `model` + serde, NOT on the deriver, so a
|
//! derivation never changes. This module depends only on `model` + serde, NOT on the deriver, so a
|
||||||
//! standalone `source2rosetta-gen` binary can link just this to turn a published model JSON into files.
|
//! standalone `source2rosetta-gen` binary can link just this to turn a published model JSON into files.
|
||||||
|
|
||||||
use crate::model::{Entry, Gamedata, MonoEntry, Monolith, Schema, Tier, TierSelect};
|
use crate::model::{
|
||||||
|
self, Entry, Gamedata, MonoEntry, Monolith, Schema, Tier, TierSelect, VScriptBinding, one_line,
|
||||||
|
};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
/// Render the monolith as the CounterStrikeSharp combined `gamedata.json`: the guaranteed `core` section, a
|
/// Render the monolith as the CounterStrikeSharp combined `gamedata.json`: the guaranteed `core` section, a
|
||||||
/// `//` banner, then the extrapolated section (the tiers `select` includes beyond core), both key-sorted with
|
/// `//` banner, then the extrapolated section (the tiers `select` includes beyond core), both key-sorted with
|
||||||
|
|
@ -177,8 +180,9 @@ pub fn by_id(id: &str) -> Option<Box<dyn GamedataEmitter>> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every `--format` id `by_id` accepts — for help text and error messages (keep in sync with `by_id`).
|
/// Every gamedata emitter id `by_id` accepts (keep in sync with it), named like its three sibling
|
||||||
pub const FORMAT_IDS: &[&str] = &[
|
/// families. NOT the `--format` list — see [`FORMAT_IDS`], which names CONSUMERS rather than emitters.
|
||||||
|
pub const GAMEDATA_FORMAT_IDS: &[&str] = &[
|
||||||
"cssharp", "metamod", "modsharp", "swiftly", "plugify", "model",
|
"cssharp", "metamod", "modsharp", "swiftly", "plugify", "model",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -432,10 +436,17 @@ impl SchemaEmitter for CsSdk {
|
||||||
let m = &s.meta;
|
let m = &s.meta;
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
|
// The artifact is named by the GAME TOKEN (`cs2`), which the schema does not carry — it knows
|
||||||
|
// its content key (`csgo`). So the command is spelled generically rather than interpolated
|
||||||
|
// into something that does not exist.
|
||||||
"// <auto-generated> source2rosetta — {} build {}. Source-2 SchemaSystem field offsets.\n\
|
"// <auto-generated> source2rosetta — {} build {}. Source-2 SchemaSystem field offsets.\n\
|
||||||
// {} classes, {} typed fields, {} enums. Regenerate: source2rosetta-gen --netvars netvars-{}.json --format cs-sdk\n\
|
// {} classes, {} typed fields, {} enums. Regenerate: source2rosetta-gen --from rosetta-<game>.json --format cs-sdk\n\
|
||||||
namespace Source2.Schema;\n",
|
namespace Source2.Schema;\n",
|
||||||
m.game_key, m.source_build, s.classes.len(), m.typed, s.enums.len(), m.game_key
|
m.game_key,
|
||||||
|
m.source_build,
|
||||||
|
s.classes.len(),
|
||||||
|
m.typed,
|
||||||
|
s.enums.len()
|
||||||
));
|
));
|
||||||
// Enums first: a field offset is only half the story, and the C# side wants the type in scope
|
// Enums first: a field offset is only half the story, and the C# side wants the type in scope
|
||||||
// before the classes that reference it. Emitted with the engine's own underlying width, so a
|
// before the classes that reference it. Emitted with the engine's own underlying width, so a
|
||||||
|
|
@ -754,6 +765,10 @@ pub struct CallShape<'a> {
|
||||||
/// the signatures section at all.
|
/// the signatures section at all.
|
||||||
pub vtable: Option<i64>,
|
pub vtable: Option<i64>,
|
||||||
pub provenance: &'a [String],
|
pub provenance: &'a [String],
|
||||||
|
/// What the function is FOR, where anything says — see [`model::FunctionRecord::doc`]. The one
|
||||||
|
/// field here that is not about the call frame, and the reason a generated call site is readable:
|
||||||
|
/// an author hovering `CBaseEntity_Kill` sees a sentence rather than a type list.
|
||||||
|
pub doc: Option<&'a model::Doc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CallShape<'_> {
|
impl CallShape<'_> {
|
||||||
|
|
@ -877,6 +892,7 @@ pub fn callable_shapes(m: &crate::model::AbiManifest) -> Vec<CallShape<'_>> {
|
||||||
lower_bound,
|
lower_bound,
|
||||||
vtable: e.vtable,
|
vtable: e.vtable,
|
||||||
provenance: &e.provenance,
|
provenance: &e.provenance,
|
||||||
|
doc: e.doc.as_ref(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|
@ -913,6 +929,24 @@ const LOWER_BOUND: &str = "ARGUMENTS ARE A LOWER BOUND — the callee reads fewe
|
||||||
passes, so a trailing argument may be ignored; calling through it is safe, the extra register is \
|
passes, so a trailing argument may be ignored; calling through it is safe, the extra register is \
|
||||||
simply unread";
|
simply unread";
|
||||||
|
|
||||||
|
/// What every emitter says about where a description came from. One wording per source, for the same
|
||||||
|
/// reason as the two constants above: five outputs must not describe one fact differently.
|
||||||
|
///
|
||||||
|
/// The marker is load-bearing, not decoration. Most of a shipped artifact's prose is this project's own
|
||||||
|
/// reading of the build rather than Valve's, and the two carry very different weight — an author acting
|
||||||
|
/// on a sentence has to know which they are reading. An id this does not recognise is printed VERBATIM
|
||||||
|
/// rather than quietly presented as Valve's.
|
||||||
|
fn doc_origin(source: &str) -> String {
|
||||||
|
match source {
|
||||||
|
model::Doc::VALVE => "Valve's own text, read from the registry in the binary".into(),
|
||||||
|
"derived" => {
|
||||||
|
"source2rosetta's, DERIVED — fixed by the surrounding facts, not Valve's".into()
|
||||||
|
}
|
||||||
|
"generated" => "source2rosetta's, GENERATED — a reading of this build, not Valve's".into(),
|
||||||
|
other => format!("source2rosetta's, source `{other}` — not Valve's"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Escape text destined for a C# XML doc comment.
|
/// Escape text destined for a C# XML doc comment.
|
||||||
///
|
///
|
||||||
/// Not cosmetic: the prototypes this carries are full of characters XML reserves. A `CAI_Concept&`
|
/// Not cosmetic: the prototypes this carries are full of characters XML reserves. A `CAI_Concept&`
|
||||||
|
|
@ -940,6 +974,46 @@ fn source_banner(m: &crate::model::AbiManifest, comment: &str) -> String {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The head of the C# XML doc block above one generated call site — everything above the caveats.
|
||||||
|
///
|
||||||
|
/// **The description takes the `<summary>` and the prototype moves down to a `<remarks>`**, because an
|
||||||
|
/// editor shows the summary first and "what does this thing do" is what an author hovering a
|
||||||
|
/// `MemoryFunctionVoid<nint, nint>` is asking. With no description the shape is exactly what it was
|
||||||
|
/// before descriptions existed: the prototype IS the summary, and the block is one line.
|
||||||
|
///
|
||||||
|
/// Shared with ModSharp rather than written twice — the two targets differ in what they generate and
|
||||||
|
/// not in how a function is documented, and two copies of an XML doc block is how they come to disagree.
|
||||||
|
fn cs_doc_head(c: &CallShape) -> String {
|
||||||
|
match c.doc {
|
||||||
|
Some(d) => format!(
|
||||||
|
" /// <summary>{}</summary>\n \
|
||||||
|
/// <remarks><c>{}</c></remarks>\n \
|
||||||
|
/// <remarks>Description: {}.</remarks>",
|
||||||
|
xml(&d.text),
|
||||||
|
xml(&c.prototype()),
|
||||||
|
// Escaped like everything else here: the fixed wordings need none, but an artifact's own
|
||||||
|
// `source` id ends up in this line verbatim, and one `<` in it is a malformed comment.
|
||||||
|
xml(&doc_origin(&d.source)),
|
||||||
|
),
|
||||||
|
None => format!(" /// <summary><c>{}</c></summary>", xml(&c.prototype())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The caveat remarks that follow it: what this artifact will not let a call site pretend about itself.
|
||||||
|
fn cs_doc_caveats(c: &CallShape) -> String {
|
||||||
|
let mut s = String::new();
|
||||||
|
if c.ret == Ret::Undeclared {
|
||||||
|
s.push_str(&format!(
|
||||||
|
"\n /// <remarks>{UNDECLARED_RETURN} (measured class: <c>{}</c>).</remarks>",
|
||||||
|
xml(c.ret_source)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if c.lower_bound {
|
||||||
|
s.push_str(&format!("\n /// <remarks>{LOWER_BOUND}.</remarks>"));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
/// CounterStrikeSharp: a C# source file of `MemoryFunction*` fields bound to the gamedata key.
|
/// CounterStrikeSharp: a C# source file of `MemoryFunction*` fields bound to the gamedata key.
|
||||||
///
|
///
|
||||||
/// It has to be SOURCE, not data: the argument list is the generic parameter list of
|
/// It has to be SOURCE, not data: the argument list is the generic parameter list of
|
||||||
|
|
@ -979,17 +1053,6 @@ impl AbiEmitter for CsSharpAbi {
|
||||||
"FunctionWithReturn"
|
"FunctionWithReturn"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut caveat = if c.ret == Ret::Undeclared {
|
|
||||||
format!(
|
|
||||||
"\n /// <remarks>{UNDECLARED_RETURN} (measured class: <c>{}</c>).</remarks>",
|
|
||||||
xml(c.ret_source)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
if c.lower_bound {
|
|
||||||
caveat.push_str(&format!("\n /// <remarks>{LOWER_BOUND}.</remarks>"));
|
|
||||||
}
|
|
||||||
// The two binding forms are not interchangeable. A signature resolves to ONE address and
|
// The two binding forms are not interchangeable. A signature resolves to ONE address and
|
||||||
// can be a static field; a vtable slot is per-object — `VirtualFunction*` takes the
|
// can be a static field; a vtable slot is per-object — `VirtualFunction*` takes the
|
||||||
// instance and reads the slot out of that object's own vtable — so it has to be a factory
|
// instance and reads the slot out of that object's own vtable — so it has to be a factory
|
||||||
|
|
@ -1026,11 +1089,10 @@ impl AbiEmitter for CsSharpAbi {
|
||||||
None => "signature".to_string(),
|
None => "signature".to_string(),
|
||||||
};
|
};
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
" /// <summary><c>{}</c></summary>\n \
|
"{}\n /// <remarks>verified · {how} · {}</remarks>{}\n{}",
|
||||||
/// <remarks>verified · {how} · {}</remarks>{}\n{}",
|
cs_doc_head(&c),
|
||||||
xml(&c.prototype()),
|
|
||||||
c.provenance.join(", "),
|
c.provenance.join(", "),
|
||||||
caveat,
|
cs_doc_caveats(&c),
|
||||||
binding,
|
binding,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -1097,6 +1159,16 @@ impl AbiEmitter for MetamodAbi {
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
// The prose goes ABOVE the identity line, which is where a C++ reader expects a member's
|
||||||
|
// documentation and where a `//` comment is safe: the text is one line by construction
|
||||||
|
// (`model::one_line`), so nothing after it can fall out of the comment and into the header.
|
||||||
|
if let Some(d) = c.doc {
|
||||||
|
s.push_str(&format!(
|
||||||
|
"// {}\n// Description: {}.\n",
|
||||||
|
d.text,
|
||||||
|
doc_origin(&d.source)
|
||||||
|
));
|
||||||
|
}
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"// {} · verified · {}{}\nusing {}_t = {} (*)({});\n",
|
"// {} · verified · {}{}\nusing {}_t = {} (*)({});\n",
|
||||||
c.key,
|
c.key,
|
||||||
|
|
@ -1141,24 +1213,7 @@ impl AbiEmitter for ModSharpAbi {
|
||||||
s.push_str("using Sharp.Shared;\nusing Sharp.Shared.Attributes;\nusing Sharp.Shared.Calls;\n\nnamespace Sharp.Generated;\n\n");
|
s.push_str("using Sharp.Shared;\nusing Sharp.Shared.Attributes;\nusing Sharp.Shared.Calls;\n\nnamespace Sharp.Generated;\n\n");
|
||||||
|
|
||||||
let shapes = callable_shapes(m);
|
let shapes = callable_shapes(m);
|
||||||
let doc = |c: &CallShape| {
|
let doc = |c: &CallShape| format!("{}{}\n", cs_doc_head(c), cs_doc_caveats(c));
|
||||||
let mut caveat = if c.ret == Ret::Undeclared {
|
|
||||||
format!(
|
|
||||||
"\n /// <remarks>{UNDECLARED_RETURN} (measured class: <c>{}</c>).</remarks>",
|
|
||||||
xml(c.ret_source)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
if c.lower_bound {
|
|
||||||
caveat.push_str(&format!("\n /// <remarks>{LOWER_BOUND}.</remarks>"));
|
|
||||||
}
|
|
||||||
format!(
|
|
||||||
" /// <summary><c>{}</c></summary>{}\n",
|
|
||||||
xml(&c.prototype()),
|
|
||||||
caveat
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let ret_cs = |c: &CallShape| match c.ret {
|
let ret_cs = |c: &CallShape| match c.ret {
|
||||||
Ret::Void => "void",
|
Ret::Void => "void",
|
||||||
Ret::Declared(r) => r.cs(),
|
Ret::Declared(r) => r.cs(),
|
||||||
|
|
@ -1278,6 +1333,11 @@ impl AbiEmitter for SwiftlyAbi {
|
||||||
doc.insert(
|
doc.insert(
|
||||||
c.key.to_string(),
|
c.key.to_string(),
|
||||||
json!({
|
json!({
|
||||||
|
// The prose, and the id saying whose it is — the machine-readable form of the
|
||||||
|
// marker the generated-source targets print in words. A consumer rendering this
|
||||||
|
// into its own docs needs to be able to attribute it.
|
||||||
|
"description": c.doc.map(|d| d.text.clone()),
|
||||||
|
"description_source": c.doc.map(|d| d.source.clone()),
|
||||||
"args": c.params.iter().map(|t| t.swiftly()).collect::<String>(),
|
"args": c.params.iter().map(|t| t.swiftly()).collect::<String>(),
|
||||||
"ret": match c.ret {
|
"ret": match c.ret {
|
||||||
Ret::Void => 'v',
|
Ret::Void => 'v',
|
||||||
|
|
@ -1338,6 +1398,9 @@ impl AbiEmitter for PlugifyAbi {
|
||||||
fns.insert(
|
fns.insert(
|
||||||
c.key.to_string(),
|
c.key.to_string(),
|
||||||
json!({
|
json!({
|
||||||
|
// As for Swiftly: the text plus whose it is, so a consumer can attribute it.
|
||||||
|
"description": c.doc.map(|d| d.text.clone()),
|
||||||
|
"descriptionSource": c.doc.map(|d| d.source.clone()),
|
||||||
"paramTypes": c.params.iter().map(|t| plg(*t)).collect::<Vec<_>>(),
|
"paramTypes": c.params.iter().map(|t| plg(*t)).collect::<Vec<_>>(),
|
||||||
"retType": match c.ret {
|
"retType": match c.ret {
|
||||||
Ret::Void => "void",
|
Ret::Void => "void",
|
||||||
|
|
@ -1361,6 +1424,294 @@ impl AbiEmitter for PlugifyAbi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================ VSCRIPT / BINDINGS emitters ============================
|
||||||
|
|
||||||
|
/// An emitter over `bindings-<game>.json`'s VScript section.
|
||||||
|
///
|
||||||
|
/// A separate family from the gamedata, schema and ABI emitters because it renders a different INPUT:
|
||||||
|
/// the script-facing surface, grouped by the class that owns it. Both targets here exist to feed the
|
||||||
|
/// Dota custom-game ecosystem, which already has a toolchain for exactly this shape.
|
||||||
|
pub trait BindingsEmitter {
|
||||||
|
fn id(&self) -> &'static str;
|
||||||
|
/// `schema` supplies the class BASE CHAIN, which the binding registry does not carry. With it, `.d.ts`
|
||||||
|
/// emits `interface X extends Y` the way the ecosystem's published types do; without it, every
|
||||||
|
/// interface is flat but still correct — an offline artifact has no schema, and that is the only case
|
||||||
|
/// where it is absent.
|
||||||
|
fn render(&self, vscript: &[VScriptBinding], schema: Option<&Schema>) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The base class to declare an `extends` against, but ONLY when we also emit an interface for it.
|
||||||
|
///
|
||||||
|
/// A dangling `extends` would make the file unusable on its own, and self-contained is the safer default:
|
||||||
|
/// an author merging this beside the ecosystem's packages loses nothing, while an author using it alone
|
||||||
|
/// would otherwise get an unresolved reference. The base graph comes from the typed schema, so it is
|
||||||
|
/// present exactly when the artifact carries a schema, i.e. when it came from a full build.
|
||||||
|
fn vs_base<'a>(
|
||||||
|
cls: &str,
|
||||||
|
schema: Option<&'a Schema>,
|
||||||
|
emitted: &BTreeMap<&str, Vec<&VScriptBinding>>,
|
||||||
|
) -> Option<&'a str> {
|
||||||
|
schema?
|
||||||
|
.bases
|
||||||
|
.get(cls)?
|
||||||
|
.first()
|
||||||
|
.map(|b| b.name.as_str())
|
||||||
|
.filter(|b| emitted.contains_key(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group the VScript bindings by owning class, dropping the ones that have none.
|
||||||
|
///
|
||||||
|
/// `class` is live-only (see `VScriptBinding::class`), so an OFFLINE build renders nothing here. That is
|
||||||
|
/// the honest outcome rather than a bug: both output formats declare members under their interface, and a
|
||||||
|
/// flat list of function names is not a thing either consumer can use.
|
||||||
|
fn vscript_by_class(vscript: &[VScriptBinding]) -> BTreeMap<&str, Vec<&VScriptBinding>> {
|
||||||
|
let mut out: BTreeMap<&str, Vec<&VScriptBinding>> = BTreeMap::new();
|
||||||
|
for v in vscript {
|
||||||
|
if let Some(c) = v.class.as_deref() {
|
||||||
|
out.entry(c).or_default().push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for v in out.values_mut() {
|
||||||
|
// By the C++ name as well as the script one: a script name is NOT unique on a class —
|
||||||
|
// `CBodyComponent::SetMaterialGroup` is two different implementations, and `ConnectOutput` is
|
||||||
|
// three — so sorting on the script name alone leaves their order decided by the order the
|
||||||
|
// registry happened to be read in, and the rendered API stops being reproducible.
|
||||||
|
v.sort_by(|a, b| (&a.name, &a.cpp).cmp(&(&b.name, &b.cpp)));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One member per script-facing name, preferring the row Valve documented — see the call site.
|
||||||
|
fn dedup_by_script_name<'a>(members: &[&'a VScriptBinding]) -> Vec<&'a VScriptBinding> {
|
||||||
|
let mut out: Vec<&VScriptBinding> = Vec::with_capacity(members.len());
|
||||||
|
for m in members {
|
||||||
|
match out.last_mut() {
|
||||||
|
Some(prev) if prev.name == m.name => {
|
||||||
|
if prev.description.is_empty() && !m.description.is_empty() {
|
||||||
|
*prev = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => out.push(m),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape text destined for a `/** … */` doc comment.
|
||||||
|
///
|
||||||
|
/// Two hazards, both present in shipped data: a `*/` inside the text ENDS the comment early (3 Dota
|
||||||
|
/// descriptions contain one), and a newline drops the rest of the sentence out of the `*`-continued
|
||||||
|
/// block the format expects (82 do).
|
||||||
|
fn jsdoc(s: &str) -> String {
|
||||||
|
one_line(s).replace("*/", "*\\/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The script return type as its TypeScript spelling.
|
||||||
|
///
|
||||||
|
/// Lua has one number type, so every numeric `ScriptDataType_t` collapses to `number` — the distinction
|
||||||
|
/// between `int`, `float` and `uint` is real in the binary and meaningless in the target language, and
|
||||||
|
/// preserving it would produce declarations that do not typecheck against the ecosystem's own types.
|
||||||
|
fn vs_ts_type(ret: Option<&str>) -> &'static str {
|
||||||
|
match ret {
|
||||||
|
Some("void") | None => "void",
|
||||||
|
Some("bool") => "boolean",
|
||||||
|
Some("int") | Some("uint") | Some("float") => "number",
|
||||||
|
Some("string") => "string",
|
||||||
|
Some("Vector") => "Vector",
|
||||||
|
Some("QAngle") => "QAngle",
|
||||||
|
Some("handle") | Some("ehandle") => "CBaseEntity",
|
||||||
|
Some("table") => "object",
|
||||||
|
_ => "any",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `api.json` — the shape ModDota's `dota-data` publishes and its `TypeScriptDeclarations` renders from.
|
||||||
|
///
|
||||||
|
/// Emitting THIS rather than declarations directly is deliberate: their toolchain already turns this
|
||||||
|
/// shape into typed `.d.ts` packages, so matching it means the ecosystem's existing pipeline produces
|
||||||
|
/// up-to-date output instead of a parallel artifact competing with it.
|
||||||
|
///
|
||||||
|
/// Two fields are honestly absent. Parameter lists, because the registry does not carry them — types
|
||||||
|
/// appear only inside Valve's prose descriptions, inconsistently, in about a fifth of entries. And
|
||||||
|
/// `available`, because a dedicated server never maps `libclient`, so this derivation cannot see the
|
||||||
|
/// client side at all and claiming `both` would be an assertion about something never read.
|
||||||
|
pub struct VScriptApiJson;
|
||||||
|
|
||||||
|
impl BindingsEmitter for VScriptApiJson {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"api-json"
|
||||||
|
}
|
||||||
|
fn render(&self, vscript: &[VScriptBinding], _schema: Option<&Schema>) -> String {
|
||||||
|
let mut classes: Vec<Value> = Vec::new();
|
||||||
|
for (cls, members) in vscript_by_class(vscript) {
|
||||||
|
let ms: Vec<Value> = members
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let mut o = serde_json::Map::new();
|
||||||
|
o.insert("kind".into(), json!("function"));
|
||||||
|
o.insert("name".into(), json!(m.name));
|
||||||
|
o.insert("available".into(), json!("server"));
|
||||||
|
if !m.description.is_empty() {
|
||||||
|
o.insert("description".into(), json!(m.description));
|
||||||
|
} else if let Some(d) = &m.doc {
|
||||||
|
// Under a key of OUR name, never theirs. `description` is Valve's field in
|
||||||
|
// their shape and their toolchain renders it as Valve's word; putting this
|
||||||
|
// project's reading of the build there would launder it into their published
|
||||||
|
// types as something Valve wrote.
|
||||||
|
o.insert("rosetta_description".into(), json!(d.text));
|
||||||
|
o.insert("rosetta_description_source".into(), json!(d.source));
|
||||||
|
}
|
||||||
|
o.insert("returns".into(), json!([m.ret.clone().unwrap_or_default()]));
|
||||||
|
o.insert("args".into(), json!([]));
|
||||||
|
// Ours and not theirs: the C++ binding this resolves to, which is the key the same
|
||||||
|
// function appears under in `gamedata-<game>.json`.
|
||||||
|
o.insert("cpp".into(), json!(m.cpp));
|
||||||
|
Value::Object(o)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
classes.push(json!({ "kind": "class", "name": cls, "members": ms }));
|
||||||
|
}
|
||||||
|
serde_json::to_string_pretty(&Value::Array(classes)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `.d.ts` — TypeScript declarations in the style the Dota ecosystem's published types use.
|
||||||
|
///
|
||||||
|
/// For authors working against the packages as shipped rather than regenerating them. Members are
|
||||||
|
/// declared on an interface per class, with Valve's own description as the doc comment — which is the
|
||||||
|
/// point: hovering a function in an editor shows what Valve wrote about it.
|
||||||
|
///
|
||||||
|
/// Parameters are declared `...args: any[]` because the registry does not state them. That is deliberately
|
||||||
|
/// ugly: it is visible in every signature, so nobody mistakes this for a complete declaration, and it
|
||||||
|
/// still typechecks. Inventing plausible parameter lists would be worse than saying nothing.
|
||||||
|
pub struct VScriptDts;
|
||||||
|
|
||||||
|
impl BindingsEmitter for VScriptDts {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"dts"
|
||||||
|
}
|
||||||
|
fn render(&self, vscript: &[VScriptBinding], schema: Option<&Schema>) -> String {
|
||||||
|
let mut s = String::new();
|
||||||
|
s.push_str("/** @noSelfInFile */\n");
|
||||||
|
s.push_str("// Generated by source2rosetta-gen from rosetta-<game>.json — do not edit.\n");
|
||||||
|
s.push_str(
|
||||||
|
"// Return types are Valve's own, read from the script VM's registry. Parameter lists are\n\
|
||||||
|
// NOT in that registry, so every member takes `...args: any[]`: the arity is unknown, and\n\
|
||||||
|
// guessing it would produce declarations that lie rather than declarations that abstain.\n\n",
|
||||||
|
);
|
||||||
|
let grouped = vscript_by_class(vscript);
|
||||||
|
for (cls, members) in &grouped {
|
||||||
|
match vs_base(cls, schema, &grouped) {
|
||||||
|
Some(base) => s.push_str(&format!("declare interface {cls} extends {base} {{\n")),
|
||||||
|
None => s.push_str(&format!("declare interface {cls} {{\n")),
|
||||||
|
}
|
||||||
|
// One line per SCRIPT-facing name. Where a class registers a name twice — two C++
|
||||||
|
// implementations behind one script member — an author still sees one member, so declaring
|
||||||
|
// it twice adds a redundant overload and no information. The row carrying Valve's
|
||||||
|
// description wins; `api-json` keeps both, because it states the `cpp` that tells them apart.
|
||||||
|
for m in dedup_by_script_name(members) {
|
||||||
|
// Valve's own description if the registration carried one — that is the point of this
|
||||||
|
// format, and it renders bare, the way the ecosystem's published types do. Where Valve
|
||||||
|
// documents nothing, this project's own reading fills the gap and SAYS SO: a hover
|
||||||
|
// tooltip is exactly where an unmarked sentence would be taken for Valve's word.
|
||||||
|
if !m.description.is_empty() {
|
||||||
|
s.push_str(&format!(" /** {} */\n", jsdoc(&m.description)));
|
||||||
|
} else if let Some(d) = &m.doc {
|
||||||
|
s.push_str(&format!(
|
||||||
|
" /** {}\n * ({}.) */\n",
|
||||||
|
jsdoc(&d.text),
|
||||||
|
doc_origin(&d.source)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
s.push_str(&format!(
|
||||||
|
" {}(...args: any[]): {};\n",
|
||||||
|
m.name,
|
||||||
|
vs_ts_type(m.ret.as_deref())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
s.push_str("}\n\n");
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a bindings emitter by its `--format` id.
|
||||||
|
pub fn bindings_by_id(id: &str) -> Option<Box<dyn BindingsEmitter>> {
|
||||||
|
match id {
|
||||||
|
"api-json" => Some(Box::new(VScriptApiJson)),
|
||||||
|
"dts" => Some(Box::new(VScriptDts)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every bindings emitter id `bindings_by_id` accepts (keep in sync with it).
|
||||||
|
///
|
||||||
|
/// NOT a `--format` list: both of these are rendered by the single `moddota` format, because they are
|
||||||
|
/// one consumer's two files — their toolchain reads the JSON and their authors read the declarations.
|
||||||
|
pub const BINDINGS_FORMAT_IDS: &[&str] = &["api-json", "dts"];
|
||||||
|
|
||||||
|
/// Every `--format` `gen` accepts, in the order its help lists them.
|
||||||
|
///
|
||||||
|
/// A format names WHO the output is for, not which file it is, so one of them can write several files:
|
||||||
|
/// a framework gets the gamedata its loader resolves through plus the typed call sites that go through
|
||||||
|
/// it, and `moddota` gets both of the shapes that ecosystem publishes.
|
||||||
|
pub const FORMAT_IDS: &[&str] = &[
|
||||||
|
"cssharp", "metamod", "modsharp", "swiftly", "plugify", "cs-sdk", "netvars", "moddota", "flat",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Where a format's consumer is known not to run on a game, and the evidence for saying so.
|
||||||
|
///
|
||||||
|
/// **A warning rather than a rule.** Each entry is a claim about somebody else's project, read out of
|
||||||
|
/// their source at one point in time, and third-party projects add games. So `gen` declines by default
|
||||||
|
/// and takes `--force`: being wrong here costs a flag, never an outcome.
|
||||||
|
///
|
||||||
|
/// What earns an entry is a framework that resolves its own binaries through a FIXED game directory,
|
||||||
|
/// which is a thing its own source states rather than a thing this project infers. Two do:
|
||||||
|
/// CounterStrikeSharp and Swiftly.
|
||||||
|
///
|
||||||
|
/// **ModSharp is deliberately absent**, though it is equally CS2-first in its paths
|
||||||
|
/// (`../../csgo/steam.inf`): its gamedata carries no game key at all — flat `Addresses` / `VFuncs`,
|
||||||
|
/// keyed only by platform — so the file rendered here is byte-for-byte the same whatever game the build
|
||||||
|
/// targets. The two multi-game targets are absent for the opposite reason: Metamod takes Dota 2 as a
|
||||||
|
/// first-class SDK and Plugify is built per game (`S2SDK_GAME_NAME`), and BOTH key their gamedata by the
|
||||||
|
/// game directory, which is what `meta.game_key` already carries.
|
||||||
|
pub const GAME_SUPPORT: &[(&str, &[&str], &str)] = &[
|
||||||
|
(
|
||||||
|
"cssharp",
|
||||||
|
&["csgo"],
|
||||||
|
"CounterStrikeSharp resolves its binaries out of `<dir>/csgo/bin/` (src/core/memory.h)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"swiftly",
|
||||||
|
&["csgo"],
|
||||||
|
"Swiftly initialises against the `csgo` game directory (src/core/entrypoint.cpp)",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// What is wrong with rendering `format` from a `game_key` artifact, or `None` where nothing is.
|
||||||
|
///
|
||||||
|
/// Names the formats that DO cover the game, because the useful thing to tell someone holding a Dota
|
||||||
|
/// artifact is not that this one is a dead end but which ones are not.
|
||||||
|
pub fn game_mismatch(format: &str, game_key: &str) -> Option<String> {
|
||||||
|
let (_, _, why) = GAME_SUPPORT
|
||||||
|
.iter()
|
||||||
|
.find(|(f, games, _)| *f == format && !games.contains(&game_key))?;
|
||||||
|
let covered: Vec<&str> = FORMAT_IDS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|f| {
|
||||||
|
!GAME_SUPPORT
|
||||||
|
.iter()
|
||||||
|
.any(|(id, games, _)| id == f && !games.contains(&game_key))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Some(format!(
|
||||||
|
"`{format}` is for a consumer that does not run on this game. {why}, and this artifact is \
|
||||||
|
`{game_key}`.\n Formats that do cover `{game_key}`: {}",
|
||||||
|
covered.join(", ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -1625,6 +1976,138 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_description_reaches_every_call_site_format_and_says_whose_it_is() {
|
||||||
|
let doc = |text: &str, source: &str| crate::model::Doc {
|
||||||
|
text: text.into(),
|
||||||
|
source: source.into(),
|
||||||
|
};
|
||||||
|
let mut e = abi_entry(&["A*", "int"], true, "void", 2);
|
||||||
|
e.doc = Some(doc("Kills the entity.", "generated"));
|
||||||
|
let m = manifest(&[("A::M", e)]);
|
||||||
|
|
||||||
|
for out in [CsSharpAbi.render(&m), ModSharpAbi.render(&m)] {
|
||||||
|
// The prose takes the summary — an editor shows that first, and it is what an author
|
||||||
|
// hovering a generic type list is asking for — and the prototype keeps a home of its own.
|
||||||
|
assert!(
|
||||||
|
out.contains("<summary>Kills the entity.</summary>"),
|
||||||
|
"{out}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.contains("<remarks><c>A::M(A*, int) -> void</c></remarks>"),
|
||||||
|
"{out}"
|
||||||
|
);
|
||||||
|
assert!(out.contains("GENERATED — a reading of this build, not Valve's"));
|
||||||
|
}
|
||||||
|
let mm = MetamodAbi.render(&m);
|
||||||
|
assert!(mm.contains("// Kills the entity.\n// Description: source2rosetta's, GENERATED"));
|
||||||
|
assert!(
|
||||||
|
SwiftlyAbi
|
||||||
|
.render(&m)
|
||||||
|
.contains("\"description_source\": \"generated\"")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
PlugifyAbi
|
||||||
|
.render(&m)
|
||||||
|
.contains("\"descriptionSource\": \"generated\"")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Valve's own text is attributed to Valve, in the same place, by the same one wording.
|
||||||
|
let mut e = abi_entry(&["A*", "int"], true, "void", 2);
|
||||||
|
e.doc = Some(doc("Valve wrote this.", crate::model::Doc::VALVE));
|
||||||
|
let valve = CsSharpAbi.render(&manifest(&[("A::M", e)]));
|
||||||
|
assert!(valve.contains("<summary>Valve wrote this.</summary>"));
|
||||||
|
assert!(
|
||||||
|
valve.contains("Description: Valve's own text, read from the registry in the binary.")
|
||||||
|
);
|
||||||
|
|
||||||
|
// An id nothing here knows is printed verbatim rather than passed off as Valve's — the same
|
||||||
|
// rule `flags_raw` follows for a bit whose meaning this build cannot state.
|
||||||
|
let mut e = abi_entry(&["A*", "int"], true, "void", 2);
|
||||||
|
e.doc = Some(doc("t", "handwritten"));
|
||||||
|
let odd = CsSharpAbi.render(&manifest(&[("A::M", e)]));
|
||||||
|
assert!(odd.contains("source `handwritten` — not Valve's"), "{odd}");
|
||||||
|
|
||||||
|
// And with nothing to say, the block is exactly what it was before descriptions existed.
|
||||||
|
let bare = CsSharpAbi.render(&manifest(&[("A::M", abi_entry(&["A*"], true, "void", 1))]));
|
||||||
|
assert!(bare.contains("<summary><c>A::M(A*) -> void</c></summary>"));
|
||||||
|
assert!(!bare.contains("Description:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_description_cannot_break_out_of_the_comment_it_is_printed_in() {
|
||||||
|
// Real shipped text carries all three hazards: XML metacharacters (~100 descriptions per
|
||||||
|
// game), a newline (78), and `*/` (3 in Dota).
|
||||||
|
let mut e = abi_entry(&["A*"], true, "void", 1);
|
||||||
|
e.doc = Some(crate::model::Doc {
|
||||||
|
text: crate::model::one_line("a <b> & c\nsecond line */ end"),
|
||||||
|
source: "generated".into(),
|
||||||
|
});
|
||||||
|
let m = manifest(&[("A::M", e)]);
|
||||||
|
for out in [CsSharpAbi.render(&m), ModSharpAbi.render(&m)] {
|
||||||
|
assert!(
|
||||||
|
out.contains("a <b> & c second line */ end"),
|
||||||
|
"{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A `//` comment ends at a newline, so every prose line in the C++ header must be one line.
|
||||||
|
for line in MetamodAbi.render(&m).lines() {
|
||||||
|
assert!(
|
||||||
|
line.starts_with("//") || !line.contains("second line"),
|
||||||
|
"{line}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_script_api_fills_a_gap_valve_left_and_never_overwrites_one() {
|
||||||
|
let member = |name: &str, valve: &str, doc: Option<&str>| VScriptBinding {
|
||||||
|
name: name.into(),
|
||||||
|
class: Some("CBaseEntity".into()),
|
||||||
|
cpp: format!("Script_{name}"),
|
||||||
|
library: "server".into(),
|
||||||
|
description: valve.into(),
|
||||||
|
ret: Some("void".into()),
|
||||||
|
ret_raw: 0,
|
||||||
|
addr: None,
|
||||||
|
vtable_slot: None,
|
||||||
|
doc: doc.map(|t| crate::model::Doc {
|
||||||
|
text: t.into(),
|
||||||
|
source: "generated".into(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let vs = vec![
|
||||||
|
member("Kill", "Valve's own.", Some("ours, and outranked")),
|
||||||
|
member("Quiet", "", Some("Ours: it does a thing. */")),
|
||||||
|
member("Silent", "", None),
|
||||||
|
];
|
||||||
|
|
||||||
|
let dts = VScriptDts.render(&vs, None);
|
||||||
|
assert!(dts.contains("/** Valve's own. */"), "{dts}");
|
||||||
|
assert!(!dts.contains("outranked"), "{dts}");
|
||||||
|
// Ours is marked where it lands, because a hover tooltip is exactly where an unattributed
|
||||||
|
// sentence gets taken for Valve's word. And `*/` inside it must not end the comment.
|
||||||
|
assert!(
|
||||||
|
dts.contains("/** Ours: it does a thing. *\\/\n * (source2rosetta's, GENERATED")
|
||||||
|
);
|
||||||
|
// A member nothing describes gets no comment at all — an empty one would read as "documented,
|
||||||
|
// and it says nothing". (Counting the indented form: the file's own `@noSelfInFile` banner is
|
||||||
|
// a doc comment too.)
|
||||||
|
assert!(dts.contains(" Silent(...args: any[]): void;"));
|
||||||
|
assert_eq!(dts.matches(" /**").count(), 2);
|
||||||
|
|
||||||
|
// `api.json` is ModDota's shape: `description` is Valve's field, so ours goes under a key of
|
||||||
|
// our own name rather than being laundered into their published types as Valve's word.
|
||||||
|
let api: Value = serde_json::from_str(&VScriptApiJson.render(&vs, None)).unwrap();
|
||||||
|
let ms = &api[0]["members"];
|
||||||
|
assert_eq!(ms[0]["description"], "Valve's own.");
|
||||||
|
assert!(ms[0].get("rosetta_description").is_none());
|
||||||
|
assert!(ms[1].get("description").is_none());
|
||||||
|
assert_eq!(ms[1]["rosetta_description"], "Ours: it does a thing. */");
|
||||||
|
assert_eq!(ms[1]["rosetta_description_source"], "generated");
|
||||||
|
assert!(ms[2].get("rosetta_description").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_declared_return_that_cannot_be_represented_drops_the_function() {
|
fn a_declared_return_that_cannot_be_represented_drops_the_function() {
|
||||||
// `Vector` by value is a real declaration this cannot express — dropping it is not the same as
|
// `Vector` by value is a real declaration this cannot express — dropping it is not the same as
|
||||||
|
|
@ -1738,10 +2221,60 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_format_id_resolves_and_renders() {
|
fn every_format_id_resolves_and_renders() {
|
||||||
for id in FORMAT_IDS {
|
for id in GAMEDATA_FORMAT_IDS {
|
||||||
let em = by_id(id).unwrap_or_else(|| panic!("by_id({id}) is None"));
|
let em = by_id(id).unwrap_or_else(|| panic!("by_id({id}) is None"));
|
||||||
assert!(!em.render(&sample()).is_empty(), "{id} rendered empty");
|
assert!(!em.render(&sample()).is_empty(), "{id} rendered empty");
|
||||||
}
|
}
|
||||||
|
// Every `--format` has to be reachable through one of the four emitter families, or `gen`
|
||||||
|
// advertises an id it then rejects. `moddota` is the one that maps to a family rather than to
|
||||||
|
// an emitter: it is a CONSUMER with two files, which is why the CLI list is not a registry.
|
||||||
|
for id in FORMAT_IDS {
|
||||||
|
let known = by_id(id).is_some()
|
||||||
|
|| schema_by_id(id).is_some()
|
||||||
|
|| abi_by_id(id).is_some()
|
||||||
|
|| *id == "moddota"
|
||||||
|
|| *id == "flat";
|
||||||
|
assert!(known, "--format {id} resolves to no emitter");
|
||||||
|
}
|
||||||
|
for id in BINDINGS_FORMAT_IDS {
|
||||||
|
assert!(bindings_by_id(id).is_some(), "bindings_by_id({id}) is None");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_consumer_that_does_not_run_on_this_game_is_named_and_so_are_the_ones_that_do() {
|
||||||
|
// CounterStrikeSharp resolves its own binaries out of `<dir>/csgo/bin/`, so Dota locators in
|
||||||
|
// its shape describe a file that can never load. Saying which formats DO cover the game is the
|
||||||
|
// useful half: someone holding a Dota artifact needs a way forward, not just a closed door.
|
||||||
|
let m = game_mismatch("cssharp", "dota").expect("cssharp does not run on dota");
|
||||||
|
assert!(m.contains("CounterStrikeSharp"), "{m}");
|
||||||
|
assert!(
|
||||||
|
m.contains("metamod") && m.contains("moddota") && m.contains("plugify"),
|
||||||
|
"{m}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!m.contains("swiftly"),
|
||||||
|
"swiftly does not cover dota either: {m}"
|
||||||
|
);
|
||||||
|
assert!(game_mismatch("cssharp", "csgo").is_none());
|
||||||
|
|
||||||
|
// Multi-game by evidence: Metamod takes Dota 2 as a first-class SDK, Plugify is built per game,
|
||||||
|
// and both key their gamedata by the game directory — which is what `game_key` already carries.
|
||||||
|
for f in ["metamod", "plugify", "moddota", "flat", "cs-sdk", "netvars"] {
|
||||||
|
assert!(game_mismatch(f, "dota").is_none(), "{f} should cover dota");
|
||||||
|
}
|
||||||
|
// ModSharp is CS2-FIRST but not CS2-only in the shape rendered here: its gamedata carries no
|
||||||
|
// game key at all, so the file is the same whatever game the build targets.
|
||||||
|
assert!(game_mismatch("modsharp", "dota").is_none());
|
||||||
|
|
||||||
|
// Every id the table constrains has to be a format that exists, or the constraint silently
|
||||||
|
// applies to nothing.
|
||||||
|
for (id, _, _) in GAME_SUPPORT {
|
||||||
|
assert!(
|
||||||
|
FORMAT_IDS.contains(id),
|
||||||
|
"GAME_SUPPORT names unknown format {id}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ fuzz_target!(|data: &[u8]| {
|
||||||
};
|
};
|
||||||
let vts = rtti::enumerate_vtables(&img, 128);
|
let vts = rtti::enumerate_vtables(&img, 128);
|
||||||
for cv in vts.iter().take(32) {
|
for cv in vts.iter().take(32) {
|
||||||
let _ = (&cv.name, &cv.mangled, cv.offset_to_top, cv.slots.len());
|
let _ = (&cv.name, cv.offset_to_top, cv.slots.len());
|
||||||
}
|
}
|
||||||
// The by-name lookup path (mangling + candidate walk) on a name pulled from the input itself.
|
// The by-name lookup path (mangling + candidate walk) on a name pulled from the input itself.
|
||||||
if let Some(name) = vts.first().map(|c| c.name.clone()) {
|
if let Some(name) = vts.first().map(|c| c.name.clone()) {
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,16 @@ fuzz_target!(|data: &[u8]| {
|
||||||
// width/count word, and an enumerator array whose length that word supplies. A crafted count is the
|
// 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
|
// sharp edge — it drives the per-enumerator read loop — so the reader must bound it rather than
|
||||||
// trust it.
|
// trust it.
|
||||||
for e in schema::enumerate_enums(&img) {
|
let classes = schema::enumerate_schema(&img);
|
||||||
|
// The enum walk takes the classes because a class FIELD descriptor is byte-compatible with an enum
|
||||||
|
// binding — so a crafted image can aim the field-array spans it derives from them anywhere too.
|
||||||
|
for e in schema::enumerate_enums(&img, &classes) {
|
||||||
let _ = (e.name.len(), e.size, e.align);
|
let _ = (e.name.len(), e.size, e.align);
|
||||||
for (n, v) in &e.values {
|
for (n, v) in &e.values {
|
||||||
let _ = (n.len(), *v);
|
let _ = (n.len(), *v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for c in schema::enumerate_schema(&img) {
|
for c in &classes {
|
||||||
let _ = c.primary_base();
|
let _ = c.primary_base();
|
||||||
for f in &c.fields {
|
for f in &c.fields {
|
||||||
let _ = (f.offset, f.name.len());
|
let _ = (f.offset, f.name.len());
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ fuzz_target!(|data: &[u8]| {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let xr = xref::XrefIndex::build(&img);
|
let xr = xref::XrefIndex::build(&img);
|
||||||
// Exercise the lookups over a bounded set of the discovered call targets — none may panic.
|
// Exercise the lookups over a bounded set of the discovered function entries — none may panic.
|
||||||
for &t in xr.call_targets().iter().take(64) {
|
for &t in xr.entries().iter().take(64) {
|
||||||
let _ = xr.referrers(t);
|
let _ = xr.referrers(t);
|
||||||
let _ = xr.refs_to(t);
|
let _ = xr.refs_to(t);
|
||||||
let _ = xr.containing_func(t);
|
let _ = xr.containing_func(t);
|
||||||
|
|
|
||||||
12312
mappings/semantics-cs2.json
Normal file
12312
mappings/semantics-cs2.json
Normal file
File diff suppressed because it is too large
Load diff
12748
mappings/semantics-dota2.json
Normal file
12748
mappings/semantics-dota2.json
Normal file
File diff suppressed because it is too large
Load diff
280
src/abi.rs
280
src/abi.rs
|
|
@ -24,10 +24,12 @@
|
||||||
//! 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
|
||||||
//! stable across builds (a thunk stays a thunk), so they don't manufacture false transitions — the
|
//! stable across builds (a thunk stays a thunk), so they don't manufacture false transitions. `int_args`
|
||||||
//! diff's `int==0` low-confidence bucket also absorbs the thunk case. `int_args` is the OBSERVABLE
|
//! is the OBSERVABLE footprint = a lower bound on the declared prototype (a constant-returner reads
|
||||||
//! footprint = a lower bound on the declared prototype (a constant-returner reads nothing → `int=0`);
|
//! nothing → `int=0`); that too is stable per function, which is what lets a shape measured in one build
|
||||||
//! that too is stable per function, so the cross-build diff still works.
|
//! be compared against the model's consensus in the next — see `pipeline::AbiSig::differs`, which treats an
|
||||||
|
//! `Unknown` return class as "no disagreement" for exactly this reason, and the derive-time
|
||||||
|
//! `FlagReason::AbiDrift` check that reports the survivors.
|
||||||
//!
|
//!
|
||||||
//! The lower-bound property is MEASURED, not assumed: Valve's entity-IO datadesc declares hundreds of
|
//! 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
|
//! independent handlers to one fixed `void(CEntityInstance*, InputData_t&)` prototype, and every one of
|
||||||
|
|
@ -594,10 +596,205 @@ pub fn abi_shape(img: &CodeImage, entry: u64) -> Option<AbiShape> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The 16 general-purpose registers as a slot index, sub-registers folded to their 64-bit parent.
|
||||||
|
///
|
||||||
|
/// `pub(crate)` because it is a fixed SysV fact, not per-pass tuning: `concmd` and `vscript` index
|
||||||
|
/// `[_; 16]` arrays by exactly this mapping and each carried its own copy of it. (Unlike `MAX_NAME`, or
|
||||||
|
/// the two `V` lattices, which differ between those readers deliberately.)
|
||||||
|
pub(crate) fn gp_slot(r: Register) -> Option<usize> {
|
||||||
|
let full = r.full_register();
|
||||||
|
(full.is_gpr64() && full != Register::RIP).then(|| full as usize - Register::RAX as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a `call` destroys — every caller-saved GPR. A pointer that SURVIVES a call is in a
|
||||||
|
/// callee-saved register, which is exactly how a real `this` is kept across one.
|
||||||
|
///
|
||||||
|
/// ONE list, and every shape of it is derived from this array: [`caller_saved_mask`]'s bitmask, the slot
|
||||||
|
/// indices [`caller_saved_slots`] hands the `concmd` and `vscript` value trackers, and `pulse`'s two
|
||||||
|
/// invalidation loops, which read it directly. Nothing transcribes it, because a register present in one
|
||||||
|
/// copy and missing from another is a tracker that forgets a value the machine kept, or keeps one the
|
||||||
|
/// machine destroyed — and a fork retargeting this (Windows/MSVC makes RSI and RDI callee-saved) has to
|
||||||
|
/// change exactly one place.
|
||||||
|
pub(crate) const CALLER_SAVED: [Register; 9] = [
|
||||||
|
Register::RAX,
|
||||||
|
Register::RCX,
|
||||||
|
Register::RDX,
|
||||||
|
Register::RSI,
|
||||||
|
Register::RDI,
|
||||||
|
Register::R8,
|
||||||
|
Register::R9,
|
||||||
|
Register::R10,
|
||||||
|
Register::R11,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn caller_saved_mask() -> u32 {
|
||||||
|
CALLER_SAVED
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&r| gp_slot(r))
|
||||||
|
.fold(0u32, |m, s| m | (1 << s))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`CALLER_SAVED`] as the `[_; 16]` slot indices the instruction readers clear after a call — the shape
|
||||||
|
/// `concmd` and `vscript` need, derived once here instead of transcribed into each.
|
||||||
|
pub(crate) fn caller_saved_slots() -> [usize; 9] {
|
||||||
|
let mut out = [0usize; 9];
|
||||||
|
for (i, &r) in CALLER_SAVED.iter().enumerate() {
|
||||||
|
out[i] = gp_slot(r).expect("every caller-saved register is a GPR");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The largest displacement the function reaches through the pointer it was handed in RDI — for a
|
||||||
|
/// member function, how far into `this` it touches.
|
||||||
|
///
|
||||||
|
/// **What it is for.** Every other check in this project verifies that a locator RESOLVES; none verifies
|
||||||
|
/// that it resolves to the RIGHT function. This one can, against a fact the SchemaSystem already states
|
||||||
|
/// offline: a `CFoo::` method reaches its own object through `this`, so every `this + N` it touches must
|
||||||
|
/// satisfy `N < sizeof(CFoo)`. Reaching past the end means the pointer is not a `CFoo`.
|
||||||
|
///
|
||||||
|
/// **Deliberately conservative, in the same direction as `abi_shape`.** A register stops holding `this`
|
||||||
|
/// on any write that is not a move from another register already holding it, every caller-saved register
|
||||||
|
/// is dropped across a `call`, and a path merge keeps only what holds on BOTH paths. So `this` is followed
|
||||||
|
/// only where it is provably still `this`, and the error direction is a reach that is too SMALL — a missed
|
||||||
|
/// contradiction rather than a false accusation against a correct entry.
|
||||||
|
///
|
||||||
|
/// Indexed memory operands (`(%rax,%rcx,8)`) are skipped: there the displacement is an array base rather
|
||||||
|
/// than a field offset, so its magnitude says nothing about the object's size.
|
||||||
|
pub fn this_reach(img: &CodeImage, entry: u64) -> Option<u64> {
|
||||||
|
const MAX_SPAN: usize = 16 * 1024;
|
||||||
|
const MAX_STEPS: usize = 6000;
|
||||||
|
let code = img.code_at(entry)?;
|
||||||
|
let cap = code.len().min(MAX_SPAN);
|
||||||
|
let in_span = |t: u64| t >= entry && ((t - entry) as usize) < cap;
|
||||||
|
let clobber = caller_saved_mask();
|
||||||
|
|
||||||
|
let mut factory = InstructionInfoFactory::new();
|
||||||
|
let mut seen: HashMap<u64, u32> = HashMap::new();
|
||||||
|
let rdi = 1u32 << gp_slot(Register::RDI)?;
|
||||||
|
let mut work = vec![(entry, rdi)];
|
||||||
|
let mut best: Option<u64> = None;
|
||||||
|
let mut steps = 0usize;
|
||||||
|
let mut insn = Instruction::default();
|
||||||
|
|
||||||
|
while let Some((ip, incoming)) = work.pop() {
|
||||||
|
if !in_span(ip) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
steps += 1;
|
||||||
|
if steps > MAX_STEPS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Path merge is INTERSECTION: a register holds `this` here only if it did on every path in.
|
||||||
|
let held = match seen.get(&ip) {
|
||||||
|
Some(&prev) => {
|
||||||
|
let merged = prev & incoming;
|
||||||
|
if merged == prev {
|
||||||
|
continue; // nothing new to propagate
|
||||||
|
}
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
None => incoming,
|
||||||
|
};
|
||||||
|
seen.insert(ip, held);
|
||||||
|
|
||||||
|
let off = (ip - entry) as usize;
|
||||||
|
let mut dec = Decoder::with_ip(64, &code[off..], ip, DecoderOptions::NONE);
|
||||||
|
if !dec.can_decode() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dec.decode_out(&mut insn);
|
||||||
|
if insn.is_invalid() || insn.len() == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record every field access made through a register that still holds `this`.
|
||||||
|
if insn.memory_index() == Register::None
|
||||||
|
&& let Some(slot) = gp_slot(insn.memory_base())
|
||||||
|
&& held & (1 << slot) != 0
|
||||||
|
&& (0..insn.op_count()).any(|i| insn.op_kind(i) == OpKind::Memory)
|
||||||
|
{
|
||||||
|
let d = insn.memory_displacement64();
|
||||||
|
if d < MAX_SPAN as u64 {
|
||||||
|
best = Some(best.map_or(d, |b: u64| b.max(d)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propagate. A plain 64-bit register-to-register move carries `this`; anything else that writes
|
||||||
|
// a register destroys whatever it held.
|
||||||
|
let mut next = held;
|
||||||
|
let is_reg_move = insn.mnemonic() == Mnemonic::Mov
|
||||||
|
&& insn.op_count() == 2
|
||||||
|
&& insn.op0_kind() == OpKind::Register
|
||||||
|
&& insn.op1_kind() == OpKind::Register
|
||||||
|
&& insn.op0_register().is_gpr64();
|
||||||
|
let carried = is_reg_move
|
||||||
|
.then(|| gp_slot(insn.op1_register()))
|
||||||
|
.flatten()
|
||||||
|
.filter(|&s| held & (1 << s) != 0)
|
||||||
|
.and_then(|_| gp_slot(insn.op0_register()));
|
||||||
|
for used in factory.info(&insn).used_registers() {
|
||||||
|
if matches!(
|
||||||
|
used.access(),
|
||||||
|
OpAccess::Write | OpAccess::ReadWrite | OpAccess::CondWrite
|
||||||
|
) && let Some(s) = gp_slot(used.register())
|
||||||
|
{
|
||||||
|
next &= !(1 << s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(s) = carried {
|
||||||
|
next |= 1 << s;
|
||||||
|
}
|
||||||
|
if insn.flow_control() == FlowControl::Call
|
||||||
|
|| insn.flow_control() == FlowControl::IndirectCall
|
||||||
|
{
|
||||||
|
next &= !clobber;
|
||||||
|
}
|
||||||
|
|
||||||
|
let after = ip + insn.len() as u64;
|
||||||
|
match insn.flow_control() {
|
||||||
|
FlowControl::Return
|
||||||
|
| FlowControl::IndirectBranch
|
||||||
|
| FlowControl::Exception
|
||||||
|
| FlowControl::Interrupt => {}
|
||||||
|
FlowControl::UnconditionalBranch => {
|
||||||
|
let t = insn.near_branch_target();
|
||||||
|
if in_span(t) {
|
||||||
|
work.push((t, next));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlowControl::ConditionalBranch => {
|
||||||
|
work.push((after, next));
|
||||||
|
let t = insn.near_branch_target();
|
||||||
|
if in_span(t) {
|
||||||
|
work.push((t, next));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => work.push((after, next)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_shape_of_the_caller_saved_list_agrees_with_the_array() {
|
||||||
|
// The invariant `CALLER_SAVED` documents, checked rather than asserted. Both derived shapes are
|
||||||
|
// computed from the array here, so this can only fail if someone reintroduces a hand-written
|
||||||
|
// copy — which is exactly the drift that put a raw index list in `vscript` and a second register
|
||||||
|
// array in `pulse`.
|
||||||
|
let mask = caller_saved_mask();
|
||||||
|
let slots = caller_saved_slots();
|
||||||
|
assert_eq!(mask.count_ones() as usize, CALLER_SAVED.len());
|
||||||
|
assert_eq!(slots.len(), CALLER_SAVED.len());
|
||||||
|
for (&r, &s) in CALLER_SAVED.iter().zip(slots.iter()) {
|
||||||
|
assert_eq!(gp_slot(r), Some(s), "{r:?} lost its slot index");
|
||||||
|
assert_ne!(mask & (1 << s), 0, "{r:?} is missing from the bitmask");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Decode a tiny hand-assembled straight-line function and recover its shape through the REAL
|
// Decode a tiny hand-assembled straight-line function and recover its shape through the REAL
|
||||||
// per-instruction helper (`insn_effect`) + the real liveness formula — so a test can't pass while
|
// per-instruction helper (`insn_effect`) + the real liveness formula — so a test can't pass while
|
||||||
// the production path is wrong. (A single-successor chain; the fixpoint isn't exercised here.)
|
// the production path is wrong. (A single-successor chain; the fixpoint isn't exercised here.)
|
||||||
|
|
@ -803,4 +1000,79 @@ mod tests {
|
||||||
// ret — no result register written before returning.
|
// ret — no result register written before returning.
|
||||||
assert_eq!(shape_of(&[0xC3]).ret_class, RetClass::Void);
|
assert_eq!(shape_of(&[0xC3]).ret_class, RetClass::Void);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- this_reach: the identity check's measurement half. Every case here is one the FIELD-tracking
|
||||||
|
// has to get right for the check to be usable as a rejection rather than a hint. ----
|
||||||
|
|
||||||
|
fn reach_of(bytes: &[u8]) -> Option<u64> {
|
||||||
|
this_reach(&CodeImage::for_test(0x1000, bytes), 0x1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_reach_follows_a_move_into_a_callee_saved_register() {
|
||||||
|
// mov %rdi,%r13 ; cmpb $0,0x7bc(%r13) ; ret
|
||||||
|
// The shape that matters in practice: the prologue stashes `this` and every field access is
|
||||||
|
// through the copy, so a tracker that only watches RDI measures nothing.
|
||||||
|
assert_eq!(
|
||||||
|
reach_of(&[
|
||||||
|
0x49, 0x89, 0xFD, 0x41, 0x80, 0xBD, 0xBC, 0x07, 0x00, 0x00, 0x00, 0xC3
|
||||||
|
]),
|
||||||
|
Some(0x7bc)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_reach_stops_at_a_reloaded_register() {
|
||||||
|
// mov 0x10(%rdi),%rdi ; mov 0x110(%rdi),%rax ; ret
|
||||||
|
// RDI is REDEFINED from memory, so 0x110 is an offset into a different object. Crediting it to
|
||||||
|
// `this` is exactly the false positive that made an earlier prototype of this check unusable.
|
||||||
|
assert_eq!(
|
||||||
|
reach_of(&[
|
||||||
|
0x48, 0x8B, 0x7F, 0x10, 0x48, 0x8B, 0x87, 0x10, 0x01, 0x00, 0x00, 0xC3
|
||||||
|
]),
|
||||||
|
Some(0x10)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_reach_drops_caller_saved_registers_across_a_call() {
|
||||||
|
// call +0 ; mov 0x200(%rdi),%rax ; ret
|
||||||
|
// RDI is caller-saved, so after a call it holds whatever the callee left. A read through it is
|
||||||
|
// not a read of `this`.
|
||||||
|
assert_eq!(
|
||||||
|
reach_of(&[
|
||||||
|
0xE8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8B, 0x87, 0x00, 0x02, 0x00, 0x00, 0xC3
|
||||||
|
]),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_reach_keeps_callee_saved_copies_across_a_call() {
|
||||||
|
// mov %rdi,%rbx ; call +0 ; mov 0x200(%rbx),%rax ; ret
|
||||||
|
// The counterpart: RBX is callee-saved, so the copy survives and the access IS through `this`.
|
||||||
|
assert_eq!(
|
||||||
|
reach_of(&[
|
||||||
|
0x48, 0x89, 0xFB, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8B, 0x83, 0x00, 0x02, 0x00,
|
||||||
|
0x00, 0xC3
|
||||||
|
]),
|
||||||
|
Some(0x200)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_reach_ignores_indexed_operands() {
|
||||||
|
// mov 0x900(%rdi,%rcx,8),%rax ; ret — an array walk; the displacement is a base, not a field
|
||||||
|
// offset, so its magnitude says nothing about the object's size.
|
||||||
|
assert_eq!(
|
||||||
|
reach_of(&[0x48, 0x8B, 0x84, 0xCF, 0x00, 0x09, 0x00, 0x00, 0xC3]),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_reach_is_none_when_this_is_never_dereferenced() {
|
||||||
|
// xor %eax,%eax ; ret — a constant returner touches no object at all.
|
||||||
|
assert_eq!(reach_of(&[0x31, 0xC0, 0xC3]), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
247
src/concmd.rs
247
src/concmd.rs
|
|
@ -71,11 +71,6 @@ const RSI: usize = 6;
|
||||||
const RDI: usize = 7;
|
const RDI: usize = 7;
|
||||||
const R8: usize = 8;
|
const R8: usize = 8;
|
||||||
const R9: usize = 9;
|
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
|
/// Longest string accepted as a command name. Names are identifiers; anything longer is not one, so the
|
||||||
/// cap doubles as a validity gate.
|
/// cap doubles as a validity gate.
|
||||||
const MAX_NAME: usize = 64;
|
const MAX_NAME: usize = 64;
|
||||||
|
|
@ -195,10 +190,10 @@ pub fn flag_names(flags: u64) -> Vec<&'static str> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Index 0-15 of a GPR, after widening an 8/16/32-bit name to its 64-bit parent.
|
/// Index 0-15 of a GPR, after widening an 8/16/32-bit name to its 64-bit parent.
|
||||||
|
/// The 64-bit parent register as a slot index. Thin wrapper over [`crate::abi::gp_slot`] — the mapping is a
|
||||||
|
/// fixed SysV fact, and this file only narrows it to the `u8` its `[_; 16]` arrays index by.
|
||||||
fn gpr(r: Register) -> Option<u8> {
|
fn gpr(r: Register) -> Option<u8> {
|
||||||
let f = r.full_register();
|
crate::abi::gp_slot(r).map(|s| s as u8)
|
||||||
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
|
/// What a register provably holds. `Sym` is an offset from a value we never learned — a constructor's
|
||||||
|
|
@ -233,6 +228,18 @@ impl V {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// End a register's current life, because something has overwritten it.
|
||||||
|
///
|
||||||
|
/// Called by EVERY arm that assigns to a register, not only the catch-all — which is the correction that
|
||||||
|
/// matters. `mov`, `lea` and `xor` re-point a register just as surely as an unmodelled instruction does,
|
||||||
|
/// so leaving their epoch alone let a base register be aimed at a second object while stores made against
|
||||||
|
/// the FIRST still keyed to the same `(register, epoch)` pair — and a `lea rdx,[rbx+0x1c8]` for object B
|
||||||
|
/// could then match a `mov [rbx+0x1e8],rax` that belonged to object A, attributing one constructor's
|
||||||
|
/// handler to another's registration.
|
||||||
|
fn end_life(epoch: &mut [u32; 16], d: u8) {
|
||||||
|
epoch[d as usize] = epoch[d as usize].saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
/// Address of a `this`-relative slot: base register, that register's epoch, displacement.
|
/// Address of a `this`-relative slot: base register, that register's epoch, displacement.
|
||||||
type Slot = (u8, u32, i64);
|
type Slot = (u8, u32, i64);
|
||||||
|
|
||||||
|
|
@ -285,13 +292,24 @@ fn inits_invalid_handle(img: &CodeImage, f: u64) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A plausible console-command name: short, printable, no spaces or quoting.
|
/// A plausible console-command name: short, printable, no spaces or quoting.
|
||||||
fn cmd_name(img: &CodeImage, va: u64) -> Option<String> {
|
/// Whether `s` is shaped like a console COMMAND name.
|
||||||
let s = img.read_c_string(va)?;
|
///
|
||||||
let ok = !s.is_empty()
|
/// Split from the read so a test can call the rule instead of restating it — restating it is how the
|
||||||
|
/// convar test came to assert this rule while claiming to pin the other one, and would have passed with
|
||||||
|
/// the two gates swapped.
|
||||||
|
///
|
||||||
|
/// Deliberately looser than [`is_convar_name`]: a command name may lead with punctuation, because the
|
||||||
|
/// `+bugvoice` / `-bugvoice` on/off pairs are real commands and a convar can never be spelled that way.
|
||||||
|
fn is_cmd_name(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
&& s.len() <= MAX_NAME
|
&& s.len() <= MAX_NAME
|
||||||
&& s.bytes()
|
&& s.bytes()
|
||||||
.all(|c| c.is_ascii_graphic() && c != b'"' && c != b'%');
|
.all(|c| c.is_ascii_graphic() && c != b'"' && c != b'%')
|
||||||
ok.then_some(s)
|
}
|
||||||
|
|
||||||
|
fn cmd_name(img: &CodeImage, va: u64) -> Option<String> {
|
||||||
|
let s = img.read_c_string(va)?;
|
||||||
|
is_cmd_name(&s).then_some(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every console command `img` registers.
|
/// Every console command `img` registers.
|
||||||
|
|
@ -316,10 +334,7 @@ fn collect_sites(
|
||||||
img: &CodeImage,
|
img: &CodeImage,
|
||||||
mut accept: impl FnMut(&CodeImage, u64, &[V; 16]) -> bool,
|
mut accept: impl FnMut(&CodeImage, u64, &[V; 16]) -> bool,
|
||||||
) -> Vec<Site> {
|
) -> Vec<Site> {
|
||||||
let mut entries = crate::locate::candidate_entries(img);
|
let entries = crate::locate::function_entries(img);
|
||||||
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
|
|
||||||
entries.sort_unstable();
|
|
||||||
entries.dedup();
|
|
||||||
|
|
||||||
let mut sites: Vec<Site> = Vec::new();
|
let mut sites: Vec<Site> = Vec::new();
|
||||||
let mut factory = InstructionInfoFactory::new();
|
let mut factory = InstructionInfoFactory::new();
|
||||||
|
|
@ -351,7 +366,17 @@ fn collect_sites(
|
||||||
if accept(img, t, &val) {
|
if accept(img, t, &val) {
|
||||||
found.push((t, val));
|
found.push((t, val));
|
||||||
}
|
}
|
||||||
for c in CLOBBER {
|
// A call assigns to NINE registers at once, so it ends nine lives — the arm that most
|
||||||
|
// needs the epoch bump and the one that was missing it. The Lea arm below mints a fresh
|
||||||
|
// symbolic base for any unknown-valued register, and the store arm keys that base as
|
||||||
|
// `(reg, epoch, disp)`: without the bump, `rax` after two successive calls is ONE key
|
||||||
|
// space shared by two objects, where same-displacement stores overwrite each other.
|
||||||
|
//
|
||||||
|
// Only the caller-saved nine. The `this` a constructor threads through its registrations
|
||||||
|
// is callee-saved (rbx, r12-r15) and SURVIVES, which is what makes the member-callback
|
||||||
|
// form readable at all — so the list comes from `abi`, never from a local transcription.
|
||||||
|
for c in crate::abi::caller_saved_slots() {
|
||||||
|
end_life(&mut epoch, c as u8);
|
||||||
val[c] = V::Unknown;
|
val[c] = V::Unknown;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -385,6 +410,7 @@ fn collect_sites(
|
||||||
// `lea r,[rip+d]` is a string/global/function address; `lea r,[base+d]` walks to a member.
|
// `lea r,[rip+d]` is a string/global/function address; `lea r,[base+d]` walks to a member.
|
||||||
Mnemonic::Lea => {
|
Mnemonic::Lea => {
|
||||||
if let Some(d) = gpr(insn.op0_register()) {
|
if let Some(d) = gpr(insn.op0_register()) {
|
||||||
|
end_life(&mut epoch, d);
|
||||||
val[d as usize] = if insn.is_ip_rel_memory_operand() {
|
val[d as usize] = if insn.is_ip_rel_memory_operand() {
|
||||||
V::Const(insn.ip_rel_memory_address())
|
V::Const(insn.ip_rel_memory_address())
|
||||||
} else if insn.memory_index() == Register::None {
|
} else if insn.memory_index() == Register::None {
|
||||||
|
|
@ -401,6 +427,7 @@ fn collect_sites(
|
||||||
}
|
}
|
||||||
Mnemonic::Mov => {
|
Mnemonic::Mov => {
|
||||||
if let Some(d) = gpr(insn.op0_register()) {
|
if let Some(d) = gpr(insn.op0_register()) {
|
||||||
|
end_life(&mut epoch, d);
|
||||||
val[d as usize] = match insn.op1_kind() {
|
val[d as usize] = match insn.op1_kind() {
|
||||||
OpKind::Immediate8to64
|
OpKind::Immediate8to64
|
||||||
| OpKind::Immediate32to64
|
| OpKind::Immediate32to64
|
||||||
|
|
@ -419,6 +446,7 @@ fn collect_sites(
|
||||||
Mnemonic::Xor => {
|
Mnemonic::Xor => {
|
||||||
if let (Some(d), Some(s)) = (gpr(insn.op0_register()), gpr(insn.op1_register()))
|
if let (Some(d), Some(s)) = (gpr(insn.op0_register()), gpr(insn.op1_register()))
|
||||||
{
|
{
|
||||||
|
end_life(&mut epoch, d);
|
||||||
val[d as usize] = if d == s { V::Const(0) } else { V::Unknown };
|
val[d as usize] = if d == s { V::Const(0) } else { V::Unknown };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -432,8 +460,8 @@ fn collect_sites(
|
||||||
OpAccess::Write | OpAccess::ReadWrite | OpAccess::CondWrite
|
OpAccess::Write | OpAccess::ReadWrite | OpAccess::CondWrite
|
||||||
) && let Some(d) = gpr(ur.register())
|
) && let Some(d) = gpr(ur.register())
|
||||||
{
|
{
|
||||||
|
end_life(&mut epoch, d);
|
||||||
val[d as usize] = V::Unknown;
|
val[d as usize] = V::Unknown;
|
||||||
epoch[d as usize] = epoch[d as usize].saturating_add(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -508,62 +536,6 @@ fn interpret_commands(img: &CodeImage, sites: &[Site]) -> Vec<ConsoleCommand> {
|
||||||
out
|
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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How many call sites must present the convar argument shape before a target counts as a registrar.
|
/// How many call sites must present the convar argument shape before a target counts as a registrar.
|
||||||
///
|
///
|
||||||
/// This is the whole safety margin for identifying convar registration by shape rather than by a semantic
|
/// This is the whole safety margin for identifying convar registration by shape rather than by a semantic
|
||||||
|
|
@ -597,16 +569,24 @@ pub struct ConVar {
|
||||||
/// Stricter than [`cmd_name`], which admits any printable run because commands like `+bugvoice` exist.
|
/// Stricter than [`cmd_name`], which admits any printable run because commands like `+bugvoice` exist.
|
||||||
/// A convar name is always an identifier, and the tighter gate is what keeps prose out of the name slot
|
/// A convar name is always an identifier, and the tighter gate is what keeps prose out of the name slot
|
||||||
/// when the shape test is the only thing standing between a call site and a record.
|
/// when the shape test is the only thing standing between a call site and a record.
|
||||||
fn convar_name(img: &CodeImage, va: u64) -> Option<String> {
|
/// Whether `s` is shaped like a CONVAR name — stricter than [`is_cmd_name`] in both directions: it must
|
||||||
let s = img.read_c_string(va)?;
|
/// LEAD with a letter or underscore, and its body admits only `[A-Za-z0-9_.]`.
|
||||||
let ok = !s.is_empty()
|
///
|
||||||
|
/// This gate is the only shape check between a call site and an emitted ConVar record, so it is what keeps
|
||||||
|
/// prose out of the name slot.
|
||||||
|
fn is_convar_name(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
&& s.len() <= MAX_NAME
|
&& s.len() <= MAX_NAME
|
||||||
&& s.chars()
|
&& s.chars()
|
||||||
.next()
|
.next()
|
||||||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||||
&& s.bytes()
|
&& s.bytes()
|
||||||
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'.');
|
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'.')
|
||||||
ok.then_some(s)
|
}
|
||||||
|
|
||||||
|
fn convar_name(img: &CodeImage, va: u64) -> Option<String> {
|
||||||
|
let s = img.read_c_string(va)?;
|
||||||
|
is_convar_name(&s).then_some(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Help text: prose, or nothing. Deliberately permissive about content and strict about being a real
|
/// Help text: prose, or nothing. Deliberately permissive about content and strict about being a real
|
||||||
|
|
@ -844,3 +824,110 @@ pub fn convars(img: &CodeImage, library: &str) -> Vec<ConVar> {
|
||||||
out.dedup_by(|a, b| a.name == b.name && a.addr == b.addr);
|
out.dedup_by(|a, b| a.name == b.name && a.addr == b.addr);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ---- command reader ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_measured_flag_bits_are_named() {
|
||||||
|
// bot_add ships 0x80004 = bits 2 and 19. Bit 19 is `release`; bit 2 stays unnamed because no
|
||||||
|
// name in Valve's dump matches it, and naming it anyway is the whole mistake to avoid.
|
||||||
|
assert_eq!(flag_names(0x80004), vec!["release"]);
|
||||||
|
// bot_place ships 0x4004 = bits 2 and 14 — bit 14 is `cheat`.
|
||||||
|
assert_eq!(flag_names(0x4004), vec!["cheat"]);
|
||||||
|
// A command with no flags names none, rather than falling back to a default.
|
||||||
|
assert!(flag_names(0).is_empty());
|
||||||
|
// Every listed bit is distinct and in range.
|
||||||
|
let mut seen: Vec<u32> = FLAG_BITS.iter().map(|&(b, _)| b).collect();
|
||||||
|
seen.sort_unstable();
|
||||||
|
seen.dedup();
|
||||||
|
assert_eq!(seen.len(), FLAG_BITS.len());
|
||||||
|
assert!(FLAG_BITS.iter().all(|&(b, _)| b < 64));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn symbolic_offsets_keep_the_base_and_track_the_epoch() {
|
||||||
|
// A `this`-relative walk composes, so `lea rax,[rbx+0x1c8]` then `lea rdx,[rax+0x40]` addresses
|
||||||
|
// the same object the constructor stored into.
|
||||||
|
assert_eq!(V::Sym(3, 0, 0x1c8).offset(0x40), V::Sym(3, 0, 0x208));
|
||||||
|
// A constant walk stays constant.
|
||||||
|
assert_eq!(V::Const(0x1000).offset(8), V::Const(0x1008));
|
||||||
|
// Nothing is invented from nothing.
|
||||||
|
assert_eq!(V::Unknown.offset(8), V::Unknown);
|
||||||
|
// Two runs of the same register never address each other's slots.
|
||||||
|
assert_ne!(V::Sym(3, 0, 0x1c8), V::Sym(3, 1, 0x1c8));
|
||||||
|
// Only a constant is a usable address.
|
||||||
|
assert_eq!(V::Const(7).konst(), Some(7));
|
||||||
|
assert_eq!(V::Sym(3, 0, 7).konst(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_arm_that_clobbers_a_register_ends_its_life() {
|
||||||
|
// The invariant `end_life` documents, checked against the arm that breaks it most cheaply. A
|
||||||
|
// CALL assigns to nine caller-saved registers at once; leaving their epochs alone made `rax`
|
||||||
|
// after two successive calls one key space shared by two objects, so a store to `[rax+0x18]`
|
||||||
|
// made through the FIRST could be read back as a slot of the SECOND — and the member-callback
|
||||||
|
// recovery ships whatever executable pointer that merged window holds.
|
||||||
|
let mut epoch = [0u32; 16];
|
||||||
|
let clobber = crate::abi::caller_saved_slots();
|
||||||
|
let before: Vec<u32> = clobber.iter().map(|&c| epoch[c]).collect();
|
||||||
|
for c in clobber {
|
||||||
|
end_life(&mut epoch, c as u8);
|
||||||
|
}
|
||||||
|
for (i, &c) in clobber.iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
epoch[c],
|
||||||
|
before[i] + 1,
|
||||||
|
"register {c} kept its epoch across a call"
|
||||||
|
);
|
||||||
|
// …and the two runs are therefore distinguishable keys, which is the point.
|
||||||
|
assert_ne!(
|
||||||
|
V::Sym(c as u8, before[i], 0x18),
|
||||||
|
V::Sym(c as u8, epoch[c], 0x18)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A callee-saved register is NOT clobbered: the registration `this` a constructor threads
|
||||||
|
// through survives the call, which is what the epoch widening was for in the first place.
|
||||||
|
for saved in [3u8 /* rbx */, 12, 13, 14, 15] {
|
||||||
|
assert!(
|
||||||
|
!clobber.contains(&(saved as usize)),
|
||||||
|
"r{saved} is callee-saved and must survive a call"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_command_name_is_an_identifier_not_prose() {
|
||||||
|
// Calls the shipped rule, for the same reason as the convar test below it.
|
||||||
|
assert!(is_cmd_name("bot_add"));
|
||||||
|
assert!(is_cmd_name("+bugvoice")); // an on/off pair is a real command name
|
||||||
|
assert!(!is_cmd_name("")); // an empty string is not a name
|
||||||
|
assert!(!is_cmd_name("Adds a bot matching the given criteria.")); // a description (spaces)
|
||||||
|
assert!(!is_cmd_name("%s: no varname specified\n")); // a format string
|
||||||
|
assert!(!is_cmd_name(&"x".repeat(MAX_NAME + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- convar reader ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_convar_name_is_stricter_than_a_command_name() {
|
||||||
|
// CALLS the gate rather than restating it. The previous version of this test re-implemented the
|
||||||
|
// COMMAND rule and asserted only inputs both rules agree on, so it would have passed with the two
|
||||||
|
// gates swapped — the exact regression it is named for.
|
||||||
|
assert!(is_convar_name("sv_cheats"));
|
||||||
|
assert!(is_convar_name("mp_roundtime_defuse"));
|
||||||
|
assert!(is_convar_name("_internal.thing")); // leading underscore and a dot are both legal
|
||||||
|
assert!(!is_convar_name(""));
|
||||||
|
assert!(!is_convar_name("Set to 1 to enable cheats")); // help text, not a name
|
||||||
|
assert!(!is_convar_name(&"x".repeat(MAX_NAME + 1)));
|
||||||
|
|
||||||
|
// The DISCRIMINATING cases — the ones that fail if the two gates are confused. A command may lead
|
||||||
|
// with punctuation (the `+`/`-` on/off pairs); a convar may not, and admits no other punctuation.
|
||||||
|
assert!(is_cmd_name("+bugvoice") && !is_convar_name("+bugvoice"));
|
||||||
|
assert!(is_cmd_name("1st_arg") && !is_convar_name("1st_arg")); // digit-led
|
||||||
|
assert!(is_cmd_name("say/all") && !is_convar_name("say/all"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
25
src/elf.rs
25
src/elf.rs
|
|
@ -109,6 +109,21 @@ fn kind_tag_of(sym: &str) -> Option<KindTag> {
|
||||||
const PT_GNU_EH_FRAME: u32 = 0x6474_e550;
|
const PT_GNU_EH_FRAME: u32 = 0x6474_e550;
|
||||||
|
|
||||||
impl CodeImage {
|
impl CodeImage {
|
||||||
|
/// A bare image wrapping one executable span — enough for a decoder test to run the REAL analysis
|
||||||
|
/// over hand-assembled bytes instead of a parallel mock of it.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn for_test(vaddr: u64, code: &[u8]) -> Self {
|
||||||
|
Self {
|
||||||
|
data: code.to_vec(),
|
||||||
|
exec: vec![(0, vaddr, code.len())],
|
||||||
|
secs: Vec::new(),
|
||||||
|
sym_addr: HashMap::new(),
|
||||||
|
reloc: HashMap::new(),
|
||||||
|
reloc_by_val: HashMap::new(),
|
||||||
|
kind_at: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load(path: &Path) -> Result<Self> {
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
|
let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
|
||||||
Self::from_bytes(data)
|
Self::from_bytes(data)
|
||||||
|
|
@ -406,6 +421,16 @@ impl CodeImage {
|
||||||
self.data_at(vaddr, 8).map(|b| u64le(b, 0))
|
self.data_at(vaddr, 8).map(|b| u64le(b, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Does a relocation land ON this slot — i.e. is the qword here a POINTER the linker resolved,
|
||||||
|
/// rather than a compile-time literal?
|
||||||
|
///
|
||||||
|
/// The distinction is what separates two records that are otherwise byte-compatible: a table of
|
||||||
|
/// `{ name, integer }` pairs and a table of `{ name, pointer }` pairs read identically until you ask
|
||||||
|
/// whether the second word was relocated.
|
||||||
|
pub fn is_reloc_slot(&self, vaddr: u64) -> bool {
|
||||||
|
self.reloc.contains_key(&vaddr)
|
||||||
|
}
|
||||||
|
|
||||||
/// Slot vaddrs whose (relocated) pointer value equals `target`.
|
/// Slot vaddrs whose (relocated) pointer value equals `target`.
|
||||||
pub fn ptrs_to(&self, target: u64) -> &[u64] {
|
pub fn ptrs_to(&self, target: u64) -> &[u64] {
|
||||||
self.reloc_by_val.get(&target).map_or(&[], |v| v.as_slice())
|
self.reloc_by_val.get(&target).map_or(&[], |v| v.as_slice())
|
||||||
|
|
|
||||||
26
src/lib.rs
26
src/lib.rs
|
|
@ -5,11 +5,16 @@
|
||||||
//! out and scraping stdout.
|
//! out and scraping stdout.
|
||||||
//!
|
//!
|
||||||
//! # Supported API surface
|
//! # Supported API surface
|
||||||
//! A fork or embedder calls into these. Every engine entry point takes an explicit `&profile::GameProfile`
|
//! A fork or embedder calls into these. Every engine entry point that needs game-specific knowledge takes
|
||||||
//! (there is NO process-global — CS2 and Dota can be derived in the same process):
|
//! an explicit `&profile::GameProfile` — there is NO process-global, so CS2 and Dota can be derived in the
|
||||||
|
//! same process. (`classify_change_cmd` is the one exception, and takes none because it needs none: it
|
||||||
|
//! compares two builds of one named library and reads nothing game-specific.)
|
||||||
//! - [`pipeline`] — the pure OFFLINE derivation engine (nothing here attaches to a running server):
|
//! - [`pipeline`] — the pure OFFLINE derivation engine (nothing here attaches to a running server):
|
||||||
//! `corpus_model_cmd` (distill the corpus model), `fold_model_cmd` (roll model N → N+1), `backfill_cmd`
|
//! `corpus_model_cmd` (distill the corpus model, taking a [`pipeline::ClassScope`]), `fold_model_cmd`
|
||||||
//! (cross-build name/offset timelines), plus the `ClassScope` / `CorpusSource` inputs.
|
//! (roll model N → N+1, over a [`pipeline::CorpusModel`] that [`pipeline::load_model`] reads off disk —
|
||||||
|
//! the only way to build its first argument), `backfill_cmd` (cross-build name/offset timelines). The
|
||||||
|
//! derive that consumes a corpus source is reached through `produce::produce_cmd`, which builds one
|
||||||
|
//! internally from its `--corpus` / `--corpus-model` arguments — `CorpusSource` itself is crate-private.
|
||||||
//! - [`produce`] — CI orchestration + the LIVE half (everything that drives a running server): `produce_cmd`
|
//! - [`produce`] — CI orchestration + the LIVE half (everything that drives a running server): `produce_cmd`
|
||||||
//! (the whole per-game build — boots its own bots server for validate-live + typed netvars when a game is
|
//! (the whole per-game build — boots its own bots server for validate-live + typed netvars when a game is
|
||||||
//! given), `integration_test_cmd` (the standalone live oracle), `classify_change_cmd` / `filter_corpus_cmd`
|
//! given), `integration_test_cmd` (the standalone live oracle), `classify_change_cmd` / `filter_corpus_cmd`
|
||||||
|
|
@ -20,8 +25,10 @@
|
||||||
//!
|
//!
|
||||||
//! # Low-level engine (implementation detail)
|
//! # Low-level engine (implementation detail)
|
||||||
//! The modules below are the building blocks the API composes (ELF/RTTI/SchemaSystem readers, the fingerprint
|
//! The modules below are the building blocks the API composes (ELF/RTTI/SchemaSystem readers, the fingerprint
|
||||||
//! metric, the sig/abi machinery, the data-parallel primitive, the name taxonomy). They stay `pub` for the fuzz
|
//! metric, the sig/abi machinery, the data-parallel primitive). They stay `pub` for the fuzz harness and
|
||||||
//! harness and advanced embedders, but carry NO stability promise — treat them as internal.
|
//! advanced embedders, but carry NO stability promise — treat them as internal. The name taxonomy is NOT
|
||||||
|
//! among them: it is crate-private, because the knob a fork retunes is the `GameProfile` vocabulary block
|
||||||
|
//! those predicates read, not the predicates.
|
||||||
|
|
||||||
// ---- supported API ----
|
// ---- supported API ----
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
|
@ -42,10 +49,15 @@ 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 valvetab;
|
pub mod valvetab;
|
||||||
|
pub mod vscript;
|
||||||
pub mod xref;
|
pub mod xref;
|
||||||
|
|
||||||
|
// ---- crate-private ----
|
||||||
|
// The name taxonomy: every item is `pub(crate)`, so publishing the module published an empty page. The
|
||||||
|
// per-game vocabulary it reads is the fork-retunable part, and that is already `pub` on `GameProfile`.
|
||||||
|
mod taxonomy;
|
||||||
|
|
||||||
// The canonical model + emitters live in the deriver-free `source2rosetta-core` crate; re-export them so
|
// The canonical model + emitters live in the deriver-free `source2rosetta-core` crate; re-export them so
|
||||||
// existing `source2rosetta::{model, render}` paths keep resolving.
|
// existing `source2rosetta::{model, render}` paths keep resolving.
|
||||||
pub use source2rosetta_core::{model, render};
|
pub use source2rosetta_core::{model, render};
|
||||||
|
|
|
||||||
95
src/live.rs
95
src/live.rs
|
|
@ -1,6 +1,15 @@
|
||||||
//! Read-only window into a *running* CS2 server's memory — the runtime oracle that verifies the
|
//! Window into a *running* CS2 server — the runtime oracle that verifies the offline derivations against
|
||||||
//! offline derivations against ground truth. No injection, no debugger: just `/proc/<pid>/mem` (needs
|
//! ground truth. Needs ptrace access (same-user with `yama/ptrace_scope=0`, or `CAP_SYS_PTRACE`).
|
||||||
//! ptrace access — same-user with `yama/ptrace_scope=0`, or `CAP_SYS_PTRACE`).
|
//!
|
||||||
|
//! **Mostly reading, but not only reading, and the difference is worth stating plainly.** The bulk of this
|
||||||
|
//! module reads `/proc/<pid>/mem`. Two things go further: `poke_bytes` writes bytes in with
|
||||||
|
//! `PTRACE_POKEDATA`, and [`call_remote`] ATTACHES, saves the main thread's registers, builds a call frame
|
||||||
|
//! and executes a function in the live process before restoring the thread exactly. Both exist because
|
||||||
|
//! some claims cannot be checked any other way — a lazy-init singleton is zeroed until something calls its
|
||||||
|
//! accessor — and both are used only against the narrow set of functions the derivation has already
|
||||||
|
//! measured as safe to call (nullary, `this`-only, no game state). Nothing is injected and nothing
|
||||||
|
//! persists: the process is left as it was found, and a faulting call is caught and the thread restored
|
||||||
|
//! rather than allowed to kill the server.
|
||||||
//!
|
//!
|
||||||
//! Offline we resolve `.rela.dyn` by hand to recover as-loaded pointer values; the running process is
|
//! Offline we resolve `.rela.dyn` by hand to recover as-loaded pointer values; the running process is
|
||||||
//! the authority on what those values actually are. So reading the same structures live and comparing
|
//! the authority on what those values actually are. So reading the same structures live and comparing
|
||||||
|
|
@ -37,6 +46,19 @@ fn maps_path(line: &str) -> &str {
|
||||||
rest.trim_start()
|
rest.trim_start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The scheduler state character from `/proc/<pid>/stat` (`R`/`S`/`D`/`Z`/`T`/…), or `None` if the process
|
||||||
|
/// is gone entirely. Parsed from AFTER the final `)`, because the comm field is parenthesised and may itself
|
||||||
|
/// contain spaces and brackets — splitting the line on whitespace from the left gets this wrong for any
|
||||||
|
/// process whose name has a space in it.
|
||||||
|
fn proc_state(pid: u32) -> Option<char> {
|
||||||
|
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||||
|
stat[stat.rfind(')')? + 1..]
|
||||||
|
.split_whitespace()
|
||||||
|
.next()?
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
}
|
||||||
|
|
||||||
impl LiveProcess {
|
impl LiveProcess {
|
||||||
pub fn attach(pid: u32) -> Result<Self> {
|
pub fn attach(pid: u32) -> Result<Self> {
|
||||||
let maps = std::fs::read_to_string(format!("/proc/{pid}/maps"))
|
let maps = std::fs::read_to_string(format!("/proc/{pid}/maps"))
|
||||||
|
|
@ -80,10 +102,30 @@ impl LiveProcess {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
executable.sort_unstable();
|
executable.sort_unstable();
|
||||||
|
// Distinguish the two ways this fails, because they call for opposite responses and the kernel
|
||||||
|
// reports BOTH as EACCES. If the process is gone, `/proc/<pid>` is gone with it — so check that
|
||||||
|
// first: a server that CRASHED mid-derive otherwise reads as a permissions problem, and the
|
||||||
|
// operator goes off tuning `ptrace_scope` for a fault that had nothing to do with it. (Seen: a
|
||||||
|
// CS2 server crashed in Steam auth and this line blamed ptrace.)
|
||||||
let mem = File::open(format!("/proc/{pid}/mem")).with_context(|| {
|
let mem = File::open(format!("/proc/{pid}/mem")).with_context(|| {
|
||||||
format!(
|
match proc_state(pid) {
|
||||||
|
// A crashed child stays a ZOMBIE until the parent reaps it, so `/proc/<pid>` still exists
|
||||||
|
// and only `mem` is unreadable — an existence check alone reports it as a permissions
|
||||||
|
// fault. Read the state instead.
|
||||||
|
Some('Z') | None => format!(
|
||||||
|
"the game process {pid} DIED during the live stage — it is {}, so there is nothing \
|
||||||
|
left to read. This is NOT a ptrace-permission problem: check the server's own log \
|
||||||
|
and /tmp/dumps for a minidump.",
|
||||||
|
if proc_state(pid) == Some('Z') {
|
||||||
|
"a zombie (crashed, not yet reaped)"
|
||||||
|
} else {
|
||||||
|
"gone"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Some(_) => format!(
|
||||||
"open /proc/{pid}/mem — needs ptrace access (yama ptrace_scope=0 or run as root)"
|
"open /proc/{pid}/mem — needs ptrace access (yama ptrace_scope=0 or run as root)"
|
||||||
)
|
),
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
mem,
|
mem,
|
||||||
|
|
@ -207,9 +249,6 @@ impl LiveProcess {
|
||||||
pub struct CallResult {
|
pub struct CallResult {
|
||||||
pub rax: u64,
|
pub rax: u64,
|
||||||
pub clean_return: bool,
|
pub clean_return: bool,
|
||||||
/// Where the scratch blob was placed, so the caller can read back what the callee wrote into it.
|
|
||||||
/// Zero when the call carried no scratch.
|
|
||||||
pub scratch_base: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One argument to a remote call.
|
/// One argument to a remote call.
|
||||||
|
|
@ -234,6 +273,13 @@ pub struct Scratch<'a> {
|
||||||
pub relocs: &'a [(usize, i64)],
|
pub relocs: &'a [(usize, i64)],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long an injected call may run before it is abandoned and the thread restored.
|
||||||
|
///
|
||||||
|
/// Generous by design: every call site here is a nullary accessor or a `this`-only query, which returns in
|
||||||
|
/// microseconds, so a second is four orders of magnitude of headroom and only a genuinely stuck callee
|
||||||
|
/// reaches it. `clean_return: false` is then the honest verdict — the same one a faulting call gets.
|
||||||
|
const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
|
||||||
|
|
||||||
/// Write `data` into the target at `addr`, a word at a time.
|
/// Write `data` into the target at `addr`, a word at a time.
|
||||||
///
|
///
|
||||||
/// A trailing partial word is read back and merged rather than zero-filled: `PTRACE_POKEDATA` writes a
|
/// A trailing partial word is read back and merged rather than zero-filled: `PTRACE_POKEDATA` writes a
|
||||||
|
|
@ -413,10 +459,37 @@ pub fn call_remote_ex(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run, absorbing any spurious signals, until the function returns into our null trap.
|
// Run, absorbing any spurious signals, until the function returns into our null trap — or until
|
||||||
|
// the deadline. BOUNDED, because the alternative is unbounded: the injected callee is chosen to
|
||||||
|
// be leaf-ish, but "chosen to be" is not "proven to be", and one that blocks on a lock, a socket
|
||||||
|
// or a condition variable would park this `waitpid` forever with the tracee STOPPED — hanging a
|
||||||
|
// CI derive with no output and no timeout above it. A live check that cannot finish is a failed
|
||||||
|
// live check, not a reason to stop the release from ever being decided.
|
||||||
|
let deadline = std::time::Instant::now() + CALL_TIMEOUT;
|
||||||
loop {
|
loop {
|
||||||
libc::ptrace(libc::PTRACE_CONT, pid, 0usize, 0usize);
|
libc::ptrace(libc::PTRACE_CONT, pid, 0usize, 0usize);
|
||||||
if libc::waitpid(pid, &mut status, 0) < 0 || !libc::WIFSTOPPED(status) {
|
// Polled rather than blocking, so the deadline is observable at all.
|
||||||
|
let waited = loop {
|
||||||
|
let r = libc::waitpid(pid, &mut status, libc::WNOHANG);
|
||||||
|
if r != 0 {
|
||||||
|
break r;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
break 0;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||||
|
};
|
||||||
|
if waited == 0 {
|
||||||
|
// Still RUNNING, so `restore` would fail ESRCH — stop it first, then put it back exactly.
|
||||||
|
libc::kill(pid, libc::SIGSTOP);
|
||||||
|
libc::waitpid(pid, &mut status, 0);
|
||||||
|
restore(&saved);
|
||||||
|
return Ok(CallResult {
|
||||||
|
rax: 0,
|
||||||
|
clean_return: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if waited < 0 || !libc::WIFSTOPPED(status) {
|
||||||
restore(&saved);
|
restore(&saved);
|
||||||
bail!("target vanished mid-call (status {status:#x})");
|
bail!("target vanished mid-call (status {status:#x})");
|
||||||
}
|
}
|
||||||
|
|
@ -433,7 +506,6 @@ pub fn call_remote_ex(
|
||||||
let r = CallResult {
|
let r = CallResult {
|
||||||
rax: cur.rax,
|
rax: cur.rax,
|
||||||
clean_return: true,
|
clean_return: true,
|
||||||
scratch_base,
|
|
||||||
};
|
};
|
||||||
restore(&saved);
|
restore(&saved);
|
||||||
return Ok(r);
|
return Ok(r);
|
||||||
|
|
@ -442,7 +514,6 @@ pub fn call_remote_ex(
|
||||||
let r = CallResult {
|
let r = CallResult {
|
||||||
rax: cur.rax,
|
rax: cur.rax,
|
||||||
clean_return: false,
|
clean_return: false,
|
||||||
scratch_base,
|
|
||||||
};
|
};
|
||||||
restore(&saved);
|
restore(&saved);
|
||||||
return Ok(r);
|
return Ok(r);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,23 @@ use iced_x86::{Decoder, DecoderOptions, FlowControl, OpKind};
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Every plausible function ENTRY in the image, sorted and deduped: relocation code-pointers (every vtable
|
||||||
|
/// slot, every stored function pointer) ∪ decoded `call` targets ∪ `.eh_frame` FDE starts.
|
||||||
|
///
|
||||||
|
/// The union is the point, and it is why this is one function rather than four lines repeated. CS2 strips
|
||||||
|
/// `.eh_frame` from the game code — the FDE list covers the statically-linked runtime tail, roughly 8,327
|
||||||
|
/// of libserver's ~70,000 functions — so an FDE-only list misses the entire gameplay region, while a
|
||||||
|
/// relocation/call-target-only list misses the runtime tail that has no code pointer taken. Six callers
|
||||||
|
/// need exactly this set: the xref index, the ConVar and VScript readers, the change digest, and both
|
||||||
|
/// anchor passes. A fork adding PLT or ifunc entries edits here, once.
|
||||||
|
pub fn function_entries(img: &CodeImage) -> Vec<u64> {
|
||||||
|
let mut entries = candidate_entries(img);
|
||||||
|
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
|
||||||
|
entries.sort_unstable();
|
||||||
|
entries.dedup();
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
/// Every plausible function entry address in `img`: relocation values that point into code, plus
|
/// Every plausible function entry address in `img`: relocation values that point into code, plus
|
||||||
/// the targets of direct near `call`s found by a linear sweep. Sorted, de-duplicated.
|
/// the targets of direct near `call`s found by a linear sweep. Sorted, de-duplicated.
|
||||||
pub fn candidate_entries(img: &CodeImage) -> Vec<u64> {
|
pub fn candidate_entries(img: &CodeImage) -> Vec<u64> {
|
||||||
|
|
|
||||||
143
src/main.rs
143
src/main.rs
|
|
@ -1,5 +1,7 @@
|
||||||
//! source2rosetta — CLI front-end. A thin clap layer over `source2rosetta::pipeline`: parse args,
|
//! source2rosetta — CLI front-end. A thin clap layer over BOTH engine halves —
|
||||||
//! select the game profile, dispatch to the engine.
|
//! `source2rosetta::pipeline` (the offline derivation engine) and `source2rosetta::produce` (CI
|
||||||
|
//! orchestration plus everything that drives a running server): parse args, select the game profile,
|
||||||
|
//! dispatch.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
|
@ -53,12 +55,14 @@ enum Cmd {
|
||||||
/// Server library to derive from; defaults to the active game's server lib.
|
/// Server library to derive from; defaults to the active game's server lib.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
lib: Option<String>,
|
lib: Option<String>,
|
||||||
/// Seconds to wait for the server to come up and bots to spawn alive.
|
/// Seconds to wait for the server to come up and reach its readiness anchor — an alive bot pawn
|
||||||
|
/// for a pawn game, a live `ready_class` instance otherwise.
|
||||||
#[arg(long, default_value_t = 60)]
|
#[arg(long, default_value_t = 60)]
|
||||||
wait: u64,
|
wait: u64,
|
||||||
#[arg(long)] // default resolved from the active game profile at dispatch
|
#[arg(long)] // default resolved from the active game profile at dispatch
|
||||||
map: Option<String>,
|
map: Option<String>,
|
||||||
/// Number of bots to fill the server with.
|
/// Number of bots to fill the server with. A pawn-less game uses this only to size `-maxplayers`;
|
||||||
|
/// nothing waits for a bot pawn there.
|
||||||
#[arg(long, default_value_t = 9)]
|
#[arg(long, default_value_t = 9)]
|
||||||
bots: u32,
|
bots: u32,
|
||||||
/// Optional gamedata json to also validate-live against the running server.
|
/// Optional gamedata json to also validate-live against the running server.
|
||||||
|
|
@ -66,23 +70,28 @@ enum Cmd {
|
||||||
gamedata: Option<PathBuf>,
|
gamedata: Option<PathBuf>,
|
||||||
/// Write the validated (kept) gamedata here (with --gamedata) — so this one command owns the
|
/// Write the validated (kept) gamedata here (with --gamedata) — so this one command owns the
|
||||||
/// server AND persists the live-validated result, no separate validate-live needed.
|
/// server AND persists the live-validated result, no separate validate-live needed.
|
||||||
#[arg(long)]
|
#[arg(long, requires = "gamedata")]
|
||||||
out: Option<PathBuf>,
|
out: Option<PathBuf>,
|
||||||
/// Leave the launched server running instead of killing it after the test.
|
/// Leave the launched server running instead of killing it after the test.
|
||||||
#[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). Runs against the server `produce` already launched; there is no separate command for it.
|
/// (0 = off). PAWN GAMES ONLY — a pawn-less game runs no live fuzz. Runs against the server THIS
|
||||||
|
/// command launched: `integration-test` boots its own and does not attach to one `produce` left
|
||||||
|
/// behind.
|
||||||
#[arg(long, default_value_t = 500)]
|
#[arg(long, default_value_t = 500)]
|
||||||
fuzz_iterations: usize,
|
fuzz_iterations: usize,
|
||||||
},
|
},
|
||||||
/// The whole per-game build in ONE in-memory command: derive → fold → (if `--game-dir` is given)
|
/// The whole per-game build in ONE in-memory command: derive → fold → (if `--game-dir` is given)
|
||||||
/// validate-live + typed netvars → fold model, writing the release set (`gamedata-`/`netvars-`/`model-`/
|
/// validate-live + typed netvars → merge → fold model, writing the release set
|
||||||
/// `manifest`) into --out-dir. No per-stage intermediate files. **Pass `--game-dir` for a full,
|
/// (`rosetta-<game>.json` + `manifest.json`, plus `model-<game>.json` when `--corpus-model` was the
|
||||||
/// live-validated build; omit it for a fast OFFLINE build (gamedata + model only, no server).**
|
/// source — the sidecar is that model rolled N → N+1, so a `--corpus` genesis run writes two files,
|
||||||
|
/// not three) into --out-dir. No per-stage intermediate files. **Pass `--game-dir` for a full,
|
||||||
|
/// live-validated build; omit it for a fast OFFLINE build (no server, so no live validation and a
|
||||||
|
/// `null` schema).**
|
||||||
Produce {
|
Produce {
|
||||||
/// A launchable game install → the FULL build (boots a server for validate-live + typed netvars).
|
/// A launchable game install → the FULL build (boots a server for validate-live + typed netvars).
|
||||||
/// OMIT for an offline build (gamedata + model only). The offline/full switch — no separate flag.
|
/// OMIT for an offline build. The offline/full switch — no separate flag.
|
||||||
#[arg(long = "game-dir")]
|
#[arg(long = "game-dir")]
|
||||||
game_dir: Option<PathBuf>,
|
game_dir: Option<PathBuf>,
|
||||||
/// Dir holding the on-disk libs for make-sig + live validation (defaults to --game-dir, else --target).
|
/// Dir holding the on-disk libs for make-sig + live validation (defaults to --game-dir, else --target).
|
||||||
|
|
@ -91,12 +100,13 @@ enum Cmd {
|
||||||
/// Server library to derive from; defaults to the active game's server lib.
|
/// Server library to derive from; defaults to the active game's server lib.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
lib: Option<String>,
|
lib: Option<String>,
|
||||||
/// One bundled seed (catalogue + naming sections) — the release form. Replaces the loose
|
/// One bundled seed (catalogue + naming sections) — the release form. Carries everything the loose
|
||||||
/// --catalogue/--promotable/--candidates/--full-names/--extra-offsets/--extra-sigs flags.
|
/// --catalogue/--promotable/--candidates/--full-names/--extra-offsets/--extra-sigs flags carry, and
|
||||||
|
/// CONFLICTS with each of them: pass one form or the other, never a mix.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
seed: Option<PathBuf>,
|
seed: Option<PathBuf>,
|
||||||
/// Function catalogue (loose form; omit when using --seed).
|
/// Function catalogue (loose form; omit when using --seed).
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
catalogue: Option<PathBuf>,
|
catalogue: Option<PathBuf>,
|
||||||
/// Corpus-signal source A: the raw build binaries to fingerprint on the fly. Exactly ONE of
|
/// Corpus-signal source A: the raw build binaries to fingerprint on the fly. Exactly ONE of
|
||||||
/// --corpus / --corpus-model is required (--corpus-model is the production forward-derive path).
|
/// --corpus / --corpus-model is required (--corpus-model is the production forward-derive path).
|
||||||
|
|
@ -104,7 +114,7 @@ enum Cmd {
|
||||||
corpus: Option<PathBuf>,
|
corpus: Option<PathBuf>,
|
||||||
/// Corpus-signal source B: a distilled `model-<game>.json` — forward-derives from the model + only the
|
/// Corpus-signal source B: a distilled `model-<game>.json` — forward-derives from the model + only the
|
||||||
/// target binary (no corpus). Also triggers the sidecar fold (model N → N+1). See --corpus.
|
/// target binary (no corpus). Also triggers the sidecar fold (model N → N+1). See --corpus.
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "corpus")]
|
||||||
corpus_model: Option<PathBuf>,
|
corpus_model: Option<PathBuf>,
|
||||||
/// The build DIRECTORY to DERIVE gamedata from — the primary input (its libs are searched by name).
|
/// The build DIRECTORY to DERIVE gamedata from — the primary input (its libs are searched by name).
|
||||||
/// A bare `.so` path is not searched; pass the directory that contains it. REQUIRED.
|
/// A bare `.so` path is not searched; pass the directory that contains it. REQUIRED.
|
||||||
|
|
@ -112,27 +122,31 @@ enum Cmd {
|
||||||
target: PathBuf,
|
target: PathBuf,
|
||||||
/// Optional: names eligible for promotion into high_confidence (from the naming producer flow).
|
/// Optional: names eligible for promotion into high_confidence (from the naming producer flow).
|
||||||
/// Omit to promote nothing — the catalogue still derives in full.
|
/// Omit to promote nothing — the catalogue still derives in full.
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
promotable: Option<PathBuf>,
|
promotable: Option<PathBuf>,
|
||||||
/// Optional: prefiltered per-address context for those names (`{"candidates": [...]}`). Omit for none.
|
/// Optional: prefiltered per-address context for those names (`{"candidates": [...]}`). Omit for none.
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
candidates: Option<PathBuf>,
|
candidates: Option<PathBuf>,
|
||||||
/// Optional: the full-slice name universe. When set, the monolith also carries an `experimental`
|
/// Optional: the full-slice name universe. When set, the monolith also carries an `experimental`
|
||||||
/// tier — the least-filtered inclusion band (every name guess, graded, each with a resolvable
|
/// tier — the least-filtered inclusion band (every name guess, graded, each with a resolvable
|
||||||
/// locator but an UNVERIFIED name).
|
/// locator but an UNVERIFIED name).
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
full_names: Option<PathBuf>,
|
full_names: Option<PathBuf>,
|
||||||
/// Multilib ground-truth vtable offsets to fold as high_confidence — `{lib: [{name,class,slot}]}`
|
/// Multilib ground-truth vtable offsets to fold as high_confidence — `{lib: [{name,class,slot}]}`
|
||||||
/// (e.g. the macOS symbol transfer). Folded directly, bypassing the candidate gate.
|
/// (e.g. the macOS symbol transfer). Folded directly, bypassing the candidate gate.
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
extra_offsets: Option<PathBuf>,
|
extra_offsets: Option<PathBuf>,
|
||||||
/// 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, conflicts_with = "seed")]
|
||||||
extra_sigs: Option<PathBuf>,
|
extra_sigs: Option<PathBuf>,
|
||||||
/// Declared C++ prototypes (`mappings/prototypes.json`) to judge against this build's measured
|
/// 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.
|
/// register footprints. Static repo input — omit and no function carries a declared prototype.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
prototypes: Option<PathBuf>,
|
prototypes: Option<PathBuf>,
|
||||||
|
/// Authored function descriptions (`mappings/semantics-<game>.json`), folded in beside each
|
||||||
|
/// function. Static repo input, keyed on the NAME — omit and no function carries one.
|
||||||
|
#[arg(long)]
|
||||||
|
semantics: Option<PathBuf>,
|
||||||
/// Valve's naming for the entity class behind each `PVAL_EHANDLE` Pulse parameter
|
/// 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
|
/// (`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
|
/// addresses prove are the same type. Static repo input — omit and the bindings artifact simply
|
||||||
|
|
@ -160,12 +174,12 @@ enum Cmd {
|
||||||
/// Distill the whole corpus into a shippable model (vtable-alignment hops + reference fingerprints
|
/// Distill the whole corpus into a shippable model (vtable-alignment hops + reference fingerprints
|
||||||
/// + slot timelines) so derivation needs only the model + the target binary, not the 86 GB corpus.
|
/// + slot timelines) so derivation needs only the model + the target binary, not the 86 GB corpus.
|
||||||
CorpusModel {
|
CorpusModel {
|
||||||
/// One bundled seed — the release form; its catalogue section is what gets distilled. Replaces the
|
/// One bundled seed — the release form; its catalogue section is what gets distilled. CONFLICTS with
|
||||||
/// loose --catalogue (naming sections are ignored here — the model tracks catalogue names only).
|
/// the loose --catalogue (naming sections are ignored here — the model tracks catalogue names only).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
seed: Option<PathBuf>,
|
seed: Option<PathBuf>,
|
||||||
/// Function catalogue (loose form; omit when using --seed).
|
/// Function catalogue (loose form; omit when using --seed).
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
catalogue: Option<PathBuf>,
|
catalogue: Option<PathBuf>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
corpus: PathBuf,
|
corpus: PathBuf,
|
||||||
|
|
@ -184,11 +198,12 @@ enum Cmd {
|
||||||
/// The existing model N (carries the `abi_obs` window the fold re-windows).
|
/// The existing model N (carries the `abi_obs` window the fold re-windows).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
model: PathBuf,
|
model: PathBuf,
|
||||||
/// One bundled seed — the release form; its catalogue section is folded. Replaces the loose --catalogue.
|
/// One bundled seed — the release form; its catalogue section is folded. CONFLICTS with the loose
|
||||||
|
/// --catalogue.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
seed: Option<PathBuf>,
|
seed: Option<PathBuf>,
|
||||||
/// Function catalogue (loose form; omit when using --seed). Must match the model's distill catalogue.
|
/// Function catalogue (loose form; omit when using --seed). Must match the model's distill catalogue.
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
catalogue: Option<PathBuf>,
|
catalogue: Option<PathBuf>,
|
||||||
/// The one new build dir to fold in (holds the just-updated libserver.so etc.).
|
/// The one new build dir to fold in (holds the just-updated libserver.so etc.).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
|
@ -228,7 +243,8 @@ enum Cmd {
|
||||||
out: Option<PathBuf>,
|
out: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Classify how much a library changed between two builds — the CI branch primitive. Enumerates every
|
/// Classify how much a library changed between two builds — the CI branch primitive. Enumerates every
|
||||||
/// function (`.eh_frame`) in each build and compares their bodies with the position-dependent bytes
|
/// function in each build (relocation code-pointers ∪ decoded call targets ∪ `.eh_frame` starts —
|
||||||
|
/// the FDE list alone covers ~12% of these binaries) and compares their bodies with the position-dependent bytes
|
||||||
/// (RIP-relative displacements + near-branch targets) masked out, so the verdict is shift-invariant:
|
/// (RIP-relative displacements + near-branch targets) masked out, so the verdict is shift-invariant:
|
||||||
/// a pure layout move (bodies unchanged, addresses shifted) reads as UNCHANGED, unlike a raw byte diff.
|
/// a pure layout move (bodies unchanged, addresses shifted) reads as UNCHANGED, unlike a raw byte diff.
|
||||||
/// Prints `skip` (nothing meaningful changed → no release), `normal` (an ordinary patch → re-derive) or
|
/// Prints `skip` (nothing meaningful changed → no release), `normal` (an ordinary patch → re-derive) or
|
||||||
|
|
@ -249,13 +265,15 @@ enum Cmd {
|
||||||
lib: Option<String>,
|
lib: Option<String>,
|
||||||
/// Extra `skip` tolerance: a changed-fraction below this also counts as `skip`. Default 0 —
|
/// Extra `skip` tolerance: a changed-fraction below this also counts as `skip`. Default 0 —
|
||||||
/// only a code-IDENTICAL build (0 functions changed) skips, so any real patch re-derives. Raise
|
/// only a code-IDENTICAL build (0 functions changed) skips, so any real patch re-derives. Raise
|
||||||
/// it (e.g. 0.01) to also skip changes under N%. (Calibration on 339 CS2 pairs: 311 are
|
/// it (e.g. 0.01) to also skip changes under N%. The default is the one setting that does not
|
||||||
/// code-identical, real patches touch <=6 functions / <=0.08%, the 2 toolchain jumps are 34%/53%.)
|
/// depend on the calibration below: zero changed functions is zero at any denominator.
|
||||||
#[arg(long, default_value_t = 0.0)]
|
#[arg(long, default_value_t = 0.0)]
|
||||||
skip_below: f64,
|
skip_below: f64,
|
||||||
/// changed-fraction at or above this = `shift`. Default 0.20 — the CS2 corpus's real patches top
|
/// changed-fraction at or above this = `shift`. Default 0.20. Measured over 344 CS2 builds
|
||||||
/// out near 0.08% while its two toolchain jumps are 34%/53%, so 20% cleanly separates them with
|
/// (~70,300 functions each): 82 are code-identical, the 252 ordinary patches run from 0.001% to
|
||||||
/// wide margin and (unlike 40%) doesn't misclassify the 34% jump as an ordinary patch.
|
/// 17.8% (median 0.12%), and the 9 toolchain jumps start at 22.4% and reach 93.8%. 0.20 sits in
|
||||||
|
/// that gap — but the gap is ~4.6 points wide, not the wide margin an earlier calibration
|
||||||
|
/// claimed, so recalibrate before trusting `shift` on another game or a re-cut corpus.
|
||||||
#[arg(long, default_value_t = 0.20)]
|
#[arg(long, default_value_t = 0.20)]
|
||||||
shift_above: f64,
|
shift_above: f64,
|
||||||
/// Emit a machine-readable JSON object instead of the human summary.
|
/// Emit a machine-readable JSON object instead of the human summary.
|
||||||
|
|
@ -277,7 +295,8 @@ enum Cmd {
|
||||||
/// code-identity collapses (any real change keeps the build code-distinct).
|
/// code-identity collapses (any real change keeps the build code-distinct).
|
||||||
#[arg(long, default_value_t = 0.0)]
|
#[arg(long, default_value_t = 0.0)]
|
||||||
skip_below: f64,
|
skip_below: f64,
|
||||||
/// changed-fraction at or above this marks a toolchain shift = an era boundary (default 0.20).
|
/// changed-fraction at or above this marks a toolchain shift = an era boundary (default 0.20; see
|
||||||
|
/// `classify-change --shift-above` for what that number was measured against).
|
||||||
#[arg(long, default_value_t = 0.20)]
|
#[arg(long, default_value_t = 0.20)]
|
||||||
shift_above: f64,
|
shift_above: f64,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
|
@ -294,9 +313,11 @@ fn lib_or_default(prof: &profile::GameProfile, lib: Option<String>) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the catalogue for the model commands (`corpus-model`/`fold-model`) from either a `--seed` bundle
|
/// Resolve the catalogue for the model commands (`corpus-model`/`fold-model`) from either a `--seed` bundle
|
||||||
/// (release form) or a loose `--catalogue` file. The seed's catalogue section parses to the same functions as
|
/// (release form) or a loose `--catalogue` file — never both; `catalogue` declares the conflict, so the
|
||||||
/// the loose `needed-functions.json`, so the distilled/folded model is identical either way. When a seed is
|
/// `None` arm here means the flag was genuinely absent. The seed's catalogue section parses to the same
|
||||||
/// given, its sections unpack under a `.seed` dir beside `out` (as `produce` does beside its out-dir).
|
/// functions as the loose `needed-functions.json`, so the distilled/folded model is identical either way.
|
||||||
|
/// When a seed is given, its sections unpack under a `.seed` dir beside `out` (as `produce` does beside
|
||||||
|
/// its out-dir).
|
||||||
fn model_catalogue(
|
fn model_catalogue(
|
||||||
prof: &profile::GameProfile,
|
prof: &profile::GameProfile,
|
||||||
seed: Option<PathBuf>,
|
seed: Option<PathBuf>,
|
||||||
|
|
@ -367,6 +388,7 @@ fn main() -> Result<()> {
|
||||||
extra_offsets,
|
extra_offsets,
|
||||||
extra_sigs,
|
extra_sigs,
|
||||||
prototypes,
|
prototypes,
|
||||||
|
semantics,
|
||||||
ehandle_classes,
|
ehandle_classes,
|
||||||
sig_cap,
|
sig_cap,
|
||||||
version,
|
version,
|
||||||
|
|
@ -377,6 +399,9 @@ fn main() -> Result<()> {
|
||||||
bots,
|
bots,
|
||||||
} => {
|
} => {
|
||||||
// derive inputs come from a single --seed bundle (release form) or the loose flags (dev/verify).
|
// derive inputs come from a single --seed bundle (release form) or the loose flags (dev/verify).
|
||||||
|
// The bundle arm reads NONE of the loose bindings, which is only honest because each of them
|
||||||
|
// declares `conflicts_with = "seed"` — clap rejects the mix before dispatch rather than letting
|
||||||
|
// this arm drop an explicitly passed input on the floor.
|
||||||
let inputs = match seed {
|
let inputs = match seed {
|
||||||
Some(s) => unpack_seed(profile, &s, &out_dir.join(".seed"))?,
|
Some(s) => unpack_seed(profile, &s, &out_dir.join(".seed"))?,
|
||||||
None => SeedInputs {
|
None => SeedInputs {
|
||||||
|
|
@ -406,6 +431,7 @@ fn main() -> Result<()> {
|
||||||
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(),
|
prototypes: prototypes.as_deref(),
|
||||||
|
semantics: semantics.as_deref(),
|
||||||
ehandle_classes: ehandle_classes.as_deref(),
|
ehandle_classes: ehandle_classes.as_deref(),
|
||||||
sig_cap,
|
sig_cap,
|
||||||
version: &version,
|
version: &version,
|
||||||
|
|
@ -494,3 +520,50 @@ fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use clap::CommandFactory;
|
||||||
|
|
||||||
|
/// A dropped input is a silent skip, and this is the one place the CLI could produce one: the `--seed`
|
||||||
|
/// arms unpack every loose input themselves and never read the loose bindings, so an undeclared
|
||||||
|
/// conflict means `produce --seed s.json --full-names f.json` runs to exit 0 with `--full-names`
|
||||||
|
/// ignored — and a monolith with no experimental tier is exactly what a game with no naming harvest
|
||||||
|
/// legitimately ships, so no collapse floor downstream can tell the two apart.
|
||||||
|
#[test]
|
||||||
|
fn a_seed_bundle_refuses_the_loose_inputs_rather_than_ignoring_them() {
|
||||||
|
Cli::command().debug_assert();
|
||||||
|
let parse = |argv: &[&str]| {
|
||||||
|
let full: Vec<&str> = std::iter::once("source2rosetta")
|
||||||
|
.chain(argv.iter().copied())
|
||||||
|
.collect();
|
||||||
|
Cli::try_parse_from(&full)
|
||||||
|
};
|
||||||
|
let refused = |argv: &[&str]| {
|
||||||
|
assert!(
|
||||||
|
parse(argv).is_err(),
|
||||||
|
"accepted, so one of these inputs is silently dropped: {argv:?}"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let produce = ["produce", "--target", "t", "--out-dir", "o", "--seed", "s"];
|
||||||
|
for flag in [
|
||||||
|
"--catalogue",
|
||||||
|
"--promotable",
|
||||||
|
"--candidates",
|
||||||
|
"--full-names",
|
||||||
|
"--extra-offsets",
|
||||||
|
"--extra-sigs",
|
||||||
|
] {
|
||||||
|
refused(&[&produce[..], &[flag, "x"]].concat());
|
||||||
|
}
|
||||||
|
let model = ["--out", "o", "--seed", "s", "--catalogue", "c"];
|
||||||
|
refused(&[&["corpus-model", "--corpus", "c"][..], &model].concat());
|
||||||
|
refused(&[&["fold-model", "--model", "m", "--build", "b"][..], &model].concat());
|
||||||
|
// Either form ALONE still parses — the conflict must not have made the loose form unusable.
|
||||||
|
assert!(parse(&produce).is_ok());
|
||||||
|
let loose = ["--catalogue", "c", "--full-names", "f"];
|
||||||
|
let bare = &produce[..produce.len() - 2]; // the same command minus `--seed s`
|
||||||
|
assert!(parse(&[bare, &loose[..]].concat()).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
998
src/pipeline.rs
998
src/pipeline.rs
File diff suppressed because it is too large
Load diff
949
src/produce.rs
949
src/produce.rs
File diff suppressed because it is too large
Load diff
|
|
@ -56,7 +56,13 @@ impl LaunchSpec {
|
||||||
pub struct PawnAnchor {
|
pub struct PawnAnchor {
|
||||||
pub pawn_class: &'static str, // player-pawn RTTI class — the live-oracle instance anchor
|
pub pawn_class: &'static str, // player-pawn RTTI class — the live-oracle instance anchor
|
||||||
pub health_field: &'static str, // a reliable "is this instance alive" netvar
|
pub health_field: &'static str, // a reliable "is this instance alive" netvar
|
||||||
pub is_player_pawn_slot: u64, // gamedata vtable offset of IsPlayerPawn (call-live smoke test)
|
/// A RECORDED REFERENCE value for `IsPlayerPawn`'s vtable slot — cross-checked against, never called.
|
||||||
|
///
|
||||||
|
/// The live CALL test uses the slot THIS build derived and skips entirely when the build derived none;
|
||||||
|
/// this constant only decides whether that run prints a "the slot moved, update me" note. It is not a
|
||||||
|
/// fallback, and must not become one: the slot has taken six distinct values in ten months, and calling
|
||||||
|
/// a stale index would inject a call to whatever now occupies it. A new game may record 0 until measured.
|
||||||
|
pub is_player_pawn_slot: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GameProfile {
|
pub struct GameProfile {
|
||||||
|
|
@ -114,12 +120,65 @@ pub struct GameProfile {
|
||||||
/// test from the command one — convergence of registrar wrappers on a shared core, not a sentinel in the
|
/// test from the command one — convergence of registrar wrappers on a shared core, not a sentinel in the
|
||||||
/// callee — so it can fail while commands keep working.
|
/// callee — so it can fail while commands keep working.
|
||||||
pub min_convars: usize,
|
pub min_convars: usize,
|
||||||
|
/// Floor on VScript bindings. Its own floor for the usual reason — a THIRD identification test,
|
||||||
|
/// distinct from both the command sentinel and the convar convergence: a record base computed by the
|
||||||
|
/// initialiser's own `idx*5 << 4 + [class+0x28]`. A codegen change that reshapes that arithmetic
|
||||||
|
/// yields zero bindings while every other surface keeps reading perfectly.
|
||||||
|
///
|
||||||
|
/// Set well under the observed count, which is the house rule, but the margin here is deliberately
|
||||||
|
/// wide: the reader recovers three distinct registration forms (the packed name pair, the
|
||||||
|
/// `movddup` single-string form, and a base copied between registers), and losing any ONE of them
|
||||||
|
/// would still clear a tight floor while quietly dropping a third of the surface.
|
||||||
|
pub min_vscript: usize,
|
||||||
|
/// Floor on VScript bindings attributed to an OWNING CLASS — and the only floor here that a full run
|
||||||
|
/// checks and an offline one skips, because zero is correct by construction offline: the descriptor
|
||||||
|
/// reaches its class through a register loaded from memory, so nothing static recovers it.
|
||||||
|
///
|
||||||
|
/// Separate from `min_vscript` because it fails independently and in the opposite direction. That floor
|
||||||
|
/// guards the offline READER against a Valve reshape; this one guards the LIVE WALK — the string-anchor
|
||||||
|
/// instance search, the owner read at the record's `+0x30`, the class-name read behind it. Any of those
|
||||||
|
/// breaking leaves every binding recovered, described and located, with no class on any of them: a
|
||||||
|
/// release that clears every other gate. It is `class` that `gen`'s `moddota` format GROUPS BY, so
|
||||||
|
/// the artifact would ship intact while both of the files it writes came out empty.
|
||||||
|
pub min_vscript_classed: usize,
|
||||||
pub min_schema_enums: usize,
|
pub min_schema_enums: usize,
|
||||||
|
/// Collapse floor for the recovered schema CLASS table — the largest table the deriver reads, and the
|
||||||
|
/// one every other schema claim rests on: the artifact's whole `schema` section, the entity-output and
|
||||||
|
/// datadesc joins, the derived type layouts, and `Identity::class_size`, which is half the identity
|
||||||
|
/// check's conjunction.
|
||||||
|
///
|
||||||
|
/// It needs its own floor because nothing else covers it. `min_schema_enums` does not — `enumerate_enums`
|
||||||
|
/// uses classes only to exclude field arrays, so it keeps passing at zero classes. The live oracle's
|
||||||
|
/// class gate does not either: it is SKIPPED below `ORACLE_MIN_SAMPLE` checked classes, and the sample
|
||||||
|
/// IS the class count, so a collapse into that range disables the check that would catch it. And the
|
||||||
|
/// offline/live layout comparison reads the same bytes through the same `CI_*` constants, so whatever
|
||||||
|
/// survives a reshape agrees with itself.
|
||||||
|
pub min_schema_classes: usize,
|
||||||
|
/// Collapse floor for the schema CLASS table read from a SINGLE library — the live oracle's population.
|
||||||
|
///
|
||||||
|
/// Distinct from [`min_schema_classes`](Self::min_schema_classes), and the two may never be shared: that
|
||||||
|
/// one counts the union across every mapped library, this one counts `server_lib` alone, and the union is
|
||||||
|
/// roughly twice as large. A floor calibrated on the union rejects every healthy build when applied here,
|
||||||
|
/// because the honest single-library count sits below it by construction.
|
||||||
|
///
|
||||||
|
/// Calibrated the same way as its sibling — well under the observed count, a collapse detector rather
|
||||||
|
/// than a tight bound — and it only applies to `server_lib`, the one library whose count is calibrated.
|
||||||
|
pub min_schema_classes_lib: usize,
|
||||||
|
/// Collapse floor for the DERIVED function tiers — `core + high_confidence`.
|
||||||
|
///
|
||||||
|
/// Every table read out of the binary has one of these; the tool's headline product did not, and the
|
||||||
|
/// gap is structural rather than an oversight of one number: the live oracle gates a PASS RATE over
|
||||||
|
/// entries that reached the gamedata document, and a signature that failed to resolve never enters it.
|
||||||
|
/// So a derive that emits forty functions instead of four thousand passes at 100% — a stale corpus
|
||||||
|
/// model, a `--target` from the wrong branch or a missing secondary library all land there.
|
||||||
|
///
|
||||||
|
/// A collapse detector, not a tight bound: set well below the observed count, like every sibling floor.
|
||||||
|
pub min_core_functions: usize,
|
||||||
/// Output game-key the game-keyed emitters use (Metamod `Games { <key> {..} }`, Plugify `{ "<key>": {..} }`).
|
/// 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
|
||||||
/// content-dir token `csgo`/`dota` that framework formats key on). Names the artifacts
|
/// content-dir token `csgo`/`dota` that framework formats key on). Names the artifacts
|
||||||
/// `gamedata-<token>.json` / `model-<token>.json` / `netvars-<token>.json`.
|
/// `rosetta-<token>.json` / `model-<token>.json`.
|
||||||
pub token: &'static str,
|
pub token: &'static str,
|
||||||
/// Dedicated-server launcher binary under `bin/linuxsteamrt64/` (CS2: `cs2`).
|
/// Dedicated-server launcher binary under `bin/linuxsteamrt64/` (CS2: `cs2`).
|
||||||
pub executable: &'static str,
|
pub executable: &'static str,
|
||||||
|
|
@ -199,7 +258,17 @@ pub const CS2: GameProfile = GameProfile {
|
||||||
min_entity_classes: 200,
|
min_entity_classes: 200,
|
||||||
min_commands: 400,
|
min_commands: 400,
|
||||||
min_convars: 900,
|
min_convars: 900,
|
||||||
|
min_vscript: 180,
|
||||||
|
// observed live: 271 of 300 bindings attributed across 24 classes
|
||||||
|
min_vscript_classed: 150,
|
||||||
min_schema_enums: 250,
|
min_schema_enums: 250,
|
||||||
|
// CS2 recovers 1,899 across every mapped library. A floor at 1,200 is well clear of build-to-build
|
||||||
|
// drift and nowhere near the range a `SchemaClassInfoData_t` reshape would leave.
|
||||||
|
min_schema_classes: 1_200,
|
||||||
|
// libserver.so alone holds 852 of those; the live oracle reads that library only.
|
||||||
|
min_schema_classes_lib: 550,
|
||||||
|
// CS2 ships 1,086 core + 2,899 high-confidence = 3,985.
|
||||||
|
min_core_functions: 2_500,
|
||||||
game_key: "csgo",
|
game_key: "csgo",
|
||||||
token: "cs2",
|
token: "cs2",
|
||||||
executable: "cs2",
|
executable: "cs2",
|
||||||
|
|
@ -304,7 +373,16 @@ pub const DOTA: GameProfile = GameProfile {
|
||||||
min_entity_classes: 1000,
|
min_entity_classes: 1000,
|
||||||
min_commands: 400,
|
min_commands: 400,
|
||||||
min_convars: 600,
|
min_convars: 600,
|
||||||
|
min_vscript: 1200,
|
||||||
|
// observed live: 1,638 of 1,841 bindings attributed across 63 classes
|
||||||
|
min_vscript_classed: 900,
|
||||||
min_schema_enums: 350,
|
min_schema_enums: 350,
|
||||||
|
// Dota recovers 2,962 across every mapped library.
|
||||||
|
min_schema_classes: 2_000,
|
||||||
|
// libserver.so alone holds 1,916 of those; the live oracle reads that library only.
|
||||||
|
min_schema_classes_lib: 1_250,
|
||||||
|
// Dota ships 1,096 + 4,047 = 5,143.
|
||||||
|
min_core_functions: 3_000,
|
||||||
game_key: "dota",
|
game_key: "dota",
|
||||||
token: "dota2",
|
token: "dota2",
|
||||||
executable: "dota2", // bin/linuxsteamrt64/dota2
|
executable: "dota2", // bin/linuxsteamrt64/dota2
|
||||||
|
|
@ -386,6 +464,23 @@ pub const DOTA: GameProfile = GameProfile {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The two class floors count DIFFERENT populations — the all-library union and `server_lib` alone —
|
||||||
|
/// so a profile that gives them the same value has calibrated one of them against the other's
|
||||||
|
/// population, which rejects every healthy build on whichever site got the larger number.
|
||||||
|
#[test]
|
||||||
|
fn the_single_library_class_floor_is_strictly_below_the_all_library_one() {
|
||||||
|
for prof in [&CS2, &DOTA] {
|
||||||
|
assert!(
|
||||||
|
prof.min_schema_classes_lib < prof.min_schema_classes,
|
||||||
|
"{}: single-library floor {} must sit below the all-library floor {} — one library \
|
||||||
|
cannot hold more classes than every library",
|
||||||
|
prof.token,
|
||||||
|
prof.min_schema_classes_lib,
|
||||||
|
prof.min_schema_classes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cs2_launch_args_are_byte_identical_to_the_old_hand_synced_vec() {
|
fn cs2_launch_args_are_byte_identical_to_the_old_hand_synced_vec() {
|
||||||
// The exact arg vec the live launch requires for map="de_dust2", bots=9 — pins the LaunchSpec
|
// The exact arg vec the live launch requires for map="de_dust2", bots=9 — pins the LaunchSpec
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,14 @@ use serde::Deserialize;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// The provenance the deriver stamps on a name it read out of Valve's entity-IO datadesc. Kept in step
|
// The provenance ids this module READS are the ones the pipeline STAMPS, imported rather than re-spelled:
|
||||||
/// with `pipeline::VALVE_DATADESC` — the two halves of one fact: which names the datadesc named, and
|
// they are one fact — which evidence named the function — and two copies of it "kept in step" by a
|
||||||
/// what the engine's dispatch contract therefore says about them.
|
// comment is an invariant nothing enforces. A drift there would silently stop matching, and a prototype
|
||||||
const VALVE_DATADESC: &str = "valve-datadesc";
|
// that stops matching does not fail; it simply stops being claimed.
|
||||||
|
use crate::pipeline::{VALVE_CONCOMMAND, VALVE_DATADESC, VALVE_VSCRIPT};
|
||||||
|
|
||||||
/// What the manifest calls a prototype that came from how the ENGINE invokes the function rather than
|
/// What the manifest calls a prototype that came from how the ENGINE invokes the function rather than
|
||||||
/// from anyone's declaration of it.
|
/// from anyone's declaration of it. Declared here because only this module states it.
|
||||||
const ENGINE_CONTRACT: &str = "engine-contract";
|
const ENGINE_CONTRACT: &str = "engine-contract";
|
||||||
|
|
||||||
/// The prototype the engine invokes EVERY entity-IO handler through. Kept in step with
|
/// The prototype the engine invokes EVERY entity-IO handler through. Kept in step with
|
||||||
|
|
@ -31,10 +32,6 @@ const ENGINE_CONTRACT: &str = "engine-contract";
|
||||||
/// derive as a standing oracle — two halves of one fact, one asserting it and one checking it.
|
/// 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&"];
|
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.
|
/// What the engine passes EVERY console-command callback, whatever form it takes.
|
||||||
const CONCOMMAND_PARAMS: [&str; 2] = ["CCommandContext*", "CCommand*"];
|
const CONCOMMAND_PARAMS: [&str; 2] = ["CCommandContext*", "CCommand*"];
|
||||||
|
|
||||||
|
|
@ -72,6 +69,62 @@ fn concommand_contract(source: &str) -> Option<Vec<String>> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the DIRECTION of a footprint disagreement, and decide what it means.
|
||||||
|
///
|
||||||
|
/// Split out because it is the whole content of the `mismatch` verdict, and it has to be callable: the
|
||||||
|
/// tests used to re-implement this rule rather than call it, so the regression guard could only fail if
|
||||||
|
/// someone edited both copies the same wrong way. One definition, two callers.
|
||||||
|
///
|
||||||
|
/// Only an over-READ refutes a declaration. `declared_over` alone is the documented LOWER-BOUND case —
|
||||||
|
/// calling through it loads a register nobody reads, which is safe — while `measured_over` means the
|
||||||
|
/// callee reads a register the declaration never mentions, which is not. `both` stays a mismatch: a
|
||||||
|
/// class where the callee reads more is unsafe regardless of another class where it reads fewer. 81 of
|
||||||
|
/// CS2's 140 former mismatches were the safe direction, reported as "does not describe this build".
|
||||||
|
fn adjudicate_mismatch(
|
||||||
|
chosen: &Candidate,
|
||||||
|
s: &model::AbiShape,
|
||||||
|
types: Option<&BTreeMap<String, model::TypeLayout>>,
|
||||||
|
) -> (model::AbiStatus, &'static str) {
|
||||||
|
let (i, f) = footprint(chosen.params, types);
|
||||||
|
// The direction has to be read through the SAME allowance the verdict was, or the invisible `this`
|
||||||
|
// reads as an over-count on its own: `CGameEvent::GetFloat` is declared `(char const*, float)` and
|
||||||
|
// measures `int=2 float=0`, where the extra integer register is the receiver and the only real
|
||||||
|
// disagreement is the float.
|
||||||
|
let i = if chosen.complete {
|
||||||
|
i
|
||||||
|
} else {
|
||||||
|
(i..=i + 1)
|
||||||
|
.min_by_key(|d| d.abs_diff(s.int as usize))
|
||||||
|
.expect("the range always has two elements")
|
||||||
|
};
|
||||||
|
let (i, f) = (i.min(6), f.min(8));
|
||||||
|
let measured_over = s.int as usize > i || s.float as usize > f;
|
||||||
|
let declared_over = i > s.int as usize || f > s.float as usize;
|
||||||
|
let status = if declared_over && !measured_over {
|
||||||
|
model::AbiStatus::LowerBound
|
||||||
|
} else {
|
||||||
|
model::AbiStatus::Mismatch
|
||||||
|
};
|
||||||
|
let note = match (measured_over, declared_over) {
|
||||||
|
(true, true) => {
|
||||||
|
"measured and declared footprints disagree in BOTH directions, in different register \
|
||||||
|
classes: the callee reads a register the declaration does not mention AND the declaration \
|
||||||
|
passes one the callee never reads"
|
||||||
|
}
|
||||||
|
(false, true) => {
|
||||||
|
"the declaration passes registers the callee never reads, and contradicts it in no register \
|
||||||
|
class — the measured footprint is a documented LOWER bound, so this is expected rather than \
|
||||||
|
evidence against the declaration"
|
||||||
|
}
|
||||||
|
(true, false) => {
|
||||||
|
"measured footprint EXCEEDS declared: the callee reads a register the declaration does not \
|
||||||
|
mention, so this declaration does not describe this build"
|
||||||
|
}
|
||||||
|
_ => "the footprints disagree in neither direction, which a mismatch cannot be",
|
||||||
|
};
|
||||||
|
(status, note)
|
||||||
|
}
|
||||||
|
|
||||||
/// One declared prototype as the frozen input records it.
|
/// One declared prototype as the frozen input records it.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Decl {
|
struct Decl {
|
||||||
|
|
@ -288,6 +341,7 @@ pub fn build_manifest(
|
||||||
prototypes: &Path,
|
prototypes: &Path,
|
||||||
mono: &model::Monolith,
|
mono: &model::Monolith,
|
||||||
types: Option<&BTreeMap<String, model::TypeLayout>>,
|
types: Option<&BTreeMap<String, model::TypeLayout>>,
|
||||||
|
vscript_ret: Option<&BTreeMap<String, String>>,
|
||||||
) -> Result<model::AbiManifest> {
|
) -> Result<model::AbiManifest> {
|
||||||
let doc: PrototypeDoc = serde_json::from_str(
|
let doc: PrototypeDoc = serde_json::from_str(
|
||||||
&std::fs::read_to_string(prototypes)
|
&std::fs::read_to_string(prototypes)
|
||||||
|
|
@ -397,7 +451,22 @@ pub fn build_manifest(
|
||||||
.and_then(|e| e.locator.offset);
|
.and_then(|e| e.locator.offset);
|
||||||
|
|
||||||
let decls: &[Decl] = exact.or(by_bare_hit).map_or(&[][..], |v| v.as_slice());
|
let decls: &[Decl] = exact.or(by_bare_hit).map_or(&[][..], |v| v.as_slice());
|
||||||
if decls.is_empty() && !is_contract {
|
|
||||||
|
// The SCRIPT VM'S OWN declared return, for a name the registry states. It ranks above the
|
||||||
|
// measured register class for the reason spelled out below: a callee cannot tell whether its
|
||||||
|
// caller reads RAX, so measurement is wrong about known-void functions roughly seven times
|
||||||
|
// in eight — and `void` is what the registry declares for 849 of Dota's bindings, which is
|
||||||
|
// exactly the population measurement gets wrong. It ranks BELOW a real declaration only to
|
||||||
|
// keep "a source wrote this down" ahead of anything derived; in practice the two never
|
||||||
|
// compete, because no VScript name is also a declared name (measured: zero overlap).
|
||||||
|
//
|
||||||
|
// Read BEFORE the gate below, not after: a registry-declared return is on its own enough to
|
||||||
|
// have something to say about a function, so a name carrying one must not be skipped for
|
||||||
|
// having no parameter list. That is precisely the `return-only` case.
|
||||||
|
let vs_ret = vscript_ret.and_then(|m| m.get(name)).cloned();
|
||||||
|
let has_vs_ret = vs_ret.is_some();
|
||||||
|
|
||||||
|
if decls.is_empty() && !is_contract && !has_vs_ret {
|
||||||
bump(&format!("{tier}:none"));
|
bump(&format!("{tier}:none"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -413,6 +482,7 @@ pub fn build_manifest(
|
||||||
any_ret
|
any_ret
|
||||||
.clone()
|
.clone()
|
||||||
.or(contract)
|
.or(contract)
|
||||||
|
.or(vs_ret)
|
||||||
.or_else(|| sh.map(|s| s.ret.clone()))
|
.or_else(|| sh.map(|s| s.ret.clone()))
|
||||||
};
|
};
|
||||||
let mut provenance: Vec<String> = decls
|
let mut provenance: Vec<String> = decls
|
||||||
|
|
@ -424,6 +494,9 @@ pub fn build_manifest(
|
||||||
if is_contract {
|
if is_contract {
|
||||||
provenance.push(ENGINE_CONTRACT.to_string());
|
provenance.push(ENGINE_CONTRACT.to_string());
|
||||||
}
|
}
|
||||||
|
if has_vs_ret {
|
||||||
|
provenance.push(VALVE_VSCRIPT.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
// The contract goes in FIRST, so that where it and a declaration both fit the measurement,
|
// 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
|
// `most_specific` reports the one that names its receiver — which the contract always does
|
||||||
|
|
@ -440,8 +513,14 @@ pub fn build_manifest(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
collect_candidates(decls, &mut cands);
|
collect_candidates(decls, &mut cands);
|
||||||
|
// `bare-name` is a CLAIM — "one declaration bears this method name and the measurement could
|
||||||
|
// adjudicate" — so it must not be the fallback for an entry that was never name-matched at
|
||||||
|
// all. A registry-declared return with no declaration behind it is neither exact nor
|
||||||
|
// bare-name; it is the script VM stating its own contract, and it says so.
|
||||||
let matched_by = if exact.is_some() {
|
let matched_by = if exact.is_some() {
|
||||||
"exact"
|
"exact"
|
||||||
|
} else if decls.is_empty() && has_vs_ret {
|
||||||
|
VALVE_VSCRIPT
|
||||||
} else {
|
} else {
|
||||||
"bare-name"
|
"bare-name"
|
||||||
};
|
};
|
||||||
|
|
@ -567,46 +646,9 @@ pub fn build_manifest(
|
||||||
// former mismatches were the former, reported as "does not describe this build".
|
// former mismatches were the former, reported as "does not describe this build".
|
||||||
if status == model::AbiStatus::Mismatch {
|
if status == model::AbiStatus::Mismatch {
|
||||||
let s = sh.expect("a mismatch is only reachable with a measurement");
|
let s = sh.expect("a mismatch is only reachable with a measurement");
|
||||||
let (i, f) = footprint(chosen.params, types);
|
let (verdict, why) = adjudicate_mismatch(&chosen, s, types);
|
||||||
// The direction has to be read through the SAME allowance the verdict was, or the
|
status = verdict;
|
||||||
// invisible `this` reads as an over-count on its own: `CGameEvent::GetFloat` is
|
note = Some(why.to_string());
|
||||||
// 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
|
// 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
|
// admits a bare name only when a measurement exists to adjudicate it — and adjudicating
|
||||||
|
|
@ -645,6 +687,9 @@ pub fn build_manifest(
|
||||||
note,
|
note,
|
||||||
overloads: (cands.len() > 1).then_some(all_sigs),
|
overloads: (cands.len() > 1).then_some(all_sigs),
|
||||||
vtable,
|
vtable,
|
||||||
|
// Prose belongs to the function record, not to a prototype; the merge attaches it
|
||||||
|
// there and `Rosetta::abi_manifest` joins it back on for the emitters.
|
||||||
|
doc: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -834,36 +879,28 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The verdict AND the note, for one declaration against one measurement.
|
/// The verdict AND the note, for one declaration against one measurement.
|
||||||
|
/// CALLS the shipped rule rather than restating it. It used to re-implement `build_manifest`'s
|
||||||
|
/// direction logic, which made the assertions below unfalsifiable: only an edit that changed both
|
||||||
|
/// copies the same wrong way could fail them, and that is the one edit nobody makes by accident.
|
||||||
|
/// The direction is read back out of the shipped note text, so the mapping from direction to prose
|
||||||
|
/// is under test too.
|
||||||
fn judge(params: &[&str], sh: &model::AbiShape, complete: bool) -> (model::AbiStatus, String) {
|
fn judge(params: &[&str], sh: &model::AbiShape, complete: bool) -> (model::AbiStatus, String) {
|
||||||
let ps = p(params);
|
let ps = p(params);
|
||||||
let c = cand(&ps, complete);
|
let c = cand(&ps, complete);
|
||||||
if agrees(&c, sh, None) {
|
if agrees(&c, sh, None) {
|
||||||
return (model::AbiStatus::Verified, String::new());
|
return (model::AbiStatus::Verified, String::new());
|
||||||
}
|
}
|
||||||
let (i, f) = footprint(c.params, None);
|
let (status, note) = adjudicate_mismatch(&c, sh, None);
|
||||||
let i = if complete {
|
let direction = if note.starts_with("measured and declared") {
|
||||||
i
|
"both"
|
||||||
|
} else if note.starts_with("measured footprint EXCEEDS") {
|
||||||
|
"measured-exceeds"
|
||||||
|
} else if note.starts_with("the declaration passes") {
|
||||||
|
"declared-exceeds"
|
||||||
} else {
|
} else {
|
||||||
(i..=i + 1)
|
"neither"
|
||||||
.min_by_key(|d| d.abs_diff(sh.int as usize))
|
|
||||||
.unwrap()
|
|
||||||
};
|
};
|
||||||
let (i, f) = (i.min(6), f.min(8));
|
(status, direction.to_string())
|
||||||
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]
|
#[test]
|
||||||
|
|
|
||||||
196
src/pulse.rs
196
src/pulse.rs
|
|
@ -24,9 +24,12 @@
|
||||||
//! declares. A layout change yields FEWER signatures, never wrong ones, and the profile floor turns
|
//! declares. A layout change yields FEWER signatures, never wrong ones, and the profile floor turns
|
||||||
//! "fewer" into a failed release.
|
//! "fewer" into a failed release.
|
||||||
|
|
||||||
|
// Registers whose value a call destroys. The ONE list in `abi`, not a second copy of it — both loops
|
||||||
|
// below that invalidate across a call read it directly.
|
||||||
|
use crate::abi::CALLER_SAVED;
|
||||||
use crate::elf::CodeImage;
|
use crate::elf::CodeImage;
|
||||||
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
|
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
|
||||||
/// Highest `PulseValueType_t` enumerator (`PVAL_COUNT`) plus headroom for a build that adds a few. The
|
/// 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
|
/// enum is schema-registered, so the DERIVED values are what a caller should validate against — this is
|
||||||
|
|
@ -83,41 +86,26 @@ struct Trace {
|
||||||
ret: Option<(u64, u64)>,
|
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 {
|
fn full(r: Register) -> Register {
|
||||||
if r.is_gpr() { r.full_register() } else { r }
|
if r.is_gpr() { r.full_register() } else { r }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Constant-propagate through the accessor, recording every fixed-address store, every call's argument
|
/// Every instruction address reachable from `entry` inside `[entry, entry+code.len())`, in ADDRESS order.
|
||||||
/// registers, and the vector the fast path returns.
|
|
||||||
///
|
///
|
||||||
/// Deliberately a single ADDRESS-ORDER pass rather than a CFG walk: the guard-protected initializer is
|
/// One walk, two callers: the descriptor trace and the shim's liveness read need exactly the same thing —
|
||||||
/// straight-line, and a pass that only ever believes values it computed itself cannot invent one. Every
|
/// flow-reachable addresses rather than a linear sweep, so a jump table or an interleaved neighbour cannot
|
||||||
/// instruction it does not model invalidates what it writes.
|
/// contribute instructions the function never executes. They differ only in how far they are willing to
|
||||||
fn trace(img: &CodeImage, entry: u64, seed_rdi: Option<u64>) -> Option<Trace> {
|
/// walk, which is the `cap`.
|
||||||
let code = img.code_at(entry)?;
|
///
|
||||||
let cap = code.len().min(MAX_SPAN);
|
/// `cap` bounds the SET, not the span: a crafted image can present a small span with pathological branch
|
||||||
let in_span = |t: u64| t >= entry && ((t - entry) as usize) < cap;
|
/// density, and this is on the fuzz surface.
|
||||||
|
fn reachable(code: &[u8], entry: u64, cap: usize) -> Vec<u64> {
|
||||||
// Reachable instruction addresses, then walked in address order.
|
let end = entry.saturating_add(code.len() as u64);
|
||||||
let mut seen: HashMap<u64, usize> = HashMap::new();
|
let mut seen: HashSet<u64> = HashSet::new();
|
||||||
let mut work = vec![entry];
|
let mut work = vec![entry];
|
||||||
let mut insn = Instruction::default();
|
let mut insn = Instruction::default();
|
||||||
while let Some(at) = work.pop() {
|
while let Some(at) = work.pop() {
|
||||||
if seen.contains_key(&at) || !in_span(at) || seen.len() > 4000 {
|
if at < entry || at >= end || seen.contains(&at) || seen.len() > cap {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut dec =
|
let mut dec =
|
||||||
|
|
@ -129,7 +117,7 @@ fn trace(img: &CodeImage, entry: u64, seed_rdi: Option<u64>) -> Option<Trace> {
|
||||||
if insn.is_invalid() || insn.len() == 0 {
|
if insn.is_invalid() || insn.len() == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
seen.insert(at, insn.len());
|
seen.insert(at);
|
||||||
match insn.flow_control() {
|
match insn.flow_control() {
|
||||||
FlowControl::Return
|
FlowControl::Return
|
||||||
| FlowControl::IndirectBranch
|
| FlowControl::IndirectBranch
|
||||||
|
|
@ -143,9 +131,22 @@ fn trace(img: &CodeImage, entry: u64, seed_rdi: Option<u64>) -> Option<Trace> {
|
||||||
_ => work.push(at + insn.len() as u64),
|
_ => work.push(at + insn.len() as u64),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut addrs: Vec<u64> = seen.into_iter().collect();
|
||||||
let mut addrs: Vec<u64> = seen.keys().copied().collect();
|
|
||||||
addrs.sort_unstable();
|
addrs.sort_unstable();
|
||||||
|
addrs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-propagate through the accessor, recording every fixed-address store, every call's argument
|
||||||
|
/// registers, and the vector the fast path returns.
|
||||||
|
///
|
||||||
|
/// Deliberately a single ADDRESS-ORDER pass rather than a CFG walk: the guard-protected initializer is
|
||||||
|
/// straight-line, and a pass that only ever believes values it computed itself cannot invent one. Every
|
||||||
|
/// instruction it does not model invalidates what it writes.
|
||||||
|
fn trace(img: &CodeImage, entry: u64, seed_rdi: Option<u64>) -> Option<Trace> {
|
||||||
|
let all = img.code_at(entry)?;
|
||||||
|
let code = &all[..all.len().min(MAX_SPAN)];
|
||||||
|
let mut insn = Instruction::default();
|
||||||
|
let addrs = reachable(code, entry, 4000);
|
||||||
|
|
||||||
let mut out = Trace::default();
|
let mut out = Trace::default();
|
||||||
let mut regs: HashMap<Register, u64> = HashMap::new();
|
let mut regs: HashMap<Register, u64> = HashMap::new();
|
||||||
|
|
@ -350,26 +351,6 @@ fn record(img: &CodeImage, accessor: u64) -> Option<Record> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every CODE pointer an accessor's initializer stores into its record region, with the region base:
|
|
||||||
/// `(base, [(address written, code address written)])`.
|
|
||||||
///
|
|
||||||
/// A DIAGNOSTIC, and deliberately not part of any shipped artifact. The parameter records carry a
|
|
||||||
/// function pointer whose ROLE is not established — the record reader already has to look at these in
|
|
||||||
/// order to reject them as parameter names, so exposing them costs nothing and lets that question be
|
|
||||||
/// settled against evidence collected elsewhere (a runtime call-edge trace) rather than guessed. Nothing
|
|
||||||
/// here interprets them; they are raw measurements.
|
|
||||||
pub fn code_stores(img: &CodeImage, accessor: u64) -> Option<(u64, Vec<(u64, u64)>)> {
|
|
||||||
let r = record(img, accessor)?;
|
|
||||||
let stores =
|
|
||||||
r.t.writes
|
|
||||||
.iter()
|
|
||||||
.filter(|&(a, _)| *a >= r.base)
|
|
||||||
.filter(|&(_, p)| img.is_code(*p))
|
|
||||||
.map(|(&a, &p)| (a, p))
|
|
||||||
.collect();
|
|
||||||
Some((r.base, stores))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The spacings at which this record's `count` names could sit, given that element 0's name is at
|
/// 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
|
/// `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.
|
/// string of its own offers more, which is why the stride is settled per IMAGE and not per record.
|
||||||
|
|
@ -570,10 +551,10 @@ const SHIM_SLOTS: [Register; 6] = [
|
||||||
/// What an invocation shim was measured to read, and therefore what a caller has to supply.
|
/// What an invocation shim was measured to read, and therefore what a caller has to supply.
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
pub struct ShimReads {
|
pub struct ShimReads {
|
||||||
/// The argument slots actually read, named — `rcx`, `r8`, `stack0`.
|
/// The argument slots actually read, named — `rcx`, `r8`, `stack0`. The argument array (`r8`) is
|
||||||
|
/// stated here and nowhere else: reading it is the ordinary case and constrains a caller in no way,
|
||||||
|
/// so it needs no flag of its own beside the three that do.
|
||||||
pub reads: Vec<&'static str>,
|
pub reads: Vec<&'static str>,
|
||||||
/// Does it read the argument array (`r8`)?
|
|
||||||
pub args: bool,
|
|
||||||
/// Does it read the output sink (the first stack slot)? True for exactly the bindings that declare a
|
/// Does it read the output sink (the first stack slot)? True for exactly the bindings that declare a
|
||||||
/// return, measured across both games with no exceptions.
|
/// return, measured across both games with no exceptions.
|
||||||
pub sink: bool,
|
pub sink: bool,
|
||||||
|
|
@ -606,6 +587,40 @@ impl ShimReads {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where an accessor's descriptor region LIVES, and how many elements it holds: `(base, count)`.
|
||||||
|
///
|
||||||
|
/// The signature reader reconstructs the region's CONTENTS by constant-propagating the initialiser,
|
||||||
|
/// because on disk the elements are zeroes — they are written at runtime. That reconstruction is the
|
||||||
|
/// only offline route, and it is also unverified: the multi-library duplicate check reports that CS2
|
||||||
|
/// disagrees with itself on 331 of 419 repeat registrations, and nothing offline can say which account
|
||||||
|
/// is right.
|
||||||
|
///
|
||||||
|
/// A running server can. The region is a plain static, so at `slide + base` a live process holds the
|
||||||
|
/// POPULATED elements, and reading them settles the question against the same build rather than against
|
||||||
|
/// a dump of a different one. This accessor exists for that oracle; the derivation itself never needs it.
|
||||||
|
pub fn record_region(img: &CodeImage, accessor: u64) -> Option<(u64, u64)> {
|
||||||
|
let r = record(img, accessor)?;
|
||||||
|
(r.base != 0).then_some((r.base, r.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a read of one argument slot demands of a HOST caller.
|
||||||
|
///
|
||||||
|
/// The argument array (`r8`) demands nothing — the caller builds it, so reading it is the ordinary case
|
||||||
|
/// and `reads` already states it. The Pulse context (`rcx`) is VM-owned and cannot be supplied at all.
|
||||||
|
/// Everything else is a slot the caller would otherwise pass null.
|
||||||
|
///
|
||||||
|
/// A named arm rather than a fall-through for `r8` specifically: dropping it into the `_` catch-all would
|
||||||
|
/// mark every ordinary binding as needing a slot no host can fill, retiring the entire `args-only`
|
||||||
|
/// callable tier — a collapse that reads as "this build has no callable bindings", which is a legitimate
|
||||||
|
/// answer for a game and therefore invisible.
|
||||||
|
fn slot_need(r: Register, out: &mut ShimReads) {
|
||||||
|
match r {
|
||||||
|
Register::RCX => out.context = true,
|
||||||
|
Register::R8 => {}
|
||||||
|
_ => out.other = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Measure which of a shim's seven arguments it reads.
|
/// Measure which of a shim's seven arguments it reads.
|
||||||
///
|
///
|
||||||
/// Reachable instructions in ADDRESS order, which needs two guards that cost real time to find:
|
/// Reachable instructions in ADDRESS order, which needs two guards that cost real time to find:
|
||||||
|
|
@ -618,46 +633,9 @@ impl ShimReads {
|
||||||
/// argument live that the shim never consumes; `xor edi, edi` alone accounted for 143 false positives.
|
/// argument live that the shim never consumes; `xor edi, edi` alone accounted for 143 false positives.
|
||||||
pub fn shim_reads(img: &CodeImage, entry: u64) -> Option<ShimReads> {
|
pub fn shim_reads(img: &CodeImage, entry: u64) -> Option<ShimReads> {
|
||||||
let all = img.code_at(entry)?;
|
let all = img.code_at(entry)?;
|
||||||
let extent = (all.len() as u64).min(SHIM_SPAN);
|
let code = &all[..(all.len() as u64).min(SHIM_SPAN) as usize];
|
||||||
let code = &all[..extent as usize];
|
|
||||||
// Saturating: `extent` derives from the section length, so on a crafted image `entry + extent` can
|
|
||||||
// wrap and turn the span test inside out — and the fuzz harness builds with overflow checks, where a
|
|
||||||
// plain add aborts. The same shape `fuzz_concmd` was written for.
|
|
||||||
let end = entry.saturating_add(extent);
|
|
||||||
let in_span = |t: u64| t >= entry && t < end;
|
|
||||||
|
|
||||||
let mut seen: HashMap<u64, ()> = HashMap::new();
|
|
||||||
let mut work = vec![entry];
|
|
||||||
let mut insn = Instruction::default();
|
let mut insn = Instruction::default();
|
||||||
while let Some(at) = work.pop() {
|
let addrs = reachable(code, entry, 20000);
|
||||||
if seen.contains_key(&at) || !in_span(at) || seen.len() > 20000 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut dec =
|
|
||||||
Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE);
|
|
||||||
if !dec.can_decode() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
dec.decode_out(&mut insn);
|
|
||||||
if insn.is_invalid() || insn.len() == 0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.insert(at, ());
|
|
||||||
match insn.flow_control() {
|
|
||||||
FlowControl::Return
|
|
||||||
| FlowControl::IndirectBranch
|
|
||||||
| FlowControl::Exception
|
|
||||||
| FlowControl::Interrupt => {}
|
|
||||||
FlowControl::UnconditionalBranch => work.push(insn.near_branch_target()),
|
|
||||||
FlowControl::ConditionalBranch => {
|
|
||||||
work.push(at + insn.len() as u64);
|
|
||||||
work.push(insn.near_branch_target());
|
|
||||||
}
|
|
||||||
_ => work.push(at + insn.len() as u64),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut addrs: Vec<u64> = seen.keys().copied().collect();
|
|
||||||
addrs.sort_unstable();
|
|
||||||
|
|
||||||
let mut live: BTreeMap<Register, bool> = BTreeMap::new();
|
let mut live: BTreeMap<Register, bool> = BTreeMap::new();
|
||||||
let mut sink = false;
|
let mut sink = false;
|
||||||
|
|
@ -734,11 +712,7 @@ pub fn shim_reads(img: &CodeImage, entry: u64) -> Option<ShimReads> {
|
||||||
{
|
{
|
||||||
if live.contains_key(r) {
|
if live.contains_key(r) {
|
||||||
out.reads.push(name);
|
out.reads.push(name);
|
||||||
match *r {
|
slot_need(*r, &mut out);
|
||||||
Register::RCX => out.context = true,
|
|
||||||
Register::R8 => out.args = true,
|
|
||||||
_ => out.other = true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sink {
|
if sink {
|
||||||
|
|
@ -811,26 +785,26 @@ mod tests {
|
||||||
let ctx = ShimReads {
|
let ctx = ShimReads {
|
||||||
context: true,
|
context: true,
|
||||||
sink: true,
|
sink: true,
|
||||||
args: true,
|
reads: vec!["rcx", "r8", "stack0"],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(ctx.needs(), "pulse-context");
|
assert_eq!(ctx.needs(), "pulse-context");
|
||||||
let other = ShimReads {
|
let other = ShimReads {
|
||||||
other: true,
|
other: true,
|
||||||
sink: true,
|
sink: true,
|
||||||
args: true,
|
reads: vec!["rdi", "r8", "stack0"],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(other.needs(), "other-slots");
|
assert_eq!(other.needs(), "other-slots");
|
||||||
let sink = ShimReads {
|
let sink = ShimReads {
|
||||||
sink: true,
|
sink: true,
|
||||||
args: true,
|
reads: vec!["r8", "stack0"],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(sink.needs(), "output-sink");
|
assert_eq!(sink.needs(), "output-sink");
|
||||||
// The callable tier: the argument array and nothing else.
|
// The callable tier: the argument array and nothing else.
|
||||||
let only = ShimReads {
|
let only = ShimReads {
|
||||||
args: true,
|
reads: vec!["r8"],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(only.needs(), "args-only");
|
assert_eq!(only.needs(), "args-only");
|
||||||
|
|
@ -838,6 +812,24 @@ mod tests {
|
||||||
assert_eq!(ShimReads::default().needs(), "args-only");
|
assert_eq!(ShimReads::default().needs(), "args-only");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reading_the_argument_array_leaves_a_shim_host_callable() {
|
||||||
|
// Asserted against the shipped rule rather than a copy of it. `r8` is the argument array the
|
||||||
|
// CALLER builds, so a read of it must impose nothing; the arm exists only to keep it out of the
|
||||||
|
// catch-all, where it would mark every ordinary binding uncallable at once.
|
||||||
|
let mut r8 = ShimReads::default();
|
||||||
|
slot_need(Register::R8, &mut r8);
|
||||||
|
assert_eq!(r8.needs(), "args-only");
|
||||||
|
let mut rcx = ShimReads::default();
|
||||||
|
slot_need(Register::RCX, &mut rcx);
|
||||||
|
assert_eq!(rcx.needs(), "pulse-context");
|
||||||
|
for r in [Register::RDI, Register::RSI, Register::RDX, Register::R9] {
|
||||||
|
let mut o = ShimReads::default();
|
||||||
|
slot_need(r, &mut o);
|
||||||
|
assert_eq!(o.needs(), "other-slots", "{r:?} is a slot a host must fill");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pval_void_is_negative_one_and_still_a_type() {
|
fn pval_void_is_negative_one_and_still_a_type() {
|
||||||
assert!(valid_pval(0)); // PVAL_BOOL
|
assert!(valid_pval(0)); // PVAL_BOOL
|
||||||
|
|
|
||||||
18
src/rtti.rs
18
src/rtti.rs
|
|
@ -18,11 +18,9 @@ pub struct VTable {
|
||||||
|
|
||||||
/// One vtable discovered by the whole-binary sweep — the class inventory row.
|
/// One vtable discovered by the whole-binary sweep — the class inventory row.
|
||||||
pub struct ClassVtable {
|
pub struct ClassVtable {
|
||||||
pub mangled: String, // the raw `_ZTS` type name, e.g. "11CBaseEntity"
|
|
||||||
pub name: String, // demangled, e.g. "CBaseEntity"
|
pub name: String, // demangled, e.g. "CBaseEntity"
|
||||||
pub vtable_va: u64, // vaddr of slot index 0
|
pub vtable_va: u64, // vaddr of slot index 0
|
||||||
pub offset_to_top: i64, // 0 for the primary (complete-object) vtable; <0 for sub-object tables
|
pub offset_to_top: i64, // 0 for the primary (complete-object) vtable; <0 for sub-object tables
|
||||||
pub typeinfo: u64, // vaddr of the Itanium typeinfo struct
|
|
||||||
pub slots: Vec<u64>, // method vaddrs; a method's gamedata offset == its index here
|
pub slots: Vec<u64>, // method vaddrs; a method's gamedata offset == its index here
|
||||||
pub bases: Vec<BaseClass>, // direct base classes (the is-a graph edges)
|
pub bases: Vec<BaseClass>, // direct base classes (the is-a graph edges)
|
||||||
}
|
}
|
||||||
|
|
@ -143,10 +141,10 @@ fn demangle_type(mangled: &str) -> String {
|
||||||
.unwrap_or_else(|| mangled.to_string())
|
.unwrap_or_else(|| mangled.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If `ti` addresses a valid Itanium typeinfo, return its `(mangled, demangled)` class name.
|
/// If `ti` addresses a valid Itanium typeinfo, return its DEMANGLED class name.
|
||||||
/// A typeinfo is `[kind_vtable_ptr][name_ptr][ base-class data … ]`: `+0` points at one of the
|
/// A typeinfo is `[kind_vtable_ptr][name_ptr][ base-class data … ]`: `+0` points at one of the
|
||||||
/// C++ runtime's type_info-kind vtables, `+8` at the `_ZTS` name string.
|
/// C++ runtime's type_info-kind vtables, `+8` at the `_ZTS` name string.
|
||||||
fn typeinfo_name(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Option<(String, String)> {
|
fn typeinfo_name(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Option<String> {
|
||||||
// +0 must be one of the three kind vtables. Prefer the symbol-name-derived tag (the only signal that
|
// +0 must be one of the three kind vtables. Prefer the symbol-name-derived tag (the only signal that
|
||||||
// survives a DYNAMICALLY-linked C++ runtime, where the three kinds all resolve to the same offline
|
// survives a DYNAMICALLY-linked C++ runtime, where the three kinds all resolve to the same offline
|
||||||
// value); else fall back to the in-image value check (statically-linked / stripped builds).
|
// value); else fall back to the in-image value check (statically-linked / stripped builds).
|
||||||
|
|
@ -163,7 +161,7 @@ fn typeinfo_name(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Option<(String,
|
||||||
if !(c0.is_ascii_digit() || matches!(c0, b'N' | b'I' | b'P' | b'K' | b'S')) {
|
if !(c0.is_ascii_digit() || matches!(c0, b'N' | b'I' | b'P' | b'K' | b'S')) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some((mangled.clone(), demangle_type(&mangled)))
|
Some(demangle_type(&mangled))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direct base classes of the typeinfo at `ti`, dispatched on its exact Itanium kind.
|
/// Direct base classes of the typeinfo at `ti`, dispatched on its exact Itanium kind.
|
||||||
|
|
@ -187,7 +185,7 @@ fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass>
|
||||||
Some(KindTag::Si) => {
|
Some(KindTag::Si) => {
|
||||||
// __si_class_type_info: one public, non-virtual base at offset 0; its typeinfo ptr at +16.
|
// __si_class_type_info: one public, non-virtual base at offset 0; its typeinfo ptr at +16.
|
||||||
if let Some(bp) = img.read_ptr(ti.wrapping_add(16))
|
if let Some(bp) = img.read_ptr(ti.wrapping_add(16))
|
||||||
&& let Some((_, name)) = typeinfo_name(img, bp, kinds)
|
&& let Some(name) = typeinfo_name(img, bp, kinds)
|
||||||
{
|
{
|
||||||
return vec![BaseClass {
|
return vec![BaseClass {
|
||||||
name,
|
name,
|
||||||
|
|
@ -199,7 +197,7 @@ fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass>
|
||||||
}
|
}
|
||||||
Some(KindTag::Vmi) => {
|
Some(KindTag::Vmi) => {
|
||||||
// __vmi_class_type_info: flags@+16, base_count@+20, then 16-byte {typeinfo_ptr, offset_flags}.
|
// __vmi_class_type_info: flags@+16, base_count@+20, then 16-byte {typeinfo_ptr, offset_flags}.
|
||||||
let Some(count) = img.read_u32(ti + 20) else {
|
let Some(count) = img.read_u32(ti.wrapping_add(20)) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
if count == 0 || count > 128 {
|
if count == 0 || count > 128 {
|
||||||
|
|
@ -211,7 +209,7 @@ fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass>
|
||||||
let Some(bp) = img.read_ptr(e) else {
|
let Some(bp) = img.read_ptr(e) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
if let Some((_, name)) = typeinfo_name(img, bp, kinds) {
|
if let Some(name) = typeinfo_name(img, bp, kinds) {
|
||||||
let of = img.read_i64(e.wrapping_add(8)).unwrap_or(0);
|
let of = img.read_i64(e.wrapping_add(8)).unwrap_or(0);
|
||||||
bases.push(BaseClass {
|
bases.push(BaseClass {
|
||||||
name,
|
name,
|
||||||
|
|
@ -239,7 +237,7 @@ pub fn enumerate_vtables(img: &CodeImage, max_slots: usize) -> Vec<ClassVtable>
|
||||||
if slot < 8 {
|
if slot < 8 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some((mangled, name)) = typeinfo_name(img, val, &kinds) else {
|
let Some(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
|
// No de-dup guard: `reloc_slots` iterates a map KEYED by slot vaddr, so every slot — and hence
|
||||||
|
|
@ -259,11 +257,9 @@ pub fn enumerate_vtables(img: &CodeImage, max_slots: usize) -> Vec<ClassVtable>
|
||||||
}
|
}
|
||||||
let bases = typeinfo_bases(img, val, &kinds);
|
let bases = typeinfo_bases(img, val, &kinds);
|
||||||
out.push(ClassVtable {
|
out.push(ClassVtable {
|
||||||
mangled,
|
|
||||||
name,
|
name,
|
||||||
vtable_va,
|
vtable_va,
|
||||||
offset_to_top: ott,
|
offset_to_top: ott,
|
||||||
typeinfo: val,
|
|
||||||
slots,
|
slots,
|
||||||
bases,
|
bases,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,10 @@ pub const CURRENT_LAYOUT: SchemaLayout = SchemaLayout {
|
||||||
ci_base_count: 41,
|
ci_base_count: 41,
|
||||||
ci_fields: 48,
|
ci_fields: 48,
|
||||||
ci_bases: 56,
|
ci_bases: 56,
|
||||||
|
// Every displacement below is added to a FILE-CONTROLLED pointer, so each use wraps rather than
|
||||||
|
// panicking under the overflow-checked fuzz build. That includes the ones that are 0 today: they are
|
||||||
|
// layout values, revised when Valve reshapes the struct, and "safe because this constant happens to
|
||||||
|
// be zero" is a trap that springs on the revision rather than on the code that introduced it.
|
||||||
f_name: 0,
|
f_name: 0,
|
||||||
f_offset: 16,
|
f_offset: 16,
|
||||||
f_stride: 32,
|
f_stride: 32,
|
||||||
|
|
@ -157,7 +161,10 @@ pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> {
|
||||||
out.push(cls);
|
out.push(cls);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
// By NAME, then by the record's own address. The walk iterates `reloc_slots()`, which is a HashMap,
|
||||||
|
// so the collection order is hash order; a sort on name alone is stable and therefore leaves ties —
|
||||||
|
// two libraries registering one class — resolved by that hash order, in a byte-reproducible artifact.
|
||||||
|
out.sort_by(|a, b| (&a.name, a.class_info).cmp(&(&b.name, b.class_info)));
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,11 +192,49 @@ const EV_VALUE: u64 = 8;
|
||||||
/// field that isn't a count at all before it drives an allocation.
|
/// field that isn't a count at all before it drives an allocation.
|
||||||
const EB_MAX_VALUES: u32 = 4096;
|
const EB_MAX_VALUES: u32 = 4096;
|
||||||
|
|
||||||
/// Enumerate every registered enum in `img`, alongside [`enumerate_schema`]'s classes. Same reloc-driven
|
/// Enumerate every registered enum in `img`, given the classes [`enumerate_schema`] already recovered.
|
||||||
/// discovery: an enum binding is found by the slot holding its type-name pointer, then accepted only if
|
/// Same reloc-driven discovery: an enum binding is found by the slot holding its type-name pointer, then
|
||||||
/// the width/count word and the enumerator array both read as what they claim to be — so a layout change
|
/// accepted only if the width/count word and the enumerator array both read as what they claim to be —
|
||||||
/// yields fewer enums, never wrong ones. Sorted by name.
|
/// so a layout change yields fewer enums, never wrong ones. Sorted by name.
|
||||||
pub fn enumerate_enums(img: &CodeImage) -> Vec<SchemaEnum> {
|
///
|
||||||
|
/// **A class's FIELD descriptor is byte-compatible with an enum binding**, which is why `classes` is a
|
||||||
|
/// parameter rather than a convenience. `SchemaClassFieldData_t` is `{ name, type, offset, metadataCount,
|
||||||
|
/// metadata }`: read as an enum binding, the name reads as a type name, the low bytes of the offset read
|
||||||
|
/// as a plausible size/alignment, the metadata count reads as an enumerator count, and the metadata array
|
||||||
|
/// — `{ name, data }` pairs — reads as enumerators. Every field carrying exactly one metadata tag at a
|
||||||
|
/// field offset whose low two bytes are both powers of two therefore fits, and the result would be an
|
||||||
|
/// enum that does not exist, named after a member, whose one "value" is the ADDRESS of a documentation
|
||||||
|
/// string and therefore differs between runs of the same build.
|
||||||
|
///
|
||||||
|
/// Two independent structural facts reject them, and both are needed — measured over 1,016 CS2 and 1,490
|
||||||
|
/// Dota candidates, they catch 40 apiece with zero real enums lost, and neither catches all 40 alone:
|
||||||
|
///
|
||||||
|
/// 1. **The record sits inside a class's field array**, at a `F_STRIDE` boundary. That is not a heuristic
|
||||||
|
/// — the SchemaSystem states that this address is that class's Nth field.
|
||||||
|
/// 2. **An enumerator's value is a relocation.** An enum value is a compile-time literal, so it is never
|
||||||
|
/// relocated; a metadata entry's second word is a pointer, so it always is. This is what catches a
|
||||||
|
/// field whose owning class the class walk itself rejected, leaving no array to fall inside.
|
||||||
|
pub fn enumerate_enums(img: &CodeImage, classes: &[SchemaClass]) -> Vec<SchemaEnum> {
|
||||||
|
// The address ranges class field descriptors occupy, sorted so membership is a binary search.
|
||||||
|
let mut spans: Vec<(u64, u64)> = classes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| {
|
||||||
|
let fp = img.read_ptr(c.class_info.wrapping_add(CI_FIELDS))?;
|
||||||
|
(fp != 0).then(|| (fp, fp.wrapping_add(F_STRIDE * c.fields.len() as u64)))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
spans.sort_unstable();
|
||||||
|
// Each class owns its own array, so the ranges are disjoint and the last one starting at or before
|
||||||
|
// `a` is the only one that can contain it. If that ever stopped holding, the miss would be a fake
|
||||||
|
// enum surviving rather than a real one dropped — the same direction every other guard here errs in.
|
||||||
|
let in_field_array = |a: u64| {
|
||||||
|
let i = spans.partition_point(|&(s, _)| s <= a);
|
||||||
|
i > 0 && {
|
||||||
|
let (s, e) = spans[i - 1];
|
||||||
|
a < e && (a - s).is_multiple_of(F_STRIDE)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (slot, val) in img.reloc_slots() {
|
for (slot, val) in img.reloc_slots() {
|
||||||
let Some(name) = img.read_c_string(val) else {
|
let Some(name) = img.read_c_string(val) else {
|
||||||
|
|
@ -201,6 +246,10 @@ pub fn enumerate_enums(img: &CodeImage) -> Vec<SchemaEnum> {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let base = slot.wrapping_sub(EB_TYPE_NAME);
|
let base = slot.wrapping_sub(EB_TYPE_NAME);
|
||||||
|
// Clause 1: the SchemaSystem states this address is a class's field descriptor, so it is one.
|
||||||
|
if in_field_array(base) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let Some(w) = img.read_ptr(base.wrapping_add(EB_WIDTH)) else {
|
let Some(w) = img.read_ptr(base.wrapping_add(EB_WIDTH)) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
@ -228,7 +277,10 @@ pub fn enumerate_enums(img: &CodeImage) -> Vec<SchemaEnum> {
|
||||||
) else {
|
) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
if n.is_empty() {
|
// Clause 2: a relocated "value" is a pointer, so these are `{ name, data }` metadata
|
||||||
|
// entries and not enumerators. Rejects the whole record — one pointer among the values
|
||||||
|
// means the array is the wrong kind, not that one enumerator is odd.
|
||||||
|
if n.is_empty() || img.is_reloc_slot(rec.wrapping_add(EV_VALUE)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
values.push((n, v));
|
values.push((n, v));
|
||||||
|
|
@ -242,7 +294,13 @@ pub fn enumerate_enums(img: &CodeImage) -> Vec<SchemaEnum> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
// Same reason as `enumerate_schema`, and it matters MORE here because the `dedup_by` below then keeps
|
||||||
|
// whichever row sorted first: with a name-only sort that survivor was picked by hash order. Enums have
|
||||||
|
// no record address on the struct, so the tiebreak is the content that distinguishes two accounts of
|
||||||
|
// one name — width, alignment, then the enumerator list.
|
||||||
|
out.sort_by(|a, b| {
|
||||||
|
(&a.name, a.size, a.align, &a.values).cmp(&(&b.name, b.size, b.align, &b.values))
|
||||||
|
});
|
||||||
out.dedup_by(|a, b| a.name == b.name); // one binding per name; libs re-register shared enums
|
out.dedup_by(|a, b| a.name == b.name); // one binding per name; libs re-register shared enums
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
@ -272,10 +330,13 @@ fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<
|
||||||
let mut fields = Vec::with_capacity(field_count as usize);
|
let mut fields = Vec::with_capacity(field_count as usize);
|
||||||
for i in 0..field_count as u64 {
|
for i in 0..field_count as u64 {
|
||||||
let fe = fields_ptr.wrapping_add(i.wrapping_mul(F_STRIDE));
|
let fe = fields_ptr.wrapping_add(i.wrapping_mul(F_STRIDE));
|
||||||
let Some(fname) = img.read_ptr(fe + F_NAME).and_then(|p| img.read_c_string(p)) else {
|
let Some(fname) = img
|
||||||
|
.read_ptr(fe.wrapping_add(F_NAME))
|
||||||
|
.and_then(|p| img.read_c_string(p))
|
||||||
|
else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let offset = img.read_i32(fe + F_OFFSET).unwrap_or(0);
|
let offset = img.read_i32(fe.wrapping_add(F_OFFSET)).unwrap_or(0);
|
||||||
fields.push(SchemaField {
|
fields.push(SchemaField {
|
||||||
name: fname,
|
name: fname,
|
||||||
offset,
|
offset,
|
||||||
|
|
@ -288,13 +349,13 @@ fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<
|
||||||
if bases_ptr != 0 {
|
if bases_ptr != 0 {
|
||||||
for i in 0..base_count as u64 {
|
for i in 0..base_count as u64 {
|
||||||
let be = bases_ptr.wrapping_add(i.wrapping_mul(B_STRIDE));
|
let be = bases_ptr.wrapping_add(i.wrapping_mul(B_STRIDE));
|
||||||
let offset = img.read_u32(be + B_OFFSET).unwrap_or(0);
|
let offset = img.read_u32(be.wrapping_add(B_OFFSET)).unwrap_or(0);
|
||||||
let bcls = img.read_ptr(be + B_CLASS).unwrap_or(0);
|
let bcls = img.read_ptr(be.wrapping_add(B_CLASS)).unwrap_or(0);
|
||||||
if bcls == 0 {
|
if bcls == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(bn) = img
|
if let Some(bn) = img
|
||||||
.read_ptr(bcls + CI_NAME)
|
.read_ptr(bcls.wrapping_add(CI_NAME))
|
||||||
.and_then(|p| img.read_c_string(p))
|
.and_then(|p| img.read_c_string(p))
|
||||||
{
|
{
|
||||||
bases.push(SchemaBase { name: bn, offset });
|
bases.push(SchemaBase { name: bn, offset });
|
||||||
|
|
@ -316,7 +377,7 @@ fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<
|
||||||
// The offline reader above recovers class layouts (names + field offsets) from the static reflection
|
// The offline reader above recovers class layouts (names + field offsets) from the static reflection
|
||||||
// tables. Field *types* are runtime-resolved (each record's `m_pType` is a null pointer on disk), so
|
// tables. Field *types* are runtime-resolved (each record's `m_pType` is a null pointer on disk), so
|
||||||
// `live_schema` attaches to a running process, reads the types back, and builds the typed
|
// `live_schema` attaches to a running process, reads the types back, and builds the typed
|
||||||
// `netvars-<game>.json` (`model::Schema`) directly — no `sdk.json` intermediate.
|
// the artifact's `schema` section (`model::Schema`) directly — no `sdk.json` intermediate.
|
||||||
|
|
||||||
/// FNV-1a (32-bit). The Source-2 schema field/class name hash: a field's runtime lookup key is
|
/// FNV-1a (32-bit). The Source-2 schema field/class name hash: a field's runtime lookup key is
|
||||||
/// `(fnv1a32(class_name) << 32) | fnv1a32(field_name)` (field name keeps its `m_` prefix). Confirmed
|
/// `(fnv1a32(class_name) << 32) | fnv1a32(field_name)` (field name keeps its `m_` prefix). Confirmed
|
||||||
|
|
@ -344,7 +405,7 @@ fn builtin_size(t: &str) -> i32 {
|
||||||
/// Walk a running process's schema across every server-mapped library (`profile.libs`) and build the typed
|
/// Walk a running process's schema across every server-mapped library (`profile.libs`) and build the typed
|
||||||
/// netvars (`model::Schema`) DIRECTLY — no `sdk.json` round-trip: field layout is read offline from each
|
/// netvars (`model::Schema`) DIRECTLY — no `sdk.json` round-trip: field layout is read offline from each
|
||||||
/// image, the runtime `m_pType` from the live process. Shared classes (compiled into many libs) de-dupe
|
/// image, the runtime `m_pType` from the live process. Shared classes (compiled into many libs) de-dupe
|
||||||
/// precedence-first (the earlier lib in `libs` wins). This is `netvars-<game>.json` — the shipped SDK
|
/// precedence-first (the earlier lib in `libs` wins). This is the artifact's `schema` section — the shipped SDK
|
||||||
/// material (`source2rosetta-gen` renders it on demand).
|
/// material (`source2rosetta-gen` renders it on demand).
|
||||||
pub(crate) fn live_schema(
|
pub(crate) fn live_schema(
|
||||||
prof: &GameProfile,
|
prof: &GameProfile,
|
||||||
|
|
@ -370,9 +431,13 @@ 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;
|
||||||
|
// ONE class walk per library, shared by both consumers below — the enum walk needs it to tell a
|
||||||
|
// field descriptor from an enum binding, and walking the reflection tables twice per image is
|
||||||
|
// what a large game's memory ceiling notices first.
|
||||||
|
let schema_classes = enumerate_schema(&img);
|
||||||
// Enum bindings are static, so they come from the IMAGE — no process read, unlike field types.
|
// 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.
|
// First library wins, matching the class precedence: a shared enum has one definition.
|
||||||
for e in enumerate_enums(&img) {
|
for e in enumerate_enums(&img, &schema_classes) {
|
||||||
enums.entry(e.name).or_insert_with(|| model::EnumDef {
|
enums.entry(e.name).or_insert_with(|| model::EnumDef {
|
||||||
size: e.size,
|
size: e.size,
|
||||||
values: e
|
values: e
|
||||||
|
|
@ -382,7 +447,7 @@ pub(crate) fn live_schema(
|
||||||
.collect(),
|
.collect(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for c in &enumerate_schema(&img) {
|
for c in &schema_classes {
|
||||||
// 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()) {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,13 @@ pub struct PulseBinding {
|
||||||
///
|
///
|
||||||
/// Measured as a fixed-signature marshalling stub: seven integer arguments returning int, where the
|
/// Measured as a fixed-signature marshalling stub: seven integer arguments returning int, where the
|
||||||
/// fifth is an array of pointers to the argument values (element *k* at `+8+8k`) and the seventh is an
|
/// fifth is an array of pointers to the argument values (element *k* at `+8+8k`) and the seventh is an
|
||||||
/// output sink read by exactly the bindings that declare a return. Not emitted into any artifact — the
|
/// output sink read by exactly the bindings that declare a return.
|
||||||
/// contract has not been validated by an actual call, and a locator nobody has exercised is a claim.
|
///
|
||||||
|
/// It IS emitted, and the measurement above is not the reason to trust it — the live oracle is. The
|
||||||
|
/// address and its measured read-set ship as `surfaces.pulse[].shim` / `.call`; `verify_pulse_shims`
|
||||||
|
/// actually CALLS every `args-only` shim on a running server each build (CS2 186 of 193 clean, Dota
|
||||||
|
/// 211 of 211); and `GameProfile::min_pulse_callable` floors the population that survives. A locator
|
||||||
|
/// nobody has exercised would be a claim — this one is exercised every derive.
|
||||||
pub shim: u64,
|
pub shim: u64,
|
||||||
pub flags: PulseFlags,
|
pub flags: PulseFlags,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
558
src/vscript.rs
Normal file
558
src/vscript.rs
Normal file
|
|
@ -0,0 +1,558 @@
|
||||||
|
//! The VScript binding registry — the fourth surface a Source-2 module documents about itself, and the
|
||||||
|
//! only one that states a function's PARAMETER NAMES.
|
||||||
|
//!
|
||||||
|
//! Valve exposes a subset of the C++ surface to script (Lua in Dota's custom games, and a smaller set in
|
||||||
|
//! CS2). Every exposed method is registered with the script VM through a descriptor carrying its
|
||||||
|
//! script-facing name, its C++ name, an English description, a return type, and a pointer to the
|
||||||
|
//! implementation. That is a locator AND a prototype AND documentation, all stated by Valve, which makes
|
||||||
|
//! it the same kind of find as the Pulse registry and the console-command registration.
|
||||||
|
//!
|
||||||
|
//! # Why this is not a table walk
|
||||||
|
//!
|
||||||
|
//! The obvious route — find a static array of descriptors and read it — does not work, and the reason is
|
||||||
|
//! worth stating because it costs a day to rediscover. The descriptors are built at RUNTIME: a scan of
|
||||||
|
//! Dota's `libserver.so` finds 2,268,664 `R_X86_64_RELATIVE` relocations and **not one** points at a
|
||||||
|
//! description string. On disk the descriptor array is zeroes.
|
||||||
|
//!
|
||||||
|
//! What is static is the CODE that fills it in, and every field is a constant in the instruction stream.
|
||||||
|
//! This is the same shape the Pulse parameter records turned out to have, and the same answer applies:
|
||||||
|
//! constant-propagate through the initialiser rather than read the table.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! movq xmm0, [rip+slot] ; the script-facing name, via a relocated .data.rel.ro slot
|
||||||
|
//! lea rdx, [rip+"Script_TakeDamage"]
|
||||||
|
//! pinsrq xmm0, rdx, 1 ; pack both names into one 16-byte store
|
||||||
|
//! lea rsi, [rip+"Applies damage to this entity."]
|
||||||
|
//! lea rax, [rax+rax*4] ; index * 5
|
||||||
|
//! shl rax, 4 ; * 16 -> stride 80
|
||||||
|
//! add rax, [rbx+0x28] ; base = owning class descriptor's function array
|
||||||
|
//! mov [rax+0x30], rbx ; owner
|
||||||
|
//! mov [rax+0x10], rsi ; description
|
||||||
|
//! movups [rax], xmm0 ; +0x00 script name, +0x08 C++ name
|
||||||
|
//! mov [rax+0x18], r11w ; return type
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # The record
|
||||||
|
//!
|
||||||
|
//! **80 bytes**, derived rather than assumed — the `lea r,[r+r*4]` / `shl r,4` pair states it in the
|
||||||
|
//! instruction stream, so a stride change is a decode failure rather than silent corruption.
|
||||||
|
//!
|
||||||
|
//! | offset | field |
|
||||||
|
//! |---|---|
|
||||||
|
//! | `+0x00` | script-facing name (`TakeDamage`) |
|
||||||
|
//! | `+0x08` | C++ binding name (`Script_TakeDamage`) |
|
||||||
|
//! | `+0x10` | Valve's English description |
|
||||||
|
//! | `+0x18` | return type, a `u16` |
|
||||||
|
//! | `+0x28` | a name string — the return value's, where one is given |
|
||||||
|
//! | `+0x30` | the owning class descriptor |
|
||||||
|
//! | `+0x38` | the marshalling thunk, SHARED by every binding of the same shape |
|
||||||
|
//! | `+0x40` | pointer-to-member: the implementation |
|
||||||
|
//! | `+0x48` | a `u32` count |
|
||||||
|
//!
|
||||||
|
//! `+0x40` is an Itanium pointer-to-member, which is convenient rather than awkward: a non-virtual
|
||||||
|
//! member is a plain address and a virtual one is `slot * 8 + 1`. Those are exactly the two locator
|
||||||
|
//! forms the rest of this crate already emits, so a VScript binding lands in `gamedata` as either a
|
||||||
|
//! signature or a vtable offset with no new concept.
|
||||||
|
//!
|
||||||
|
//! Do not confuse `+0x38` with `+0x40`. The thunk at `+0x38` is a compiler-generated trampoline shared
|
||||||
|
//! across every binding with the same signature; folding it would ship dozens of distinct names all
|
||||||
|
//! pointing at one address. That is the same mistake the Pulse `+24`/`+32` accessors invite, and it is
|
||||||
|
//! caught here the same way — by the sharing itself, since a real implementation is referenced once.
|
||||||
|
//!
|
||||||
|
//! # Shape-driven, so a layout change yields FEWER bindings and never wrong ones
|
||||||
|
//!
|
||||||
|
//! Nothing here is anchored on an address, a symbol or a fixed offset into the image. A record is
|
||||||
|
//! recognised by what the initialiser DOES — a 16-byte store of two plausible name strings, a
|
||||||
|
//! description or nothing at `+0x10`, a small return type, and a `+0x40` that is either executable code
|
||||||
|
//! or a small odd integer. A build that reshapes the descriptor fails those tests and produces nothing,
|
||||||
|
//! which the release floor then catches.
|
||||||
|
|
||||||
|
use crate::elf::CodeImage;
|
||||||
|
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
|
/// The record stride, in bytes. Stated by the initialiser's own `idx*5 << 4`; kept as a constant only to
|
||||||
|
/// validate what is decoded.
|
||||||
|
///
|
||||||
|
/// `pub(crate)` because the LIVE class walk in `produce` steps the same records and must step them by
|
||||||
|
/// the same number. Unlike the Pulse element stride this one is NOT derived by consensus — the
|
||||||
|
/// initialiser states it in the instruction stream, so there is nothing to vote on — and a build that
|
||||||
|
/// changes it shows up as decoded records failing validation here, not as a mis-strided live walk.
|
||||||
|
pub(crate) const STRIDE: i64 = 80;
|
||||||
|
|
||||||
|
/// Field displacements within the record.
|
||||||
|
const F_NAME: i64 = 0x00;
|
||||||
|
const F_CPP: i64 = 0x08;
|
||||||
|
const F_DESC: i64 = 0x10;
|
||||||
|
const F_RET: i64 = 0x18;
|
||||||
|
const F_IMPL: i64 = 0x40;
|
||||||
|
|
||||||
|
/// Longest accepted name/description, so a mis-decoded pointer into the middle of a blob cannot produce a
|
||||||
|
/// megabyte "name".
|
||||||
|
const MAX_NAME: usize = 128;
|
||||||
|
const MAX_DESC: usize = 512;
|
||||||
|
|
||||||
|
/// `ScriptDataType_t`, DERIVED by joining recovered bindings against Valve's own published VScript dump
|
||||||
|
/// rather than assumed from Source's historical ordering.
|
||||||
|
///
|
||||||
|
/// The distinction matters, and the first attempt at this table is the reason it is spelled out. Two
|
||||||
|
/// anchors were available by inspection — a binding returning `float` stores `1`, one returning `int`
|
||||||
|
/// stores `5` — and they fit Source 1's long-standing `FIELD_*` ordering, in which `5` is `BOOLEAN`. That
|
||||||
|
/// reading was WRONG: joined against 389 bindings whose return type Valve states, `5` is `int` and `6` is
|
||||||
|
/// `bool`. Two points are enough to fit a plausible table and not enough to check one.
|
||||||
|
///
|
||||||
|
/// Agreement on the derived table is total where a comparison is meaningful. The apparent disagreements
|
||||||
|
/// are Valve naming a SEMANTIC type over the same ABI type: `5` also covers `modifierpriority` and
|
||||||
|
/// `UnitFilterResult` (enums, which are ints), and `31` also covers `CDOTA_BaseNPC` and `CBaseEntity`
|
||||||
|
/// (entity handles, which are handles).
|
||||||
|
///
|
||||||
|
/// The raw word ships beside the decoded name regardless — the rule `flags_raw` already follows — so a
|
||||||
|
/// build that renumbers this can be re-read rather than silently mislabelled.
|
||||||
|
const RET_TYPES: [(u16, &str); 13] = [
|
||||||
|
(0, "void"),
|
||||||
|
(1, "float"),
|
||||||
|
(3, "Vector"),
|
||||||
|
(5, "int"),
|
||||||
|
(6, "bool"),
|
||||||
|
(13, "ehandle"),
|
||||||
|
(14, "Vector"),
|
||||||
|
(29, "unknown"),
|
||||||
|
(30, "string"),
|
||||||
|
(31, "handle"),
|
||||||
|
(32, "table"),
|
||||||
|
(37, "uint"),
|
||||||
|
(39, "QAngle"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Decode a return-type word, or `None` when the value is outside what is corroborated.
|
||||||
|
pub fn ret_type_name(raw: u16) -> Option<&'static str> {
|
||||||
|
RET_TYPES.iter().find(|(v, _)| *v == raw).map(|(_, n)| *n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a binding's implementation lives, decoded from the pointer-to-member at `+0x40`.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Impl {
|
||||||
|
/// A non-virtual member: the address itself.
|
||||||
|
Addr(u64),
|
||||||
|
/// A virtual member: `(pmf - 1) / 8` is the vtable slot index.
|
||||||
|
Slot(u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One registered VScript binding.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct VScriptFunc {
|
||||||
|
/// The script-facing name a Lua author calls (`TakeDamage`).
|
||||||
|
pub name: String,
|
||||||
|
/// The C++ binding name (`Script_TakeDamage`). Often but not always the script name with a prefix.
|
||||||
|
pub cpp_name: String,
|
||||||
|
/// Valve's own English description, where the registration supplies one.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// The return type as stored, undecoded.
|
||||||
|
pub ret_raw: u16,
|
||||||
|
/// The return type decoded, or `None` if the value is outside the corroborated set.
|
||||||
|
pub ret: Option<&'static str>,
|
||||||
|
/// The implementation, as an address or a vtable slot.
|
||||||
|
pub imp: Option<Impl>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a register provably holds — CONSTANTS only, which is where this parts company with `concmd`'s
|
||||||
|
/// tracker.
|
||||||
|
///
|
||||||
|
/// There is no symbolic-base variant here and none is needed: a store is credited to a record through
|
||||||
|
/// `recid`, propagated across `mov rD,rS`, rather than through a `(register, epoch)` pair. That is why
|
||||||
|
/// the base survives being copied between registers, and why this tracker needs no epoch counter.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
enum V {
|
||||||
|
Unknown,
|
||||||
|
Const(u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl V {
|
||||||
|
fn konst(self) -> Option<u64> {
|
||||||
|
match self {
|
||||||
|
V::Const(c) => Some(c),
|
||||||
|
V::Unknown => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 64-bit parent register as a slot index. Thin wrapper over [`crate::abi::gp_slot`] — the mapping is a
|
||||||
|
/// fixed SysV fact, and this file only narrows it to the `u8` its `[_; 16]` arrays index by.
|
||||||
|
fn gpr(r: Register) -> Option<u8> {
|
||||||
|
crate::abi::gp_slot(r).map(|s| s as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xmm(r: Register) -> Option<u8> {
|
||||||
|
r.is_xmm()
|
||||||
|
.then(|| (r as usize - Register::XMM0 as usize) as u8)
|
||||||
|
.filter(|i| *i < 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A field of a particular record: which record, and the displacement within it.
|
||||||
|
type Slot = (u32, i64);
|
||||||
|
|
||||||
|
/// Read a NUL-terminated string, rejecting anything that is not plausibly a name.
|
||||||
|
fn text(img: &CodeImage, va: u64, max: usize) -> Option<String> {
|
||||||
|
let s = img.read_c_string(va)?;
|
||||||
|
if s.is_empty() || s.len() > max {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
s.chars()
|
||||||
|
.all(|c| c.is_ascii_graphic() || c == ' ')
|
||||||
|
.then_some(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An identifier-shaped string — what a script-facing or C++ name must look like. Deliberately strict:
|
||||||
|
/// a mis-decoded pointer usually lands on prose or a path, and both fail this.
|
||||||
|
fn ident(img: &CodeImage, va: u64) -> Option<String> {
|
||||||
|
let s = text(img, va, MAX_NAME)?;
|
||||||
|
let ok = s
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
|
||||||
|
&& s.chars()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
|
||||||
|
ok.then_some(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the pointer-to-member at `+0x40`.
|
||||||
|
///
|
||||||
|
/// The two forms are distinguished by the low bit, per the Itanium ABI. Both are validated: an address
|
||||||
|
/// has to land in executable code, and a slot index has to be small enough to be a real vtable position.
|
||||||
|
/// Anything else means the field is not what this reader thinks it is, and yields `None` rather than a
|
||||||
|
/// confident wrong locator.
|
||||||
|
fn decode_pmf(img: &CodeImage, pmf: u64) -> Option<Impl> {
|
||||||
|
if pmf == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if pmf & 1 == 1 {
|
||||||
|
let slot = (pmf - 1) / 8;
|
||||||
|
// 2048 is the same ceiling `rtti` reads vtables to; past it this is not a slot index.
|
||||||
|
(slot < 2048 && (pmf - 1).is_multiple_of(8)).then_some(Impl::Slot(slot))
|
||||||
|
} else {
|
||||||
|
img.is_code(pmf).then_some(Impl::Addr(pmf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover every VScript binding the image registers.
|
||||||
|
///
|
||||||
|
/// One pass over the candidate functions, tracking what each register and XMM half provably holds and
|
||||||
|
/// collecting stores to record-relative slots. A group of stores is accepted as a binding only if it
|
||||||
|
/// presents the full shape, so partial or coincidental matches are dropped rather than guessed at.
|
||||||
|
pub fn vscript_functions(img: &CodeImage) -> Vec<VScriptFunc> {
|
||||||
|
let entries = crate::locate::function_entries(img);
|
||||||
|
|
||||||
|
let mut out: Vec<VScriptFunc> = Vec::new();
|
||||||
|
let mut insn = Instruction::default();
|
||||||
|
|
||||||
|
for (i, &start) in entries.iter().enumerate() {
|
||||||
|
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
|
||||||
|
let Some(code) = img.code_range(start, end) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut val = [V::Unknown; 16];
|
||||||
|
// Which registers currently hold a RECORD BASE, and which hold the half-built `idx*80` on the way
|
||||||
|
// to one. This is the structural anchor: a store is only collected when its base was computed by
|
||||||
|
// the initialiser's own `idx*5 << 4 + [class+0x28]`. Without it the pass collects any struct with
|
||||||
|
// two string pointers at +0x00/+0x08, and `libserver` has at least one other table of that shape
|
||||||
|
// (the network field serialisers) which then contributes records whose "name" is a netvar.
|
||||||
|
let mut scaled = [false; 16];
|
||||||
|
// Which RECORD each register currently points at, not merely whether it points at one. Keying on
|
||||||
|
// identity rather than on (register, epoch) is what lets a base survive `mov rcx,rax` — the
|
||||||
|
// compiler routinely copies the base and then reuses the original for something else, writing
|
||||||
|
// half a record through each. Keyed by register, those two halves land in different groups and
|
||||||
|
// neither is complete.
|
||||||
|
let mut recid: [Option<u32>; 16] = [None; 16];
|
||||||
|
let mut next_rec: u32 = 0;
|
||||||
|
// Each XMM tracked as its two 64-bit halves, which is the only way the packed name store is
|
||||||
|
// readable: both names reach the record through one 16-byte write.
|
||||||
|
let mut xr = [(V::Unknown, V::Unknown); 16];
|
||||||
|
let mut stores: HashMap<Slot, u64> = HashMap::new();
|
||||||
|
|
||||||
|
let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE);
|
||||||
|
while dec.can_decode() {
|
||||||
|
dec.decode_out(&mut insn);
|
||||||
|
|
||||||
|
if insn.flow_control() == FlowControl::Call {
|
||||||
|
// Registers a call clobbers, so a value cannot survive one and be attributed to the wrong
|
||||||
|
// record. Taken from `abi`, not transcribed as raw GPR indices — a second copy of a fixed
|
||||||
|
// SysV fact is a copy that can drift.
|
||||||
|
for c in crate::abi::caller_saved_slots() {
|
||||||
|
val[c] = V::Unknown;
|
||||||
|
scaled[c] = false;
|
||||||
|
recid[c] = None;
|
||||||
|
}
|
||||||
|
xr = [(V::Unknown, V::Unknown); 16];
|
||||||
|
// MEASURED DEAD END, recorded so it is not re-attempted: treating `rax` as a speculative
|
||||||
|
// record base after every call — on the theory that some registrations allocate a record
|
||||||
|
// and fill it through the returned pointer — reintroduces precisely the network-field
|
||||||
|
// serialisers the record-base anchor exists to reject (`CBaseEntity`/`m_fFlags`,
|
||||||
|
// `CNetworkOriginCellCoordQuantizedVector`/`m_cellX`, the `*ChangedCompat` callbacks) and
|
||||||
|
// recovers no additional binding. The structure built through a call's return here is the
|
||||||
|
// CLASS descriptor, not a function record: its `+0x00`/`+0x08` hold the class name twice.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match insn.mnemonic() {
|
||||||
|
// `lea r,[rip+d]` — a string or global address. `lea rD,[rS+rS*4]` is something else
|
||||||
|
// entirely: the first half of the record-base computation, `idx * 5`.
|
||||||
|
Mnemonic::Lea => {
|
||||||
|
if let Some(d) = gpr(insn.op0_register()) {
|
||||||
|
let times_five = insn.memory_index() != Register::None
|
||||||
|
&& insn.memory_base() == insn.memory_index()
|
||||||
|
&& insn.memory_index_scale() == 4
|
||||||
|
&& insn.memory_displacement64() == 0;
|
||||||
|
val[d as usize] = if insn.is_ip_rel_memory_operand() {
|
||||||
|
V::Const(insn.ip_rel_memory_address())
|
||||||
|
} else {
|
||||||
|
V::Unknown
|
||||||
|
};
|
||||||
|
scaled[d as usize] = times_five;
|
||||||
|
recid[d as usize] = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `xor rD,rD` is the zeroing idiom, not an arithmetic unknown. It matters more here than
|
||||||
|
// it looks: a `void` binding sets its return type with `xor r11d,r11d` and then stores
|
||||||
|
// `r11w`, so treating this as an unknown loses every void-returning binding — which on
|
||||||
|
// Dota is most of them.
|
||||||
|
Mnemonic::Xor => {
|
||||||
|
if let (Some(d), Some(s)) = (gpr(insn.op0_register()), gpr(insn.op1_register()))
|
||||||
|
{
|
||||||
|
val[d as usize] = if d == s { V::Const(0) } else { V::Unknown };
|
||||||
|
scaled[d as usize] = false;
|
||||||
|
recid[d as usize] = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `shl rD,4` completes `idx * 80`. Any other shift of a scaled register means this is
|
||||||
|
// not the idiom and the candidate is dropped.
|
||||||
|
Mnemonic::Shl => {
|
||||||
|
if let Some(d) = gpr(insn.op0_register()) {
|
||||||
|
let keep = scaled[d as usize]
|
||||||
|
&& insn.op1_kind() == OpKind::Immediate8
|
||||||
|
&& insn.immediate8() == 4;
|
||||||
|
val[d as usize] = V::Unknown;
|
||||||
|
scaled[d as usize] = keep;
|
||||||
|
recid[d as usize] = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `add rD,[class+0x28]` turns `idx * 80` into the record's own address. From here every
|
||||||
|
// store through `rD` is a field of one binding.
|
||||||
|
Mnemonic::Add => {
|
||||||
|
if let Some(d) = gpr(insn.op0_register()) {
|
||||||
|
let base = scaled[d as usize] && insn.op1_kind() == OpKind::Memory;
|
||||||
|
val[d as usize] = V::Unknown;
|
||||||
|
scaled[d as usize] = false;
|
||||||
|
recid[d as usize] = base.then(|| {
|
||||||
|
next_rec += 1;
|
||||||
|
next_rec
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `movq xmm,[rip+slot]` loads a RELOCATED pointer — the script-facing name arrives this
|
||||||
|
// way rather than as a `lea`, and reading it needs the relocation applied, which
|
||||||
|
// `read_ptr` does. `movq xmm,r64` and the reverse also appear.
|
||||||
|
Mnemonic::Movq | Mnemonic::Movd => {
|
||||||
|
if let Some(x) = xmm(insn.op0_register()) {
|
||||||
|
let lo = if insn.op1_kind() == OpKind::Memory {
|
||||||
|
if insn.is_ip_rel_memory_operand() {
|
||||||
|
img.read_ptr(insn.ip_rel_memory_address())
|
||||||
|
.map_or(V::Unknown, V::Const)
|
||||||
|
} else {
|
||||||
|
V::Unknown
|
||||||
|
}
|
||||||
|
} else if let Some(s) = gpr(insn.op1_register()) {
|
||||||
|
val[s as usize]
|
||||||
|
} else {
|
||||||
|
V::Unknown
|
||||||
|
};
|
||||||
|
// `movq` zeroes the upper half; that matters because the high name is inserted
|
||||||
|
// afterwards and must not inherit a stale value.
|
||||||
|
xr[x as usize] = (lo, V::Const(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `pinsrq xmm,r64,1` — the second name packed into the high half.
|
||||||
|
Mnemonic::Pinsrq => {
|
||||||
|
if let (Some(x), Some(s)) = (xmm(insn.op0_register()), gpr(insn.op1_register()))
|
||||||
|
&& insn.op2_kind() == OpKind::Immediate8
|
||||||
|
{
|
||||||
|
let v = val[s as usize];
|
||||||
|
if insn.immediate8() == 1 {
|
||||||
|
xr[x as usize].1 = v;
|
||||||
|
} else {
|
||||||
|
xr[x as usize].0 = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `movddup xmm,[rip+slot]` — ONE pointer written into both halves. This is the form the
|
||||||
|
// compiler picks when the script-facing name and the C++ name are the SAME string, which
|
||||||
|
// is the common case: only the bindings that need a distinct C++ name (usually a
|
||||||
|
// `Script_`-prefixed wrapper) load two pointers. Missing this mnemonic costs roughly
|
||||||
|
// four fifths of the registry on Dota, so it is not an edge case.
|
||||||
|
Mnemonic::Movddup => {
|
||||||
|
if let Some(x) = xmm(insn.op0_register()) {
|
||||||
|
let v = if insn.is_ip_rel_memory_operand() {
|
||||||
|
img.read_ptr(insn.ip_rel_memory_address())
|
||||||
|
.map_or(V::Unknown, V::Const)
|
||||||
|
} else {
|
||||||
|
V::Unknown
|
||||||
|
};
|
||||||
|
xr[x as usize] = (v, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `punpcklqdq x0,x1` — the same pack, reached the other way.
|
||||||
|
Mnemonic::Punpcklqdq => {
|
||||||
|
if let (Some(a), Some(b)) = (xmm(insn.op0_register()), xmm(insn.op1_register()))
|
||||||
|
{
|
||||||
|
xr[a as usize] = (xr[a as usize].0, xr[b as usize].0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 16-byte store that lands both names.
|
||||||
|
Mnemonic::Movups | Mnemonic::Movaps | Mnemonic::Movdqu | Mnemonic::Movdqa => {
|
||||||
|
if insn.op0_kind() == OpKind::Memory
|
||||||
|
&& let Some(x) = xmm(insn.op1_register())
|
||||||
|
&& let Some(b) = gpr(insn.memory_base())
|
||||||
|
&& insn.memory_index() == Register::None
|
||||||
|
&& let Some(rec) = recid[b as usize]
|
||||||
|
{
|
||||||
|
let d = insn.memory_displacement64() as i64;
|
||||||
|
if let Some(v) = xr[x as usize].0.konst() {
|
||||||
|
stores.insert((rec, d), v);
|
||||||
|
}
|
||||||
|
if let Some(v) = xr[x as usize].1.konst() {
|
||||||
|
stores.insert((rec, d + 8), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Mnemonic::Mov => {
|
||||||
|
// Store to a record-relative slot: `mov [base+d], reg` or `mov [base+d], imm`.
|
||||||
|
if insn.op0_kind() == OpKind::Memory
|
||||||
|
&& insn.memory_index() == Register::None
|
||||||
|
&& let Some(b) = gpr(insn.memory_base())
|
||||||
|
&& let Some(rec) = recid[b as usize]
|
||||||
|
{
|
||||||
|
let d = insn.memory_displacement64() as i64;
|
||||||
|
let v = match insn.op1_kind() {
|
||||||
|
// A 16-bit store carries the return type. The tracker follows full
|
||||||
|
// registers, so `mov [rec+0x18], r11w` reads back through `r11`.
|
||||||
|
OpKind::Register => {
|
||||||
|
gpr(insn.op1_register()).and_then(|s| val[s as usize].konst())
|
||||||
|
}
|
||||||
|
OpKind::Immediate8 | OpKind::Immediate16 | OpKind::Immediate32 => {
|
||||||
|
Some(insn.immediate32to64() as u64)
|
||||||
|
}
|
||||||
|
OpKind::Immediate32to64 | OpKind::Immediate8to64 => {
|
||||||
|
Some(insn.immediate64())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(v) = v {
|
||||||
|
stores.insert((rec, d), v);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Register-to-register and immediate loads feed the tracker.
|
||||||
|
if let Some(d) = gpr(insn.op0_register()) {
|
||||||
|
// A plain `mov rD,rS` carries the RECORD IDENTITY across, not just the value.
|
||||||
|
// This is the whole reason identity is tracked instead of a per-register flag:
|
||||||
|
// the compiler routinely computes the base in one register, copies it to a
|
||||||
|
// second, and then reuses the first — writing half the record through each.
|
||||||
|
// Without this the second half is attributed to no record and the binding is
|
||||||
|
// lost. `CBaseEntity::AddNewModifier` is the case that exposed it.
|
||||||
|
recid[d as usize] = match insn.op1_kind() {
|
||||||
|
OpKind::Register => {
|
||||||
|
gpr(insn.op1_register()).and_then(|s| recid[s as usize])
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
scaled[d as usize] = false;
|
||||||
|
val[d as usize] = match insn.op1_kind() {
|
||||||
|
OpKind::Register => {
|
||||||
|
gpr(insn.op1_register()).map_or(V::Unknown, |s| val[s as usize])
|
||||||
|
}
|
||||||
|
OpKind::Immediate8
|
||||||
|
| OpKind::Immediate16
|
||||||
|
| OpKind::Immediate32
|
||||||
|
| OpKind::Immediate32to64
|
||||||
|
| OpKind::Immediate8to64 => V::Const(insn.immediate64()),
|
||||||
|
_ => V::Unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
// Any other write invalidates the destination, so a stale constant cannot be
|
||||||
|
// attributed to a record it never reached.
|
||||||
|
if let Some(d) = gpr(insn.op0_register()) {
|
||||||
|
val[d as usize] = V::Unknown;
|
||||||
|
scaled[d as usize] = false;
|
||||||
|
recid[d as usize] = None;
|
||||||
|
}
|
||||||
|
if let Some(x) = xmm(insn.op0_register()) {
|
||||||
|
xr[x as usize] = (V::Unknown, V::Unknown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group the collected stores by the record they were written to, then keep the groups that
|
||||||
|
// present the full binding shape.
|
||||||
|
// BTreeMap, and it is not a style choice: the emit order below decides which row survives the
|
||||||
|
// `(name, cpp_name)` dedup at the end, so a HashMap made that a hash-order coin flip in a
|
||||||
|
// byte-reproducible artifact. Record ids are minted in ascending address order by the `Add` arm,
|
||||||
|
// so ordering by id is the natural reading order and changes nothing outside a tie.
|
||||||
|
let mut groups: BTreeMap<u32, HashMap<i64, u64>> = BTreeMap::new();
|
||||||
|
for ((rec, d), v) in stores {
|
||||||
|
groups.entry(rec).or_default().insert(d, v);
|
||||||
|
}
|
||||||
|
for g in groups.values() {
|
||||||
|
// The initialiser writes the name pair at the record's own `+0`, so a group without both is
|
||||||
|
// not a binding — a partial match on some other structure, or a record whose construction
|
||||||
|
// the tracker only saw half of.
|
||||||
|
let (Some(&n), Some(&c)) = (g.get(&F_NAME), g.get(&F_CPP)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (Some(name), Some(cpp_name)) = (ident(img, n), ident(img, c)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let ret_raw = g.get(&F_RET).copied().unwrap_or(u64::MAX);
|
||||||
|
if ret_raw > u16::MAX as u64 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ret_raw = ret_raw as u16;
|
||||||
|
// The implementation is recorded when present but NOT required. It is written at the top of
|
||||||
|
// the initialiser's next loop iteration, so whether it lands in this record's group depends
|
||||||
|
// on which register the compiler happened to reuse — a binding whose fields are otherwise
|
||||||
|
// complete must not be dropped over a scheduling accident. (The shared marshalling thunk at
|
||||||
|
// `+0x38` is read past for the same reason and no longer recorded: nothing consumed it, and
|
||||||
|
// the record-layout table in this module's header is where that offset is documented.)
|
||||||
|
//
|
||||||
|
// An earlier revision did require both, on the reasoning that `libserver` holds another table
|
||||||
|
// of similar stride (the network field serialisers) whose records carry no code pointer. That
|
||||||
|
// was treating a symptom: the record-base anchor above rejects those structurally, because
|
||||||
|
// they are not built by `idx*5 << 4 + [class+0x28]`. Requiring the pair on top of that cost
|
||||||
|
// real bindings — `CBaseEntity::EmitSound` among them — for no additional safety.
|
||||||
|
out.push(VScriptFunc {
|
||||||
|
name,
|
||||||
|
cpp_name,
|
||||||
|
description: g.get(&F_DESC).and_then(|&v| text(img, v, MAX_DESC)),
|
||||||
|
ret_raw,
|
||||||
|
ret: ret_type_name(ret_raw),
|
||||||
|
imp: g.get(&F_IMPL).and_then(|&v| decode_pmf(img, v)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort_by(|a, b| (&a.name, &a.cpp_name).cmp(&(&b.name, &b.cpp_name)));
|
||||||
|
out.dedup_by(|a, b| a.name == b.name && a.cpp_name == b.cpp_name);
|
||||||
|
out
|
||||||
|
}
|
||||||
50
src/xref.rs
50
src/xref.rs
|
|
@ -13,56 +13,45 @@
|
||||||
//! the next avoids the misalignment a blind section-wide linear sweep suffers on data/padding.
|
//! the next avoids the misalignment a blind section-wide linear sweep suffers on data/padding.
|
||||||
|
|
||||||
use crate::elf::CodeImage;
|
use crate::elf::CodeImage;
|
||||||
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, OpKind};
|
use iced_x86::{Decoder, DecoderOptions, Instruction, OpKind};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub struct XrefIndex {
|
pub struct XrefIndex {
|
||||||
entries: Vec<u64>, // sorted, de-duped function entry addresses
|
entries: Vec<u64>, // sorted, de-duped function entry addresses
|
||||||
refs: HashMap<u64, Vec<u64>>, // referenced VA -> source instruction VAs
|
refs: HashMap<u64, Vec<u64>>, // referenced VA -> source instruction VAs
|
||||||
call_targets: Vec<u64>, // sorted, de-duped near-call targets
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl XrefIndex {
|
impl XrefIndex {
|
||||||
pub fn build(img: &CodeImage) -> Self {
|
pub fn build(img: &CodeImage) -> Self {
|
||||||
// Reliable gameplay entries (vtable slots + fn-pointers via relocations, plus call targets),
|
// Reliable gameplay entries (vtable slots + fn-pointers via relocations, plus call targets),
|
||||||
// then add the eh_frame starts (the runtime tail). Union = coverage of the whole binary.
|
// then add the eh_frame starts (the runtime tail). Union = coverage of the whole binary.
|
||||||
let mut entries = crate::locate::candidate_entries(img);
|
let entries = crate::locate::function_entries(img);
|
||||||
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
|
|
||||||
entries.sort_unstable();
|
|
||||||
entries.dedup();
|
|
||||||
|
|
||||||
// Disassemble each function's [start, next) range independently across threads — this is the
|
// Disassemble each function's [start, next) range independently across threads — this is the
|
||||||
// single biggest decode in the tool and the ranges vary wildly in size, so the atomic work
|
// single biggest decode in the tool and the ranges vary wildly in size, so the atomic work
|
||||||
// scheduler load-balances them. Each task returns its (ref-pair, call-target) deltas; merging
|
// scheduler load-balances them. Each task returns its ref-pair deltas; merging them in entry
|
||||||
// them in entry order (parallel_map preserves input order) reproduces the serial build
|
// order (parallel_map preserves input order) reproduces the serial build byte-for-byte, because
|
||||||
// byte-for-byte: refs[t] receives its srcs in the same (ascending entry, then instruction)
|
// refs[t] receives its srcs in the same (ascending entry, then instruction) order.
|
||||||
// order and call_targets is sorted afterwards.
|
|
||||||
type EntryData = (Vec<(u64, u64)>, Vec<u64>);
|
|
||||||
let idxs: Vec<usize> = (0..entries.len()).collect();
|
let idxs: Vec<usize> = (0..entries.len()).collect();
|
||||||
let per_entry: Vec<EntryData> =
|
let per_entry: Vec<Vec<(u64, u64)>> =
|
||||||
crate::par::parallel_map(&idxs, crate::par::default_threads(None), |&i| {
|
crate::par::parallel_map(&idxs, crate::par::default_threads(None), |&i| {
|
||||||
let start = entries[i];
|
let start = entries[i];
|
||||||
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
|
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
|
||||||
let Some(code) = img.code_range(start, end) else {
|
let Some(code) = img.code_range(start, end) else {
|
||||||
return (Vec::new(), Vec::new());
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let mut ref_pairs: Vec<(u64, u64)> = Vec::new();
|
let mut ref_pairs: Vec<(u64, u64)> = Vec::new();
|
||||||
let mut call_targets: Vec<u64> = Vec::new();
|
|
||||||
let mut insn = Instruction::default();
|
let mut insn = Instruction::default();
|
||||||
let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE);
|
let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE);
|
||||||
while dec.can_decode() {
|
while dec.can_decode() {
|
||||||
dec.decode_out(&mut insn);
|
dec.decode_out(&mut insn);
|
||||||
let src = insn.ip();
|
let src = insn.ip();
|
||||||
// Near call/jmp: the target is code; call targets double as function entries.
|
// Near call/jmp: the target is code.
|
||||||
if matches!(
|
if matches!(
|
||||||
insn.op0_kind(),
|
insn.op0_kind(),
|
||||||
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
||||||
) {
|
) {
|
||||||
let t = insn.near_branch_target();
|
ref_pairs.push((insn.near_branch_target(), src));
|
||||||
ref_pairs.push((t, src));
|
|
||||||
if insn.flow_control() == FlowControl::Call {
|
|
||||||
call_targets.push(t);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// RIP-relative memory operand: a reference to a string / global / code pointer.
|
// RIP-relative memory operand: a reference to a string / global / code pointer.
|
||||||
if insn.is_ip_rel_memory_operand() {
|
if insn.is_ip_rel_memory_operand() {
|
||||||
|
|
@ -70,24 +59,16 @@ impl XrefIndex {
|
||||||
ref_pairs.push((t, src));
|
ref_pairs.push((t, src));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(ref_pairs, call_targets)
|
ref_pairs
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut refs: HashMap<u64, Vec<u64>> = HashMap::new();
|
let mut refs: HashMap<u64, Vec<u64>> = HashMap::new();
|
||||||
let mut call_targets = Vec::new();
|
for ref_pairs in per_entry {
|
||||||
for (ref_pairs, cts) in per_entry {
|
|
||||||
for (t, src) in ref_pairs {
|
for (t, src) in ref_pairs {
|
||||||
refs.entry(t).or_default().push(src);
|
refs.entry(t).or_default().push(src);
|
||||||
}
|
}
|
||||||
call_targets.extend(cts);
|
|
||||||
}
|
|
||||||
call_targets.sort_unstable();
|
|
||||||
call_targets.dedup();
|
|
||||||
Self {
|
|
||||||
entries,
|
|
||||||
refs,
|
|
||||||
call_targets,
|
|
||||||
}
|
}
|
||||||
|
Self { entries, refs }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The entry (function start) that contains `va`: the nearest entry at or below `va`.
|
/// The entry (function start) that contains `va`: the nearest entry at or below `va`.
|
||||||
|
|
@ -114,8 +95,11 @@ impl XrefIndex {
|
||||||
fs
|
fs
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn call_targets(&self) -> &[u64] {
|
/// The function entries this index was built over, ascending — the union `locate::function_entries`
|
||||||
&self.call_targets
|
/// computes. Exposed because it is the domain of `containing_func`: a caller enumerating functions
|
||||||
|
/// should read it here rather than recompute the union and risk a different one.
|
||||||
|
pub fn entries(&self) -> &[u64] {
|
||||||
|
&self.entries
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue