initial commit
This commit is contained in:
commit
a2922b8bad
59 changed files with 2684583 additions and 0 deletions
45
.forgejo/workflows/backfill.yml
Normal file
45
.forgejo/workflows/backfill.yml
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
name: backfill
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
game:
|
||||||
|
description: "Game key (cs2 or dota2)"
|
||||||
|
required: true
|
||||||
|
default: cs2
|
||||||
|
names:
|
||||||
|
description: 'Names JSON — array of {name, class, slot}'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backfill:
|
||||||
|
runs-on: s2-runner
|
||||||
|
env:
|
||||||
|
GAME: ${{ github.event.inputs.game }}
|
||||||
|
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6.0.2
|
||||||
|
|
||||||
|
- name: Fetch the model
|
||||||
|
run: |
|
||||||
|
mkdir -p in
|
||||||
|
curl -fsSL -o in/model-$GAME.json "$RELEASE_BASE/$GAME-latest/model-$GAME.json"
|
||||||
|
|
||||||
|
- name: Write the names file
|
||||||
|
run: printf '%s' "$NAMES" > names.json
|
||||||
|
env:
|
||||||
|
NAMES: ${{ github.event.inputs.names }}
|
||||||
|
|
||||||
|
- name: Build the deriver
|
||||||
|
run: cargo build --release
|
||||||
|
|
||||||
|
- name: Backfill
|
||||||
|
run: |
|
||||||
|
./target/release/source2rosetta --game "$GAME" backfill \
|
||||||
|
--corpus-model in/model-$GAME.json \
|
||||||
|
--names names.json \
|
||||||
|
--out history.json
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: backfill-${{ env.GAME }}
|
||||||
|
path: history.json
|
||||||
75
.forgejo/workflows/ci.yml
Normal file
75
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags: ['gen-v*']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: "-D warnings"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: s2-runner
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6.0.2
|
||||||
|
- name: cargo fmt
|
||||||
|
run: cargo fmt --all --check
|
||||||
|
- name: cargo clippy (advisory)
|
||||||
|
run: cargo clippy --all-targets --all-features --message-format=short
|
||||||
|
env:
|
||||||
|
RUSTFLAGS: ""
|
||||||
|
- name: cargo doc (no deps)
|
||||||
|
run: cargo doc --no-deps --document-private-items
|
||||||
|
env:
|
||||||
|
RUSTDOCFLAGS: "-D warnings"
|
||||||
|
|
||||||
|
fuzz:
|
||||||
|
runs-on: s2-runner
|
||||||
|
env:
|
||||||
|
RUSTFLAGS: ""
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6.0.2
|
||||||
|
- name: Clear artifacts from any earlier run
|
||||||
|
run: rm -rf fuzz/artifacts
|
||||||
|
- name: Fuzz — 5 targets x 2 workers x 30s
|
||||||
|
run: bash fuzz.sh 30 2
|
||||||
|
- name: Fail on any crash / timeout / OOM
|
||||||
|
run: |
|
||||||
|
found=$(find fuzz/artifacts -type f \( -name 'crash-*' -o -name 'timeout-*' -o -name 'oom-*' \) 2>/dev/null || true)
|
||||||
|
if [ -n "$found" ]; then
|
||||||
|
echo "fuzzing produced artifacts:"; echo "$found" | sed 's/^/ /'
|
||||||
|
echo "triage: cargo +nightly fuzz tmin <target> <artifact>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "no crashes / timeouts / OOMs — the offline derivation held on every explored input."
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: s2-runner
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6.0.2
|
||||||
|
- name: cargo test
|
||||||
|
run: cargo test --release
|
||||||
|
- name: Build the gen binary (it lives in the core crate, not the workspace default build)
|
||||||
|
run: cargo build --release -p source2rosetta-core
|
||||||
|
- name: Package the gen binary
|
||||||
|
if: startsWith(github.ref, 'refs/tags/gen-v')
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
cp target/release/source2rosetta-gen dist/
|
||||||
|
strip dist/source2rosetta-gen || true
|
||||||
|
(cd dist && sha256sum source2rosetta-gen > source2rosetta-gen.sha256)
|
||||||
|
|
||||||
|
- name: Publish the gen release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/gen-v')
|
||||||
|
uses: https://code.forgejo.org/actions/forgejo-release@v2
|
||||||
|
with:
|
||||||
|
direction: upload
|
||||||
|
url: ${{ github.server_url }}
|
||||||
|
repo: ${{ github.repository }}
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
release-dir: dist
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
override: true
|
||||||
|
release-notes: "`source2rosetta-gen` ${{ github.ref_name }} — renders a published gamedata release into your framework's format: CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, or a typed C# SDK. Download it, `chmod +x`, and point it at the `gamedata-<game>.json` / `netvars-<game>.json` from a per-game release (`cs2-latest`, `dota2-latest`). Usage: see `crates/source2rosetta-core/README.md`. Linux x86-64."
|
||||||
132
.forgejo/workflows/contribution.yml
Normal file
132
.forgejo/workflows/contribution.yml
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
name: contribution
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'mappings/contributions/**'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: contribution-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
contribution:
|
||||||
|
runs-on: s2-runner
|
||||||
|
env:
|
||||||
|
STEAM_APPS: /home/cs2/.steam/SteamApps
|
||||||
|
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Resolve the affected game and its paths
|
||||||
|
run: |
|
||||||
|
changed=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" -- 'mappings/contributions/**')
|
||||||
|
game=""
|
||||||
|
if echo "$changed" | grep -q 'contributions/csgo/'; then game=cs2; fi
|
||||||
|
if echo "$changed" | grep -q 'contributions/dota/'; then
|
||||||
|
if [ -n "$game" ]; then echo "PR touches both games — split it into one PR per game"; exit 1; fi
|
||||||
|
game=dota2
|
||||||
|
fi
|
||||||
|
if [ -z "$game" ]; then echo "no contributions/<game>/ files changed"; exit 1; fi
|
||||||
|
case "$game" in cs2) key=csgo; appid=730 ;; dota2) key=dota; appid=570 ;; esac
|
||||||
|
|
||||||
|
# The manifest carries the installed buildid + the install folder name (spaces and all).
|
||||||
|
manifest="$STEAM_APPS/appmanifest_$appid.acf"
|
||||||
|
[ -f "$manifest" ] || { echo "no manifest at $manifest — check STEAM_APPS"; exit 1; }
|
||||||
|
installdir=$(grep -oP '"installdir"\s+"\K[^"]+' "$manifest")
|
||||||
|
buildid=$(grep -oP '"buildid"\s+"\K[0-9]+' "$manifest")
|
||||||
|
game_dir="$STEAM_APPS/common/$installdir/game"
|
||||||
|
[ -d "$game_dir" ] || { echo "expected a game tree at '$game_dir' — check STEAM_APPS"; exit 1; }
|
||||||
|
{
|
||||||
|
echo "GAME=$game"
|
||||||
|
echo "KEY=$key"
|
||||||
|
echo "APPID=$appid"
|
||||||
|
echo "GAME_DIR=$game_dir"
|
||||||
|
echo "BUILDID=$buildid"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Next patch number for this build
|
||||||
|
run: |
|
||||||
|
# The game-update run published <game>-<buildid>-0; each contribution on that build bumps the patch.
|
||||||
|
tags=$(curl -fsSL -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" "$API/releases?limit=100" | jq -r '.[].tag_name')
|
||||||
|
max=0
|
||||||
|
while read -r t; do
|
||||||
|
case "$t" in
|
||||||
|
"$GAME-$BUILDID-"*) p=${t##*-}; if [ "$p" -gt "$max" ] 2>/dev/null; then max=$p; fi ;;
|
||||||
|
esac
|
||||||
|
done <<< "$tags"
|
||||||
|
echo "PATCH=$((max + 1))" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Fetch current model + seed
|
||||||
|
# Stored gzipped on the release (see derive.yml); the deriver reads plain JSON.
|
||||||
|
run: |
|
||||||
|
mkdir -p in dist work
|
||||||
|
curl -fsSL -o in/model.gz "$RELEASE_BASE/$GAME-latest/model-$GAME.json.gz"
|
||||||
|
gunzip -c in/model.gz > "in/model-$GAME.json"
|
||||||
|
curl -fsSL -o in/seed.gz "$RELEASE_BASE/$GAME-latest/seed-$GAME.json.gz"
|
||||||
|
gunzip -c in/seed.gz > "seed-$GAME.json"
|
||||||
|
|
||||||
|
- name: Fold the PR's contributions into the seed's snapshot
|
||||||
|
run: |
|
||||||
|
python3 - "$KEY" "seed-$GAME.json" "seed-$GAME.patched.json" <<'PY'
|
||||||
|
import json, sys, glob, os
|
||||||
|
key, seed_in, seed_out = sys.argv[1:4]
|
||||||
|
seed = json.load(open(seed_in))
|
||||||
|
contribs = dict(seed.get("contributions") or {})
|
||||||
|
for f in glob.glob(f"mappings/contributions/{key}/*.json"):
|
||||||
|
contribs[os.path.basename(f)] = json.load(open(f))
|
||||||
|
seed["contributions"] = contribs
|
||||||
|
json.dump(seed, open(seed_out, "w"))
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Build the deriver
|
||||||
|
run: cargo build --release
|
||||||
|
|
||||||
|
- name: Produce — validate the contribution live (no model fold; the build is unchanged)
|
||||||
|
run: |
|
||||||
|
# The model already contains this build (derive folded it), and the forward-only guard needs the
|
||||||
|
# target to sort strictly after the model's latest. "<buildid>-<patch>" does: it shares the buildid
|
||||||
|
# prefix and is longer, and the next update's buildid still sorts above it.
|
||||||
|
ln -sfn "$GAME_DIR" "work/$BUILDID-$PATCH"
|
||||||
|
./target/release/source2rosetta --game "$GAME" produce \
|
||||||
|
--seed "seed-$GAME.patched.json" \
|
||||||
|
--corpus-model "in/model-$GAME.json" \
|
||||||
|
--target "work/$BUILDID-$PATCH" \
|
||||||
|
--game-dir "$GAME_DIR" \
|
||||||
|
--version "$GAME-$BUILDID-$PATCH" \
|
||||||
|
--out-dir dist
|
||||||
|
cp "seed-$GAME.patched.json" "dist/seed-$GAME.json"
|
||||||
|
|
||||||
|
- name: Drop the re-folded model, compress the seed
|
||||||
|
# A contribution does not change the binary, so the sidecar fold just re-folds a build the model
|
||||||
|
# already has. Publishing that would append a duplicate row and grow the model on every PR — so the
|
||||||
|
# model asset is left alone and `<game>-latest` keeps the one the last derive published.
|
||||||
|
run: |
|
||||||
|
rm -f "dist/model-$GAME.json"
|
||||||
|
gzip -6 "dist/seed-$GAME.json"
|
||||||
|
|
||||||
|
- name: Publish the immutable patch snapshot
|
||||||
|
uses: https://code.forgejo.org/actions/forgejo-release@v2
|
||||||
|
with:
|
||||||
|
direction: upload
|
||||||
|
url: ${{ github.server_url }}
|
||||||
|
repo: ${{ github.repository }}
|
||||||
|
tag: ${{ env.GAME }}-${{ env.BUILDID }}-${{ env.PATCH }}
|
||||||
|
release-dir: dist
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
override: true
|
||||||
|
release-notes: "${{ env.GAME }} gamedata — build ${{ env.BUILDID }}, patch ${{ env.PATCH }} (contribution PR #${{ github.event.pull_request.number }})."
|
||||||
|
|
||||||
|
- name: Move <game>-latest to this patch
|
||||||
|
uses: https://code.forgejo.org/actions/forgejo-release@v2
|
||||||
|
with:
|
||||||
|
direction: upload
|
||||||
|
url: ${{ github.server_url }}
|
||||||
|
repo: ${{ github.repository }}
|
||||||
|
tag: ${{ env.GAME }}-latest
|
||||||
|
release-dir: dist
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
override: true
|
||||||
|
release-notes: "Rolling ${{ env.GAME }} gamedata — currently build ${{ env.BUILDID }} patch ${{ env.PATCH }}. Stable URL; assets overwritten on each update."
|
||||||
118
.forgejo/workflows/derive.yml
Normal file
118
.forgejo/workflows/derive.yml
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
name: derive
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
game:
|
||||||
|
description: "Game key (cs2 or dota2)"
|
||||||
|
required: true
|
||||||
|
default: cs2
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: derive-${{ github.event.inputs.game }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
derive:
|
||||||
|
runs-on: s2-runner
|
||||||
|
env:
|
||||||
|
GAME: ${{ github.event.inputs.game }}
|
||||||
|
STEAM_APPS: /home/cs2/.steam/SteamApps
|
||||||
|
STEAM_USER: source2rosetta
|
||||||
|
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
|
||||||
|
BOOTSTRAP_DIR: /home/cs2/rosetta-bootstrap
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6.0.2
|
||||||
|
|
||||||
|
- name: Update the install to the current build
|
||||||
|
run: |
|
||||||
|
case "$GAME" in
|
||||||
|
cs2) APPID=730 ;;
|
||||||
|
dota2) APPID=570 ;;
|
||||||
|
*) echo "unknown game '$GAME' (expected cs2 or dota2)"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
echo "APPID=$APPID" >> "$GITHUB_ENV"
|
||||||
|
# No password here: the runner holds a cached refresh token from a one-time interactive login, so
|
||||||
|
# this is non-interactive. If it ever fails with a login error the token has lapsed — re-run
|
||||||
|
# `steamcmd +login $STEAM_USER` once on the runner, as the runner user.
|
||||||
|
steamcmd +login "$STEAM_USER" +app_update "$APPID" +quit
|
||||||
|
|
||||||
|
- name: Resolve the game paths + the new buildid
|
||||||
|
run: |
|
||||||
|
MANIFEST="$STEAM_APPS/appmanifest_$APPID.acf"
|
||||||
|
[ -f "$MANIFEST" ] || { echo "no manifest at $MANIFEST — check STEAM_APPS"; exit 1; }
|
||||||
|
INSTALLDIR=$(grep -oP '"installdir"\s+"\K[^"]+' "$MANIFEST")
|
||||||
|
BUILDID=$(grep -oP '"buildid"\s+"\K[0-9]+' "$MANIFEST")
|
||||||
|
GAME_DIR="$STEAM_APPS/common/$INSTALLDIR/game"
|
||||||
|
[ -d "$GAME_DIR" ] || { echo "expected a game tree at '$GAME_DIR' — check STEAM_APPS"; exit 1; }
|
||||||
|
{ echo "BUILDID=$BUILDID"; echo "GAME_DIR=$GAME_DIR"; } >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build the deriver
|
||||||
|
run: cargo build --release
|
||||||
|
|
||||||
|
- name: Fetch the previous model + seed (the two non-user-facing release artifacts)
|
||||||
|
run: |
|
||||||
|
mkdir -p in dist work
|
||||||
|
code=$(curl -sSL -o in/model.gz -w '%{http_code}' "$RELEASE_BASE/$GAME-latest/model-$GAME.json.gz" || echo 000)
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
gunzip -c in/model.gz > "in/model-$GAME.json"
|
||||||
|
echo "model: from the $GAME-latest release"
|
||||||
|
elif [ "$code" = "404" ] && [ -f "${BOOTSTRAP_DIR:-}/model-$GAME.json" ]; then
|
||||||
|
# First run only. ONLY a genuine 404 falls back: on a transient failure we must NOT quietly
|
||||||
|
# re-derive from a stale on-disk model and then overwrite `latest` with the result.
|
||||||
|
cp "${BOOTSTRAP_DIR}/model-$GAME.json" "in/model-$GAME.json"
|
||||||
|
echo "model: BOOTSTRAP from $BOOTSTRAP_DIR — clear BOOTSTRAP_DIR once this run has published"
|
||||||
|
else
|
||||||
|
echo "model fetch failed (HTTP $code) and no bootstrap copy at '${BOOTSTRAP_DIR:-<unset>}/model-$GAME.json' — refusing to derive"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if curl -fsSL -o in/seed.gz "$RELEASE_BASE/$GAME-latest/seed-$GAME.json.gz"; then
|
||||||
|
gunzip -c in/seed.gz > "mappings/seed-$GAME.json"
|
||||||
|
echo "seed: from the $GAME-latest release"
|
||||||
|
else
|
||||||
|
echo "seed: none published yet — using the repo's mappings/seed-$GAME.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Produce — derive + validate-live + typed netvars + fold model N -> N+1
|
||||||
|
run: |
|
||||||
|
# --target is a buildid-named SYMLINK to the install, not the install path itself: the deriver labels
|
||||||
|
# a build by its directory NAME, and the forward-only guard demands each target sort strictly after
|
||||||
|
# the model's latest build. Passing ".../game" would label every build "game", so the SECOND run
|
||||||
|
# would be rejected as not-newer. Buildids are monotonic and sort after the corpus's date labels.
|
||||||
|
ln -sfn "$GAME_DIR" "work/$BUILDID"
|
||||||
|
./target/release/source2rosetta --game "$GAME" produce \
|
||||||
|
--seed "mappings/seed-$GAME.json" \
|
||||||
|
--corpus-model "in/model-$GAME.json" \
|
||||||
|
--target "work/$BUILDID" \
|
||||||
|
--game-dir "$GAME_DIR" \
|
||||||
|
--version "$GAME-$BUILDID-0" \
|
||||||
|
--out-dir dist
|
||||||
|
cp "mappings/seed-$GAME.json" "dist/seed-$GAME.json"
|
||||||
|
|
||||||
|
- name: Compress the internal artifacts for release
|
||||||
|
run: |
|
||||||
|
gzip -6 "dist/model-$GAME.json"
|
||||||
|
gzip -6 "dist/seed-$GAME.json"
|
||||||
|
|
||||||
|
- name: Publish the immutable per-build snapshot
|
||||||
|
uses: https://code.forgejo.org/actions/forgejo-release@v2
|
||||||
|
with:
|
||||||
|
direction: upload
|
||||||
|
url: ${{ github.server_url }}
|
||||||
|
repo: ${{ github.repository }}
|
||||||
|
tag: ${{ env.GAME }}-${{ env.BUILDID }}-0
|
||||||
|
release-dir: dist
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
override: true
|
||||||
|
release-notes: "${{ env.GAME }} gamedata for Steam build ${{ env.BUILDID }}, derived by source2rosetta. Patch 0 = the game-update build; later patches on this buildid come from contribution PRs."
|
||||||
|
|
||||||
|
- name: Move <game>-latest to this build
|
||||||
|
uses: https://code.forgejo.org/actions/forgejo-release@v2
|
||||||
|
with:
|
||||||
|
direction: upload
|
||||||
|
url: ${{ github.server_url }}
|
||||||
|
repo: ${{ github.repository }}
|
||||||
|
tag: ${{ env.GAME }}-latest
|
||||||
|
release-dir: dist
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
override: true
|
||||||
|
release-notes: "Rolling ${{ env.GAME }} gamedata — always the newest build (currently ${{ env.BUILDID }}). Stable URL; assets overwritten each update."
|
||||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Build output (root workspace + the separate fuzz workspace).
|
||||||
|
/target/
|
||||||
|
/fuzz/target/
|
||||||
|
|
||||||
|
# cargo-fuzz working state. The corpus is REGENERATED (`fuzz/gen_corpus.rs` synthesizes seeds in-process),
|
||||||
|
# crash artifacts and run logs are per-run, and a fuzzed corpus accumulates mutated engine bytes — which the
|
||||||
|
# project's clean-room provenance deliberately keeps out of the tree.
|
||||||
|
/fuzz/corpus/
|
||||||
|
/fuzz/artifacts/
|
||||||
|
/fuzz/logs/
|
||||||
|
|
||||||
|
# Derive working dirs: `produce --out-dir` and the workflows' fetch/stage dirs, plus the `.seed` bundle
|
||||||
|
# unpack that lands beside an out-dir. All are per-run and can appear inside a runner's persistent checkout.
|
||||||
|
/dist/
|
||||||
|
/in/
|
||||||
|
/out/
|
||||||
|
/work/
|
||||||
|
.seed/
|
||||||
197
ATTRIBUTIONS.md
Normal file
197
ATTRIBUTIONS.md
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
# Attributions
|
||||||
|
|
||||||
|
source2rosetta derives Source-2 gamedata — function signatures, vtable offsets, and the schema/RTTI surface —
|
||||||
|
from stripped Valve libraries. It could not exist without the decade of reverse-engineering, tooling, and
|
||||||
|
open documentation produced by the Counter-Strike / Source / Source-2 community. Much of the engine came in
|
||||||
|
name-stripped; the *names* — the catalogues, symbol dictionaries, gamedata files, and SDK dumps that let the
|
||||||
|
derived offsets and signatures be labelled meaningfully — came from the projects below. The initial seeds were
|
||||||
|
foundational: they are why this tool has anything to say.
|
||||||
|
|
||||||
|
This document credits that prior work. Where a project supplied a specific ingredient (a catalogue, a symbol
|
||||||
|
set, a technique, a format), the role is noted.
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
source2rosetta and everything it ships are derived **only from legitimately-public sources**: open-source
|
||||||
|
SDKs, Valve's own tracked game builds, community gamedata files, and published research. Leaked or otherwise
|
||||||
|
proprietary game source was **deliberately excluded** from the corpus and the naming pipeline — a clean-room
|
||||||
|
boundary kept so the tool and its outputs carry no tainted provenance. The derived facts themselves
|
||||||
|
(fingerprints, offsets, signatures) contain no third-party bytes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Game-data, symbol & offset reference corpora
|
||||||
|
|
||||||
|
The catalogues and dictionaries that give the derived offsets/signatures their names — the "seeds".
|
||||||
|
|
||||||
|
- **Valve build tracking** — [GameTracking-CS2](https://github.com/SteamDatabase/GameTracking-CS2),
|
||||||
|
[GameTracking-Dota2](https://github.com/SteamDatabase/GameTracking-Dota2),
|
||||||
|
[GameTracking-Deadlock](https://github.com/SteamDatabase/GameTracking-Deadlock),
|
||||||
|
[GameTracking-CSGO](https://github.com/SteamDatabase/GameTracking-CSGO),
|
||||||
|
[GameTracking-TF2](https://github.com/SteamDatabase/GameTracking-TF2),
|
||||||
|
[GameTracking-HalfLifeAlyx](https://github.com/SteamDatabase/GameTracking-HalfLifeAlyx),
|
||||||
|
[GameTracking](https://github.com/SteamDatabase/GameTracking),
|
||||||
|
[Protobufs](https://github.com/SteamDatabase/Protobufs) — all by **SteamDatabase**: per-build tracking of
|
||||||
|
Valve games and the protobuf definitions.
|
||||||
|
- **CS2 gamedata & offsets** — [CS2-Gamedata](https://github.com/roflmuffin/CS2-Gamedata) (roflmuffin, a
|
||||||
|
primary catalogue seed), [cs2-dumper](https://github.com/a2x/cs2-dumper) (a2x),
|
||||||
|
[s2u-gamedata](https://github.com/Wend4r/s2u-gamedata) / [s2d-schema](https://github.com/Wend4r/s2d-schema)
|
||||||
|
(Wend4r), [cs2-universal-offsets](https://github.com/scros22/cs2-universal-offsets) (scros22),
|
||||||
|
[CS2-OFFSETS](https://github.com/sezzyaep/CS2-OFFSETS) (sezzyaep),
|
||||||
|
[cs2-offsets](https://github.com/superisuer/cs2-offsets) (superisuer),
|
||||||
|
[IDA-GameDataTracker](https://github.com/K4ryuu/IDA-GameDataTracker) (K4ryuu).
|
||||||
|
- **CS2 signatures** — [cs2-signatures](https://github.com/ianlucas/cs2-signatures) (ianlucas),
|
||||||
|
[cs2-signature-list](https://github.com/Salvatore-Als/cs2-signature-list) (Salvatore-Als),
|
||||||
|
[CS2_VibeSignatures](https://github.com/HLND2T/CS2_VibeSignatures) (HLND2T),
|
||||||
|
[Aspasia](https://github.com/Aspasia1337/Aspasia) (Aspasia1337),
|
||||||
|
[cs2_things](https://github.com/GameChaos/cs2_things) (GameChaos),
|
||||||
|
[cs2-lib](https://github.com/ianlucas/cs2-lib) (ianlucas).
|
||||||
|
- **CS:GO symbols/dumps** — [csgo-signatures](https://github.com/CrackerCat/csgo-signatures) (CrackerCat),
|
||||||
|
[csgo-linux-dumper](https://github.com/foxadb/csgo-linux-dumper) (foxadb),
|
||||||
|
[CSGO-Dumper](https://github.com/Y3t1y3t/CSGO-Dumper) (Y3t1y3t),
|
||||||
|
[hazedumper](https://github.com/frk1/hazedumper) (frk1).
|
||||||
|
- **macOS symbol ground-truth** — [dota-2-symbols](https://github.com/a2x/dota-2-symbols) (a2x): symbolicated
|
||||||
|
Dota builds that anchor cross-game name transfer.
|
||||||
|
- **Dota / Deadlock gamedata** — [McDota](https://github.com/LWSS/McDota) (LWSS),
|
||||||
|
[dota2dumped](https://github.com/ikhsanprasetyo/dota2dumped) & [Dota2Cheat](https://github.com/ikhsanprasetyo/Dota2Cheat)
|
||||||
|
(ikhsanprasetyo), [D2VDump](https://github.com/ModDota/D2VDump) (ModDota),
|
||||||
|
[d2fixups](https://github.com/psychonic/d2fixups) (psychonic),
|
||||||
|
[Dota2Hack](https://github.com/or75/Dota2Hack) (or75),
|
||||||
|
[dezlock-dump](https://github.com/dougwithseismic/dezlock-dump) (dougwithseismic),
|
||||||
|
[Deadlock-GameSDK](https://github.com/aylers/Deadlock-GameSDK) (aylers),
|
||||||
|
[deadworks](https://github.com/Deadworks-net/deadworks) (Deadworks-net),
|
||||||
|
[deadunlock](https://github.com/hmate9/deadunlock) (hmate9),
|
||||||
|
[Deadlock-Offset-Dumper](https://github.com/5k-omar/Deadlock-Offset-Dumper) (5k-omar),
|
||||||
|
[deadlock-metadata](https://github.com/leamare/deadlock-metadata) (leamare),
|
||||||
|
[cvar-unhide-s2-citadel](https://github.com/Artemon121/cvar-unhide-s2-citadel) (Artemon121).
|
||||||
|
- **Cross-game symbol/name dictionaries** (TF2 / L4D / Insurgency / GoldSrc — the harvest that lets names
|
||||||
|
transfer across the shared Source lineage) —
|
||||||
|
[sigsegv-mvm](https://github.com/sigsegv-mvm/sigsegv-mvm) & [mvm-reversed](https://github.com/sigsegv-mvm/mvm-reversed)
|
||||||
|
& [libtf2mod](https://github.com/sigsegv-mvm/libtf2mod) (sigsegv-mvm) and the
|
||||||
|
[rafradek fork](https://github.com/rafradek/sigsegv-mvm),
|
||||||
|
[tf2-data](https://github.com/powerlord/tf2-data) (powerlord),
|
||||||
|
[TF2-Base](https://github.com/NicknineTheEagle/TF2-Base) (NicknineTheEagle),
|
||||||
|
[TF2Items](https://github.com/asherkin/TF2Items) (asherkin),
|
||||||
|
[tf2attributes](https://github.com/FlaminSarge/tf2attributes) (FlaminSarge),
|
||||||
|
[SM-TFUtils](https://github.com/nosoop/SM-TFUtils) / [SM-TFEconData](https://github.com/nosoop/SM-TFEconData)
|
||||||
|
/ [SM-TFAttributeSupport](https://github.com/nosoop/SM-TFAttributeSupport) (nosoop),
|
||||||
|
[TF2Classic](https://github.com/danielmm8888/TF2Classic) (danielmm8888),
|
||||||
|
[Left4DHooks](https://github.com/SilvDev/Left4DHooks) & [Various_Scripts_Collection](https://github.com/SilvDev/Various_Scripts_Collection)
|
||||||
|
(SilvDev), [L4D1-2_Signatures](https://github.com/Psykotikism/L4D1-2_Signatures) (Psykotikism),
|
||||||
|
[l4d2_structs](https://github.com/ProdigySim/l4d2_structs) (ProdigySim),
|
||||||
|
[l4d2_direct](https://github.com/ConfoglTeam/l4d2_direct) (ConfoglTeam),
|
||||||
|
[Left4Downtown2](https://github.com/Attano/Left4Downtown2) & [L4D2-Competitive-Framework](https://github.com/Attano/L4D2-Competitive-Framework)
|
||||||
|
(Attano), [L4D1_2-Plugins](https://github.com/fbef0102/L4D1_2-Plugins) (fbef0102),
|
||||||
|
[L4D2-Competitive-Rework](https://github.com/SirPlease/L4D2-Competitive-Rework) (SirPlease),
|
||||||
|
[l4dtoolz](https://github.com/lakwsh/l4dtoolz) (lakwsh),
|
||||||
|
[atomicstrykers-codedump](https://github.com/AtomicStryker/atomicstrykers-codedump) (AtomicStryker),
|
||||||
|
[insurgency-sourcemod](https://github.com/jaredballou/insurgency-sourcemod) (jaredballou),
|
||||||
|
[Insurgency-dy-sourcemod](https://github.com/thecannons/Insurgency-dy-sourcemod) (thecannons),
|
||||||
|
[ReGameDLL_CS](https://github.com/rehlds/ReGameDLL_CS) (ReHLDS).
|
||||||
|
|
||||||
|
## Schema / RTTI extraction & SDK generation
|
||||||
|
|
||||||
|
The projects that first mapped Source-2's schema system and RTTI, and generated typed SDKs from it.
|
||||||
|
|
||||||
|
- **SDK generators** — [source2gen](https://github.com/oxiKKK/source2gen) (oxiKKK, the surviving fork of
|
||||||
|
neverlosecc/source2gen), [Source2Gen](https://github.com/praydog/Source2Gen) (praydog),
|
||||||
|
[cs2-sdk](https://github.com/NotOfficer/cs2-sdk) (NotOfficer),
|
||||||
|
[cs2-sdk](https://github.com/bruhmoment21/cs2-sdk) (bruhmoment21).
|
||||||
|
- **Schema / RTTI dumpers** — [Source2SchemaDumper](https://github.com/GAMMACASE/Source2SchemaDumper) &
|
||||||
|
[PltPatcher](https://github.com/GAMMACASE/PltPatcher) (GAMMACASE),
|
||||||
|
[CS2-SchemaDumper](https://github.com/sneakyevil/CS2-SchemaDumper) (sneakyevil),
|
||||||
|
[Source2-Schema-System-Dumper-CE](https://github.com/dr-NHA/Source2-Schema-System-Dumper-CE) (dr-NHA),
|
||||||
|
[Source2Dumps](https://github.com/anarh1st47/Source2Dumps) (anarh1st47),
|
||||||
|
[source2-dumper](https://github.com/eliasmoflag/source2-dumper) (eliasmoflag),
|
||||||
|
[DumpSource2](https://github.com/ValveResourceFormat/DumpSource2) &
|
||||||
|
[ValveResourceFormat](https://github.com/ValveResourceFormat/ValveResourceFormat) (the VRF team).
|
||||||
|
- **Binary analysis / demo parsing** — [cs2-analyzer](https://github.com/a2x/cs2-analyzer) (a2x),
|
||||||
|
[source2-demo](https://github.com/Rupas1k/source2-demo) (Rupas1k),
|
||||||
|
[demofile-net](https://github.com/saul/demofile-net) (saul).
|
||||||
|
|
||||||
|
## Engine SDKs, headers & networking
|
||||||
|
|
||||||
|
- [hl2sdk](https://github.com/alliedmodders/hl2sdk) & [hl2sdk-manifests](https://github.com/alliedmodders/hl2sdk-manifests)
|
||||||
|
(AlliedModders) — the Source engine SDK and its per-game manifests.
|
||||||
|
- [source-sdk-2013](https://github.com/ValveSoftware/source-sdk-2013) &
|
||||||
|
[GameNetworkingSockets](https://github.com/ValveSoftware/GameNetworkingSockets) (Valve).
|
||||||
|
- [sourcesdk](https://github.com/Wend4r/sourcesdk) (Wend4r),
|
||||||
|
[sourcesdk-gmod](https://github.com/RaphaelIT7/sourcesdk-gmod) (RaphaelIT7),
|
||||||
|
[sbox-public](https://github.com/Facepunch/sbox-public) (Facepunch) — Source-2 headers via s&box.
|
||||||
|
- [Half-Life-Alyx-FGD](https://github.com/gvarados1/Half-Life-Alyx-FGD) (gvarados1) — Source-2 entity/FGD
|
||||||
|
definitions.
|
||||||
|
|
||||||
|
## Source-engine mod frameworks & runtime references
|
||||||
|
|
||||||
|
These defined the gamedata/format conventions source2rosetta emits into, the live-validation model, and the
|
||||||
|
hooking/loader designs the companion loader work draws on.
|
||||||
|
|
||||||
|
- [CounterStrikeSharp](https://github.com/roflmuffin/CounterStrikeSharp) (roflmuffin) — the C# CS2 framework
|
||||||
|
and the gamedata format the default output targets.
|
||||||
|
- [metamod-source](https://github.com/alliedmodders/metamod-source) & [sourcemod](https://github.com/alliedmodders/sourcemod)
|
||||||
|
(AlliedModders) — the Source mod loader + framework lineage (gamedata format, SourceHook).
|
||||||
|
- The **swiftly-solution** ecosystem — [swiftly](https://github.com/swiftly-solution/swiftly) &
|
||||||
|
[swiftlys2](https://github.com/swiftly-solution/swiftlys2),
|
||||||
|
[s2binlib](https://github.com/swiftly-solution/s2binlib),
|
||||||
|
[gamedata-validator](https://github.com/swiftly-solution/gamedata-validator),
|
||||||
|
[codegen](https://github.com/swiftly-solution/codegen) — Source-2 binary/gamedata handling and validation.
|
||||||
|
- [modsharp-public](https://github.com/Kxnrl/modsharp-public) (Kxnrl),
|
||||||
|
[source2toolkit](https://github.com/SlynxCZ/source2toolkit) (SlynxCZ),
|
||||||
|
[plugify-plugin-s2sdk](https://github.com/untrustedmodders/plugify-plugin-s2sdk) (untrustedmodders),
|
||||||
|
[Source.Python](https://github.com/Source-Python-Dev-Team/Source.Python) (Source.Python team).
|
||||||
|
- CS2 server projects — [CS2Fixes](https://github.com/Source2ZE/CS2Fixes),
|
||||||
|
[StripperCS2](https://github.com/Source2ZE/StripperCS2),
|
||||||
|
[MultiAddonManager](https://github.com/Source2ZE/MultiAddonManager) (Source2ZE),
|
||||||
|
[cs2kz-metamod](https://github.com/KZGlobalTeam/cs2kz-metamod) (KZGlobalTeam),
|
||||||
|
[SourceCoop](https://github.com/ampreeT/SourceCoop) (ampreeT),
|
||||||
|
[cs2-modded-server](https://github.com/kus/cs2-modded-server) (kus),
|
||||||
|
[gmod-holylib](https://github.com/RaphaelIT7/gmod-holylib) (RaphaelIT7),
|
||||||
|
[Osiris](https://github.com/danielkrupinski/Osiris) (danielkrupinski),
|
||||||
|
[SourceAutoRecord](https://github.com/p2sr/SourceAutoRecord) & [wormhole](https://github.com/p2sr/wormhole) (p2sr).
|
||||||
|
- Host-capability references — [Ray-Trace](https://github.com/FUNPLAY-pro-CS2/Ray-Trace) (FUNPLAY-pro-CS2),
|
||||||
|
[ResourcePrecacher](https://github.com/KillStr3aK/ResourcePrecacher) (KillStr3aK).
|
||||||
|
|
||||||
|
## Reverse-engineering tooling & techniques
|
||||||
|
|
||||||
|
The signature-making, diffing, and vtable-walking techniques source2rosetta's own passes are modelled on.
|
||||||
|
|
||||||
|
- Signature generation — [ida-sigmaker](https://github.com/mahmoudimus/ida-sigmaker) (mahmoudimus),
|
||||||
|
[sigmakerex](https://github.com/kweatherman/sigmakerex) (kweatherman).
|
||||||
|
- Binary diffing — [diaphora](https://github.com/joxeankoret/diaphora) (Joxean Koret).
|
||||||
|
- Vtable / symbol walking — [vtable](https://github.com/asherkin/vtable) (asherkin),
|
||||||
|
[ghidra_scripts](https://github.com/nosoop/ghidra_scripts) (nosoop),
|
||||||
|
[IDA-Scripts](https://github.com/Scags/IDA-Scripts) (Scags),
|
||||||
|
[ida-cs2-reversing-tools](https://github.com/oxiKKK/ida-cs2-reversing-tools) (oxiKKK).
|
||||||
|
- References & indexes — [SourceEngineReverseEngineering](https://github.com/ReservedRegister/SourceEngineReverseEngineering)
|
||||||
|
(ReservedRegister), [reverse-engineering-resources](https://github.com/srcdslab/reverse-engineering-resources)
|
||||||
|
(srcdslab), [awesome-cs2](https://github.com/samyycX/awesome-cs2) (samyycX).
|
||||||
|
|
||||||
|
## Binary function similarity research
|
||||||
|
|
||||||
|
source2rosetta identifies functions across builds by fingerprinting and nearest-history matching. That design
|
||||||
|
draws on the binary-code-similarity literature.
|
||||||
|
|
||||||
|
- Andrea Marcelli et al., **"How Machine Learning Is Solving the Binary Function Similarity Problem"**, USENIX
|
||||||
|
Security 2022 (Cisco Systems), and the accompanying dataset/code
|
||||||
|
[binary_function_similarity](https://github.com/Cisco-Talos/binary_function_similarity) (Cisco-Talos).
|
||||||
|
- Steven H. H. Ding, Benjamin C. M. Fung, Philippe Charland, **"Asm2Vec: Boosting Static Representation
|
||||||
|
Robustness for Binary Clone Search"**, IEEE S&P 2019.
|
||||||
|
- Xiaojun Xu et al., **"Neural Network-based Graph Embedding for Cross-Platform Binary Code Similarity
|
||||||
|
Detection"** (Gemini), CCS 2017 — via the "Genimi" re-implementation bundled under `refs/papers/Gemini`.
|
||||||
|
- Alexander Hermans, Lucas Beyer, Bastian Leibe, **"In Defense of the Triplet Loss for Person
|
||||||
|
Re-Identification"**, 2017 — the metric-learning framing that was pressure-tested for the fingerprint match.
|
||||||
|
- Also consulted for failure-detection framing: **"SAFE: Multitask Failure Detection for Vision-Language-Action
|
||||||
|
Models"** (arXiv:2506.09937).
|
||||||
|
|
||||||
|
## Knowledge from model training
|
||||||
|
|
||||||
|
The AI-assisted name-extrapolation pass — proposing names for functions no catalogue covers — relied on the
|
||||||
|
general knowledge of the assisting model (Anthropic's Claude) about Source-2 / CS2 engine conventions, the
|
||||||
|
Itanium C++ ABI, ELF, and Valve's naming patterns. That knowledge is itself distilled from publicly-available
|
||||||
|
open-source code and documentation — in large part the very community projects credited above. Proposed names
|
||||||
|
are always cross-checked against the derived RTTI/offsets, never trusted blind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*If your work is used here and is miscredited or missing, that is an oversight, not intent — corrections are
|
||||||
|
welcome. This tool is a small addition on top of a large community's effort.*
|
||||||
99
CONTRIBUTING.md
Normal file
99
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# Contributing gamedata
|
||||||
|
|
||||||
|
Most functions are derived automatically. When source2rosetta *can't* locate one — a newly
|
||||||
|
interesting function, or one whose signature drifted past the model — you can contribute its
|
||||||
|
locator directly. A contribution is just **a name + how to find it, dated to the build you
|
||||||
|
saw it on.** The derive merges it into the catalogue, validates it against a live server, and
|
||||||
|
tracks it forward across future builds like any first-party entry.
|
||||||
|
|
||||||
|
## Add a contribution
|
||||||
|
|
||||||
|
Drop a JSON file into the folder for your game:
|
||||||
|
|
||||||
|
```
|
||||||
|
mappings/contributions/<game_key>/<anything>.json csgo → CS2
|
||||||
|
dota → Dota 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Every file is a JSON array of entries:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "CCSPlayer_WeaponServices::BumpWeapon",
|
||||||
|
"kind": "vtable-offset",
|
||||||
|
"value": "27",
|
||||||
|
"date": "2026-07-09"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CTakeDamageInfo::CTakeDamageInfo",
|
||||||
|
"kind": "signature",
|
||||||
|
"value": "49 BB ? ? ? ? ? ? ? ? 55",
|
||||||
|
"date": "2026-07-09"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| field | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `name` | the function, `Class::Method` for a virtual, a bare name otherwise. |
|
||||||
|
| `kind` | `"signature"` (a non-virtual, located by bytes) or `"vtable-offset"` (a virtual, located by slot). |
|
||||||
|
| `value` | for a signature, a space-separated byte pattern with `?` wildcards; for an offset, the vtable slot index as a string. |
|
||||||
|
| `date` | the build you observed it on, `YYYY-MM-DD`. This anchors the observation so the derive can chain the offset / re-locate the signature forward from there. |
|
||||||
|
|
||||||
|
All four fields are required. Files are merged in sorted filename order; a malformed entry is
|
||||||
|
skipped with a warning (it never breaks the build), so keep one logical group per file.
|
||||||
|
|
||||||
|
## What happens to it
|
||||||
|
|
||||||
|
1. **Merge** — the derive folds your entries into the catalogue for that game (contributions
|
||||||
|
are never written into the historical corpus model — they live only in this folder).
|
||||||
|
2. **Validate** — `produce` launches a vanilla server and checks your locator against live
|
||||||
|
memory: a signature must resolve to executable code, an offset must land on a real vtable
|
||||||
|
slot, and a representative call must return cleanly. Passing entries ship; failing ones are
|
||||||
|
reported, not silently dropped.
|
||||||
|
3. **Track forward** — because the entry is dated, later builds re-locate it automatically
|
||||||
|
(offset chaining / string-anchor backfill), so one contribution keeps paying off across
|
||||||
|
updates instead of needing a re-submit each patch.
|
||||||
|
|
||||||
|
## Getting the value
|
||||||
|
|
||||||
|
- **vtable-offset** — the slot index of a virtual method. Read it off a class's RTTI vtable
|
||||||
|
with any Source-2 class dumper, or from an existing entry for a neighbouring method on the
|
||||||
|
same class.
|
||||||
|
- **signature** — a unique byte pattern at the function's prologue. Keep wildcards (`?`) on
|
||||||
|
relative offsets / addresses so the pattern survives minor recompiles.
|
||||||
|
|
||||||
|
Then let the tool check it for you: add the entry, run `produce` against the current build
|
||||||
|
(omit `--game-dir` for a fast offline check), and look for your name in the output. It lands in
|
||||||
|
`core` if it resolved, or in `unresolved` with a reason if it did not — every catalogue entry is
|
||||||
|
accounted for in one of the tiers, so a contribution never disappears silently.
|
||||||
|
|
||||||
|
Prefer an **offset** over a signature when the function is virtual — it's RTTI-derived and far
|
||||||
|
more stable across builds.
|
||||||
|
|
||||||
|
## Show it holds across builds (optional)
|
||||||
|
|
||||||
|
A one-build entry is fine; one that has held for hundreds of builds is stronger. For a
|
||||||
|
**vtable-offset**, `backfill` chains your slot back through the shipped model's alignment history —
|
||||||
|
no build binaries needed, just the model file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// mine.json
|
||||||
|
[{ "name": "CCSPlayer_WeaponServices::BumpWeapon", "class": "CCSPlayer_WeaponServices", "slot": 27 }]
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
source2rosetta --game cs2 backfill --corpus-model model-cs2.json --names mine.json --out history.json
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
backfill: 1 names over 0 corpus builds / 343 model builds
|
||||||
|
graduates: 1 DEEP first-class (>=50% coverage, >=0.9 consistent)
|
||||||
|
```
|
||||||
|
|
||||||
|
`history.json` then lists the slot at every build the class appears in, plus a consistency score. A
|
||||||
|
deep, consistent timeline is a strong signal the entry is solid — it's exactly what promotes a name
|
||||||
|
to first-class regardless of how it was first found. (A **signature** can be back-filled too, but
|
||||||
|
that half re-locates the anchor string in every raw build, so it needs the full build corpus the
|
||||||
|
maintainers keep — the offset half above works from the published model alone.)
|
||||||
307
Cargo.lock
generated
Normal file
307
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.103"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpp_demangle"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iced-x86"
|
||||||
|
version = "1.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c447cff8c7f384a7d4f741cfcff32f75f3ad02b406432e8d6c878d56b1edf6b"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.186"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.150"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "source2rosetta"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"cpp_demangle",
|
||||||
|
"iced-x86",
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"source2rosetta-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "source2rosetta-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.118"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
[package]
|
||||||
|
name = "source2rosetta"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Locate Source-2 engine functions (gamedata signatures/offsets) across Valve builds"
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
|
repository = "https://git.lo.sh/kamal/source2rosetta"
|
||||||
|
readme = "README.md"
|
||||||
|
authors = ["kamal"]
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = ["crates/source2rosetta-core"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
source2rosetta-core = { path = "crates/source2rosetta-core" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
memchr = "2"
|
||||||
|
iced-x86 = "1"
|
||||||
|
cpp_demangle = "0.5.1"
|
||||||
|
libc = "0.2.186"
|
||||||
661
LICENSE
Normal file
661
LICENSE
Normal file
|
|
@ -0,0 +1,661 @@
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
245
README.md
Normal file
245
README.md
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
# source2rosetta
|
||||||
|
|
||||||
|
**Re-derives Source-2 engine gamedata from stripped Valve binaries — and proves it on a live server.**
|
||||||
|
|
||||||
|
When Valve ships a CS2 or Dota 2 engine update, every Metamod / CounterStrikeSharp plugin breaks until someone hand-reverse-engineers fresh gamedata — function signatures, vtable offsets, netvar layouts. Historically that can be up to **weeks**. source2rosetta reads the stripped `.so` libraries a dedicated server maps and re-derives the whole surface in **minutes**, then launches its own vanilla server and *calls the functions* to prove they're right.
|
||||||
|
|
||||||
|
It's a **standalone Rust tool, not a plugin or a framework** — the gamedata it emits renders into whatever your stack already speaks (CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, or a typed C# SDK). CS2 and Dota 2 are both live-validated; a new game is a `--game` arm away.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- **[ATTRIBUTIONS.md](ATTRIBUTIONS.md) — start here.** This tool stands on a decade of community reverse-engineering, catalogues, dumpers, and research. The credits come first because the work does.
|
||||||
|
- **🎯 Just want the gamedata?** → **[Grab a release and render it for your framework.](crates/source2rosetta-core/README.md)** Download the published CS2 / Dota 2 gamedata, point `source2rosetta-gen` at it, and get CounterStrikeSharp / Metamod / ModSharp / Swiftly / Plugify / a typed C# SDK in one command. No build, no corpus — the 30-second path, and what most people are here for.
|
||||||
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — add or back-fill a gamedata entry.
|
||||||
|
- [LICENSE](LICENSE) — AGPL-3.0.
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
Ballpark from a recent build, on a 16-core desktop. These move build-to-build — treat them as orders of magnitude, not guarantees.
|
||||||
|
|
||||||
|
| | derived functions | typed schema | model | one-time distill |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **CS2** | ~1,150 `core` + ~1,200 `high_confidence`, all live-validated, plus ~4,400 `experimental` name guesses | ~1,900 classes / ~12,300 fields | ~48 MB (a few MB gzipped) | ~15 min |
|
||||||
|
| **Dota 2** | ~1,900 `core` + ~1,000 `high_confidence`, plus ~6,100 `experimental` | ~2,960 classes / ~17,700 fields | ~570 MB | ~1 hr |
|
||||||
|
|
||||||
|
Both derive **0-dropped** — every offset and signature that ships passed live validation. Distilling the model is a one-time cost; after that, a per-build update (fold the new build in, re-derive) is **a couple of minutes** — the "minutes, not weeks" the headline is about.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works — read → derive → validate → emit
|
||||||
|
|
||||||
|
### 1. Read — the whole server, not just `libserver`
|
||||||
|
|
||||||
|
A dedicated server maps roughly 22 shared libraries, and `libserver.so` is only a fraction of the reachable engine surface. source2rosetta reads **all of them** (multilib): `libserver`, `libengine2`, `libtier0`, `libnetworksystem`, `libschemasystem`, and the rest — each locator it emits carries the library it belongs to. ELF parsing is done by hand; `.rela.dyn` relocations are resolved so that `.data.rel.ro` pointer slots (zero on disk) come back as their true as-loaded values.
|
||||||
|
|
||||||
|
### 2. Derive
|
||||||
|
|
||||||
|
**Offsets & netvars — near-deterministic.** Vtable offsets come from **Itanium C++ RTTI**: the type hierarchy and vtable layout are read straight out of the binary, so a method's slot index is a fact, not a guess. Field layouts come from **Valve's own SchemaSystem** reflection tables — the engine emits class/field metadata (name, type, offset) as static data for its own use, and source2rosetta reads it directly. No fingerprints, no guessing.
|
||||||
|
|
||||||
|
**Non-virtual signatures — located, then verified.** A stripped, non-virtual function has no slot and no symbol, so it has to be *found*. source2rosetta computes a **recompilation-invariant fingerprint** of each catalogued function — CFG shape, call-graph degree, mnemonic histograms, imported-symbol references. These are deliberately **abstracted statistics, never raw bytes**. Locating a function in a new build is **nearest-history under a plain, unweighted L1 distance** over those features, accepted only within a small fixed recompile threshold. To be exact about what this is *not*: there is **no trained model, no machine learning, no learned or weighted metric, no embedding network** — it's a deterministic nearest-neighbour lookup against recent history, and the per-game "model" it reads is a bundle of derived *facts*, not a network. Every located address is then **re-verified independently of the match**: a fresh byte-signature is regenerated at the predicted address and confirmed to be **unique** in the target library and on a **function prologue** — the same check a loader does. A match that doesn't verify isn't shipped.
|
||||||
|
|
||||||
|
**ABI-shape guard.** A byte-signature sees a function's *body* drift and re-derives it, but it can't see the *argument list* change while the prologue stays recognisable — the sig still resolves and points at real code, yet a caller using the old prototype passes the wrong registers. source2rosetta recovers each function's observable **SysV-AMD64 ABI shape** (which argument registers are live-in, plus the return class) via a bounded backward-liveness pass and diffs it across builds, flagging exactly those prototype changes and marking struct-by-value (sret) returns that are unsafe to blind-call.
|
||||||
|
|
||||||
|
### 3. Validate — against a live server, not a spec
|
||||||
|
|
||||||
|
This is what separates source2rosetta from a static dumper. `produce` and `integration-test` **launch their own** vanilla dedicated server for the game (bots on an empty deathmatch for pawn games; a pawn-less game like Dota waits on a `ready_class` proxy) — no Steam, no separate instance, no human. Then, reading the running process through `/proc/<pid>/mem` (read-only ptrace — no injection, no debugger), it checks against ground truth:
|
||||||
|
|
||||||
|
- every **offset** lands on a real vtable slot, and every **signature** on live executable code;
|
||||||
|
- a gamedata function is **actually called** via ptrace to prove it's the semantically right function, not a plausible byte-match (pawn games);
|
||||||
|
- derived probes are **fuzzed across changing game state** for many iterations;
|
||||||
|
- field **types** are read from the live process for the typed netvars — and fields that are non-null live but zero on disk (e.g. `m_pSchemaBinding`) confirm the reader is seeing real live state, not stale disk bytes.
|
||||||
|
|
||||||
|
The contract is blunt: **"degrades or stops loudly, never lies."** An entry live validation confidently rejects is dropped, not shipped under a banner claiming it resolves; if the oracle can't run, it fails loudly rather than emit an unverified result.
|
||||||
|
|
||||||
|
### 4. Confidence tiers — nothing vanishes silently
|
||||||
|
|
||||||
|
The output is a per-game **monolith** in which every catalogue entry is accounted for, sorted into four tiers:
|
||||||
|
|
||||||
|
| tier | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `core` | derived and, in a full run, **live-validated** — the load-bearing gamedata |
|
||||||
|
| `high_confidence` | corroborated names folded in as verified offsets/sigs (dictionary-exact or macOS ground-truth transfer) |
|
||||||
|
| `experimental` | the least-filtered band — every graded name guess, each with a **resolvable locator** but an **unverified name** |
|
||||||
|
| `unresolved` | catalogued but not confidently produced this build, with a reason (`sig-drifted`, `offset-low-conf`, …) and no locator |
|
||||||
|
|
||||||
|
A function that couldn't be derived this build shows up as `unresolved` with a reason — it never just disappears.
|
||||||
|
|
||||||
|
### 5. Emit
|
||||||
|
|
||||||
|
The canonical model and every format emitter live in `crates/source2rosetta-core`; the deriver writes format-neutral JSON and the tiny `source2rosetta-gen` binary renders it into any framework's shape (see [Artifacts & formats](#artifacts-schemas--output-formats)).
|
||||||
|
|
||||||
|
Two things to keep straight. **Multi-game:** the ELF / RTTI / SysV / SchemaSystem machinery is engine-generic; game-specific knobs (library set, launch spec, schema-probe classes, pawn anchor) live on a `profile::GameProfile`, and `--game <cs2|dota2>` selects one — adding a game is a `const` plus a clap arm. **Naming is not derivation:** AI-assisted name extrapolation exists but is producer-side dev tooling, not part of the shipped deriver, and every proposed name is gated on self-naming or ground-truth corroboration and then live-validated. A wrong *name* only mislabels a real slot; it never touches the **offset**, which comes from RTTI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Install & CLI usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# The deriver (`source2rosetta`) — the root binary.
|
||||||
|
cargo build --release # → ./target/release/source2rosetta
|
||||||
|
|
||||||
|
# The renderer (`source2rosetta-gen`) lives in the core crate and is NOT built by the root
|
||||||
|
# build — build it explicitly (or use --workspace). See crates/source2rosetta-core/README.md.
|
||||||
|
cargo build --release -p source2rosetta-core # → ./target/release/source2rosetta-gen
|
||||||
|
```
|
||||||
|
|
||||||
|
`--game <cs2|dota2>` is a global flag (default `cs2`), given before the subcommand: `source2rosetta --game dota2 produce …`.
|
||||||
|
|
||||||
|
| command | one line |
|
||||||
|
|---|---|
|
||||||
|
| `produce` | The whole per-game build in one command: derive → fold → (with `--game-dir`) validate-live + typed netvars → roll the model forward, into `--out-dir`. **`--game-dir` present = full live-validated build; absent = fast offline build (gamedata + model only). That flag is the entire offline/full switch.** |
|
||||||
|
| `corpus-model` | Distill a corpus of past builds into one shippable model (vtable-alignment hops, reference fingerprints, slot timelines), so future derivation needs only the model + the target binary, not the corpus. |
|
||||||
|
| `fold-model` | Roll an existing model forward by ONE build (`model N + build → N+1`), reading only the model and that one binary — equal to a full re-distill. The production update path (also a sidecar inside `produce`). |
|
||||||
|
| `integration-test` | Stand-alone CI live oracle: launch a vanilla server, populate it, and verify derived gamedata against it — schema oracle, a semantic ptrace CALL on a live pawn, and (with `--gamedata`) a full validate-live plus optional live fuzzing. |
|
||||||
|
| `backfill` | Give an extrapolated name a real cross-build timeline — resolve its string anchor in every corpus build, or chain a vtable slot through the model — and report history depth + consistency (how a guess graduates to first-class). |
|
||||||
|
| `classify-change` | `--prev`/`--new` → `skip` / `normal` / `shift` + the exact % of function bodies that changed, comparing with position-dependent bytes masked so a pure layout shift reads as unchanged. Decides whether a build even warrants a re-derive. |
|
||||||
|
| `filter-corpus` | Collapse runs of code-identical builds to one representative, label each transition `normal`/`shift`, and segment the timeline into toolchain eras. Writes the selection manifest the distill reads. |
|
||||||
|
|
||||||
|
### Quickstart
|
||||||
|
|
||||||
|
Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/releases)**. For the offline path you need one file from there — the model (`model-<game>.json`) — plus the derive inputs, which ship in this repo under `mappings/`. Put the downloaded model wherever you like; the examples assume it's in the working directory.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# OFFLINE — derive gamedata + roll the model forward. No server, fully deterministic.
|
||||||
|
./target/release/source2rosetta --game cs2 produce \
|
||||||
|
--seed mappings/seed-cs2.json \
|
||||||
|
--corpus-model model-cs2.json \
|
||||||
|
--target <build-dir> \
|
||||||
|
--out-dir out
|
||||||
|
|
||||||
|
# FULL — the same, plus it launches its own vanilla+bots server to validate on the live
|
||||||
|
# process and read field types for the typed netvars. Adding --game-dir is the only change.
|
||||||
|
./target/release/source2rosetta --game cs2 produce \
|
||||||
|
--seed mappings/seed-cs2.json \
|
||||||
|
--corpus-model model-cs2.json \
|
||||||
|
--target <build-dir> \
|
||||||
|
--game-dir <cs2-install> \
|
||||||
|
--out-dir out
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--target <dir>` (required) — the build **directory** to derive from; its libraries are searched by name, so pass the directory, not a bare `.so`.
|
||||||
|
- `--seed <bundle>` — one file bundling every derive input (catalogue + optional naming/offset/sig sections). The loose equivalent is `--catalogue <file>` plus the optional `--promotable` / `--candidates` / `--full-names` / `--extra-offsets` / `--extra-sigs`, all defaulting to empty — **so a brand-new game needs only a catalogue to start deriving.**
|
||||||
|
- Corpus signal — exactly one of `--corpus-model <model.json>` (the normal path: forward-derive from the model + the target binary, and roll the model N→N+1 as a sidecar) or `--corpus <dir>` (fingerprint the raw build binaries on the fly).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fork it & distill your own model
|
||||||
|
|
||||||
|
Nothing is hosted — fork it, `cargo build --release`, and point it at a build on disk. Two ways to run, depending on whether you already have a model:
|
||||||
|
|
||||||
|
- **Have a model** (downloaded from releases, or distilled): `--corpus-model model-<game>.json` forward-derives from the model plus *only the target binary* — no corpus on disk. This is the normal path.
|
||||||
|
- **No model yet:** distill one from a corpus of past builds. "Distilling" is what this project means by "training" — there's no ML (see [Derive](#2-derive) above); the model is the bundle of facts already described: vtable-alignment hops, reference-fingerprint windows, ABI-shape consensus, slot timelines.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Distill a corpus into a model (streaming, bounded RAM even over Dota's ~1k builds).
|
||||||
|
./target/release/source2rosetta --game cs2 corpus-model \
|
||||||
|
--seed mappings/seed-cs2.json \
|
||||||
|
--corpus corpus/binaries \
|
||||||
|
--out model-cs2.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`--class-scope` (default `clean` — every real game class, enough for any modding offset to derive model-only) picks which classes get slot hops. **Whatever scope you distill with, `fold-model` and `produce`'s sidecar fold must use the same one.**
|
||||||
|
|
||||||
|
### Keeping a model fresh — the incremental fold
|
||||||
|
|
||||||
|
Once a model exists you never need the corpus again. `fold-model` rolls it forward one build, reading only the model plus the single new binary — identical to a full re-distill:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./target/release/source2rosetta --game cs2 fold-model \
|
||||||
|
--model model-cs2.json \
|
||||||
|
--seed mappings/seed-cs2.json \
|
||||||
|
--build <new-build-dir> \
|
||||||
|
--out model-cs2.next.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`produce --corpus-model` runs exactly this fold as a sidecar, so a full build both derives *and* advances the model in one command. (`--class-scope` must match the model's.)
|
||||||
|
|
||||||
|
### Getting the corpus (only to bootstrap a model)
|
||||||
|
|
||||||
|
A corpus is a directory of past builds, one subdirectory of `.so` files per build (`corpus/binaries/<label>/*.so`). Fetch it yourself, one time:
|
||||||
|
|
||||||
|
1. Use **DepotDownloader** — the self-contained release binary from <https://github.com/SteamRE/DepotDownloader/releases>, **not** `dotnet tool install` (its NuGet package is pinned ancient).
|
||||||
|
2. Pull manifests from the **Linux binaries depot `2347773`** — *not* the content depot `2347770`. `2347773`'s manifest only advances when the binaries actually change, so its history already *is* the list of real recompiles; content micropatches only bump `2347770`. Read the manifest history off SteamDB, not the Steam client.
|
||||||
|
3. Download **oldest-first** (chronological = version order), then content-hash-dedup. `filter-corpus` further collapses code-identical builds and segments toolchain eras before you distill, so you never fingerprint the same code twice.
|
||||||
|
|
||||||
|
A **partial corpus is fine** — fewer labels is a shallower history, not a broken model; skip very old manifests if they're un-downloadable.
|
||||||
|
|
||||||
|
### Adding a game
|
||||||
|
|
||||||
|
Add a `profile::GameProfile` const (library set, schema-probe classes, launch spec, pawn anchor, dead-weight knobs) plus one `--game` clap-enum arm, then point `corpus-model` at that game's corpus. The rest is engine-generic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifacts, schemas & output formats
|
||||||
|
|
||||||
|
A full `produce` run writes a small, self-contained release set per game into `--out-dir`:
|
||||||
|
|
||||||
|
| File | What it is | When |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `gamedata-<game>.json` | The **monolith** — the tiered function catalogue (signatures + vtable offsets) with provenance and live-validation folded inline | always |
|
||||||
|
| `netvars-<game>.json` | The **typed schema** — every SchemaSystem class → field → offset/type | full (`--game-dir`) runs only |
|
||||||
|
| `model-<game>.json` | The **per-game model** — the distilled facts derivation reads instead of the corpus (the shippable artifact) | when the run folds an existing model (`--corpus-model`) |
|
||||||
|
| `manifest.json` | Volatile release metadata: `{ version, artifacts: [...] }` | always |
|
||||||
|
|
||||||
|
Wall-clock and other volatile metadata live only in `manifest.json`; the monolith and schema carry no timestamp, so they're **byte-reproducible** — the same build in yields the same JSON out.
|
||||||
|
|
||||||
|
### `gamedata-<game>.json` — the monolith
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"meta": { "game_key", "game", "source_build", "version",
|
||||||
|
"counts": { "core", "high_confidence", "experimental", "unresolved" } },
|
||||||
|
"core": { "<fn name>": <MonoEntry>, ... },
|
||||||
|
"high_confidence": { "<fn name>": <MonoEntry>, ... },
|
||||||
|
"experimental": { "<fn name>": <MonoEntry>, ... },
|
||||||
|
"unresolved": { "<fn name>": { "reason", "detail" }, ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A **`MonoEntry`** is a locator (flattened to the top level) plus its grading. The locator obeys a strict **signature-XOR-offset** invariant — a virtual method ships as a bare integer `offset` (its RTTI vtable slot index), a non-virtual function as a `signature` object with the `library` it scans and a space-hex `linux` byte pattern with `?` wildcards:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"signature": { "library": "server", "linux": "55 48 89 ? E5" }, // non-virtual: located scan pattern
|
||||||
|
"offset": 158, // virtual: RTTI slot index (a bare int)
|
||||||
|
"class": "CCSPlayerPawn", // experimental offsets only: the vtable class, for an eyeball check
|
||||||
|
"provenance": { "tier", "source", "confidence", "self_named", "by_value",
|
||||||
|
"rationale", "corroboration", "abi_drift", ... }, // grading; fields present by tier
|
||||||
|
"validated": true // true = passed live validation · false = rejected · null = not validated (offline)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`validated: false` entries stay in the file for transparency but are **dropped by every renderer**.
|
||||||
|
|
||||||
|
> The `source2rosetta-gen` renderers write a slightly different *on-disk* shape (plural `{"signatures":{…}}` / `{"offsets":{…}}` for CounterStrikeSharp-family output). The keys above are the **canonical model JSON** as `gamedata-<game>.json` stores it.
|
||||||
|
|
||||||
|
### `netvars-<game>.json` — the typed schema
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"meta": { "game_key", "source_build", "typed", "untyped" },
|
||||||
|
"classes": { "<class>": { "<field>": { "offset", "type", "kind", "size", "name_hash" } } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Offsets come from SchemaSystem tables (available offline); `type` / `kind` / `size` are read from the live process during a full run (`kind` ∈ `ref` | `ptr` | `fixed_array`). A full run refuses to ship a schema whose fields resolved wholesale-untyped rather than emit a typeless file — the same "stop loudly" contract.
|
||||||
|
|
||||||
|
### `model-<game>.json` — the per-game model
|
||||||
|
|
||||||
|
The distilled per-game **facts** — vtable-alignment hops, reference-fingerprint windows, ABI-shape consensus, slot timelines — the artifact derivation reads *instead of* the corpus. What "distill a model" produces (above).
|
||||||
|
|
||||||
|
### Rendering — the `gen` binary
|
||||||
|
|
||||||
|
The monolith and schema are format-neutral; **`source2rosetta-gen`** renders them, so the deriver never changes when a new consumer format is added.
|
||||||
|
|
||||||
|
**Gamedata** (`--from gamedata-<game>.json`, at a cumulative `--tier core | high_confidence | experimental`, default `high_confidence`): `cssharp` (CounterStrikeSharp), `metamod` (Metamod/SourceMod VDF), `modsharp`, `swiftly`, `plugify`, `model` (the canonical model re-serialized). **Schema** (`--netvars netvars-<game>.json`): `cs-sdk` (a typed C# SDK — one `static class` per schema class, `const int` field offsets tagged with their type), `netvars` (a flat `{ class: { field: offset } }` map). Full render walkthrough: **[gen binary README](crates/source2rosetta-core/README.md)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copyright
|
||||||
|
|
||||||
|
The build **corpus** — Valve's `.so` files (~86 GB) — is never shipped and never baked into a release. The published artifacts (model, gamedata, netvars) are *designed to* contain **derived facts** — vtable offsets, abstracted fingerprint statistics, and byte scan-patterns — rather than copies of the original code. That a statistic *about* code is a fact and not a copy is **the project's position, not settled law** — reverse-engineering Valve binaries under AGPL is exactly the territory a court hasn't ruled on, so use accordingly. Leaked or proprietary game source was deliberately kept out of the corpus and the naming pipeline; see [ATTRIBUTIONS.md](ATTRIBUTIONS.md).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[AGPL-3.0](LICENSE). Built on a decade of community work — see **[ATTRIBUTIONS.md](ATTRIBUTIONS.md)** first.
|
||||||
15
crates/source2rosetta-core/Cargo.toml
Normal file
15
crates/source2rosetta-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
[package]
|
||||||
|
name = "source2rosetta-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "source2rosetta's deriver-free core: canonical gamedata model + format emitters (serde-only)"
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
|
repository = "https://git.lo.sh/kamal/source2rosetta"
|
||||||
|
readme = "README.md"
|
||||||
|
authors = ["kamal"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
82
crates/source2rosetta-core/README.md
Normal file
82
crates/source2rosetta-core/README.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# source2rosetta-gen
|
||||||
|
|
||||||
|
Render a published [source2rosetta](../../README.md) gamedata release into whatever format your framework
|
||||||
|
reads. `source2rosetta` does the hard part — deriving CS2 / Dota 2 gamedata from the stripped engine and
|
||||||
|
validating it on a live server — and publishes two JSON files per game. `source2rosetta-gen` turns those into
|
||||||
|
CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, or a typed C# SDK, locally, in a second.
|
||||||
|
|
||||||
|
It's deliberately tiny: it links only `source2rosetta-core` (serde + the format emitters) — **no** ELF reader,
|
||||||
|
no disassembler, no ptrace. So a consumer who "just wants the files" downloads one release + this small binary
|
||||||
|
and generates exactly what they need, instead of every format being pre-baked into the release.
|
||||||
|
|
||||||
|
## Get it
|
||||||
|
|
||||||
|
Grab the prebuilt `source2rosetta-gen` from the release page, or build it from source:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release -p source2rosetta-core
|
||||||
|
# -> target/release/source2rosetta-gen
|
||||||
|
```
|
||||||
|
|
||||||
|
(The `gen` binary lives in the `source2rosetta-core` crate, so a plain `cargo build --release` at the repo root
|
||||||
|
does **not** build it — use `-p source2rosetta-core` or `--workspace`.)
|
||||||
|
|
||||||
|
## Use it
|
||||||
|
|
||||||
|
Download the two artifacts for your game from the release page:
|
||||||
|
|
||||||
|
- `gamedata-<game>.json` — the derived gamedata (function signatures + vtable offsets), tiered by confidence.
|
||||||
|
- `netvars-<game>.json` — the typed schema (every class's field offsets + runtime types).
|
||||||
|
|
||||||
|
Then point `gen` at whichever you need and pick a `--format`. Output goes to `--out`, or stdout if omitted.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# CounterStrikeSharp combined gamedata (the default)
|
||||||
|
source2rosetta-gen --from gamedata-cs2.json --format cssharp --out gamedata.json
|
||||||
|
|
||||||
|
# Metamod / SourceMod gamedata VDF (one .games.txt)
|
||||||
|
source2rosetta-gen --from gamedata-cs2.json --format metamod --out csgo.games.txt
|
||||||
|
|
||||||
|
# Swiftly / ModSharp / Plugify gamedata
|
||||||
|
source2rosetta-gen --from gamedata-cs2.json --format swiftly --out gamedata.json
|
||||||
|
|
||||||
|
# Typed C# SDK from the schema — one `static class` per engine class, `const` field offsets + types
|
||||||
|
source2rosetta-gen --netvars netvars-cs2.json --format cs-sdk --out Schema.cs
|
||||||
|
|
||||||
|
# Flat netvar offset map (class -> field -> offset)
|
||||||
|
source2rosetta-gen --netvars netvars-cs2.json --format netvars --out netvars.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Formats
|
||||||
|
|
||||||
|
| `--format` | needs | output |
|
||||||
|
|---|---|---|
|
||||||
|
| `cssharp` *(default)* | `--from` | CounterStrikeSharp combined gamedata (a commented, sectioned file) |
|
||||||
|
| `metamod` | `--from` | Metamod:Source / SourceMod gamedata VDF (`.games.txt`) |
|
||||||
|
| `modsharp` | `--from` | ModSharp gamedata JSON |
|
||||||
|
| `swiftly` | `--from` | Swiftly gamedata JSON |
|
||||||
|
| `plugify` | `--from` | Plugify gamedata JSON |
|
||||||
|
| `model` | `--from` | the canonical model, re-serialized (format-neutral) |
|
||||||
|
| `cs-sdk` | `--netvars` | typed C# SDK — `static class` per schema class, `const int` field offsets tagged with their type |
|
||||||
|
| `netvars` | `--netvars` | flat schema map, `{ class: { field: offset } }` |
|
||||||
|
|
||||||
|
## Confidence tier
|
||||||
|
|
||||||
|
The gamedata formats (the `--from` ones) take a `--tier`, cumulative and defaulting to `high_confidence`:
|
||||||
|
|
||||||
|
| `--tier` | includes |
|
||||||
|
|---|---|
|
||||||
|
| `core` | only the guaranteed, first-class entries |
|
||||||
|
| `high_confidence` *(default)* | `core` + the promoted (verified-name) entries |
|
||||||
|
| `experimental` | the above + every graded name guess (each has a resolvable locator, but an **unverified** name) |
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# only the rock-solid set:
|
||||||
|
source2rosetta-gen --from gamedata-cs2.json --format cssharp --tier core --out gamedata.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The schema formats (`cs-sdk`, `netvars`) ignore `--tier`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Part of [source2rosetta](../../README.md) · [AGPL-3.0](../../LICENSE).
|
||||||
87
crates/source2rosetta-core/src/bin/source2rosetta-gen.rs
Normal file
87
crates/source2rosetta-core/src/bin/source2rosetta-gen.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
//! `source2rosetta-gen` — the standalone generator. Reads the published monolith (`gamedata-<game>.json`
|
||||||
|
//! from `source2rosetta produce`) and renders it into any framework's gamedata format at a chosen confidence
|
||||||
|
//! tier.
|
||||||
|
//!
|
||||||
|
//! 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
|
||||||
|
//! whatever their framework needs locally, instead of every format being pre-baked into releases. It lives in
|
||||||
|
//! the `source2rosetta-core` crate (serde-only), so it stays genuinely lean.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use clap::Parser;
|
||||||
|
use source2rosetta_core::{model, render};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "source2rosetta-gen",
|
||||||
|
about = "Render a source2rosetta monolith into a framework gamedata format"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// The monolith `gamedata-<game>.json` (for a GAMEDATA --format: cssharp/metamod/modsharp/swiftly/plugify/model).
|
||||||
|
#[arg(long)]
|
||||||
|
from: Option<PathBuf>,
|
||||||
|
/// The typed `netvars-<game>.json` (for a SCHEMA --format: cs-sdk/netvars).
|
||||||
|
#[arg(long)]
|
||||||
|
netvars: Option<PathBuf>,
|
||||||
|
/// Output format. GAMEDATA (needs --from): cssharp | metamod | modsharp | swiftly | plugify | model.
|
||||||
|
/// SCHEMA (needs --netvars): cs-sdk (typed C# SDK) | netvars (flat offset map). cssharp = the
|
||||||
|
/// `//`-bannered CS# combined file; metamod also covers SourceMod (the VDF `.games.txt`).
|
||||||
|
#[arg(long, default_value = "cssharp")]
|
||||||
|
format: String,
|
||||||
|
/// Confidence tier for a gamedata format (cumulative): core | high_confidence | experimental. Defaults to
|
||||||
|
/// `high_confidence` (core + the promoted names). Ignored by schema formats.
|
||||||
|
#[arg(long, default_value = "high_confidence")]
|
||||||
|
tier: String,
|
||||||
|
/// Write here (default: stdout).
|
||||||
|
#[arg(long)]
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let fmt = cli.format.as_str();
|
||||||
|
|
||||||
|
let text = if render::SCHEMA_FORMAT_IDS.contains(&fmt) {
|
||||||
|
// schema formats render the typed netvars (class -> field -> offset/type), not the gamedata monolith.
|
||||||
|
let path = cli.netvars.as_ref().context(
|
||||||
|
"a schema --format (cs-sdk | netvars) requires --netvars <netvars-<game>.json>",
|
||||||
|
)?;
|
||||||
|
let schema: model::Schema = serde_json::from_str(&std::fs::read_to_string(path)?)
|
||||||
|
.with_context(|| format!("parse netvars json {}", path.display()))?;
|
||||||
|
render::schema_by_id(fmt)
|
||||||
|
.expect("known schema format")
|
||||||
|
.render(&schema)
|
||||||
|
} else {
|
||||||
|
let path = cli
|
||||||
|
.from
|
||||||
|
.as_ref()
|
||||||
|
.context("a gamedata --format requires --from <gamedata-<game>.json>")?;
|
||||||
|
let tier = model::TierSelect::from_id(&cli.tier).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"unknown --tier {:?} (want one of: {})",
|
||||||
|
cli.tier,
|
||||||
|
model::TIER_IDS.join(" | ")
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mono: model::Monolith = serde_json::from_str(&std::fs::read_to_string(path)?)
|
||||||
|
.with_context(|| format!("parse monolith json {}", path.display()))?;
|
||||||
|
match fmt {
|
||||||
|
// cssharp is the bannered combined file (guaranteed + extrapolated sections), not a flat map.
|
||||||
|
"cssharp" => render::render_monolith_cssharp(&mono, tier),
|
||||||
|
f @ ("metamod" | "modsharp" | "swiftly" | "plugify" | "model") => render::by_id(f)
|
||||||
|
.expect("known flat format")
|
||||||
|
.render(&mono.select(tier)),
|
||||||
|
other => bail!(
|
||||||
|
"unknown --format {other:?} (gamedata: cssharp|metamod|modsharp|swiftly|plugify|model; \
|
||||||
|
schema: cs-sdk|netvars)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match cli.out {
|
||||||
|
Some(p) => std::fs::write(&p, text).with_context(|| format!("write {}", p.display()))?,
|
||||||
|
None => println!("{text}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
7
crates/source2rosetta-core/src/lib.rs
Normal file
7
crates/source2rosetta-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
//! source2rosetta-core — the deriver-free core: the canonical gamedata [`model`] + the format [`render`]
|
||||||
|
//! emitters. Depends only on serde (+ clap/anyhow for the `source2rosetta-gen` binary), NOT on the ELF
|
||||||
|
//! reader / disassembler / ptrace layers. Both the `source2rosetta` deriver and the standalone
|
||||||
|
//! `source2rosetta-gen` link this, so the generator carries none of the derivation weight.
|
||||||
|
|
||||||
|
pub mod model;
|
||||||
|
pub mod render;
|
||||||
553
crates/source2rosetta-core/src/model.rs
Normal file
553
crates/source2rosetta-core/src/model.rs
Normal file
|
|
@ -0,0 +1,553 @@
|
||||||
|
//! The canonical derived-gamedata model — the single in-memory representation the derivation produces
|
||||||
|
//! and the emitters consume. Deliberately format-agnostic (no serde_json shapes here): `render::*`
|
||||||
|
//! turns it into CSSharp JSON, Metamod VDF, etc. Keeping the shape here (not smeared through `json!`
|
||||||
|
//! call sites) is what lets one derivation feed every output format and a standalone generator.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// A byte-pattern signature located in a specific library.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Sig {
|
||||||
|
pub library: String, // "server", "engine2", … (the module the pattern scans)
|
||||||
|
pub linux: String, // space-hex pattern with `?` wildcards, e.g. "55 48 89 ? E5"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One gamedata function: a vtable-method offset, a scan signature, or (rarely) both.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Entry {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub signature: Option<Sig>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub offset: Option<i64>, // vtable slot index (or a carried member offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Entry {
|
||||||
|
/// A signature-only locator (the deriver's sig-XOR-offset invariant as a constructor).
|
||||||
|
pub fn signature(library: impl Into<String>, linux: impl Into<String>) -> Entry {
|
||||||
|
Entry {
|
||||||
|
signature: Some(Sig {
|
||||||
|
library: library.into(),
|
||||||
|
linux: linux.into(),
|
||||||
|
}),
|
||||||
|
offset: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A vtable-offset-only locator.
|
||||||
|
pub fn offset(linux: i64) -> Entry {
|
||||||
|
Entry {
|
||||||
|
signature: None,
|
||||||
|
offset: Some(linux),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default output game-key when none is set — CS2's Steam content-dir token. Games-keyed emitters
|
||||||
|
/// (Metamod/Plugify) fall back to this so an older model JSON (no `game_key`) still renders as CS2.
|
||||||
|
fn default_game_key() -> String {
|
||||||
|
"csgo".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The derived gamedata for one build: function name -> entry. BTreeMap so iteration/output is
|
||||||
|
/// deterministically key-sorted, matching serde_json's Map key ordering byte-for-byte.
|
||||||
|
///
|
||||||
|
/// Serializing this IS the canonical "model JSON" — the single per-build artifact the deriver
|
||||||
|
/// publishes and the standalone `source2rosetta-gen` reads back to produce every framework's format.
|
||||||
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Gamedata {
|
||||||
|
pub entries: BTreeMap<String, Entry>,
|
||||||
|
/// The game token the game-keyed emitters wrap output in (Metamod `Games { <game_key> {..} }`,
|
||||||
|
/// Plugify `{ "<game_key>": {..} }`). Persisted here because `source2rosetta-gen` renders from the model
|
||||||
|
/// JSON alone, with no access to the deriver's `GameProfile`. Defaults to CS2's `csgo`.
|
||||||
|
#[serde(default = "default_game_key")]
|
||||||
|
pub game_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Gamedata {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: BTreeMap::new(),
|
||||||
|
game_key: default_game_key(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the derivation could not produce a shipped locator — the closed domain the `unresolved` tier reports.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum FlagReason {
|
||||||
|
/// A catalogued signature that no longer resolves uniquely / wasn't recovered in the target.
|
||||||
|
SigDrifted,
|
||||||
|
/// A vtable offset whose recency-weighted vote fell below the confidence bar.
|
||||||
|
OffsetLowConf,
|
||||||
|
/// No chainable anchor / no reference history at all.
|
||||||
|
Unresolved,
|
||||||
|
/// The sig SHIPPED, but its ABI prototype shape drifted from the model consensus (review the prototype).
|
||||||
|
AbiDrift,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlagReason {
|
||||||
|
/// The kebab id — the same string the `kebab-case` serialization emits, for callers that carry the
|
||||||
|
/// reason across to the monolith's `Unresolved.reason` (a `String`, kept stable for byte-reproducibility).
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FlagReason::SigDrifted => "sig-drifted",
|
||||||
|
FlagReason::OffsetLowConf => "offset-low-conf",
|
||||||
|
FlagReason::Unresolved => "unresolved",
|
||||||
|
FlagReason::AbiDrift => "abi-drift",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A catalogue entry the derivation could NOT confidently produce. Emitted as a first-class sidecar
|
||||||
|
/// (never guessed into the gamedata — safety > recall) so it is both reviewable and machine-readable.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct Flagged {
|
||||||
|
pub name: String,
|
||||||
|
pub reason: FlagReason,
|
||||||
|
/// The signal we do have: the carried value, vote confidence, "no reference", …
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Flagged {
|
||||||
|
pub fn new(name: impl Into<String>, reason: FlagReason, detail: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
reason,
|
||||||
|
detail: detail.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Gamedata {
|
||||||
|
pub fn set_signature(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
library: impl Into<String>,
|
||||||
|
linux: impl Into<String>,
|
||||||
|
) {
|
||||||
|
self.entries.entry(name.into()).or_default().signature = Some(Sig {
|
||||||
|
library: library.into(),
|
||||||
|
linux: linux.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_offset(&mut self, name: impl Into<String>, linux: i64) {
|
||||||
|
self.entries.entry(name.into()).or_default().offset = Some(linux);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.entries.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================================
|
||||||
|
// The monolith model — the shipped `gamedata-<game>.json`. Four confidence tiers with provenance
|
||||||
|
// + live-validation folded inline; `source2rosetta-gen` renders it into any framework format, and it is
|
||||||
|
// equally readable as-is by a consumer. A `MonoEntry` EMBEDS `Entry`, so the locator shape stays
|
||||||
|
// single-sourced on `render::locator_value` and never diverges. Lib-agnostic: an entry carries its
|
||||||
|
// `library` in the signature locator, so the monolith spans every derived library, not just libserver.
|
||||||
|
// ===========================================================================================
|
||||||
|
|
||||||
|
/// A monolith entry's confidence tier — its finer label within a section, serialized as kebab strings
|
||||||
|
/// (`core`, `self-named`, `dict-exact`, `contextual`, `corroborated`, `high`, `medium`, `low`).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum Tier {
|
||||||
|
Core,
|
||||||
|
SelfNamed,
|
||||||
|
/// Dictionary-corroborated by the FOLD (an exact hit in the harvested name catalogue).
|
||||||
|
DictExact,
|
||||||
|
Contextual,
|
||||||
|
/// Dictionary-corroborated by the EXPERIMENTAL band, and also the label the macOS ground-truth
|
||||||
|
/// transfer carries. Kept distinct from [`Tier::DictExact`] rather than merged: the two are produced
|
||||||
|
/// by different paths, and folding them together would additionally conflate ground-truth symbols
|
||||||
|
/// with dictionary guesses. Anything counting "corroborated" for display must count BOTH — see
|
||||||
|
/// [`Tier::is_dict_corroborated`].
|
||||||
|
Corroborated,
|
||||||
|
High,
|
||||||
|
Medium,
|
||||||
|
Low,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tier {
|
||||||
|
/// Is this tier a dictionary/ground-truth corroboration, under either of its two labels?
|
||||||
|
pub fn is_dict_corroborated(self) -> bool {
|
||||||
|
matches!(self, Tier::DictExact | Tier::Corroborated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-entry provenance, folded inline. Optional by tier: a `core` entry carries almost nothing, an
|
||||||
|
/// `experimental` guess carries the full grading.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Provenance {
|
||||||
|
pub tier: Tier,
|
||||||
|
/// Raw address in THIS build — a debugging/trace anchor (the monolith is per-build, so it's coherent).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub addr: Option<String>,
|
||||||
|
/// The confidence LABEL as the harvest records it — `"high"` / `"medium"` / `"low"` — a separate axis
|
||||||
|
/// from `tier` (a self-named entry can still be low-confidence). A string, not a number.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub confidence: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub self_named: Option<bool>,
|
||||||
|
/// Return is struct-by-value → unsafe to naive-call (the `RetClass::ByValue` flag).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub by_value: Option<bool>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ret_class: Option<String>,
|
||||||
|
/// "catalogue" | "source2rosetta-nameext" | "contribution:<date>" | …
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rtti_class: Option<String>,
|
||||||
|
/// high_confidence tier only — the naming rationale.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rationale: Option<String>,
|
||||||
|
/// experimental tier only — how the dictionary corroborated the guess (`"exact"` / `"bare"` / `"none"`).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub corroboration: Option<String>,
|
||||||
|
/// experimental tier only — the same name was guessed at more than one address.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub collision: Option<bool>,
|
||||||
|
/// experimental tier only — protobuf/serializer/foreign plumbing (flagged, kept for completeness).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dead_weight: Option<bool>,
|
||||||
|
/// core only — the target's ABI prototype-shape differs from the model's consensus (`"target [..] vs
|
||||||
|
/// history [..]"`). The signature still ships (a drifted arg-list is a loader-hook seam a byte-sig can't
|
||||||
|
/// see, not a wrong locator), but a consumer that ptrace-calls it should re-check the prototype.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub abi_drift: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tier {
|
||||||
|
/// The tier's kebab id — the same string the `kebab-case` serialization emits, for a deriver that needs
|
||||||
|
/// it as a plain `&str` (e.g. a count-by-tier tally) without going through serde.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Tier::Core => "core",
|
||||||
|
Tier::SelfNamed => "self-named",
|
||||||
|
Tier::DictExact => "dict-exact",
|
||||||
|
Tier::Contextual => "contextual",
|
||||||
|
Tier::Corroborated => "corroborated",
|
||||||
|
Tier::High => "high",
|
||||||
|
Tier::Medium => "medium",
|
||||||
|
Tier::Low => "low",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a tier from its kebab id (the inverse of the `kebab-case` serialization) — so a deriver holding
|
||||||
|
/// a tier as a computed string can lift it to the typed enum without a serde_json round-trip. `None` for
|
||||||
|
/// an unrecognised id (the caller decides whether that is a hard error).
|
||||||
|
pub fn from_id(s: &str) -> Option<Tier> {
|
||||||
|
Some(match s {
|
||||||
|
"core" => Tier::Core,
|
||||||
|
"self-named" => Tier::SelfNamed,
|
||||||
|
"dict-exact" => Tier::DictExact,
|
||||||
|
"contextual" => Tier::Contextual,
|
||||||
|
"corroborated" => Tier::Corroborated,
|
||||||
|
"high" => Tier::High,
|
||||||
|
"medium" => Tier::Medium,
|
||||||
|
"low" => Tier::Low,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Provenance {
|
||||||
|
/// A provenance with only its tier set (all optional fields `None`) — the base for `core` entries and
|
||||||
|
/// the start point for functional-update construction (`Provenance { source: …, ..with_tier(t) }`).
|
||||||
|
pub fn with_tier(tier: Tier) -> Self {
|
||||||
|
Self {
|
||||||
|
tier,
|
||||||
|
addr: None,
|
||||||
|
confidence: None,
|
||||||
|
self_named: None,
|
||||||
|
by_value: None,
|
||||||
|
ret_class: None,
|
||||||
|
source: None,
|
||||||
|
rtti_class: None,
|
||||||
|
rationale: None,
|
||||||
|
corroboration: None,
|
||||||
|
collision: None,
|
||||||
|
dead_weight: None,
|
||||||
|
abi_drift: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One monolith function: a locator (the `signature`/`offset` model shape, flattened in from [`Entry`] so
|
||||||
|
/// the shape is single-sourced) plus its provenance and live-validation verdict.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct MonoEntry {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub locator: Entry,
|
||||||
|
/// experimental offsets only: the vtable class the slot lives on (a reader's eyeball check).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub class: Option<String>,
|
||||||
|
pub provenance: Provenance,
|
||||||
|
/// Live-validation verdict: `Some(true)` passed, `Some(false)` dropped confident-bad, `None` unvalidated.
|
||||||
|
#[serde(default)]
|
||||||
|
pub validated: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A catalogued function the derivation could not confidently produce — kept in-file (never a shipped
|
||||||
|
/// locator) so the monolith is the complete catalogue picture.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Unresolved {
|
||||||
|
pub reason: String, // "sig-drifted" | "offset-low-conf" | "unresolved" | …
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry counts per section — a struct (not a map) so it serializes in this logical order, deterministically.
|
||||||
|
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Counts {
|
||||||
|
pub core: usize,
|
||||||
|
pub high_confidence: usize,
|
||||||
|
pub experimental: usize,
|
||||||
|
pub unresolved: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The monolith's intrinsic release identity. NO wall-clock field — volatile release metadata (`produced_at`,
|
||||||
|
/// `status`, urls, sha256, `based_on`) lives in the per-buildid manifest, so the monolith is fully
|
||||||
|
/// byte-reproducible.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct MonoMeta {
|
||||||
|
pub game_key: String,
|
||||||
|
pub game: String, // display name
|
||||||
|
pub source_build: String,
|
||||||
|
/// `<game>-<buildid>-<patch>` — the buildid is embedded here; the per-buildid MANIFEST carries it as its
|
||||||
|
/// own field (volatile metadata is kept OUT of this byte-reproducible monolith).
|
||||||
|
pub version: String,
|
||||||
|
pub counts: Counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full derived gamedata for one build — the shipped `gamedata-<game>.json`. Four confidence tiers, each
|
||||||
|
/// a key-sorted map. `source2rosetta-gen` renders it into any framework format; a consumer can equally read it
|
||||||
|
/// directly, gating `experimental` behind a runtime toggle off each entry's tier.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Monolith {
|
||||||
|
pub meta: MonoMeta,
|
||||||
|
pub core: BTreeMap<String, MonoEntry>,
|
||||||
|
pub high_confidence: BTreeMap<String, MonoEntry>,
|
||||||
|
pub experimental: BTreeMap<String, MonoEntry>,
|
||||||
|
pub unresolved: BTreeMap<String, Unresolved>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of the monolith's three SHIPPABLE tiers a render includes — cumulative, most-confident first
|
||||||
|
/// (`unresolved` is never rendered; it has no locator). The `--tier` arg of `source2rosetta-gen`.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum TierSelect {
|
||||||
|
/// `core` only — the guaranteed, live-validated set.
|
||||||
|
Core,
|
||||||
|
/// core + high_confidence — adds the promoted name-extrapolations.
|
||||||
|
HighConfidence,
|
||||||
|
/// core + high_confidence + experimental — every locatable guess.
|
||||||
|
Experimental,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TierSelect {
|
||||||
|
/// Parse the `--tier` id (the monolith tier names, plus a couple of intuitive aliases).
|
||||||
|
pub fn from_id(s: &str) -> Option<TierSelect> {
|
||||||
|
match s {
|
||||||
|
"core" => Some(TierSelect::Core),
|
||||||
|
"high_confidence" | "high-confidence" | "stable" => Some(TierSelect::HighConfidence),
|
||||||
|
"experimental" | "full" => Some(TierSelect::Experimental),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `--tier` id `from_id` accepts (canonical names) — for help text.
|
||||||
|
pub const TIER_IDS: &[&str] = &["core", "high_confidence", "experimental"];
|
||||||
|
|
||||||
|
impl Monolith {
|
||||||
|
/// Flatten the tiers up to `select` into one `name -> Entry` map — the input the flat framework emitters
|
||||||
|
/// (metamod/modsharp/swiftly/plugify) consume. Drops confident-bad entries (`validated == Some(false)`);
|
||||||
|
/// `unresolved` is never included (no locator); a more-confident tier wins a name collision.
|
||||||
|
pub fn select(&self, select: TierSelect) -> Gamedata {
|
||||||
|
let mut gd = Gamedata {
|
||||||
|
entries: BTreeMap::new(),
|
||||||
|
game_key: self.meta.game_key.clone(),
|
||||||
|
};
|
||||||
|
let mut add = |m: &BTreeMap<String, MonoEntry>| {
|
||||||
|
for (name, e) in m {
|
||||||
|
if e.validated == Some(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
gd.entries
|
||||||
|
.entry(name.clone())
|
||||||
|
.or_insert_with(|| e.locator.clone());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
add(&self.core);
|
||||||
|
if select != TierSelect::Core {
|
||||||
|
add(&self.high_confidence);
|
||||||
|
}
|
||||||
|
if select == TierSelect::Experimental {
|
||||||
|
add(&self.experimental);
|
||||||
|
}
|
||||||
|
gd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One typed schema field (the field NAME is the map key). `offset` is static; `ty`/`kind`/`size` are
|
||||||
|
/// runtime-resolved (empty/zero when derived offline without a live process).
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Field {
|
||||||
|
pub offset: i32,
|
||||||
|
#[serde(rename = "type", default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub ty: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: FieldKind,
|
||||||
|
pub size: usize,
|
||||||
|
pub name_hash: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a schema field holds its value — a closed runtime domain (Source-2 `CSchemaType` category).
|
||||||
|
/// Serializes to lowercase tokens, the values `netvars-<game>.json` consumers expect.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FieldKind {
|
||||||
|
/// Builtin / atomic / declared class / declared enum — held inline (the common case).
|
||||||
|
#[default]
|
||||||
|
Ref,
|
||||||
|
/// A pointer to the value.
|
||||||
|
Ptr,
|
||||||
|
/// A fixed-size inline array.
|
||||||
|
FixedArray,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The typed schema — the shipped `netvars-<game>.json`. Merges field offsets with runtime types:
|
||||||
|
/// class -> field -> [`Field`].
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Schema {
|
||||||
|
pub meta: SchemaMeta,
|
||||||
|
pub classes: BTreeMap<String, BTreeMap<String, Field>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The typed schema's intrinsic identity (no wall-clock field, same rationale as [`MonoMeta`]).
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct SchemaMeta {
|
||||||
|
pub game_key: String,
|
||||||
|
pub source_build: String,
|
||||||
|
pub typed: usize,
|
||||||
|
pub untyped: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod monolith_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mono_entry_flattens_locator_and_kebabs_tier() {
|
||||||
|
let e = MonoEntry {
|
||||||
|
locator: Entry {
|
||||||
|
signature: None,
|
||||||
|
offset: Some(158),
|
||||||
|
},
|
||||||
|
class: None,
|
||||||
|
provenance: Provenance {
|
||||||
|
source: Some("catalogue".into()),
|
||||||
|
..Provenance::with_tier(Tier::Core)
|
||||||
|
},
|
||||||
|
validated: Some(true),
|
||||||
|
};
|
||||||
|
let v = serde_json::to_value(&e).unwrap();
|
||||||
|
assert_eq!(v["offset"], 158); // locator flattened to the top level
|
||||||
|
assert!(v.get("signature").is_none()); // None locator field omitted
|
||||||
|
assert_eq!(v["provenance"]["tier"], "core"); // kebab-case enum
|
||||||
|
assert_eq!(v["provenance"]["source"], "catalogue");
|
||||||
|
assert!(v["provenance"].get("confidence").is_none()); // None provenance field skipped
|
||||||
|
assert_eq!(v["validated"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn experimental_entry_keeps_class_and_serializes_null_validated() {
|
||||||
|
let e = MonoEntry {
|
||||||
|
locator: Entry {
|
||||||
|
signature: None,
|
||||||
|
offset: Some(40),
|
||||||
|
},
|
||||||
|
class: Some("CFoo".into()),
|
||||||
|
provenance: Provenance {
|
||||||
|
confidence: Some("low".into()),
|
||||||
|
self_named: Some(false),
|
||||||
|
collision: Some(true),
|
||||||
|
dead_weight: Some(false),
|
||||||
|
..Provenance::with_tier(Tier::Low)
|
||||||
|
},
|
||||||
|
validated: None,
|
||||||
|
};
|
||||||
|
let v = serde_json::to_value(&e).unwrap();
|
||||||
|
assert_eq!(v["class"], "CFoo");
|
||||||
|
assert_eq!(v["provenance"]["tier"], "low");
|
||||||
|
assert_eq!(v["provenance"]["collision"], true);
|
||||||
|
assert_eq!(v["validated"], serde_json::Value::Null); // present as null, not omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monolith_round_trips() {
|
||||||
|
let mut m = Monolith {
|
||||||
|
meta: MonoMeta {
|
||||||
|
game_key: "csgo".into(),
|
||||||
|
game: "CS2".into(),
|
||||||
|
source_build: "2026-07-15_003539".into(),
|
||||||
|
version: "cs2-12345-0".into(),
|
||||||
|
counts: Counts {
|
||||||
|
core: 1,
|
||||||
|
high_confidence: 0,
|
||||||
|
experimental: 0,
|
||||||
|
unresolved: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
core: BTreeMap::new(),
|
||||||
|
high_confidence: BTreeMap::new(),
|
||||||
|
experimental: BTreeMap::new(),
|
||||||
|
unresolved: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
m.core.insert(
|
||||||
|
"A::b".into(),
|
||||||
|
MonoEntry {
|
||||||
|
locator: Entry {
|
||||||
|
signature: Some(Sig {
|
||||||
|
library: "server".into(),
|
||||||
|
linux: "55 48 89 E5".into(),
|
||||||
|
}),
|
||||||
|
offset: None,
|
||||||
|
},
|
||||||
|
class: None,
|
||||||
|
provenance: Provenance {
|
||||||
|
source: Some("catalogue".into()),
|
||||||
|
..Provenance::with_tier(Tier::Core)
|
||||||
|
},
|
||||||
|
validated: Some(true),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
m.unresolved.insert(
|
||||||
|
"C::d".into(),
|
||||||
|
Unresolved {
|
||||||
|
reason: "sig-drifted".into(),
|
||||||
|
detail: "no unique/recovered signature in target".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let s = serde_json::to_string_pretty(&m).unwrap();
|
||||||
|
let back: Monolith = serde_json::from_str(&s).unwrap();
|
||||||
|
assert_eq!(back.core.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
back.core["A::b"]
|
||||||
|
.locator
|
||||||
|
.signature
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.library,
|
||||||
|
"server"
|
||||||
|
);
|
||||||
|
assert_eq!(back.unresolved["C::d"].reason, "sig-drifted");
|
||||||
|
assert_eq!(back.meta.counts.unresolved, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
644
crates/source2rosetta-core/src/render.rs
Normal file
644
crates/source2rosetta-core/src/render.rs
Normal file
|
|
@ -0,0 +1,644 @@
|
||||||
|
//! Emitters: render the canonical [`Gamedata`] model into each framework's on-disk format. Adding a
|
||||||
|
//! new consumer (SourceMod, a diff, a language SDK) is a new `impl GamedataEmitter` here — the
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
use crate::model::{Entry, Gamedata, MonoEntry, Monolith, Schema, Tier, TierSelect};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// compact locators. At `TierSelect::HighConfidence` the output is exactly core + high_confidence; other
|
||||||
|
/// selections extend the extrapolated section.
|
||||||
|
///
|
||||||
|
/// Drops `validated == Some(false)` entries, matching [`Monolith::select`]: an entry live validation
|
||||||
|
/// confidently rejected must not reach a consumer under a banner claiming it resolves on a running server.
|
||||||
|
/// (Pre-validation every `validated` is `None`, so there this is a no-op.)
|
||||||
|
pub fn render_monolith_cssharp(mono: &Monolith, select: TierSelect) -> String {
|
||||||
|
// the extrapolated section: the tiers beyond core that `select` includes, merged key-sorted. The tiers are
|
||||||
|
// documented disjoint and are so for anything this crate derives, but an externally-supplied monolith need
|
||||||
|
// not be — so keep the more-confident tier on collision (`select`'s rule) and subtract `core`, which would
|
||||||
|
// otherwise emit the same JSON key in both sections.
|
||||||
|
let mut extra: std::collections::BTreeMap<&String, &MonoEntry> =
|
||||||
|
std::collections::BTreeMap::new();
|
||||||
|
let mut sources: Vec<&std::collections::BTreeMap<String, MonoEntry>> = Vec::new();
|
||||||
|
if select != TierSelect::Core {
|
||||||
|
sources.push(&mono.high_confidence);
|
||||||
|
}
|
||||||
|
if select == TierSelect::Experimental {
|
||||||
|
sources.push(&mono.experimental);
|
||||||
|
}
|
||||||
|
for m in sources {
|
||||||
|
for (name, e) in m {
|
||||||
|
if e.validated == Some(false) || mono.core.contains_key(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
extra.entry(name).or_insert(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let n_self = extra
|
||||||
|
.values()
|
||||||
|
.filter(|e| e.provenance.tier == Tier::SelfNamed)
|
||||||
|
.count();
|
||||||
|
// BOTH labels count: dictionary hits are tagged `DictExact` in the high-confidence tier and
|
||||||
|
// `Corroborated` in the experimental tier, so counting only one variant misses genuinely corroborated
|
||||||
|
// entries when the experimental tier is selected.
|
||||||
|
let n_dict = extra
|
||||||
|
.values()
|
||||||
|
.filter(|e| e.provenance.tier.is_dict_corroborated())
|
||||||
|
.count();
|
||||||
|
let n_byval = extra
|
||||||
|
.values()
|
||||||
|
.filter(|e| e.provenance.by_value == Some(true))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let line = |name: &str, e: &MonoEntry| {
|
||||||
|
format!(
|
||||||
|
" {}: {}",
|
||||||
|
serde_json::to_string(name).unwrap_or_default(),
|
||||||
|
serde_json::to_string(&locator_value(&e.locator)).unwrap_or_default()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let core: Vec<(&String, &MonoEntry)> = mono
|
||||||
|
.core
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, e)| e.validated != Some(false))
|
||||||
|
.collect();
|
||||||
|
let core_lines: Vec<String> = core.iter().map(|(n, e)| line(n, e)).collect();
|
||||||
|
let extra_lines: Vec<String> = extra.iter().map(|(n, e)| line(n, e)).collect();
|
||||||
|
let total = core.len() + extra.len();
|
||||||
|
let header = format!(
|
||||||
|
"// ============================================================================\n\
|
||||||
|
// source2rosetta combined gamedata ({version})\n\
|
||||||
|
// {} guaranteed + {} extrapolated = {total} functions.\n\
|
||||||
|
// Auto-derived from the stripped {} server libraries, live-validated against a running server.\n\
|
||||||
|
// (JSON with // comments — CS#'s loader and source2rosetta's own reader both tolerate them.)\n\
|
||||||
|
// ============================================================================",
|
||||||
|
core.len(),
|
||||||
|
extra.len(),
|
||||||
|
mono.meta.game,
|
||||||
|
version = mono.meta.version,
|
||||||
|
);
|
||||||
|
let divider = [
|
||||||
|
" // ==========================================================================".to_string(),
|
||||||
|
format!(" // EXTRAPOLATED NAMES ({} total: {n_self} self-named, {n_dict} dict-corroborated; {n_byval} by-value)", extra.len()),
|
||||||
|
" // ---------------------------------------------------------------------------".to_string(),
|
||||||
|
" // Everything BELOW is AI-extrapolated from the stripped binary. Each LOCATOR".to_string(),
|
||||||
|
" // (sig / vtable offset) is live-validated — it resolves to real executable code".to_string(),
|
||||||
|
" // on a running server. The NAME is a best-effort label, not a symbol Valve".to_string(),
|
||||||
|
" // shipped (the binary is stripped); the exact C++ prototype (arg/return types)".to_string(),
|
||||||
|
" // is unrecoverable, so confirm the call signature yourself. Trust tiers + a".to_string(),
|
||||||
|
" // by-value (unsafe-to-naive-call) flag live in the provenance sidecar. Above = guaranteed.".to_string(),
|
||||||
|
" // ==========================================================================".to_string(),
|
||||||
|
]
|
||||||
|
.join("\n");
|
||||||
|
let mut s = header;
|
||||||
|
s.push_str("\n{\n");
|
||||||
|
s.push_str(&core_lines.join(",\n"));
|
||||||
|
if !core_lines.is_empty() && !extra_lines.is_empty() {
|
||||||
|
s.push(','); // the last core entry needs a comma — the extrapolated section follows the banner
|
||||||
|
}
|
||||||
|
s.push('\n');
|
||||||
|
s.push_str(÷r);
|
||||||
|
s.push('\n');
|
||||||
|
s.push_str(&extra_lines.join(",\n"));
|
||||||
|
s.push_str("\n}\n");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a derived gamedata model to one output format's text.
|
||||||
|
pub trait GamedataEmitter {
|
||||||
|
fn id(&self) -> &'static str;
|
||||||
|
fn render(&self, gd: &Gamedata) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The canonical CS#-gamedata locator for one entry: an object carrying `"signatures":{library,linux}`
|
||||||
|
/// when it has a signature and/or `"offsets":{linux}` when it has a vtable slot. This is the on-disk shape
|
||||||
|
/// the cssharp `gamedata.json` and the deriver's combined/experimental files all share, so every producer
|
||||||
|
/// builds it through this ONE function instead of an ad-hoc `json!`. Keys serialize sorted (serde_json's
|
||||||
|
/// default `Map`), so an entry carrying both a signature and an offset is deterministic.
|
||||||
|
pub fn locator_value(e: &Entry) -> Value {
|
||||||
|
let mut obj = Map::new();
|
||||||
|
if let Some(s) = &e.signature {
|
||||||
|
obj.insert(
|
||||||
|
"signatures".into(),
|
||||||
|
json!({ "library": s.library, "linux": s.linux }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(o) = e.offset {
|
||||||
|
obj.insert("offsets".into(), json!({ "linux": o }));
|
||||||
|
}
|
||||||
|
Value::Object(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inverse of [`locator_value`]: parse the on-disk cssharp locator shape
|
||||||
|
/// (`{"signatures":{library,linux}}` and/or `{"offsets":{linux}}`) back into a typed [`Entry`]. Lives here,
|
||||||
|
/// beside the forward writer, so the round-trip is single-sourced in `core` instead of hand-rolled in the
|
||||||
|
/// deriver. Tolerant: a missing `signatures.library` defaults to `server`, a
|
||||||
|
/// missing `signatures.linux` to empty, and an entry may carry a signature and/or an offset (or neither).
|
||||||
|
pub fn entry_from_value(v: &Value) -> Entry {
|
||||||
|
let signature = v.get("signatures").map(|s| crate::model::Sig {
|
||||||
|
library: s
|
||||||
|
.get("library")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("server")
|
||||||
|
.to_string(),
|
||||||
|
linux: s
|
||||||
|
.get("linux")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
});
|
||||||
|
let offset = v
|
||||||
|
.get("offsets")
|
||||||
|
.and_then(|o| o.get("linux"))
|
||||||
|
.and_then(Value::as_i64);
|
||||||
|
Entry { signature, offset }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up an emitter by its `--format` id.
|
||||||
|
pub fn by_id(id: &str) -> Option<Box<dyn GamedataEmitter>> {
|
||||||
|
match id {
|
||||||
|
"cssharp" => Some(Box::new(CsSharp)),
|
||||||
|
"metamod" => Some(Box::new(Metamod)),
|
||||||
|
"modsharp" => Some(Box::new(ModSharp)),
|
||||||
|
"swiftly" => Some(Box::new(Swiftly)),
|
||||||
|
"plugify" => Some(Box::new(Plugify)),
|
||||||
|
"model" => Some(Box::new(ModelJson)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `--format` id `by_id` accepts — for help text and error messages (keep in sync with `by_id`).
|
||||||
|
pub const FORMAT_IDS: &[&str] = &[
|
||||||
|
"cssharp", "metamod", "modsharp", "swiftly", "plugify", "model",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The canonical model itself, serialized — the format-neutral artifact `source2rosetta-gen` reads back.
|
||||||
|
pub struct ModelJson;
|
||||||
|
|
||||||
|
impl GamedataEmitter for ModelJson {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"model"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, gd: &Gamedata) -> String {
|
||||||
|
serde_json::to_string_pretty(gd).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CounterStrikeSharp `gamedata.json`: `{ name: { "signatures": {library,linux} | "offsets": {linux} } }`.
|
||||||
|
/// Built through serde_json's key-sorted Map for deterministic output.
|
||||||
|
pub struct CsSharp;
|
||||||
|
|
||||||
|
impl GamedataEmitter for CsSharp {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"cssharp"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, gd: &Gamedata) -> String {
|
||||||
|
let mut doc = Map::new();
|
||||||
|
for (name, e) in &gd.entries {
|
||||||
|
doc.insert(name.clone(), locator_value(e));
|
||||||
|
}
|
||||||
|
serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metamod/SourceMod `*.games.txt` (Valve KeyValues): `Games { csgo { Signatures{..} Offsets{..} } }`.
|
||||||
|
/// Signature bytes become `\xAB` escapes with `\x2A` for wildcards.
|
||||||
|
pub struct Metamod;
|
||||||
|
|
||||||
|
/// "55 48 ? E5" -> "\x55\x48\x2A\xE5". Handles every wildcard token `sig::Pattern` accepts (`?`/`??`/`*`).
|
||||||
|
///
|
||||||
|
/// LIMITATION: SourceMod/Metamod's wildcard byte IS 0x2A (`*`), so a signature with a *fixed* 0x2A
|
||||||
|
/// byte is inherently ambiguous in this format — it renders as `\x2A` and Metamod reads it as a
|
||||||
|
/// wildcard, widening the pattern. That's a constraint of the VDF signature format itself (real
|
||||||
|
/// SourceMod gamedata shares it), not something the emitter can encode away; a fully Metamod-safe
|
||||||
|
/// pattern would need make_sig to avoid depending on a fixed 0x2A byte for uniqueness.
|
||||||
|
fn vdf_pattern(spacehex: &str) -> String {
|
||||||
|
spacehex
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|t| match t {
|
||||||
|
"?" | "??" | "*" => "\\x2A".to_string(),
|
||||||
|
b => format!("\\x{}", b.to_uppercase()),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GamedataEmitter for Metamod {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"metamod"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, gd: &Gamedata) -> String {
|
||||||
|
let mut sigs = String::new();
|
||||||
|
let mut offs = String::new();
|
||||||
|
for (name, e) in &gd.entries {
|
||||||
|
if let Some(s) = &e.signature {
|
||||||
|
sigs.push_str(&format!(
|
||||||
|
"\t\t\t\"{name}\"\n\t\t\t{{\n\t\t\t\t\"library\"\t\"{}\"\n\t\t\t\t\"linux\"\t\"{}\"\n\t\t\t}}\n",
|
||||||
|
s.library,
|
||||||
|
vdf_pattern(&s.linux),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(o) = e.offset {
|
||||||
|
offs.push_str(&format!(
|
||||||
|
"\t\t\t\"{name}\"\n\t\t\t{{\n\t\t\t\t\"linux\"\t\"{o}\"\n\t\t\t}}\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let key = &gd.game_key;
|
||||||
|
format!(
|
||||||
|
"\"Games\"\n{{\n\t\"{key}\"\n\t{{\n\t\t\"Signatures\"\n\t\t{{\n{sigs}\t\t}}\n\t\t\"Offsets\"\n\t\t{{\n{offs}\t\t}}\n\t}}\n}}\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ModSharp `*.games.jsonc`: `{ "Addresses": {sig}, "VFuncs": {offset} }`. Signatures carry a `library`;
|
||||||
|
/// VFuncs are a bare `linux` slot index. Linux-only (windows isn't derived from a `.so`).
|
||||||
|
pub struct ModSharp;
|
||||||
|
|
||||||
|
impl GamedataEmitter for ModSharp {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"modsharp"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, gd: &Gamedata) -> String {
|
||||||
|
let (mut addresses, mut vfuncs) = (Map::new(), Map::new());
|
||||||
|
for (name, e) in &gd.entries {
|
||||||
|
if let Some(s) = &e.signature {
|
||||||
|
addresses.insert(
|
||||||
|
name.clone(),
|
||||||
|
json!({ "library": s.library, "linux": s.linux }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(o) = e.offset {
|
||||||
|
vfuncs.insert(name.clone(), json!({ "linux": o }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let doc = json!({ "Addresses": addresses, "VFuncs": vfuncs });
|
||||||
|
serde_json::to_string_pretty(&doc).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SwiftlyS2 `signatures.jsonc`: `{ name: {"lib": lib, "linux": sig} }`. Swiftly keeps offsets in a
|
||||||
|
/// separate `offsets.jsonc`; this emits the signatures file (the bulk of a gamedata).
|
||||||
|
pub struct Swiftly;
|
||||||
|
|
||||||
|
impl GamedataEmitter for Swiftly {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"swiftly"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, gd: &Gamedata) -> String {
|
||||||
|
let mut doc = Map::new();
|
||||||
|
for (name, e) in &gd.entries {
|
||||||
|
if let Some(s) = &e.signature {
|
||||||
|
doc.insert(name.clone(), json!({ "lib": s.library, "linux": s.linux }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugify (s2sdk) `gamedata.jsonc`: `{ "csgo": { "Signatures": {..}, "Offsets": {..} } }`. Uses the
|
||||||
|
/// `linuxsteamrt64` platform key; signatures carry a `library`.
|
||||||
|
pub struct Plugify;
|
||||||
|
|
||||||
|
impl GamedataEmitter for Plugify {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"plugify"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, gd: &Gamedata) -> String {
|
||||||
|
let (mut sigs, mut offs) = (Map::new(), Map::new());
|
||||||
|
for (name, e) in &gd.entries {
|
||||||
|
if let Some(s) = &e.signature {
|
||||||
|
sigs.insert(
|
||||||
|
name.clone(),
|
||||||
|
json!({ "library": s.library, "linuxsteamrt64": s.linux }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(o) = e.offset {
|
||||||
|
offs.insert(name.clone(), json!({ "linuxsteamrt64": o }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut doc = Map::new();
|
||||||
|
doc.insert(
|
||||||
|
gd.game_key.clone(),
|
||||||
|
json!({ "Signatures": sigs, "Offsets": offs }),
|
||||||
|
);
|
||||||
|
serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ SCHEMA / NETVAR emitters ============================
|
||||||
|
// The typed schema (`netvars-<game>.json`, `model::Schema`) has its own emitter family, parallel to
|
||||||
|
// `GamedataEmitter`: turn the class->field->offset/type surface into a consumable SDK or netvar file.
|
||||||
|
// Adding a language SDK or a framework netvar format is a new `impl SchemaEmitter` here.
|
||||||
|
|
||||||
|
/// Render `model::Schema` (the typed netvars) into a consumer format (a language SDK, a netvar map).
|
||||||
|
pub trait SchemaEmitter {
|
||||||
|
fn id(&self) -> &'static str;
|
||||||
|
fn render(&self, schema: &Schema) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Source-2 schema type as its C# spelling: the primitive scalars map to C# built-ins; everything else
|
||||||
|
/// (Source-2 math types, handles, `CUtl*`, enums, templates) is kept verbatim as the ground-truth type name
|
||||||
|
/// — a fully-typed SDK for all 1,400+ custom types isn't ours to define, and the raw name is the honest hint.
|
||||||
|
fn cs_type(ty: &str) -> &str {
|
||||||
|
match ty {
|
||||||
|
"int8" | "char8" => "sbyte",
|
||||||
|
"uint8" => "byte",
|
||||||
|
"int16" => "short",
|
||||||
|
"uint16" => "ushort",
|
||||||
|
"int32" => "int",
|
||||||
|
"uint32" => "uint",
|
||||||
|
"int64" => "long",
|
||||||
|
"uint64" => "ulong",
|
||||||
|
"float32" => "float",
|
||||||
|
"float64" => "double",
|
||||||
|
"bool" => "bool",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A schema name (class or nested-type) as a valid C# identifier: nested `Outer::Inner` and any other
|
||||||
|
/// non-identifier char collapse to `_`. The original name rides along in an XML-doc comment when it changed.
|
||||||
|
fn cs_ident(name: &str) -> String {
|
||||||
|
let id: String = name
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '_' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if id.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||||
|
format!("_{id}")
|
||||||
|
} else {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A typed C# SDK: one `static class` per schema class, each field a `const int <name> = 0x<offset>;` tagged
|
||||||
|
/// with its C# / Source-2 type. Framework-neutral (offsets + types, no runtime-read assumption), complete
|
||||||
|
/// (every field, every type), deterministic (the schema's BTreeMaps sort classes then fields).
|
||||||
|
pub struct CsSdk;
|
||||||
|
|
||||||
|
impl SchemaEmitter for CsSdk {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"cs-sdk"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, s: &Schema) -> String {
|
||||||
|
let m = &s.meta;
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str(&format!(
|
||||||
|
"// <auto-generated> source2rosetta — {} build {}. Source-2 SchemaSystem field offsets.\n\
|
||||||
|
// {} classes, {} typed fields. Regenerate: source2rosetta-gen --netvars netvars-{}.json --format cs-sdk\n\
|
||||||
|
namespace Source2.Schema;\n",
|
||||||
|
m.game_key, m.source_build, s.classes.len(), m.typed, m.game_key
|
||||||
|
));
|
||||||
|
for (cls, fields) in &s.classes {
|
||||||
|
let ident = cs_ident(cls);
|
||||||
|
out.push('\n');
|
||||||
|
if ident != *cls {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"/// <summary><c>{cls}</c> — {} fields</summary>\n",
|
||||||
|
fields.len()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"/// <summary>{cls} — {} fields</summary>\n",
|
||||||
|
fields.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str(&format!("public static class {ident}\n{{\n"));
|
||||||
|
for (fname, f) in fields {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" public const int {fname} = 0x{:X}; // {}\n",
|
||||||
|
f.offset,
|
||||||
|
cs_type(&f.ty)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str("}\n");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Framework-neutral netvar map: `{ class: { field: offset } }` — the raw offset table any tool/framework
|
||||||
|
/// can consume without the SDK's C# packaging.
|
||||||
|
pub struct NetvarsJson;
|
||||||
|
|
||||||
|
impl SchemaEmitter for NetvarsJson {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"netvars"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, s: &Schema) -> String {
|
||||||
|
let mut doc = Map::new();
|
||||||
|
for (cls, fields) in &s.classes {
|
||||||
|
let mut fm = Map::new();
|
||||||
|
for (fname, f) in fields {
|
||||||
|
fm.insert(fname.clone(), json!(f.offset));
|
||||||
|
}
|
||||||
|
doc.insert(cls.clone(), Value::Object(fm));
|
||||||
|
}
|
||||||
|
serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a schema emitter by its `--format` id.
|
||||||
|
pub fn schema_by_id(id: &str) -> Option<Box<dyn SchemaEmitter>> {
|
||||||
|
match id {
|
||||||
|
"cs-sdk" => Some(Box::new(CsSdk)),
|
||||||
|
"netvars" => Some(Box::new(NetvarsJson)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every schema `--format` id `schema_by_id` accepts (keep in sync with it).
|
||||||
|
pub const SCHEMA_FORMAT_IDS: &[&str] = &["cs-sdk", "netvars"];
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::model::Gamedata;
|
||||||
|
|
||||||
|
fn sample() -> Gamedata {
|
||||||
|
let mut gd = Gamedata::default();
|
||||||
|
gd.set_signature("Host_Say", "server", "55 48 89 ? E5");
|
||||||
|
gd.set_offset("GameEntitySystem", 80);
|
||||||
|
gd
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cssharp_shape_is_sorted_and_correct() {
|
||||||
|
let out = CsSharp.render(&sample());
|
||||||
|
// key-sorted: GameEntitySystem before Host_Say; offsets/signatures shapes intact.
|
||||||
|
let g = out.find("GameEntitySystem").unwrap();
|
||||||
|
let h = out.find("Host_Say").unwrap();
|
||||||
|
assert!(g < h, "entries must be key-sorted");
|
||||||
|
assert!(out.contains("\"offsets\""));
|
||||||
|
assert!(out.contains("\"library\": \"server\""));
|
||||||
|
assert!(out.contains("\"linux\": \"55 48 89 ? E5\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locator_value_and_entry_from_value_round_trip() {
|
||||||
|
use crate::model::{Entry, Sig};
|
||||||
|
// the canonical write/read pair is a true inverse for both locator kinds
|
||||||
|
let sig = Entry {
|
||||||
|
signature: Some(Sig {
|
||||||
|
library: "engine2".into(),
|
||||||
|
linux: "55 48 ? E5".into(),
|
||||||
|
}),
|
||||||
|
offset: None,
|
||||||
|
};
|
||||||
|
let off = Entry {
|
||||||
|
signature: None,
|
||||||
|
offset: Some(158),
|
||||||
|
};
|
||||||
|
assert_eq!(entry_from_value(&locator_value(&sig)), sig);
|
||||||
|
assert_eq!(entry_from_value(&locator_value(&off)), off);
|
||||||
|
// a signature missing its library reads back as "server" — the reader's tolerance
|
||||||
|
let v = json!({ "signatures": { "linux": "90" } });
|
||||||
|
assert_eq!(entry_from_value(&v).signature.unwrap().library, "server");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metamod_escapes_bytes_and_wildcards() {
|
||||||
|
let out = Metamod.render(&sample());
|
||||||
|
assert!(out.contains("\"Games\""));
|
||||||
|
assert!(out.contains(r"\x55\x48\x89\x2A\xE5")); // ? -> \x2A
|
||||||
|
assert!(out.contains("\"linux\"\t\"80\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modsharp_splits_addresses_and_vfuncs() {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&ModSharp.render(&sample())).unwrap();
|
||||||
|
assert_eq!(v["Addresses"]["Host_Say"]["library"], "server");
|
||||||
|
assert_eq!(v["Addresses"]["Host_Say"]["linux"], "55 48 89 ? E5");
|
||||||
|
assert_eq!(v["VFuncs"]["GameEntitySystem"]["linux"], 80);
|
||||||
|
assert!(v["Addresses"].get("GameEntitySystem").is_none()); // offset isn't an address
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swiftly_signatures_use_lib_key() {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&Swiftly.render(&sample())).unwrap();
|
||||||
|
assert_eq!(v["Host_Say"]["lib"], "server");
|
||||||
|
assert_eq!(v["Host_Say"]["linux"], "55 48 89 ? E5");
|
||||||
|
assert!(v.get("GameEntitySystem").is_none()); // offsets are a separate file
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugify_is_game_keyed_with_linuxsteamrt64() {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&Plugify.render(&sample())).unwrap();
|
||||||
|
assert_eq!(v["csgo"]["Signatures"]["Host_Say"]["library"], "server");
|
||||||
|
assert_eq!(
|
||||||
|
v["csgo"]["Signatures"]["Host_Say"]["linuxsteamrt64"],
|
||||||
|
"55 48 89 ? E5"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
v["csgo"]["Offsets"]["GameEntitySystem"]["linuxsteamrt64"],
|
||||||
|
80
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_keyed_emitters_honor_a_non_csgo_game_key() {
|
||||||
|
// The game-keyed formats (Metamod, Plugify) must wrap output in the model's game_key, not a
|
||||||
|
// hardcoded "csgo" — the multi-game seam. A dota-keyed model renders under "dota".
|
||||||
|
let mut gd = sample();
|
||||||
|
gd.game_key = "dota".to_string();
|
||||||
|
let mm = Metamod.render(&gd);
|
||||||
|
assert!(
|
||||||
|
mm.contains("\t\"dota\"\n") && !mm.contains("\"csgo\""),
|
||||||
|
"metamod: {mm}"
|
||||||
|
);
|
||||||
|
let pl: Value = serde_json::from_str(&Plugify.render(&gd)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pl["dota"]["Offsets"]["GameEntitySystem"]["linuxsteamrt64"],
|
||||||
|
80
|
||||||
|
);
|
||||||
|
assert!(pl.get("csgo").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_format_id_resolves_and_renders() {
|
||||||
|
for id in FORMAT_IDS {
|
||||||
|
let em = by_id(id).unwrap_or_else(|| panic!("by_id({id}) is None"));
|
||||||
|
assert!(!em.render(&sample()).is_empty(), "{id} rendered empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metamod_handles_all_wildcard_token_forms() {
|
||||||
|
// sig::Pattern accepts ?, ?? and * as wildcards — all must render as the VDF wildcard \x2A,
|
||||||
|
// never as a corrupt \x?? / \x* escape.
|
||||||
|
let mut gd = Gamedata::default();
|
||||||
|
gd.set_signature("F", "server", "48 ?? 89 * E5 ?");
|
||||||
|
let out = Metamod.render(&gd);
|
||||||
|
assert!(out.contains(r"\x48\x2A\x89\x2A\xE5\x2A"), "got: {out}");
|
||||||
|
assert!(!out.contains(r"\x??") && !out.contains(r"\x*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_schema() -> Schema {
|
||||||
|
use crate::model::{Field, SchemaMeta};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
let f = |offset, ty: &str| Field {
|
||||||
|
offset,
|
||||||
|
ty: ty.into(),
|
||||||
|
kind: crate::model::FieldKind::Ref,
|
||||||
|
size: 4,
|
||||||
|
name_hash: 0,
|
||||||
|
};
|
||||||
|
let mut base = BTreeMap::new();
|
||||||
|
base.insert("m_iHealth".to_string(), f(0x5B0, "int32"));
|
||||||
|
let mut nested = BTreeMap::new();
|
||||||
|
nested.insert("m_x".to_string(), f(0, "float32"));
|
||||||
|
let mut classes = BTreeMap::new();
|
||||||
|
classes.insert("CBaseEntity".to_string(), base);
|
||||||
|
classes.insert("Outer_t::Inner_t".to_string(), nested); // must sanitize to a valid C# identifier
|
||||||
|
Schema {
|
||||||
|
meta: SchemaMeta {
|
||||||
|
game_key: "csgo".into(),
|
||||||
|
source_build: "b".into(),
|
||||||
|
typed: 2,
|
||||||
|
untyped: 0,
|
||||||
|
},
|
||||||
|
classes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_schema_format_renders_nonempty() {
|
||||||
|
let s = sample_schema();
|
||||||
|
for id in SCHEMA_FORMAT_IDS {
|
||||||
|
let em = schema_by_id(id).unwrap_or_else(|| panic!("schema_by_id({id}) is None"));
|
||||||
|
assert!(!em.render(&s).is_empty(), "{id} rendered empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cs_sdk_sanitizes_idents_and_maps_primitives() {
|
||||||
|
let cs = CsSdk.render(&sample_schema());
|
||||||
|
// primitive types map to C# built-ins; the offset is hex
|
||||||
|
assert!(
|
||||||
|
cs.contains("public const int m_iHealth = 0x5B0; // int"),
|
||||||
|
"{cs}"
|
||||||
|
);
|
||||||
|
// a `::` nested class name becomes a valid C# identifier, original kept in the doc comment
|
||||||
|
assert!(cs.contains("public static class Outer_t__Inner_t"), "{cs}");
|
||||||
|
assert!(cs.contains("<c>Outer_t::Inner_t</c>"), "{cs}");
|
||||||
|
// no raw `::` ever leaks into an emitted identifier
|
||||||
|
for line in cs.lines().filter(|l| l.starts_with("public static class ")) {
|
||||||
|
assert!(!line.contains("::"), "invalid C# class ident: {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
fuzz.sh
Executable file
96
fuzz.sh
Executable file
|
|
@ -0,0 +1,96 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Parallel fuzz runner + stats for source2rosetta — unifies the run + report steps.
|
||||||
|
#
|
||||||
|
# Fans all offline-derivation fuzz targets out across the box with GNU parallel (each target getting
|
||||||
|
# N libFuzzer workers), tee's per-target logs to a timestamped dir, then prints a coverage/execs/crash
|
||||||
|
# table. Tuned for 8C/16T: 5 targets x 3 workers = 15 threads by default.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./fuzz.sh [seconds] [workers] run for `seconds` (default 60) with `workers`/target (default 3),
|
||||||
|
# then print stats.
|
||||||
|
# ./fuzz.sh --stats skip fuzzing, just re-print stats from the latest run + corpus.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CRATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # source2rosetta/ — cargo fuzz runs from here
|
||||||
|
cd "$CRATE_DIR"
|
||||||
|
|
||||||
|
TARGETS=(fuzz_elf fuzz_schema fuzz_rtti fuzz_sig_abi fuzz_xref)
|
||||||
|
FUZZ_ROOT="$CRATE_DIR/fuzz"
|
||||||
|
LOG_BASE="$FUZZ_ROOT/logs"
|
||||||
|
|
||||||
|
# Ignore iced_x86's intentional one-time 'static decoder-table allocation (see lsan_suppressions.txt);
|
||||||
|
# a real leak in our own code still fails the run.
|
||||||
|
export LSAN_OPTIONS="suppressions=$FUZZ_ROOT/lsan_suppressions.txt"
|
||||||
|
|
||||||
|
STATS_ONLY=false
|
||||||
|
if [[ "${1:-}" == "--stats" ]]; then STATS_ONLY=true; fi
|
||||||
|
SECS="${1:-60}"
|
||||||
|
WORKERS="${2:-3}"
|
||||||
|
|
||||||
|
human() { # human-readable count
|
||||||
|
awk -v n="$1" 'BEGIN{if(n>=1e9)printf"%.1fB",n/1e9;else if(n>=1e6)printf"%.1fM",n/1e6;else if(n>=1e3)printf"%.1fK",n/1e3;else printf"%d",n}'
|
||||||
|
}
|
||||||
|
parse_sum() { grep -h "stat::$2:" "$1"/*.log 2>/dev/null | awk -F: '{s+=$NF}END{print s+0}'; }
|
||||||
|
parse_max() { grep -h "stat::$2:" "$1"/*.log 2>/dev/null | awk -F: '{v=$NF+0;if(v>m)m=v}END{print m+0}'; }
|
||||||
|
|
||||||
|
run_fuzz() {
|
||||||
|
command -v parallel >/dev/null || { echo "ERROR: GNU parallel required." >&2; exit 1; }
|
||||||
|
echo "== seeding corpus =="
|
||||||
|
(cd fuzz && cargo +nightly run --bin gen_corpus --quiet)
|
||||||
|
echo "== building targets =="
|
||||||
|
cargo +nightly fuzz build >/dev/null 2>&1
|
||||||
|
|
||||||
|
LOG_DIR="$LOG_BASE/$(date +%Y-%m-%d_%H%M%S)"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
ln -sfn "$(basename "$LOG_DIR")" "$LOG_BASE/latest"
|
||||||
|
echo "== fuzzing ${#TARGETS[@]} targets x ${WORKERS} workers = $(( ${#TARGETS[@]} * WORKERS )) threads, ${SECS}s each =="
|
||||||
|
echo " logs: $LOG_DIR"
|
||||||
|
|
||||||
|
# -jobs/-workers=N → N concurrent libFuzzer workers per target; -max_len=65536 so real-ELF seeds
|
||||||
|
# aren't truncated to the 4 KiB default. `parallel -j` launches all targets at once.
|
||||||
|
printf '%s\n' "${TARGETS[@]}" | parallel -j "${#TARGETS[@]}" --lb --tagstring '{}' \
|
||||||
|
"cargo +nightly fuzz run {} -- -max_total_time=${SECS} -jobs=${WORKERS} -workers=${WORKERS} -max_len=65536 -print_final_stats=1 >${LOG_DIR}/{}.log 2>&1; echo {} done"
|
||||||
|
# libFuzzer with -jobs writes fuzz-*.log turds into the crate dir; sweep them into the log dir.
|
||||||
|
mv fuzz-*.log "$LOG_DIR/" 2>/dev/null || true
|
||||||
|
echo "LATEST_LOG=$LOG_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_stats() {
|
||||||
|
local log_dir=""
|
||||||
|
[[ -L "$LOG_BASE/latest" ]] && log_dir="$(cd "$LOG_BASE/latest" && pwd)"
|
||||||
|
echo
|
||||||
|
echo "source2rosetta fuzzing stats ${log_dir:+(logs: $log_dir)}"
|
||||||
|
printf '%-16s %7s %8s %7s %9s %8s %8s %5s %4s\n' Target Corpus Size Cov Execs Exec/s Crashes T/O OOM
|
||||||
|
printf '%-16s %7s %8s %7s %9s %8s %8s %5s %4s\n' --- --- --- --- --- --- --- --- ---
|
||||||
|
local tc=0 tx=0 tcr=0 tto=0 too=0
|
||||||
|
for t in "${TARGETS[@]}"; do
|
||||||
|
local cdir="$FUZZ_ROOT/corpus/$t" adir="$FUZZ_ROOT/artifacts/$t"
|
||||||
|
local n=0 bytes=0
|
||||||
|
[[ -d "$cdir" ]] && { n=$(find "$cdir" -maxdepth 1 -type f | wc -l); bytes=$(find "$cdir" -maxdepth 1 -type f -printf '%s\n' 2>/dev/null | awk '{s+=$1}END{print s+0}'); }
|
||||||
|
local cr=0 to=0 oo=0
|
||||||
|
[[ -d "$adir" ]] && { cr=$(find "$adir" -maxdepth 1 -name 'crash-*' | wc -l); to=$(find "$adir" -maxdepth 1 -name 'timeout-*' | wc -l); oo=$(find "$adir" -maxdepth 1 -name 'oom-*' | wc -l); }
|
||||||
|
local execs="-" execps="-" cov="-"
|
||||||
|
if [[ -n "$log_dir" && -f "$log_dir/$t.log" ]]; then
|
||||||
|
execs=$(human "$(grep -h 'stat::number_of_executed_units:' "$log_dir/$t.log" 2>/dev/null | awk -F: '{s+=$NF}END{print s+0}')")
|
||||||
|
execps=$(human "$(grep -h 'stat::average_exec_per_sec:' "$log_dir/$t.log" 2>/dev/null | awk -F: '{v=$NF+0;if(v>m)m=v}END{print m+0}')")
|
||||||
|
fi
|
||||||
|
# coverage: replay the corpus once (-runs=0). Skipped when empty.
|
||||||
|
if (( n > 0 )); then
|
||||||
|
cov=$(cargo +nightly fuzz run "$t" "fuzz/corpus/$t" -- -runs=0 -max_len=65536 2>&1 | grep -oP 'cov: \K[0-9]+' | tail -1 || true)
|
||||||
|
cov="${cov:--}"
|
||||||
|
fi
|
||||||
|
printf '%-16s %7d %8s %7s %9s %8s %8d %5d %4d\n' "$t" "$n" "$(human "$bytes")B" "$cov" "$execs" "$execps" "$cr" "$to" "$oo"
|
||||||
|
tc=$((tc+n)); tcr=$((tcr+cr)); tto=$((tto+to)); too=$((too+oo))
|
||||||
|
done
|
||||||
|
printf '%-16s %7s %8s %7s %9s %8s %8s %5s %4s\n' --- --- --- --- --- --- --- --- ---
|
||||||
|
printf '%-16s %7d %8s %7s %9s %8s %8d %5d %4d\n' "TOTAL" "$tc" "" "" "" "" "$tcr" "$tto" "$too"
|
||||||
|
if (( tcr + tto + too > 0 )); then
|
||||||
|
echo; echo "!! artifacts found — triage: cargo +nightly fuzz tmin <target> <fuzz/artifacts/<target>/crash-...>"
|
||||||
|
find "$FUZZ_ROOT/artifacts" -type f 2>/dev/null | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo; echo "no crashes / timeouts / OOMs — the offline derivation held on every explored input."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
$STATS_ONLY || run_fuzz
|
||||||
|
print_stats
|
||||||
6
fuzz/.gitignore
vendored
Normal file
6
fuzz/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
target
|
||||||
|
corpus
|
||||||
|
artifacts
|
||||||
|
coverage
|
||||||
|
Cargo.lock
|
||||||
|
logs
|
||||||
67
fuzz/Cargo.toml
Normal file
67
fuzz/Cargo.toml
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# cargo-fuzz workspace for the OFFLINE derivation surface. Every target feeds fully
|
||||||
|
# attacker-controlled bytes (a Valve `.so`, or a fuzzer mutation of one) to the ELF/schema/RTTI/abi/
|
||||||
|
# sig readers and asserts the tool never PANICS — only returns Err/None. This earns the "degrades,
|
||||||
|
# never crashes" half of the robustness guarantee: a future build with an unusual layout must reduce
|
||||||
|
# the gamedata, not abort CI. Run: cd source2rosetta/fuzz && cargo +nightly fuzz run fuzz_elf
|
||||||
|
[package]
|
||||||
|
name = "source2rosetta-fuzz"
|
||||||
|
version = "0.0.0"
|
||||||
|
publish = false
|
||||||
|
edition = "2024"
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
|
repository = "https://git.lo.sh/kamal/source2rosetta"
|
||||||
|
authors = ["kamal"]
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
cargo-fuzz = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libfuzzer-sys = "0.4"
|
||||||
|
|
||||||
|
[dependencies.source2rosetta]
|
||||||
|
path = ".."
|
||||||
|
|
||||||
|
# Seeds are generated in-process (no committed Valve bytes) — see gen_corpus.rs.
|
||||||
|
[[bin]]
|
||||||
|
name = "gen_corpus"
|
||||||
|
path = "gen_corpus.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_elf"
|
||||||
|
path = "fuzz_targets/fuzz_elf.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_schema"
|
||||||
|
path = "fuzz_targets/fuzz_schema.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_rtti"
|
||||||
|
path = "fuzz_targets/fuzz_rtti.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_sig_abi"
|
||||||
|
path = "fuzz_targets/fuzz_sig_abi.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_xref"
|
||||||
|
path = "fuzz_targets/fuzz_xref.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
28
fuzz/fuzz_targets/fuzz_elf.rs
Normal file
28
fuzz/fuzz_targets/fuzz_elf.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
#![no_main]
|
||||||
|
//! The ELF64 reader must return `Err` on any malformed input, never panic — no out-of-bounds index,
|
||||||
|
//! no arithmetic overflow. `from_bytes` parses section headers, the symbol table, and the relocation
|
||||||
|
//! map straight out of attacker-controlled bytes; this drives it and every allocated-section accessor.
|
||||||
|
use source2rosetta::elf::CodeImage;
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Reloc-driven / eh_frame walks over whatever the header claimed.
|
||||||
|
let _ = img.eh_frame_functions();
|
||||||
|
let targets = img.code_pointer_targets();
|
||||||
|
// Pointer/string reads at reloc slots and their values (bounded — inputs are small anyway).
|
||||||
|
for (slot, val) in img.reloc_slots().take(256) {
|
||||||
|
let _ = img.read_ptr(slot);
|
||||||
|
let _ = img.read_c_string(val);
|
||||||
|
let _ = img.is_code(val);
|
||||||
|
let _ = img.ptrs_to(val);
|
||||||
|
}
|
||||||
|
for &t in targets.iter().take(64) {
|
||||||
|
let _ = img.code_at(t);
|
||||||
|
let _ = img.rodata_at(t, 32);
|
||||||
|
let _ = img.read_i64(t);
|
||||||
|
let _ = img.read_u32(t);
|
||||||
|
}
|
||||||
|
});
|
||||||
21
fuzz/fuzz_targets/fuzz_rtti.rs
Normal file
21
fuzz/fuzz_targets/fuzz_rtti.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#![no_main]
|
||||||
|
//! The Itanium RTTI reader chases `_ZTV`/`_ZTI`/`_ZTS` pointer chains and demangles names out of the
|
||||||
|
//! bytes; `enumerate_vtables` sweeps every reloc slot as a candidate typeinfo. Arbitrary bytes must
|
||||||
|
//! not panic it (or the demangler). Also runs the `NetworkStateChanged` slot detector over the slots.
|
||||||
|
use source2rosetta::elf::CodeImage;
|
||||||
|
use source2rosetta::rtti;
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let vts = rtti::enumerate_vtables(&img, 128);
|
||||||
|
for cv in vts.iter().take(32) {
|
||||||
|
let _ = (&cv.name, &cv.mangled, cv.offset_to_top, cv.slots.len());
|
||||||
|
}
|
||||||
|
// 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()) {
|
||||||
|
let _ = rtti::find_vtable(&img, &name, 128);
|
||||||
|
}
|
||||||
|
});
|
||||||
23
fuzz/fuzz_targets/fuzz_schema.rs
Normal file
23
fuzz/fuzz_targets/fuzz_schema.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#![no_main]
|
||||||
|
//! The Source-2 SchemaSystem reader walks reloc-slot candidates as `SchemaClassInfoData_t` structs,
|
||||||
|
//! chasing `m_pFields`/`m_pBaseClasses` pointers. A crafted (or truncated) `.so` can point those
|
||||||
|
//! anywhere; the reader must survive it with `Err`/empty, never a panic. Also exercises the field and
|
||||||
|
//! base-class accessors the derivation reads.
|
||||||
|
use source2rosetta::elf::CodeImage;
|
||||||
|
use source2rosetta::schema;
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for c in schema::enumerate_schema(&img) {
|
||||||
|
let _ = c.primary_base();
|
||||||
|
for f in &c.fields {
|
||||||
|
let _ = (f.offset, f.name.len());
|
||||||
|
}
|
||||||
|
for b in &c.bases {
|
||||||
|
let _ = (b.offset, b.name.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
19
fuzz/fuzz_targets/fuzz_sig_abi.rs
Normal file
19
fuzz/fuzz_targets/fuzz_sig_abi.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#![no_main]
|
||||||
|
//! The single-function disassembly consumers: `candidate_entries` linear-sweeps the exec sections,
|
||||||
|
//! then `abi_shape` (backward register liveness) and `make_sig` (wildcard-emitting decode) run from
|
||||||
|
//! each entry. All decode attacker-controlled code bytes and must never panic — the decoder can hit
|
||||||
|
//! any instruction, any truncation, any span boundary.
|
||||||
|
use source2rosetta::elf::CodeImage;
|
||||||
|
use source2rosetta::{abi, emit, locate};
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for &addr in locate::candidate_entries(&img).iter().take(96) {
|
||||||
|
// Discard results: this fuzzer only asserts the decoders never panic.
|
||||||
|
let _ = abi::abi_shape(&img, addr);
|
||||||
|
let _ = emit::make_sig(&img, addr, 128);
|
||||||
|
}
|
||||||
|
});
|
||||||
21
fuzz/fuzz_targets/fuzz_xref.rs
Normal file
21
fuzz/fuzz_targets/fuzz_xref.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#![no_main]
|
||||||
|
//! The whole-binary cross-reference index decodes every function's `[start,next)` range and records
|
||||||
|
//! call/data references; the string-anchor locators then query it. Feeding arbitrary code + rodata
|
||||||
|
//! bytes exercises the decode, the containing-function lookup, and the string search — none may panic.
|
||||||
|
use source2rosetta::elf::CodeImage;
|
||||||
|
use source2rosetta::xref;
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
let Ok(img) = CodeImage::from_bytes(data.to_vec()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let xr = xref::XrefIndex::build(&img);
|
||||||
|
// Exercise the lookups over a bounded set of the discovered call targets — none may panic.
|
||||||
|
for &t in xr.call_targets().iter().take(64) {
|
||||||
|
let _ = xr.referrers(t);
|
||||||
|
let _ = xr.refs_to(t);
|
||||||
|
let _ = xr.containing_func(t);
|
||||||
|
}
|
||||||
|
let _ = xref::funcs_using_string(&img, &xr, "CBaseEntity");
|
||||||
|
});
|
||||||
143
fuzz/gen_corpus.rs
Normal file
143
fuzz/gen_corpus.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
//! Seed-corpus generator for the source2rosetta fuzz targets.
|
||||||
|
//!
|
||||||
|
//! The targets parse ELF64 bytes, so a good seed is a *valid* ELF that gets the fuzzer past the magic
|
||||||
|
//! and into the section/symbol/reloc/schema/RTTI parsing where the real edge cases live. We build a
|
||||||
|
//! minimal-but-structurally-valid ELF in-process (no committed Valve bytes), add a couple of malformed
|
||||||
|
//! variants to seed the error paths, and — for richer real-world coverage — copy a small system ELF if
|
||||||
|
//! one is present. libfuzzer mutates + minimises from there.
|
||||||
|
//!
|
||||||
|
//! Run: cd source2rosetta/fuzz && cargo +nightly run --bin gen_corpus
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const TARGETS: &[&str] = &[
|
||||||
|
"fuzz_elf",
|
||||||
|
"fuzz_schema",
|
||||||
|
"fuzz_rtti",
|
||||||
|
"fuzz_sig_abi",
|
||||||
|
"fuzz_xref",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn w16(v: &mut [u8], o: usize, x: u16) {
|
||||||
|
v[o..o + 2].copy_from_slice(&x.to_le_bytes());
|
||||||
|
}
|
||||||
|
fn w32(v: &mut [u8], o: usize, x: u32) {
|
||||||
|
v[o..o + 4].copy_from_slice(&x.to_le_bytes());
|
||||||
|
}
|
||||||
|
fn w64(v: &mut [u8], o: usize, x: u64) {
|
||||||
|
v[o..o + 8].copy_from_slice(&x.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 64-byte ELF64 section header, matching the fields `elf.rs` reads.
|
||||||
|
fn sec_hdr(typ: u32, flags: u64, addr: u64, off: u64, size: u64, link: u32, entsize: u64) -> Vec<u8> {
|
||||||
|
let mut h = vec![0u8; 64];
|
||||||
|
w32(&mut h, 4, typ);
|
||||||
|
w64(&mut h, 8, flags);
|
||||||
|
w64(&mut h, 16, addr);
|
||||||
|
w64(&mut h, 24, off);
|
||||||
|
w64(&mut h, 32, size);
|
||||||
|
w32(&mut h, 40, link);
|
||||||
|
w64(&mut h, 56, entsize);
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal, valid ELF64 with an executable `.text` (one `mov eax,1; ret`), a `.rodata` carrying an
|
||||||
|
/// Itanium type name + a distinctive string, a dynstr/dynsym pair, and two relocations (one pointing a
|
||||||
|
/// slot at the code, one at the type name). Enough for the fuzzer to reach every reader's happy path.
|
||||||
|
fn minimal_elf() -> Vec<u8> {
|
||||||
|
const SHF_ALLOC: u64 = 0x2;
|
||||||
|
const SHF_EXEC: u64 = 0x4;
|
||||||
|
let text: &[u8] = &[0xB8, 0x01, 0x00, 0x00, 0x00, 0xC3]; // mov eax,1 ; ret
|
||||||
|
let rodata: &[u8] = b"11CBaseEntity\0a distinctive fuzz seed string\0";
|
||||||
|
let dynstr: &[u8] = b"\0_ZTV11CBaseEntity\0";
|
||||||
|
// one Elf64_Sym (24B): st_name=1, st_info=0x12, st_shndx=1(.text), st_value=0x1000, st_size=6
|
||||||
|
let mut sym = vec![0u8; 24];
|
||||||
|
w32(&mut sym, 0, 1);
|
||||||
|
sym[4] = 0x12;
|
||||||
|
w16(&mut sym, 6, 1);
|
||||||
|
w64(&mut sym, 8, 0x1000);
|
||||||
|
w64(&mut sym, 16, text.len() as u64);
|
||||||
|
// two Elf64_Rela (24B each): R_X86_64_RELATIVE (type 8), addend = target vaddr
|
||||||
|
let mut relas = vec![0u8; 48];
|
||||||
|
w64(&mut relas, 0, 0x4010); // slot
|
||||||
|
w64(&mut relas, 8, 8); // r_info: type RELATIVE
|
||||||
|
w64(&mut relas, 16, 0x1000); // -> .text (a code-pointer target)
|
||||||
|
w64(&mut relas, 24, 0x4018);
|
||||||
|
w64(&mut relas, 32, 8);
|
||||||
|
w64(&mut relas, 40, 0x2000); // -> .rodata type name
|
||||||
|
|
||||||
|
// Lay content out after the 64-byte header; section headers go at the end.
|
||||||
|
let mut body: Vec<u8> = Vec::new();
|
||||||
|
let push = |data: &[u8], body: &mut Vec<u8>| -> u64 {
|
||||||
|
let off = 64 + body.len() as u64;
|
||||||
|
body.extend_from_slice(data);
|
||||||
|
off
|
||||||
|
};
|
||||||
|
let text_off = push(text, &mut body);
|
||||||
|
let rodata_off = push(rodata, &mut body);
|
||||||
|
let dynstr_off = push(dynstr, &mut body);
|
||||||
|
let dynsym_off = push(&sym, &mut body);
|
||||||
|
let rela_off = push(&relas, &mut body);
|
||||||
|
|
||||||
|
let secs = [
|
||||||
|
sec_hdr(0, 0, 0, 0, 0, 0, 0), // [0] null
|
||||||
|
sec_hdr(1, SHF_ALLOC | SHF_EXEC, 0x1000, text_off, text.len() as u64, 0, 0), // [1] .text
|
||||||
|
sec_hdr(1, SHF_ALLOC, 0x2000, rodata_off, rodata.len() as u64, 0, 0), // [2] .rodata
|
||||||
|
sec_hdr(3, SHF_ALLOC, 0x3000, dynstr_off, dynstr.len() as u64, 0, 0), // [3] .dynstr (STRTAB)
|
||||||
|
sec_hdr(11, SHF_ALLOC, 0x4000, dynsym_off, sym.len() as u64, 3, 24), // [4] .dynsym -> link 3
|
||||||
|
sec_hdr(4, SHF_ALLOC, 0x5000, rela_off, relas.len() as u64, 4, 24), // [5] .rela.dyn
|
||||||
|
];
|
||||||
|
let shoff = 64 + body.len() as u64;
|
||||||
|
|
||||||
|
let mut elf = vec![0u8; 64];
|
||||||
|
elf[0..4].copy_from_slice(b"\x7fELF");
|
||||||
|
elf[4] = 2; // ELF64
|
||||||
|
elf[5] = 1; // little-endian
|
||||||
|
w16(&mut elf, 16, 3); // e_type = ET_DYN
|
||||||
|
w16(&mut elf, 18, 0x3e); // e_machine = x86-64
|
||||||
|
w64(&mut elf, 40, shoff); // e_shoff
|
||||||
|
w16(&mut elf, 58, 64); // e_shentsize
|
||||||
|
w16(&mut elf, 60, secs.len() as u16); // e_shnum
|
||||||
|
elf.extend_from_slice(&body);
|
||||||
|
for s in &secs {
|
||||||
|
elf.extend_from_slice(s);
|
||||||
|
}
|
||||||
|
elf
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(dir: &Path, name: &str, data: &[u8]) {
|
||||||
|
fs::create_dir_all(dir).unwrap();
|
||||||
|
fs::write(dir.join(name), data).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let minimal = minimal_elf();
|
||||||
|
// sanity: the minimal seed must actually parse (else it's a poor seed)
|
||||||
|
match source2rosetta::elf::CodeImage::from_bytes(minimal.clone()) {
|
||||||
|
Ok(_) => println!("minimal_elf() parses OK ({} bytes)", minimal.len()),
|
||||||
|
Err(e) => println!("WARNING: minimal_elf() failed to parse: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut magic_only = vec![0u8; 64];
|
||||||
|
magic_only[0..4].copy_from_slice(b"\x7fELF");
|
||||||
|
magic_only[4] = 2;
|
||||||
|
|
||||||
|
// a small real ELF for richer coverage (real symtab/eh_frame/relocs), if one is around
|
||||||
|
let real = ["/usr/bin/true", "/bin/true", "/usr/bin/head"]
|
||||||
|
.iter()
|
||||||
|
.find_map(|p| fs::read(p).ok());
|
||||||
|
|
||||||
|
for t in TARGETS {
|
||||||
|
let dir = Path::new("corpus").join(t);
|
||||||
|
write(&dir, "minimal.elf", &minimal);
|
||||||
|
write(&dir, "magic_only.bin", &magic_only);
|
||||||
|
if let Some(r) = &real {
|
||||||
|
write(&dir, "real.elf", r);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"seeded corpus/{t}/ ({} files)",
|
||||||
|
2 + real.is_some() as usize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
fuzz/lsan_suppressions.txt
Normal file
9
fuzz/lsan_suppressions.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# LeakSanitizer suppressions for the fuzz targets.
|
||||||
|
#
|
||||||
|
# iced_x86 builds its instruction-decode tables once, lazily, and holds them for the whole process
|
||||||
|
# lifetime via `'static` (Box::leak-style) references — LSan can't trace those roots, so it reports the
|
||||||
|
# one-time allocation as a leak on the first input that decodes an instruction of that encoding family.
|
||||||
|
# It is NOT a leak: the tables are immutable process-lifetime globals, allocated once, reused forever.
|
||||||
|
#
|
||||||
|
# This is scoped to iced_x86 ONLY — a real leak in sigtrack's own code still fails the run.
|
||||||
|
leak:iced_x86
|
||||||
7
mappings/contributions/csgo/legacy-source2toolkit.json
Normal file
7
mappings/contributions/csgo/legacy-source2toolkit.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[
|
||||||
|
{ "name": "CCSPlayer_WeaponServices::Destroy", "kind": "signature", "value": "55 48 89 E5 41 54 49 89 FC 53 48 89 F3 E8 ? ? ? ? 48 39 C3 74 29 4C 89 E7", "date": "2026-07-09" },
|
||||||
|
{ "name": "CCSPlayerController::LegacyGameEventListener", "kind": "signature", "value": "48 8B 05 ? ? ? ? 48 85 C0 74 ? 83 FF ? 77 ? 48 63 FF 48 C1 E7 ? 48 8D 44 38", "date": "2026-07-09" },
|
||||||
|
{ "name": "CTakeDamageInfo::CTakeDamageInfo", "kind": "signature", "value": "49 BB ? ? ? ? ? ? ? ? 55", "date": "2026-07-09" },
|
||||||
|
{ "name": "CCSPlayer_WeaponServices::BumpWeapon", "kind": "vtable-offset", "value": "27", "date": "2026-07-09" },
|
||||||
|
{ "name": "CNavPhysicsInterface::TraceShape", "kind": "vtable-offset", "value": "5", "date": "2026-07-09" }
|
||||||
|
]
|
||||||
2744
mappings/naming/ai-sigs-dota.json
Normal file
2744
mappings/naming/ai-sigs-dota.json
Normal file
File diff suppressed because it is too large
Load diff
3616
mappings/naming/ai-sigs.json
Normal file
3616
mappings/naming/ai-sigs.json
Normal file
File diff suppressed because it is too large
Load diff
495463
mappings/naming/candidates-cs2-v2.json
Normal file
495463
mappings/naming/candidates-cs2-v2.json
Normal file
File diff suppressed because it is too large
Load diff
1526541
mappings/naming/candidates-dota.json
Normal file
1526541
mappings/naming/candidates-dota.json
Normal file
File diff suppressed because it is too large
Load diff
438594
mappings/naming/candidates-names-cs2-full.json
Normal file
438594
mappings/naming/candidates-names-cs2-full.json
Normal file
File diff suppressed because it is too large
Load diff
5721
mappings/naming/combined-offsets-dota.json
Normal file
5721
mappings/naming/combined-offsets-dota.json
Normal file
File diff suppressed because it is too large
Load diff
5568
mappings/naming/combined-offsets.json
Normal file
5568
mappings/naming/combined-offsets.json
Normal file
File diff suppressed because it is too large
Load diff
27348
mappings/naming/cs2-promotable.json
Normal file
27348
mappings/naming/cs2-promotable.json
Normal file
File diff suppressed because it is too large
Load diff
1
mappings/naming/dota-empty-promotable.json
Normal file
1
mappings/naming/dota-empty-promotable.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[]
|
||||||
57052
mappings/naming/dota-promotable.json
Normal file
57052
mappings/naming/dota-promotable.json
Normal file
File diff suppressed because it is too large
Load diff
62772
mappings/naming/fullnames-dota.json
Normal file
62772
mappings/naming/fullnames-dota.json
Normal file
File diff suppressed because it is too large
Load diff
45516
mappings/naming/fullnames-multilib.json
Normal file
45516
mappings/naming/fullnames-multilib.json
Normal file
File diff suppressed because it is too large
Load diff
1
mappings/seed-cs2.json
Normal file
1
mappings/seed-cs2.json
Normal file
File diff suppressed because one or more lines are too long
1
mappings/seed-dota2.json
Normal file
1
mappings/seed-dota2.json
Normal file
File diff suppressed because one or more lines are too long
731
src/abi.rs
Normal file
731
src/abi.rs
Normal file
|
|
@ -0,0 +1,731 @@
|
||||||
|
//! Derive a function's observable **ABI shape** — its SysV-AMD64 argument footprint — straight from
|
||||||
|
//! the machine code, so a C++ *prototype* change (an edit to the argument list) becomes an offline
|
||||||
|
//! diff instead of a silently-stale loader hook.
|
||||||
|
//!
|
||||||
|
//! The byte-signature already handles the function's *body* drifting across recompiles: a changed
|
||||||
|
//! prologue is re-derived and re-validated. What a byte-signature CANNOT see is the argument list
|
||||||
|
//! changing while the body's opening bytes stay recognisable — the sig still resolves, the offset
|
||||||
|
//! still points at real code, `validate-live` still passes, yet a loader that calls the function with
|
||||||
|
//! the OLD prototype now passes the wrong registers. That failure is invisible to every existing gate.
|
||||||
|
//!
|
||||||
|
//! This module recovers the one thing that pins the prototype: which argument registers the function
|
||||||
|
//! reads as inputs. On SysV-AMD64 the first six integer/pointer arguments arrive in RDI, RSI, RDX,
|
||||||
|
//! RCX, R8, R9 and the first eight floating arguments in XMM0..XMM7, each assigned left-to-right. A
|
||||||
|
//! register is an *input* exactly when it is live-in at the entry — read on some path before being
|
||||||
|
//! written. We compute that with a bounded backward liveness over the 14 argument registers, then
|
||||||
|
//! read off the contiguous integer- and float-argument counts. The result is recompilation-invariant
|
||||||
|
//! (a rebuild doesn't change which arguments a function takes) and moves precisely when the prototype
|
||||||
|
//! does — so comparing it across builds flags exactly the prototype changes the byte-sig misses.
|
||||||
|
//!
|
||||||
|
//! Known limits (all bias toward UNDER-counting = a missed flag, never a false one): a pure forwarding
|
||||||
|
//! thunk (`jmp Helper`) reads no arg register of its own, so it shapes as `(0,0)`; an argument used
|
||||||
|
//! only inside a jump-table (indirect-branch) case isn't followed, so it can be missed. Both stay
|
||||||
|
//! stable across builds (a thunk stays a thunk), so they don't manufacture false transitions — the
|
||||||
|
//! diff's `int==0` low-confidence bucket also absorbs the thunk case. `int_args` is the OBSERVABLE
|
||||||
|
//! footprint = a lower bound on the declared prototype (a constant-returner reads nothing → `int=0`);
|
||||||
|
//! that too is stable per function, so the cross-build diff still works.
|
||||||
|
|
||||||
|
use crate::elf::CodeImage;
|
||||||
|
use iced_x86::{
|
||||||
|
Decoder, DecoderOptions, FlowControl, Instruction, InstructionInfoFactory, Mnemonic, OpAccess,
|
||||||
|
OpKind, Register,
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Argument-register slots, in ABI order: 0..6 = RDI,RSI,RDX,RCX,R8,R9; 6..14 = XMM0..XMM7. A `u16`
|
||||||
|
/// bitmask over these 14 slots is a function's live-in argument set.
|
||||||
|
const N_INT: usize = 6;
|
||||||
|
const N_XMM: usize = 8;
|
||||||
|
|
||||||
|
/// A function's recovered ABI shape: how many integer/pointer and floating arguments it reads, plus
|
||||||
|
/// whether it also loads arguments off the stack (a 7th+ integer / 9th+ float argument, or a large
|
||||||
|
/// by-value struct). The `(int_args, float_args)` pair is the stable cross-build key — `stack_args`
|
||||||
|
/// is a best-effort extra signal, reported but not used to key the diff.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||||
|
pub struct AbiShape {
|
||||||
|
pub int_args: u8,
|
||||||
|
pub float_args: u8,
|
||||||
|
pub stack_args: bool,
|
||||||
|
/// The register class of the return value — a prototype dimension the argument footprint can't see.
|
||||||
|
pub ret_class: RetClass,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a function returns its result, recovered from the return paths. Complements the argument
|
||||||
|
/// footprint: a change here (int↔float↔by-value) is a prototype change the arg counts alone miss, and
|
||||||
|
/// `ByValue` marks the RVO/sret functions that are UNSAFE to blind-call — the caller must pass an
|
||||||
|
/// output-buffer pointer in RDI, so calling with the object there makes the function WRITE into it
|
||||||
|
/// (the `CSwapTeams::GetDisplayString` sret trap). Best-effort, with an explicit
|
||||||
|
/// `Unknown` when the return path doesn't decode — so it only ever adds a signal, never a false one.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Debug)]
|
||||||
|
pub enum RetClass {
|
||||||
|
/// No decodable return path (a forwarding thunk / tail call / undecoded) — no signal.
|
||||||
|
#[default]
|
||||||
|
Unknown,
|
||||||
|
/// No result register written before returning (best-effort void).
|
||||||
|
Void,
|
||||||
|
/// Scalar / pointer result in RAX (the common case).
|
||||||
|
Int,
|
||||||
|
/// Floating result in XMM0.
|
||||||
|
Float,
|
||||||
|
/// Large by-value aggregate (sret): the function writes the result through its incoming RDI output
|
||||||
|
/// pointer and returns that pointer. UNSAFE to call with an object in RDI.
|
||||||
|
ByValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetClass {
|
||||||
|
pub fn describe(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RetClass::Unknown => "ret=?",
|
||||||
|
RetClass::Void => "ret=void",
|
||||||
|
RetClass::Int => "ret=int",
|
||||||
|
RetClass::Float => "ret=float",
|
||||||
|
RetClass::ByValue => "ret=byval",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of [`Self::describe`] — parse the token back. `None` for an unrecognised string.
|
||||||
|
pub fn from_describe(s: &str) -> Option<RetClass> {
|
||||||
|
Some(match s {
|
||||||
|
"ret=?" => RetClass::Unknown,
|
||||||
|
"ret=void" => RetClass::Void,
|
||||||
|
"ret=int" => RetClass::Int,
|
||||||
|
"ret=float" => RetClass::Float,
|
||||||
|
"ret=byval" => RetClass::ByValue,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order + on-disk form are pinned to `describe()`: `AbiSig` stores a `RetClass`, is serialized into the
|
||||||
|
// model, and `mode_abi` sorts `AbiSig` with a tie-break "to the larger by Ord". Both must serialize and
|
||||||
|
// order by the describe() token EXACTLY, or the consensus bytes move — so serialization is the
|
||||||
|
// describe() token and Ord is that token's lexical order, not the enum's declaration order.
|
||||||
|
impl Ord for RetClass {
|
||||||
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
|
self.describe().cmp(other.describe())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl PartialOrd for RetClass {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl serde::Serialize for RetClass {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
s.serialize_str(self.describe())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<'de> serde::Deserialize<'de> for RetClass {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
|
let s = String::deserialize(d)?;
|
||||||
|
RetClass::from_describe(&s)
|
||||||
|
.ok_or_else(|| serde::de::Error::custom(format!("bad ret class {s:?}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AbiShape {
|
||||||
|
/// The recompilation-invariant identity used to compare shapes across builds. Deliberately omits
|
||||||
|
/// `stack_args` (a frame-layout heuristic that a rebuild could flip) so a diff never false-flags
|
||||||
|
/// on it. Return class is compared separately (`ret_class`) — a return-type change is reported
|
||||||
|
/// distinctly from an argument-list change.
|
||||||
|
pub fn key(&self) -> (u8, u8) {
|
||||||
|
(self.int_args, self.float_args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe to blind-call with only a `this` pointer: at most one integer arg (the implicit `this`), no
|
||||||
|
/// float args, no stack args, AND not an sret return. The live oracle invokes ONLY such methods —
|
||||||
|
/// anything else needs arguments it doesn't have, so calling it would pass garbage. Both live callers
|
||||||
|
/// gate on exactly this shape, so it lives here (one safety-critical definition) rather than inline.
|
||||||
|
///
|
||||||
|
/// The `ByValue` guard is the real teeth: an sret/RVO function receives a hidden output-buffer pointer in
|
||||||
|
/// its FIRST integer register (RDI) and returns it, so a `this`-less sret (`int_args == 1` = just that
|
||||||
|
/// pointer) would otherwise look "this-only" — and blind-calling it with the live object as RDI makes the
|
||||||
|
/// callee WRITE its return value THROUGH the object = memory corruption. That is precisely what
|
||||||
|
/// `RetClass::ByValue` marks, so consult it here rather than trust the naming convention alone.
|
||||||
|
pub fn is_this_only(&self) -> bool {
|
||||||
|
self.int_args <= 1
|
||||||
|
&& self.float_args == 0
|
||||||
|
&& !self.stack_args
|
||||||
|
&& self.ret_class != RetClass::ByValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The argument-register slot a register belongs to, or `None` if it isn't one. Sub-registers fold to
|
||||||
|
/// their slot (EDI/DI/DIL -> RDI's slot); YMM/ZMM 0..7 fold to the matching XMM slot.
|
||||||
|
fn arg_slot(r: Register) -> Option<usize> {
|
||||||
|
match r.full_register() {
|
||||||
|
Register::RDI => return Some(0),
|
||||||
|
Register::RSI => return Some(1),
|
||||||
|
Register::RDX => return Some(2),
|
||||||
|
Register::RCX => return Some(3),
|
||||||
|
Register::R8 => return Some(4),
|
||||||
|
Register::R9 => return Some(5),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
// Vector argument registers: XMM/YMM/ZMM 0..7 all map to the same 8 float slots.
|
||||||
|
let base = if r.is_xmm() {
|
||||||
|
Register::XMM0
|
||||||
|
} else if r.is_ymm() {
|
||||||
|
Register::YMM0
|
||||||
|
} else if r.is_zmm() {
|
||||||
|
Register::ZMM0
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let i = (r as u32).wrapping_sub(base as u32) as usize;
|
||||||
|
(i < N_XMM).then_some(N_INT + i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The destination register of an instruction that decodes as read-writing that register but whose
|
||||||
|
/// RESULT does not depend on the register's prior value — so it is a def, not a genuine input read.
|
||||||
|
/// Three cases:
|
||||||
|
/// (1) same-register zeroing idioms (`xor r,r`, `pxor x,x`, `vxorps x,x,x`);
|
||||||
|
/// (2) same-register all-ones idioms (`pcmpeqd x,x`);
|
||||||
|
/// (3) legacy scalar-SSE writes (`cvtsi2sd`, `sqrtsd`, `movsd`-reg, …) whose low lanes are FULLY
|
||||||
|
/// written and whose read-write access only models the preserved UPPER lanes — a decode artifact.
|
||||||
|
/// Case (3) is essential: without it a float argument's count would move with body codegen (a bare
|
||||||
|
/// `cvtsi2sd xmm0,rax` decodes as reading xmm0; a dependency-broken `xorps xmm0,xmm0; cvtsi2sd …` does
|
||||||
|
/// not), which is exactly the recompilation drift the shape must be immune to. Genuine RMW arithmetic
|
||||||
|
/// (`addss`/`mulss`/…) is NOT listed — those really do read their destination, so their read is kept.
|
||||||
|
/// VEX forms take their merge lanes from an explicit source operand, so their destination is a pure
|
||||||
|
/// Write and never decodes as a false read.
|
||||||
|
fn false_read_dst(insn: &Instruction) -> Option<Register> {
|
||||||
|
let self_idiom = matches!(
|
||||||
|
insn.mnemonic(),
|
||||||
|
Mnemonic::Xor
|
||||||
|
| Mnemonic::Sub
|
||||||
|
| Mnemonic::Sbb
|
||||||
|
| Mnemonic::Pxor
|
||||||
|
| Mnemonic::Xorps
|
||||||
|
| Mnemonic::Xorpd
|
||||||
|
| Mnemonic::Vpxor
|
||||||
|
| Mnemonic::Vxorps
|
||||||
|
| Mnemonic::Vxorpd
|
||||||
|
| Mnemonic::Pcmpeqb
|
||||||
|
| Mnemonic::Pcmpeqw
|
||||||
|
| Mnemonic::Pcmpeqd
|
||||||
|
| Mnemonic::Pcmpeqq
|
||||||
|
);
|
||||||
|
if self_idiom {
|
||||||
|
match insn.op_count() {
|
||||||
|
// legacy 2-operand: `xor r, r` / `pcmpeqd x, x`
|
||||||
|
2 if insn.op0_kind() == OpKind::Register
|
||||||
|
&& insn.op1_kind() == OpKind::Register
|
||||||
|
&& insn.op0_register() == insn.op1_register() =>
|
||||||
|
{
|
||||||
|
return Some(insn.op0_register());
|
||||||
|
}
|
||||||
|
// VEX 3-operand: `vpxor dst, src, src`
|
||||||
|
3 if insn.op1_kind() == OpKind::Register
|
||||||
|
&& insn.op2_kind() == OpKind::Register
|
||||||
|
&& insn.op1_register() == insn.op2_register() =>
|
||||||
|
{
|
||||||
|
return Some(insn.op0_register());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legacy scalar-SSE merge-only writes: low lanes fully written, old value doesn't feed the result.
|
||||||
|
let merge_only = matches!(
|
||||||
|
insn.mnemonic(),
|
||||||
|
Mnemonic::Movss
|
||||||
|
| Mnemonic::Movsd
|
||||||
|
| Mnemonic::Cvtsi2ss
|
||||||
|
| Mnemonic::Cvtsi2sd
|
||||||
|
| Mnemonic::Cvtss2sd
|
||||||
|
| Mnemonic::Cvtsd2ss
|
||||||
|
| Mnemonic::Sqrtss
|
||||||
|
| Mnemonic::Sqrtsd
|
||||||
|
| Mnemonic::Roundss
|
||||||
|
| Mnemonic::Roundsd
|
||||||
|
| Mnemonic::Rcpss
|
||||||
|
| Mnemonic::Rsqrtss
|
||||||
|
);
|
||||||
|
(merge_only && insn.op0_kind() == OpKind::Register).then(|| insn.op0_register())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One instruction's effect on the argument registers: `(use_mask, def_mask, reads_a_stack_arg)`.
|
||||||
|
/// `use` = arg slots read (Read/CondRead/ReadWrite) minus false-read destinations; `def` = arg slots
|
||||||
|
/// fully written (>=32-bit Write/ReadWrite, plus false-read destinations, which kill upward liveness).
|
||||||
|
/// Shared by `decode_region` and the tests so a test can never mirror-drift from the real logic.
|
||||||
|
fn insn_effect(factory: &mut InstructionInfoFactory, insn: &Instruction) -> (u16, u16, bool) {
|
||||||
|
let (mut use_m, mut def_m) = (0u16, 0u16);
|
||||||
|
let mut stack = false;
|
||||||
|
let info = factory.info(insn);
|
||||||
|
for ur in info.used_registers() {
|
||||||
|
let Some(slot) = arg_slot(ur.register()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if matches!(
|
||||||
|
ur.access(),
|
||||||
|
OpAccess::Read | OpAccess::CondRead | OpAccess::ReadWrite | OpAccess::ReadCondWrite
|
||||||
|
) {
|
||||||
|
use_m |= 1 << slot;
|
||||||
|
}
|
||||||
|
// A def kills upward liveness only for a full-width write (>=32-bit writes clear the upper
|
||||||
|
// bits; an 8/16-bit partial write leaves the register partly live, so it doesn't kill).
|
||||||
|
if matches!(ur.access(), OpAccess::Write | OpAccess::ReadWrite) && ur.register().size() >= 4
|
||||||
|
{
|
||||||
|
def_m |= 1 << slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stack argument: a read of `[rbp + disp]` above the saved frame (return addr at +8, first stack
|
||||||
|
// arg at +16). Best-effort — frame-pointer-omitted stack args aren't caught.
|
||||||
|
for um in info.used_memory() {
|
||||||
|
if um.base() == Register::RBP
|
||||||
|
&& matches!(
|
||||||
|
um.access(),
|
||||||
|
OpAccess::Read | OpAccess::CondRead | OpAccess::ReadWrite
|
||||||
|
)
|
||||||
|
&& (16..0x1000).contains(&(um.displacement() as i64))
|
||||||
|
{
|
||||||
|
stack = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A false-read destination (zeroing/all-ones idiom, or a scalar-SSE merge-only write) is a def,
|
||||||
|
// not a use — even though it decodes as read-write of that register.
|
||||||
|
if let Some(dst) = false_read_dst(insn)
|
||||||
|
&& let Some(slot) = arg_slot(dst)
|
||||||
|
{
|
||||||
|
use_m &= !(1 << slot);
|
||||||
|
def_m |= 1 << slot;
|
||||||
|
}
|
||||||
|
(use_m, def_m, stack)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an instruction writes RAX — the return-value register. `FromRdi` is the sret / return-this tell
|
||||||
|
/// (`mov rax, rdi` / `lea rax, [rdi]`): RAX takes the incoming output pointer.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum RaxWrite {
|
||||||
|
None,
|
||||||
|
FromRdi,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One instruction's effect on the RETURN registers: does it write RAX (and from RDI?), does it write
|
||||||
|
/// XMM0, and does it store through RDI (the sret output-write). Used to classify the return value.
|
||||||
|
fn result_effect(
|
||||||
|
factory: &mut InstructionInfoFactory,
|
||||||
|
insn: &Instruction,
|
||||||
|
) -> (RaxWrite, bool, bool) {
|
||||||
|
// The sret / return-this tell: a full 64-bit RAX <- RDI copy.
|
||||||
|
let rax_from_rdi = match insn.mnemonic() {
|
||||||
|
Mnemonic::Mov => {
|
||||||
|
insn.op0_kind() == OpKind::Register
|
||||||
|
&& insn.op0_register() == Register::RAX
|
||||||
|
&& insn.op1_kind() == OpKind::Register
|
||||||
|
&& insn.op1_register() == Register::RDI
|
||||||
|
}
|
||||||
|
Mnemonic::Lea => {
|
||||||
|
insn.op0_kind() == OpKind::Register
|
||||||
|
&& insn.op0_register() == Register::RAX
|
||||||
|
&& insn.memory_base() == Register::RDI
|
||||||
|
&& insn.memory_index() == Register::None
|
||||||
|
&& insn.memory_displacement64() == 0
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
let info = factory.info(insn);
|
||||||
|
let (mut rax_written, mut xmm0_written) = (false, false);
|
||||||
|
for ur in info.used_registers() {
|
||||||
|
if !matches!(ur.access(), OpAccess::Write | OpAccess::ReadWrite) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// RAX written as a full (>=32-bit) value = a scalar/pointer return candidate.
|
||||||
|
if ur.register().full_register() == Register::RAX && ur.register().size() >= 4 {
|
||||||
|
rax_written = true;
|
||||||
|
}
|
||||||
|
// XMM0/YMM0/ZMM0 (the float-return slot) written = a float return candidate.
|
||||||
|
if arg_slot(ur.register()) == Some(N_INT) {
|
||||||
|
xmm0_written = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A store through RDI as base = the function uses RDI as an output buffer (the sret write).
|
||||||
|
let stores_rdi = info.used_memory().iter().any(|um| {
|
||||||
|
um.base() == Register::RDI && matches!(um.access(), OpAccess::Write | OpAccess::ReadWrite)
|
||||||
|
});
|
||||||
|
let rax = if rax_from_rdi {
|
||||||
|
RaxWrite::FromRdi
|
||||||
|
} else if rax_written {
|
||||||
|
RaxWrite::Other
|
||||||
|
} else {
|
||||||
|
RaxWrite::None
|
||||||
|
};
|
||||||
|
(rax, xmm0_written, stores_rdi)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify the return value from the decoded region: for each `ret`, find the nearest preceding result
|
||||||
|
/// write and read off its register class, then take a consensus. `ByValue` (sret) requires BOTH the
|
||||||
|
/// return-the-RDI-pointer pattern AND a store through RDI — so a plain `return this` (returns RDI but
|
||||||
|
/// doesn't write through it) reads as an `Int` pointer return, not a by-value trap.
|
||||||
|
fn derive_ret_class(list: &[Insn], stores_rdi: bool) -> RetClass {
|
||||||
|
let mut votes: Vec<RetClass> = Vec::new();
|
||||||
|
for (r, insn) in list.iter().enumerate() {
|
||||||
|
if !insn.is_ret {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut cls = RetClass::Void; // no result write found before the return
|
||||||
|
for k in (r.saturating_sub(64)..r).rev() {
|
||||||
|
match list[k].rax {
|
||||||
|
RaxWrite::FromRdi => {
|
||||||
|
cls = if stores_rdi {
|
||||||
|
RetClass::ByValue
|
||||||
|
} else {
|
||||||
|
RetClass::Int
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
RaxWrite::Other => {
|
||||||
|
cls = RetClass::Int;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
RaxWrite::None if list[k].xmm0 => {
|
||||||
|
cls = RetClass::Float;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
RaxWrite::None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
votes.push(cls);
|
||||||
|
}
|
||||||
|
if votes.is_empty() {
|
||||||
|
return RetClass::Unknown; // no return path (thunk / tail call)
|
||||||
|
}
|
||||||
|
if votes.contains(&RetClass::ByValue) {
|
||||||
|
return RetClass::ByValue; // sret is definitive wherever it appears
|
||||||
|
}
|
||||||
|
match (
|
||||||
|
votes.contains(&RetClass::Int),
|
||||||
|
votes.contains(&RetClass::Float),
|
||||||
|
) {
|
||||||
|
(true, true) => RetClass::Unknown, // paths disagree on the return register — ambiguous
|
||||||
|
(true, false) => RetClass::Int,
|
||||||
|
(false, true) => RetClass::Float,
|
||||||
|
(false, false) => RetClass::Void,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One decoded instruction's argument-register effect + return effect + in-function successors.
|
||||||
|
struct Insn {
|
||||||
|
ip: u64,
|
||||||
|
use_m: u16, // arg slots read before this instruction can write them (a use)
|
||||||
|
def_m: u16, // arg slots fully written (kills upward liveness)
|
||||||
|
rax: RaxWrite, // how it writes RAX (the return register)
|
||||||
|
xmm0: bool, // whether it writes XMM0 (the float-return register)
|
||||||
|
is_ret: bool, // whether it returns
|
||||||
|
succ: Vec<u64>, // successor instruction addresses inside the analysed region
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the function at `entry` into its bounded reachable instructions, following the same
|
||||||
|
/// conditional/unconditional control flow as the fingerprinter. Each instruction records which
|
||||||
|
/// argument registers it uses/defs (for liveness) and its in-region successors (for the CFG). Also
|
||||||
|
/// returns whether a stack argument was read anywhere in the region.
|
||||||
|
fn decode_region(img: &CodeImage, entry: u64) -> Option<(Vec<Insn>, bool, bool)> {
|
||||||
|
const MAX_SPAN: usize = 96 * 1024;
|
||||||
|
const MAX_INSNS: usize = 8000;
|
||||||
|
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 mut factory = InstructionInfoFactory::new();
|
||||||
|
let mut recs: HashMap<u64, Insn> = HashMap::new();
|
||||||
|
let mut stack_args = false;
|
||||||
|
let mut stores_rdi = false;
|
||||||
|
let mut work = vec![entry];
|
||||||
|
let mut insn = Instruction::default();
|
||||||
|
while let Some(start) = work.pop() {
|
||||||
|
if recs.contains_key(&start) || !in_span(start) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let off = (start - entry) as usize;
|
||||||
|
let mut dec = Decoder::with_ip(64, &code[off..], start, DecoderOptions::NONE);
|
||||||
|
if !dec.can_decode() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dec.decode_out(&mut insn);
|
||||||
|
if insn.is_invalid() || insn.len() == 0 || recs.len() >= MAX_INSNS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (use_m, def_m, stack_hit) = insn_effect(&mut factory, &insn);
|
||||||
|
stack_args |= stack_hit;
|
||||||
|
let (rax, xmm0, rdi_store) = result_effect(&mut factory, &insn);
|
||||||
|
stores_rdi |= rdi_store;
|
||||||
|
let is_ret = insn.flow_control() == FlowControl::Return;
|
||||||
|
|
||||||
|
let next = start + insn.len() as u64;
|
||||||
|
let mut succ = Vec::new();
|
||||||
|
match insn.flow_control() {
|
||||||
|
FlowControl::Return | FlowControl::IndirectBranch => {}
|
||||||
|
FlowControl::UnconditionalBranch => {
|
||||||
|
let t = insn.near_branch_target();
|
||||||
|
if in_span(t) {
|
||||||
|
succ.push(t); // in-function jump; else it's a tail call (no in-region successor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlowControl::ConditionalBranch => {
|
||||||
|
succ.push(next);
|
||||||
|
let t = insn.near_branch_target();
|
||||||
|
if in_span(t) {
|
||||||
|
succ.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => succ.push(next), // fall-through (incl. call/indirect-call: the call reads no arg regs)
|
||||||
|
}
|
||||||
|
for &s in &succ {
|
||||||
|
if !recs.contains_key(&s) {
|
||||||
|
work.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recs.insert(
|
||||||
|
start,
|
||||||
|
Insn {
|
||||||
|
ip: start,
|
||||||
|
use_m,
|
||||||
|
def_m,
|
||||||
|
rax,
|
||||||
|
xmm0,
|
||||||
|
is_ret,
|
||||||
|
succ,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if recs.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut list: Vec<Insn> = recs.into_values().collect();
|
||||||
|
list.sort_by_key(|i| i.ip);
|
||||||
|
Some((list, stack_args, stores_rdi))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover the ABI shape of the function at `entry`, or `None` if it doesn't decode. Runs a bounded
|
||||||
|
/// backward liveness over the 14 argument registers to find the entry's live-in set, then reads off
|
||||||
|
/// the contiguous integer- and float-argument counts (a later argument register being live-in implies
|
||||||
|
/// the earlier ones are arguments too — the SysV assignment is left-to-right and gap-free).
|
||||||
|
pub fn abi_shape(img: &CodeImage, entry: u64) -> Option<AbiShape> {
|
||||||
|
let (list, stack_args, stores_rdi) = decode_region(img, entry)?;
|
||||||
|
let n = list.len();
|
||||||
|
let idx: HashMap<u64, usize> = list.iter().enumerate().map(|(i, r)| (r.ip, i)).collect();
|
||||||
|
let succ: Vec<Vec<usize>> = list
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.succ.iter().filter_map(|s| idx.get(s).copied()).collect())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// live_in[i] = use[i] | (live_out[i] & !def[i]); live_out[i] = union of successors' live_in.
|
||||||
|
// Iterating in reverse index order converges in a couple of passes on a mostly-forward CFG; the
|
||||||
|
// pass cap bounds the pathological (deeply nested loops) case. Deterministic regardless.
|
||||||
|
let mut live_in = vec![0u16; n];
|
||||||
|
for _ in 0..64 {
|
||||||
|
let mut changed = false;
|
||||||
|
for i in (0..n).rev() {
|
||||||
|
let mut out = 0u16;
|
||||||
|
for &s in &succ[i] {
|
||||||
|
out |= live_in[s];
|
||||||
|
}
|
||||||
|
let v = list[i].use_m | (out & !list[i].def_m);
|
||||||
|
if v != live_in[i] {
|
||||||
|
live_in[i] = v;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry_live = idx.get(&entry).map(|&i| live_in[i]).unwrap_or(live_in[0]);
|
||||||
|
let int_args = (0..N_INT)
|
||||||
|
.rev()
|
||||||
|
.find(|&s| entry_live & (1 << s) != 0)
|
||||||
|
.map_or(0, |s| s + 1) as u8;
|
||||||
|
let float_args = (0..N_XMM)
|
||||||
|
.rev()
|
||||||
|
.find(|&s| entry_live & (1 << (N_INT + s)) != 0)
|
||||||
|
.map_or(0, |s| s + 1) as u8;
|
||||||
|
// `stack_args` only makes sense once the register slots are full; a stray rbp read below that is a
|
||||||
|
// spill, not an argument.
|
||||||
|
let stack_args = stack_args && (int_args as usize == N_INT || float_args as usize == N_XMM);
|
||||||
|
Some(AbiShape {
|
||||||
|
int_args,
|
||||||
|
float_args,
|
||||||
|
stack_args,
|
||||||
|
ret_class: derive_ret_class(&list, stores_rdi),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// Decode a tiny hand-assembled straight-line function and recover its shape through the REAL
|
||||||
|
// per-instruction helper (`insn_effect`) + the real liveness formula — so a test can't pass while
|
||||||
|
// the production path is wrong. (A single-successor chain; the fixpoint isn't exercised here.)
|
||||||
|
fn shape_of(bytes: &[u8]) -> AbiShape {
|
||||||
|
let entry = 0x1000u64;
|
||||||
|
let mut factory = InstructionInfoFactory::new();
|
||||||
|
let mut recs: Vec<Insn> = Vec::new();
|
||||||
|
let mut stores_rdi = false;
|
||||||
|
let mut dec = Decoder::with_ip(64, bytes, entry, DecoderOptions::NONE);
|
||||||
|
let mut insn = Instruction::default();
|
||||||
|
while dec.can_decode() {
|
||||||
|
dec.decode_out(&mut insn);
|
||||||
|
if insn.is_invalid() || insn.len() == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let (use_m, def_m, _) = insn_effect(&mut factory, &insn);
|
||||||
|
let (rax, xmm0, rdi_store) = result_effect(&mut factory, &insn);
|
||||||
|
stores_rdi |= rdi_store;
|
||||||
|
let ip = insn.ip();
|
||||||
|
let stop = insn.flow_control() == FlowControl::Return;
|
||||||
|
let next = ip + insn.len() as u64;
|
||||||
|
recs.push(Insn {
|
||||||
|
ip,
|
||||||
|
use_m,
|
||||||
|
def_m,
|
||||||
|
rax,
|
||||||
|
xmm0,
|
||||||
|
is_ret: stop,
|
||||||
|
succ: if stop { vec![] } else { vec![next] },
|
||||||
|
});
|
||||||
|
if stop {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// straight-line liveness (single-successor chain)
|
||||||
|
let n = recs.len();
|
||||||
|
let mut live = vec![0u16; n];
|
||||||
|
for i in (0..n).rev() {
|
||||||
|
let out = recs[i].succ.first().map_or(0, |_| live[i + 1]);
|
||||||
|
live[i] = recs[i].use_m | (out & !recs[i].def_m);
|
||||||
|
}
|
||||||
|
let el = live[0];
|
||||||
|
let int_args = (0..N_INT)
|
||||||
|
.rev()
|
||||||
|
.find(|&s| el & (1 << s) != 0)
|
||||||
|
.map_or(0, |s| s + 1) as u8;
|
||||||
|
let float_args = (0..N_XMM)
|
||||||
|
.rev()
|
||||||
|
.find(|&s| el & (1 << (N_INT + s)) != 0)
|
||||||
|
.map_or(0, |s| s + 1) as u8;
|
||||||
|
AbiShape {
|
||||||
|
int_args,
|
||||||
|
float_args,
|
||||||
|
stack_args: false,
|
||||||
|
ret_class: derive_ret_class(&recs, stores_rdi),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mov_rax_rdi_is_one_int_arg() {
|
||||||
|
// mov rax, rdi ; ret
|
||||||
|
assert_eq!(shape_of(&[0x48, 0x89, 0xF8, 0xC3]).key(), (1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deref_this_is_one_int_arg() {
|
||||||
|
// mov rax, [rdi+8] ; ret (rdi read as a memory base = `this` pointer)
|
||||||
|
assert_eq!(shape_of(&[0x48, 0x8B, 0x47, 0x08, 0xC3]).key(), (1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xor_eax_eax_is_zero_args() {
|
||||||
|
// xor eax, eax ; ret (zeroing idiom is a def, not a use; eax isn't an arg reg anyway)
|
||||||
|
assert_eq!(shape_of(&[0x31, 0xC0, 0xC3]).key(), (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reading_rdx_fills_earlier_int_args() {
|
||||||
|
// mov rax, rdx ; ret — rdx (slot 2) live-in => contiguity fills rdi, rsi => 3 int args
|
||||||
|
assert_eq!(shape_of(&[0x48, 0x89, 0xD0, 0xC3]).key(), (3, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addss_reads_two_float_args() {
|
||||||
|
// addss xmm0, xmm1 ; ret — xmm0 (read-write) + xmm1 (read) live-in => 2 float args
|
||||||
|
assert_eq!(shape_of(&[0xF3, 0x0F, 0x58, 0xC1, 0xC3]).key(), (0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pxor_self_is_not_a_float_arg() {
|
||||||
|
// pxor xmm0, xmm0 ; ret — zeroing idiom, xmm0 is a def not an input
|
||||||
|
assert_eq!(shape_of(&[0x66, 0x0F, 0xEF, 0xC0, 0xC3]).key(), (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pcmpeqd_self_all_ones_is_not_a_float_arg() {
|
||||||
|
// pcmpeqd xmm2, xmm2 ; ret — all-ones idiom (result independent of xmm2's prior value)
|
||||||
|
assert_eq!(shape_of(&[0x66, 0x0F, 0x76, 0xD2, 0xC3]).key(), (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- scalar-SSE merge-only writes must NOT be counted as float arguments (regression guards) ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cvtsi2sd_dest_is_not_a_float_arg() {
|
||||||
|
// cvtsi2sd xmm0, edi ; ret — converts an INT arg (edi) to double; xmm0 is a merge-write dest,
|
||||||
|
// its read-write access is an upper-lane artifact, not a float argument => (1 int, 0 float).
|
||||||
|
assert_eq!(shape_of(&[0xF2, 0x0F, 0x2A, 0xC7, 0xC3]).key(), (1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cvtsi2sd_shape_is_codegen_invariant() {
|
||||||
|
// The SAME function must shape identically whether or not the compiler emits a
|
||||||
|
// dependency-breaking `xorps xmm0,xmm0` before the convert.
|
||||||
|
let bare = shape_of(&[0xF2, 0x0F, 0x2A, 0xC7, 0xC3]); // cvtsi2sd xmm0,edi ; ret
|
||||||
|
let broken = shape_of(&[0x0F, 0x57, 0xC0, 0xF2, 0x0F, 0x2A, 0xC7, 0xC3]); // xorps xmm0,xmm0 ; …
|
||||||
|
assert_eq!(bare.key(), (1, 0));
|
||||||
|
assert_eq!(broken.key(), bare.key());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn movsd_reg_dest_does_not_inflate_float_args() {
|
||||||
|
// movsd xmm2, xmm0 ; ret — copies float arg xmm0 into scratch xmm2; only xmm0 is an argument.
|
||||||
|
assert_eq!(shape_of(&[0xF2, 0x0F, 0x10, 0xD0, 0xC3]).key(), (0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqrtsd_scratch_dest_not_counted() {
|
||||||
|
// sqrtsd xmm3, xmm1 ; ret — result into scratch xmm3 from float arg xmm1; xmm1 (slot 1) fills
|
||||||
|
// xmm0 by contiguity => 2 float args, NOT 4 (xmm3 the merge-dest must not inflate the count).
|
||||||
|
assert_eq!(shape_of(&[0xF2, 0x0F, 0x51, 0xD9, 0xC3]).key(), (0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- return class ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scalar_return_is_int() {
|
||||||
|
// mov eax, edi ; ret — returns a scalar in RAX.
|
||||||
|
assert_eq!(shape_of(&[0x89, 0xF8, 0xC3]).ret_class, RetClass::Int);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn float_return_is_float() {
|
||||||
|
// movsd xmm0, xmm1 ; ret — the return register is XMM0.
|
||||||
|
assert_eq!(
|
||||||
|
shape_of(&[0xF2, 0x0F, 0x10, 0xC1, 0xC3]).ret_class,
|
||||||
|
RetClass::Float
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn return_this_pointer_is_int_not_byval() {
|
||||||
|
// mov rax, rdi ; ret — returns the RDI pointer but never writes THROUGH it, so it's a plain
|
||||||
|
// pointer return (`return this`), not an sret trap.
|
||||||
|
assert_eq!(shape_of(&[0x48, 0x89, 0xF8, 0xC3]).ret_class, RetClass::Int);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sret_write_through_rdi_is_byval() {
|
||||||
|
// mov [rdi], rsi ; mov rax, rdi ; ret — writes the result through the incoming RDI output
|
||||||
|
// pointer AND returns it => the by-value (sret) shape that is unsafe to blind-call.
|
||||||
|
assert_eq!(
|
||||||
|
shape_of(&[0x48, 0x89, 0x37, 0x48, 0x89, 0xF8, 0xC3]).ret_class,
|
||||||
|
RetClass::ByValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bare_ret_is_void() {
|
||||||
|
// ret — no result register written before returning.
|
||||||
|
assert_eq!(shape_of(&[0xC3]).ret_class, RetClass::Void);
|
||||||
|
}
|
||||||
|
}
|
||||||
717
src/elf.rs
Normal file
717
src/elf.rs
Normal file
|
|
@ -0,0 +1,717 @@
|
||||||
|
//! Minimal hand-rolled ELF64 reader for CS2 Linux `.so` files.
|
||||||
|
//!
|
||||||
|
//! Two jobs: (1) expose executable code for signature scanning, and (2) expose the metadata
|
||||||
|
//! the vtable/RTTI resolver needs — sections by name, dynamic symbols, and a relocation map
|
||||||
|
//! (vtable slots in `.data.rel.ro` are 0 on disk and supplied by `.rela.dyn` at load, so we
|
||||||
|
//! reconstruct their values here). No external ELF crate; the ELF64 layout is fixed.
|
||||||
|
|
||||||
|
use crate::sig::Pattern;
|
||||||
|
use anyhow::{Context, Result, ensure};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Sec {
|
||||||
|
typ: u32,
|
||||||
|
flags: u64,
|
||||||
|
addr: u64,
|
||||||
|
off: usize,
|
||||||
|
size: usize,
|
||||||
|
link: usize,
|
||||||
|
entsize: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CodeImage {
|
||||||
|
data: Vec<u8>,
|
||||||
|
exec: Vec<(usize, u64, usize)>, // (file_off, vaddr, size) of executable sections
|
||||||
|
secs: Vec<Sec>,
|
||||||
|
sym_addr: HashMap<String, u64>, // symbol name -> vaddr
|
||||||
|
reloc: HashMap<u64, u64>, // vaddr slot -> resolved pointer value
|
||||||
|
reloc_by_val: HashMap<u64, Vec<u64>>, // pointer value -> slot vaddrs holding it
|
||||||
|
kind_at: HashMap<u64, KindTag>, // typeinfo vaddr -> its Itanium kind (by reloc symbol name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// These read attacker-controlled offsets, so they are bounds- AND overflow-safe: an out-of-range read
|
||||||
|
// returns 0 (a truncated field is treated as zero, which downstream validity checks reject) rather
|
||||||
|
// than panicking. This alone removes the largest class of malformed-input panics.
|
||||||
|
fn u16le(b: &[u8], o: usize) -> u16 {
|
||||||
|
o.checked_add(2)
|
||||||
|
.and_then(|e| b.get(o..e))
|
||||||
|
.and_then(|s| s.try_into().ok())
|
||||||
|
.map_or(0, u16::from_le_bytes)
|
||||||
|
}
|
||||||
|
fn u32le(b: &[u8], o: usize) -> u32 {
|
||||||
|
o.checked_add(4)
|
||||||
|
.and_then(|e| b.get(o..e))
|
||||||
|
.and_then(|s| s.try_into().ok())
|
||||||
|
.map_or(0, u32::from_le_bytes)
|
||||||
|
}
|
||||||
|
fn u64le(b: &[u8], o: usize) -> u64 {
|
||||||
|
o.checked_add(8)
|
||||||
|
.and_then(|e| b.get(o..e))
|
||||||
|
.and_then(|s| s.try_into().ok())
|
||||||
|
.map_or(0, u64::from_le_bytes)
|
||||||
|
}
|
||||||
|
fn cstr(b: &[u8], o: usize) -> String {
|
||||||
|
let Some(sub) = b.get(o..) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let end = sub.iter().position(|&c| c == 0).unwrap_or(sub.len());
|
||||||
|
String::from_utf8_lossy(&sub[..end]).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte width of a DWARF exception-handling pointer encoding (its low nibble is the value format).
|
||||||
|
/// Returns 0 for LEB128 / unsupported formats, which callers treat as "give up, use the fallback".
|
||||||
|
fn dw_ptr_size(enc: u8) -> usize {
|
||||||
|
match enc & 0x0f {
|
||||||
|
0x02 | 0x0a => 2, // udata2 / sdata2
|
||||||
|
0x03 | 0x0b => 4, // udata4 / sdata4
|
||||||
|
0x04 | 0x0c => 8, // udata8 / sdata8
|
||||||
|
0x00 => 8, // absptr (LP64)
|
||||||
|
_ => 0, // uleb128 / sleb128 / unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHF_WRITE: u64 = 0x1;
|
||||||
|
const SHF_EXECINSTR: u64 = 0x4;
|
||||||
|
const SHF_ALLOC: u64 = 0x2;
|
||||||
|
const SHT_NOBITS: u32 = 8;
|
||||||
|
const SHT_DYNSYM: u32 = 11;
|
||||||
|
const SHT_SYMTAB: u32 = 2;
|
||||||
|
const SHT_RELA: u32 = 4;
|
||||||
|
const R_X86_64_64: u32 = 1;
|
||||||
|
const R_X86_64_RELATIVE: u32 = 8;
|
||||||
|
const R_X86_64_GLOB_DAT: u32 = 6;
|
||||||
|
|
||||||
|
/// The Itanium `type_info` "kind" a typeinfo's `+0` field references. Recovered by the referenced SYMBOL
|
||||||
|
/// NAME rather than its pointer value, because when the C++ runtime is DYNAMICALLY linked (old Source-2
|
||||||
|
/// builds `DT_NEEDED libstdc++`) the three kind vtables are UND imports with value 0 — so their `+0` reloc
|
||||||
|
/// resolves offline to the same `0 + addend` for all three and can't be told apart (or found) by value.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum KindTag {
|
||||||
|
Class, // __class_type_info — no bases
|
||||||
|
Si, // __si_class_type_info — one public base at offset 0
|
||||||
|
Vmi, // __vmi_class_type_info — multiple / virtual / non-public bases
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a `_ZTVN10__cxxabiv1…` kind-vtable symbol name to its [`KindTag`]. The dynstr stores the undecorated
|
||||||
|
/// mangled name (symbol versioning lives in a separate table), so an exact match is correct.
|
||||||
|
fn kind_tag_of(sym: &str) -> Option<KindTag> {
|
||||||
|
match sym {
|
||||||
|
"_ZTVN10__cxxabiv117__class_type_infoE" => Some(KindTag::Class),
|
||||||
|
"_ZTVN10__cxxabiv120__si_class_type_infoE" => Some(KindTag::Si),
|
||||||
|
"_ZTVN10__cxxabiv121__vmi_class_type_infoE" => Some(KindTag::Vmi),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Program-header type marking the `.eh_frame_hdr` FDE lookup table. The loader locates it this way,
|
||||||
|
// so we do too — no dependence on section names, which stripping can remove.
|
||||||
|
const PT_GNU_EH_FRAME: u32 = 0x6474_e550;
|
||||||
|
|
||||||
|
impl CodeImage {
|
||||||
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
|
let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
|
||||||
|
Self::from_bytes(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an in-memory ELF64 image — the filesystem-free core of `load`. This is the fuzz/property
|
||||||
|
/// surface: it consumes fully attacker-controlled bytes (a Valve `.so`, or a fuzzer mutation) and
|
||||||
|
/// MUST return `Err` on any malformed input, never panic (no out-of-bounds index, no overflow).
|
||||||
|
pub fn from_bytes(data: Vec<u8>) -> Result<Self> {
|
||||||
|
ensure!(
|
||||||
|
data.len() > 64 && &data[0..4] == b"\x7fELF",
|
||||||
|
"not an ELF file"
|
||||||
|
);
|
||||||
|
ensure!(data[4] == 2, "only ELF64 is supported");
|
||||||
|
|
||||||
|
let shoff = u64le(&data, 40) as usize;
|
||||||
|
let shentsize = u16le(&data, 58) as usize;
|
||||||
|
let shnum = u16le(&data, 60) as usize;
|
||||||
|
ensure!(
|
||||||
|
shentsize >= 64,
|
||||||
|
"unexpected section header size {shentsize}"
|
||||||
|
);
|
||||||
|
// Section headers (matched by type/flags, never by name). Every field is attacker-controlled:
|
||||||
|
// compute the header offset with checked arithmetic and keep only sections whose file range and
|
||||||
|
// virtual range don't overflow / exceed the file. A malformed section becomes an inert empty
|
||||||
|
// placeholder (so `link` indices stay aligned and every downstream slice/address stays in
|
||||||
|
// bounds). A valid ELF skips none of this — its headers are all in range.
|
||||||
|
let mut secs: Vec<Sec> = Vec::with_capacity(shnum);
|
||||||
|
for i in 0..shnum {
|
||||||
|
let hdr_ok = i
|
||||||
|
.checked_mul(shentsize)
|
||||||
|
.and_then(|x| shoff.checked_add(x))
|
||||||
|
.filter(|&o| o.checked_add(64).is_some_and(|e| e <= data.len()));
|
||||||
|
let Some(o) = hdr_ok else {
|
||||||
|
secs.push(Sec::default());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let typ = u32le(&data, o + 4);
|
||||||
|
let off = u64le(&data, o + 24) as usize;
|
||||||
|
let size = u64le(&data, o + 32) as usize;
|
||||||
|
let addr = u64le(&data, o + 16);
|
||||||
|
let file_ok =
|
||||||
|
typ == SHT_NOBITS || off.checked_add(size).is_some_and(|e| e <= data.len());
|
||||||
|
let addr_ok = addr.checked_add(size as u64).is_some();
|
||||||
|
if file_ok && addr_ok {
|
||||||
|
secs.push(Sec {
|
||||||
|
typ,
|
||||||
|
flags: u64le(&data, o + 8),
|
||||||
|
addr,
|
||||||
|
off,
|
||||||
|
size,
|
||||||
|
link: u32le(&data, o + 40) as usize,
|
||||||
|
entsize: u64le(&data, o + 56) as usize,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
secs.push(Sec::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let exec: Vec<(usize, u64, usize)> = secs
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.flags & SHF_EXECINSTR != 0 && s.typ != SHT_NOBITS)
|
||||||
|
.map(|s| (s.off, s.addr, s.size))
|
||||||
|
.collect();
|
||||||
|
ensure!(!exec.is_empty(), "no executable sections found");
|
||||||
|
|
||||||
|
// dynamic symbols (prefer .dynsym; fall back to .symtab if present)
|
||||||
|
let mut sym_addr = HashMap::new();
|
||||||
|
let mut sym_values: Vec<u64> = Vec::new();
|
||||||
|
// Every symbol's name, pushed in lockstep with `sym_values` so a reloc's `r_sym` index recovers the
|
||||||
|
// name even for UND (value-0) imports — the only way to identify the dynamically-linked kind vtables.
|
||||||
|
let mut sym_names: Vec<String> = Vec::new();
|
||||||
|
if let Some(symtab) = secs
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.typ == SHT_DYNSYM)
|
||||||
|
.or_else(|| secs.iter().find(|s| s.typ == SHT_SYMTAB))
|
||||||
|
{
|
||||||
|
let str_off = secs.get(symtab.link).map_or(0, |s| s.off);
|
||||||
|
let n = if symtab.entsize >= 24 {
|
||||||
|
symtab.size / symtab.entsize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
for i in 0..n {
|
||||||
|
let Some(o) = i
|
||||||
|
.checked_mul(symtab.entsize)
|
||||||
|
.and_then(|x| symtab.off.checked_add(x))
|
||||||
|
.filter(|&o| o.checked_add(24).is_some_and(|e| e <= data.len()))
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let name = cstr(&data, str_off.wrapping_add(u32le(&data, o) as usize));
|
||||||
|
let value = u64le(&data, o + 8);
|
||||||
|
sym_values.push(value);
|
||||||
|
sym_names.push(name.clone()); // lockstep with sym_values, ALL symbols (incl. UND/value-0)
|
||||||
|
if !name.is_empty() && value != 0 {
|
||||||
|
sym_addr.entry(name.clone()).or_insert(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// relocations: reconstruct the as-loaded pointer values for .data.rel.ro etc.
|
||||||
|
let mut reloc = HashMap::new();
|
||||||
|
let mut reloc_by_val: HashMap<u64, Vec<u64>> = HashMap::new();
|
||||||
|
let mut kind_at: HashMap<u64, KindTag> = HashMap::new();
|
||||||
|
for s in secs.iter().filter(|s| s.typ == SHT_RELA) {
|
||||||
|
let n = if s.entsize >= 24 {
|
||||||
|
s.size / s.entsize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
for i in 0..n {
|
||||||
|
let Some(o) = i
|
||||||
|
.checked_mul(s.entsize)
|
||||||
|
.and_then(|x| s.off.checked_add(x))
|
||||||
|
.filter(|&o| o.checked_add(24).is_some_and(|e| e <= data.len()))
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let r_offset = u64le(&data, o);
|
||||||
|
let r_info = u64le(&data, o + 8);
|
||||||
|
let r_addend = u64le(&data, o + 16);
|
||||||
|
let r_type = (r_info & 0xffff_ffff) as u32;
|
||||||
|
let r_sym = (r_info >> 32) as usize;
|
||||||
|
// A typeinfo's `+0` field is a symbolic reloc against a `__cxxabiv1` kind vtable. Record the
|
||||||
|
// kind by the referenced symbol NAME (keyed by `r_offset` = the typeinfo's base vaddr), so a
|
||||||
|
// dynamically-linked runtime — where the value resolves to a useless `0 + 0x10` for all three
|
||||||
|
// kinds — is still classifiable. Recorded regardless of the value gate below.
|
||||||
|
if matches!(r_type, R_X86_64_64 | R_X86_64_GLOB_DAT)
|
||||||
|
&& let Some(tag) = sym_names.get(r_sym).and_then(|n| kind_tag_of(n))
|
||||||
|
{
|
||||||
|
kind_at.insert(r_offset, tag);
|
||||||
|
}
|
||||||
|
let val = match r_type {
|
||||||
|
R_X86_64_RELATIVE => r_addend,
|
||||||
|
R_X86_64_64 | R_X86_64_GLOB_DAT => sym_values
|
||||||
|
.get(r_sym)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0)
|
||||||
|
.wrapping_add(r_addend),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
if val != 0 {
|
||||||
|
reloc.insert(r_offset, val);
|
||||||
|
reloc_by_val.entry(val).or_default().push(r_offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
data,
|
||||||
|
exec,
|
||||||
|
secs,
|
||||||
|
sym_addr,
|
||||||
|
reloc,
|
||||||
|
reloc_by_val,
|
||||||
|
kind_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Virtual addresses where `pat` matches inside any executable section.
|
||||||
|
pub fn find(&self, pat: &Pattern) -> Vec<u64> {
|
||||||
|
let mut hits = Vec::new();
|
||||||
|
for &(off, vaddr, size) in &self.exec {
|
||||||
|
let end = (off + size).min(self.data.len());
|
||||||
|
if off >= end {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for m in pat.find_all(&self.data[off..end]) {
|
||||||
|
hits.push(vaddr + m as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executable bytes starting at virtual address `vaddr` (to the end of its section).
|
||||||
|
pub fn code_at(&self, vaddr: u64) -> Option<&[u8]> {
|
||||||
|
for &(off, sec_va, size) in &self.exec {
|
||||||
|
if vaddr >= sec_va && vaddr < sec_va + size as u64 {
|
||||||
|
let start = off + (vaddr - sec_va) as usize;
|
||||||
|
let end = (off + size).min(self.data.len());
|
||||||
|
if start < end {
|
||||||
|
return Some(&self.data[start..end]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executable bytes for the half-open virtual range `[start, end)` — used to disassemble a
|
||||||
|
/// single function from its known `.eh_frame` boundary (so linear decode can't misalign on data
|
||||||
|
/// between functions).
|
||||||
|
pub fn code_range(&self, start: u64, end: u64) -> Option<&[u8]> {
|
||||||
|
let all = self.code_at(start)?;
|
||||||
|
let len = end.checked_sub(start)? as usize;
|
||||||
|
Some(&all[..len.min(all.len())])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `vaddr` inside an executable section (i.e. plausibly a function pointer)?
|
||||||
|
pub fn is_code(&self, vaddr: u64) -> bool {
|
||||||
|
self.exec
|
||||||
|
.iter()
|
||||||
|
.any(|&(_, va, size)| vaddr >= va && vaddr < va + size as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executable sections as `(vaddr, bytes)` for linear disassembly.
|
||||||
|
pub fn exec_blocks(&self) -> Vec<(u64, &[u8])> {
|
||||||
|
self.exec
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&(off, va, size)| {
|
||||||
|
let end = (off + size).min(self.data.len());
|
||||||
|
(off < end).then(|| (va, &self.data[off..end]))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relocation values that point into executable code — vtable slots and function pointers, i.e.
|
||||||
|
/// a large set of real function entry addresses obtained without disassembling anything.
|
||||||
|
pub fn code_pointer_targets(&self) -> Vec<u64> {
|
||||||
|
let mut out: Vec<u64> = self
|
||||||
|
.reloc
|
||||||
|
.values()
|
||||||
|
.copied()
|
||||||
|
.filter(|&v| self.is_code(v))
|
||||||
|
.collect();
|
||||||
|
out.sort_unstable();
|
||||||
|
out.dedup();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw allocated bytes at `vaddr`, up to `len`.
|
||||||
|
fn data_at(&self, vaddr: u64, len: usize) -> Option<&[u8]> {
|
||||||
|
for s in &self.secs {
|
||||||
|
if s.flags & SHF_ALLOC != 0
|
||||||
|
&& s.typ != SHT_NOBITS
|
||||||
|
&& vaddr >= s.addr
|
||||||
|
&& vaddr < s.addr + s.size as u64
|
||||||
|
{
|
||||||
|
let start = s.off + (vaddr - s.addr) as usize;
|
||||||
|
if start + len <= self.data.len() {
|
||||||
|
return Some(&self.data[start..start + len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only initialised data bytes at `vaddr` (i.e. `.rodata`): allocated, not writable, not
|
||||||
|
/// executable, not NOBITS. This is the build-invariant content — referenced strings, magic
|
||||||
|
/// constants — so a fingerprint over it survives recompiles, unlike writable/relocated data.
|
||||||
|
pub fn rodata_at(&self, vaddr: u64, len: usize) -> Option<&[u8]> {
|
||||||
|
for s in &self.secs {
|
||||||
|
if s.flags & SHF_ALLOC != 0
|
||||||
|
&& s.flags & SHF_WRITE == 0
|
||||||
|
&& s.flags & SHF_EXECINSTR == 0
|
||||||
|
&& s.typ != SHT_NOBITS
|
||||||
|
&& vaddr >= s.addr
|
||||||
|
&& vaddr < s.addr + s.size as u64
|
||||||
|
{
|
||||||
|
let start = s.off + (vaddr - s.addr) as usize;
|
||||||
|
let end = (start + len).min(s.off + s.size).min(self.data.len());
|
||||||
|
if start < end {
|
||||||
|
return Some(&self.data[start..end]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pointer value stored at `vaddr` — from the relocation map if relocated, else the
|
||||||
|
/// raw qword in the file.
|
||||||
|
pub fn read_ptr(&self, vaddr: u64) -> Option<u64> {
|
||||||
|
if let Some(&v) = self.reloc.get(&vaddr) {
|
||||||
|
return Some(v);
|
||||||
|
}
|
||||||
|
self.data_at(vaddr, 8).map(|b| u64le(b, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slot vaddrs whose (relocated) pointer value equals `target`.
|
||||||
|
pub fn ptrs_to(&self, target: u64) -> &[u64] {
|
||||||
|
self.reloc_by_val.get(&target).map_or(&[], |v| v.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Itanium kind of the typeinfo at `ti`, recovered from its `+0` reloc's SYMBOL NAME. `Some` when
|
||||||
|
/// the kind vtable is a named `__cxxabiv1` symbol (always so for a dynamically-linked runtime — the case
|
||||||
|
/// the value-based check can't handle); `None` for a statically-linked build, where the caller falls
|
||||||
|
/// back to comparing the resolved `+0` pointer against the in-image kind vtables.
|
||||||
|
pub fn kind_at(&self, ti: u64) -> Option<KindTag> {
|
||||||
|
self.kind_at.get(&ti).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate `(slot_vaddr, resolved_pointer)` over every relocation — the reloc-driven way to
|
||||||
|
/// sweep for vtables/typeinfos without brute-scanning section bytes.
|
||||||
|
pub fn reloc_slots(&self) -> impl Iterator<Item = (u64, u64)> + '_ {
|
||||||
|
self.reloc.iter().map(|(&k, &v)| (k, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw signed qword at `vaddr` from file bytes — for non-relocated integers (e.g. an Itanium
|
||||||
|
/// vtable's offset-to-top), where `read_ptr`'s reloc lookup would be meaningless.
|
||||||
|
pub fn read_i64(&self, vaddr: u64) -> Option<i64> {
|
||||||
|
self.data_at(vaddr, 8)
|
||||||
|
.map(|b| i64::from_le_bytes(b[..8].try_into().unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw `u32` at `vaddr` from file bytes (e.g. an Itanium `__vmi` typeinfo's base count).
|
||||||
|
pub fn read_u32(&self, vaddr: u64) -> Option<u32> {
|
||||||
|
self.data_at(vaddr, 4).map(|b| u32le(b, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw `i32` at `vaddr` from file bytes (e.g. a schema field's inheritance offset).
|
||||||
|
pub fn read_i32(&self, vaddr: u64) -> Option<i32> {
|
||||||
|
self.data_at(vaddr, 4)
|
||||||
|
.map(|b| i32::from_le_bytes(b[..4].try_into().unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw `u16` at `vaddr` from file bytes (e.g. a schema class's field count).
|
||||||
|
pub fn read_u16(&self, vaddr: u64) -> Option<u16> {
|
||||||
|
self.data_at(vaddr, 2).map(|b| u16le(b, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw `u8` at `vaddr` from file bytes (e.g. a schema class's base count).
|
||||||
|
pub fn read_u8(&self, vaddr: u64) -> Option<u8> {
|
||||||
|
self.data_at(vaddr, 1).map(|b| b[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `vaddr` inside any allocated section (code or data)? The "does this pointer land in the
|
||||||
|
/// image" test RTTI validation needs.
|
||||||
|
pub fn contains(&self, vaddr: u64) -> bool {
|
||||||
|
self.secs
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.flags & SHF_ALLOC != 0 && vaddr >= s.addr && vaddr < s.addr + s.size as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn symbol_addr(&self, name: &str) -> Option<u64> {
|
||||||
|
self.sym_addr.get(name).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Virtual addresses of an exact byte string within allocated, non-executable sections
|
||||||
|
/// (used to find RTTI name strings in `.rodata`).
|
||||||
|
pub fn find_bytes(&self, needle: &[u8]) -> Vec<u64> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if needle.is_empty() {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for s in &self.secs {
|
||||||
|
if s.flags & SHF_ALLOC == 0
|
||||||
|
|| s.typ == SHT_NOBITS
|
||||||
|
|| s.flags & SHF_EXECINSTR != 0
|
||||||
|
|| s.off + s.size > self.data.len()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let hay = &self.data[s.off..s.off + s.size];
|
||||||
|
let mut i = 0;
|
||||||
|
while let Some(p) = memchr::memmem::find(&hay[i..], needle) {
|
||||||
|
out.push(s.addr + (i + p) as u64);
|
||||||
|
i += p + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The NUL-terminated string at `vaddr` in any allocated, initialised section — RTTI `_ZTS`
|
||||||
|
/// names, schema class/field names, referenced literals. Capped so a missing terminator (e.g.
|
||||||
|
/// a bogus pointer into a non-string section) can't run to the end of the file.
|
||||||
|
pub fn read_c_string(&self, vaddr: u64) -> Option<String> {
|
||||||
|
for s in &self.secs {
|
||||||
|
if s.flags & SHF_ALLOC != 0
|
||||||
|
&& s.typ != SHT_NOBITS
|
||||||
|
&& vaddr >= s.addr
|
||||||
|
&& vaddr < s.addr + s.size as u64
|
||||||
|
{
|
||||||
|
let start = s.off + (vaddr - s.addr) as usize;
|
||||||
|
let end = (s.off + s.size).min(self.data.len()).min(start + 4096);
|
||||||
|
if start >= end {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rel = self.data[start..end].iter().position(|&c| c == 0)?;
|
||||||
|
return Some(String::from_utf8_lossy(&self.data[start..start + rel]).into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Virtual address + file offset of `.eh_frame_hdr`, via the PT_GNU_EH_FRAME program header.
|
||||||
|
fn eh_frame_hdr(&self) -> Option<(u64, usize)> {
|
||||||
|
let d = &self.data;
|
||||||
|
let phoff = u64le(d, 32) as usize;
|
||||||
|
let phentsize = u16le(d, 54) as usize;
|
||||||
|
let phnum = u16le(d, 56) as usize;
|
||||||
|
if phentsize < 56 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for i in 0..phnum {
|
||||||
|
let Some(o) = i
|
||||||
|
.checked_mul(phentsize)
|
||||||
|
.and_then(|x| phoff.checked_add(x))
|
||||||
|
.filter(|&o| o.checked_add(56).is_some_and(|e| e <= d.len()))
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if u32le(d, o) == PT_GNU_EH_FRAME {
|
||||||
|
return Some((u64le(d, o + 16), u64le(d, o + 8) as usize));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a DWARF-encoded value at `field_va`, applying its pcrel/datarel base. Returns
|
||||||
|
/// `(value, byte_width)`. `datarel_base` is the `.eh_frame_hdr` vaddr (only used by datarel enc).
|
||||||
|
fn read_enc(&self, enc: u8, field_va: u64, datarel_base: u64) -> Option<(u64, usize)> {
|
||||||
|
let sz = dw_ptr_size(enc);
|
||||||
|
if sz == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let b = self.data_at(field_va, sz)?;
|
||||||
|
let raw = match sz {
|
||||||
|
2 => u16le(b, 0) as u64,
|
||||||
|
4 => u32le(b, 0) as u64,
|
||||||
|
_ => u64le(b, 0),
|
||||||
|
};
|
||||||
|
let base = match enc & 0x70 {
|
||||||
|
0x00 => 0, // absolute — no base (also how lengths/sizes are stored)
|
||||||
|
0x10 => field_va, // pcrel: relative to this field's own address
|
||||||
|
0x30 => datarel_base, // datarel: relative to `.eh_frame_hdr`
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let val = if matches!(enc & 0x0f, 0x0a..=0x0c) {
|
||||||
|
let s = match sz {
|
||||||
|
2 => raw as u16 as i16 as i64,
|
||||||
|
4 => raw as u32 as i32 as i64,
|
||||||
|
_ => raw as i64,
|
||||||
|
};
|
||||||
|
base.wrapping_add(s as u64)
|
||||||
|
} else {
|
||||||
|
base.wrapping_add(raw)
|
||||||
|
};
|
||||||
|
Some((val, sz))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte length of the LEB128 value at `va` (the value itself is unused here — we only skip it).
|
||||||
|
fn leb_len(&self, va: u64) -> Option<u64> {
|
||||||
|
let mut n = 0u64;
|
||||||
|
loop {
|
||||||
|
let byte = self.data_at(va.wrapping_add(n), 1)?[0];
|
||||||
|
n += 1;
|
||||||
|
if byte & 0x80 == 0 {
|
||||||
|
return Some(n);
|
||||||
|
}
|
||||||
|
if n >= 16 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The FDE pointer encoding a CIE advertises — its `'R'` augmentation byte. Absptr (0) when the
|
||||||
|
/// CIE carries no `z`/`R` augmentation (then FDE addresses are absolute).
|
||||||
|
fn cie_fde_enc(&self, cie_va: u64) -> u8 {
|
||||||
|
let Some(head) = self.data_at(cie_va, 9) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
if u32le(head, 4) != 0 {
|
||||||
|
return 0; // CIE id field must be 0
|
||||||
|
}
|
||||||
|
let version = head[8];
|
||||||
|
let mut p = cie_va.wrapping_add(9);
|
||||||
|
let Some(aug) = self.read_c_string(p) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
p = p.wrapping_add(aug.len() as u64 + 1);
|
||||||
|
if !aug.starts_with('z') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// code_align (uleb), data_align (sleb), return-addr reg (uleb v>=3 else 1 byte), aug_len (uleb)
|
||||||
|
for _ in 0..2 {
|
||||||
|
match self.leb_len(p) {
|
||||||
|
Some(n) => p = p.wrapping_add(n),
|
||||||
|
None => return 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if version >= 3 {
|
||||||
|
match self.leb_len(p) {
|
||||||
|
Some(n) => p = p.wrapping_add(n),
|
||||||
|
None => return 0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p = p.wrapping_add(1);
|
||||||
|
}
|
||||||
|
match self.leb_len(p) {
|
||||||
|
Some(n) => p = p.wrapping_add(n),
|
||||||
|
None => return 0,
|
||||||
|
}
|
||||||
|
// The augmentation letters after 'z' name the aug-data fields, in order.
|
||||||
|
for c in aug.bytes().skip(1) {
|
||||||
|
match c {
|
||||||
|
b'R' => return self.data_at(p, 1).map_or(0, |b| b[0]),
|
||||||
|
b'L' => p = p.wrapping_add(1),
|
||||||
|
b'P' => {
|
||||||
|
let Some(e) = self.data_at(p, 1).map(|b| b[0]) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
p = p.wrapping_add(1 + dw_ptr_size(e) as u64);
|
||||||
|
}
|
||||||
|
b'S' | b'B' | b'G' => {}
|
||||||
|
_ => return 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the FDE at `fde_va` to `(pc_begin, pc_end)`, using its owning CIE's pointer encoding.
|
||||||
|
/// `enc_cache` memoises CIE encodings (nearly all FDEs share one CIE).
|
||||||
|
fn fde_range(&self, fde_va: u64, enc_cache: &mut HashMap<u64, u8>) -> Option<(u64, u64)> {
|
||||||
|
let head = self.data_at(fde_va, 8)?;
|
||||||
|
let len = u32le(head, 0);
|
||||||
|
if len == 0 || len == 0xffff_ffff {
|
||||||
|
return None; // terminator, or 64-bit DWARF (not emitted by the CS2 toolchain)
|
||||||
|
}
|
||||||
|
let cie_ptr = u32le(head, 4);
|
||||||
|
if cie_ptr == 0 {
|
||||||
|
return None; // a CIE, not an FDE
|
||||||
|
}
|
||||||
|
let cie_va = fde_va.wrapping_add(4).wrapping_sub(cie_ptr as u64);
|
||||||
|
let enc = *enc_cache
|
||||||
|
.entry(cie_va)
|
||||||
|
.or_insert_with(|| self.cie_fde_enc(cie_va));
|
||||||
|
let (pc_begin, sz) = self.read_enc(enc, fde_va.wrapping_add(8), 0)?;
|
||||||
|
// PC_range follows PC_begin at the same width; it's an absolute size (no base applied).
|
||||||
|
let rb = self.data_at(fde_va.wrapping_add(8).wrapping_add(sz as u64), sz)?;
|
||||||
|
let range = match sz {
|
||||||
|
2 => u16le(rb, 0) as u64,
|
||||||
|
4 => u32le(rb, 0) as u64,
|
||||||
|
_ => u64le(rb, 0),
|
||||||
|
};
|
||||||
|
Some((pc_begin, pc_begin.wrapping_add(range)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every function `.eh_frame` unwind data describes, as sorted `(start, end)` virtual-address
|
||||||
|
/// pairs. This enumerates far more functions than the dynamic symbol table exposes (stripped
|
||||||
|
/// internal functions still need unwind info), making it the completeness denominator for
|
||||||
|
/// coverage audits and the source of exact byte extents for the content-locator.
|
||||||
|
pub fn eh_frame_functions(&self) -> Vec<(u64, u64)> {
|
||||||
|
let Some((hdr_va, hdr_off)) = self.eh_frame_hdr() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let d = &self.data;
|
||||||
|
// header: version(1) + eh_frame_ptr_enc(1) + fde_count_enc(1) + table_enc(1)
|
||||||
|
let Some(hb) = hdr_off.checked_add(4).and_then(|e| d.get(hdr_off..e)) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if hb[0] != 1 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let ptr_sz = dw_ptr_size(hb[1]); // eh_frame_ptr encoding (we skip the pointer)
|
||||||
|
let count_sz = dw_ptr_size(hb[2]);
|
||||||
|
let table_enc = hb[3];
|
||||||
|
let esz = dw_ptr_size(table_enc);
|
||||||
|
if ptr_sz == 0 || count_sz == 0 || esz == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let Some(table_off) = hdr_off
|
||||||
|
.checked_add(4)
|
||||||
|
.and_then(|x| x.checked_add(ptr_sz))
|
||||||
|
.and_then(|count_off| count_off.checked_add(count_sz).map(|t| (count_off, t)))
|
||||||
|
.filter(|&(_, t)| t <= d.len())
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let (count_off, table_off) = table_off;
|
||||||
|
// fde_count is attacker-controlled; each table entry is `2*esz` bytes, so a real count can't
|
||||||
|
// exceed the file. Cap it BEFORE any allocation — a crafted count would otherwise drive an
|
||||||
|
// out-of-memory abort.
|
||||||
|
let fde_count = match count_sz {
|
||||||
|
2 => u16le(d, count_off) as usize,
|
||||||
|
4 => u32le(d, count_off) as usize,
|
||||||
|
_ => u64le(d, count_off) as usize,
|
||||||
|
}
|
||||||
|
.min(d.len() / (2 * esz).max(1) + 1);
|
||||||
|
let entry_bytes = 2 * esz;
|
||||||
|
let table_va = hdr_va.wrapping_add(table_off.wrapping_sub(hdr_off) as u64);
|
||||||
|
let mut starts: Vec<(u64, u64)> = Vec::new(); // (fn start, fde vaddr)
|
||||||
|
for i in 0..fde_count {
|
||||||
|
let field_va = table_va.wrapping_add(i.wrapping_mul(entry_bytes) as u64);
|
||||||
|
let (Some((start, _)), Some((fde_va, _))) = (
|
||||||
|
self.read_enc(table_enc, field_va, hdr_va),
|
||||||
|
self.read_enc(table_enc, field_va.wrapping_add(esz as u64), hdr_va),
|
||||||
|
) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
starts.push((start, fde_va));
|
||||||
|
}
|
||||||
|
starts.sort_unstable_by_key(|&(s, _)| s);
|
||||||
|
let mut cache = HashMap::new();
|
||||||
|
let mut out = Vec::with_capacity(starts.len());
|
||||||
|
for (i, &(start, fde_va)) in starts.iter().enumerate() {
|
||||||
|
// Prefer the FDE's own extent; fall back to the next function's start (padding included).
|
||||||
|
let end = self
|
||||||
|
.fde_range(fde_va, &mut cache)
|
||||||
|
.map(|(_, e)| e)
|
||||||
|
.filter(|&e| e > start)
|
||||||
|
.unwrap_or_else(|| starts.get(i + 1).map_or(start, |&(s, _)| s));
|
||||||
|
out.push((start, end));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/emit.rs
Normal file
163
src/emit.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
//! Generate a fresh, unique signature at a known address — the tool's product output.
|
||||||
|
//!
|
||||||
|
//! Walk instructions from the function entry, emit their bytes, but wildcard the
|
||||||
|
//! position-dependent ones (RIP-relative displacements and near-branch targets) so the
|
||||||
|
//! signature survives relocation. Stop as soon as the accumulated pattern matches exactly
|
||||||
|
//! once in the binary. Pure integer/byte work; the only float-free dependency is the decoder.
|
||||||
|
|
||||||
|
use crate::elf::CodeImage;
|
||||||
|
use crate::sig::Pattern;
|
||||||
|
use iced_x86::{ConstantOffsets, Decoder, DecoderOptions, Instruction, OpKind};
|
||||||
|
|
||||||
|
/// Call `emit(k, masked)` for each byte `k` of a just-decoded instruction, where `masked` is true for
|
||||||
|
/// the position-dependent bytes — a RIP-relative displacement or a near-branch target — that move on
|
||||||
|
/// relocation and so must be wildcarded (in a signature) or normalized away (in a cross-build digest).
|
||||||
|
/// The single source of truth for that masking, shared by `make_sig` and `normalized_digest` so the
|
||||||
|
/// signature the tool ships and the digest it compares builds with can never drift apart.
|
||||||
|
fn for_each_byte(
|
||||||
|
instr: &Instruction,
|
||||||
|
co: &ConstantOffsets,
|
||||||
|
ilen: usize,
|
||||||
|
mut emit: impl FnMut(usize, bool),
|
||||||
|
) {
|
||||||
|
let rip_rel = instr.is_ip_rel_memory_operand();
|
||||||
|
let branch = (0..instr.op_count()).any(|i| {
|
||||||
|
matches!(
|
||||||
|
instr.op_kind(i),
|
||||||
|
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
||||||
|
)
|
||||||
|
});
|
||||||
|
for k in 0..ilen {
|
||||||
|
let in_disp = rip_rel
|
||||||
|
&& co.has_displacement()
|
||||||
|
&& k >= co.displacement_offset()
|
||||||
|
&& k < co.displacement_offset() + co.displacement_size();
|
||||||
|
let in_imm = branch
|
||||||
|
&& co.has_immediate()
|
||||||
|
&& k >= co.immediate_offset()
|
||||||
|
&& k < co.immediate_offset() + co.immediate_size();
|
||||||
|
emit(k, in_disp || in_imm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a unique signature for the function at `vaddr`, or `None` if it can't be made unique
|
||||||
|
/// within `max_bytes`.
|
||||||
|
pub fn make_sig(img: &CodeImage, vaddr: u64, max_bytes: usize) -> Option<String> {
|
||||||
|
let code = img.code_at(vaddr)?;
|
||||||
|
let mut decoder = Decoder::with_ip(64, code, vaddr, DecoderOptions::NONE);
|
||||||
|
let mut instr = Instruction::default();
|
||||||
|
let mut tokens: Vec<String> = Vec::new();
|
||||||
|
let mut off = 0usize;
|
||||||
|
|
||||||
|
while decoder.can_decode() && off < max_bytes {
|
||||||
|
decoder.decode_out(&mut instr);
|
||||||
|
let ilen = instr.len();
|
||||||
|
if ilen == 0 || off + ilen > code.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let co = decoder.get_constant_offsets(&instr);
|
||||||
|
for_each_byte(&instr, &co, ilen, |k, masked| {
|
||||||
|
tokens.push(if masked {
|
||||||
|
"?".into()
|
||||||
|
} else {
|
||||||
|
format!("{:02X}", code[off + k])
|
||||||
|
});
|
||||||
|
});
|
||||||
|
off += ilen;
|
||||||
|
|
||||||
|
if let Ok(pat) = Pattern::parse(&tokens.join(" "))
|
||||||
|
&& img.find(&pat).len() == 1
|
||||||
|
{
|
||||||
|
return Some(tokens.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FNV-1a 64-bit digest of the function `[start, end)`, with the position-dependent bytes masked to a
|
||||||
|
/// constant (the same masking `make_sig` wildcards). The digest is therefore recompilation-shift-
|
||||||
|
/// invariant: a function whose body is unchanged but whose call/jump targets moved with the surrounding
|
||||||
|
/// layout digests IDENTICALLY across builds, while a genuine opcode/operand edit changes it. This is the
|
||||||
|
/// per-function identity `classify-change` compares two builds by. `None` if nothing decodes.
|
||||||
|
pub fn normalized_digest(img: &CodeImage, start: u64, end: u64) -> Option<u64> {
|
||||||
|
let code = img.code_at(start)?;
|
||||||
|
let span = (end.saturating_sub(start) as usize).min(code.len());
|
||||||
|
digest_code(&code[..span], start)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared core of `normalized_digest`: FNV-1a over a raw code slice with the position-dependent
|
||||||
|
/// bytes masked. Split out from the `CodeImage` wrapper so it can be unit-tested on hand-assembled
|
||||||
|
/// bytes. `None` if nothing decodes.
|
||||||
|
fn digest_code(code: &[u8], ip: u64) -> Option<u64> {
|
||||||
|
if code.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut decoder = Decoder::with_ip(64, code, ip, DecoderOptions::NONE);
|
||||||
|
let mut instr = Instruction::default();
|
||||||
|
let mut h: u64 = 0xcbf29ce484222325; // FNV-1a offset basis
|
||||||
|
let mut off = 0usize;
|
||||||
|
let mut decoded = false;
|
||||||
|
while decoder.can_decode() && off < code.len() {
|
||||||
|
decoder.decode_out(&mut instr);
|
||||||
|
let ilen = instr.len();
|
||||||
|
if ilen == 0 || off + ilen > code.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let co = decoder.get_constant_offsets(&instr);
|
||||||
|
for_each_byte(&instr, &co, ilen, |k, masked| {
|
||||||
|
// A masked byte hashes as a fixed 0 regardless of its build-specific value; kept in place
|
||||||
|
// (not skipped) so instruction length still participates in the digest.
|
||||||
|
let byte = if masked { 0 } else { code[off + k] };
|
||||||
|
h = (h ^ byte as u64).wrapping_mul(0x100000001b3);
|
||||||
|
});
|
||||||
|
off += ilen;
|
||||||
|
decoded = true;
|
||||||
|
}
|
||||||
|
decoded.then_some(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::digest_code;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn masks_call_target_shift_invariant() {
|
||||||
|
// Two builds of the same function whose only difference is where its `call rel32` lands (the
|
||||||
|
// callee moved with the layout) must digest IDENTICALLY — the shift-invariance the whole
|
||||||
|
// command rests on. `E8 xx xx xx xx` = call; the 4 immediate bytes differ, nothing else.
|
||||||
|
let a = digest_code(&[0xE8, 0x11, 0x22, 0x33, 0x44, 0xC3], 0x1000); // call +0x44332211 ; ret
|
||||||
|
let b = digest_code(&[0xE8, 0x55, 0x66, 0x77, 0x00, 0xC3], 0x1000); // call +0x00776655 ; ret
|
||||||
|
assert_eq!(a, b);
|
||||||
|
assert!(a.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn masks_rip_relative_displacement() {
|
||||||
|
// `lea rax, [rip+disp]` — the displacement moves every build; masking it makes the two equal.
|
||||||
|
let a = digest_code(&[0x48, 0x8D, 0x05, 0x11, 0x22, 0x33, 0x44, 0xC3], 0x1000);
|
||||||
|
let b = digest_code(&[0x48, 0x8D, 0x05, 0xAA, 0xBB, 0xCC, 0x00, 0xC3], 0x1000);
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinguishes_opcode_change() {
|
||||||
|
// A real body edit (add vs sub) must change the digest — the masking must NOT wash it out.
|
||||||
|
let add = digest_code(&[0x48, 0x01, 0xD8, 0xC3], 0x1000); // add rax, rbx ; ret
|
||||||
|
let sub = digest_code(&[0x48, 0x29, 0xD8, 0xC3], 0x1000); // sub rax, rbx ; ret
|
||||||
|
assert_ne!(add, sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinguishes_non_branch_immediate() {
|
||||||
|
// A plain immediate (`mov eax, IMM`) is NOT masked — it's part of the function's identity, so a
|
||||||
|
// changed constant is a real change, unlike a relocated branch/RIP displacement.
|
||||||
|
let a = digest_code(&[0xB8, 0x01, 0x00, 0x00, 0x00, 0xC3], 0x1000); // mov eax, 1 ; ret
|
||||||
|
let b = digest_code(&[0xB8, 0x02, 0x00, 0x00, 0x00, 0xC3], 0x1000); // mov eax, 2 ; ret
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_is_none() {
|
||||||
|
assert_eq!(digest_code(&[], 0x1000), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
288
src/fingerprint.rs
Normal file
288
src/fingerprint.rs
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
//! Structural, recompilation-invariant fingerprint of a function — integer features only.
|
||||||
|
//!
|
||||||
|
//! Walks the control-flow graph from the entry point (bounded), decoding with iced-x86, and
|
||||||
|
//! summarises shape into counts that survive a recompile: instruction/block/call/branch counts,
|
||||||
|
//! distinct callees, RIP-relative data references, and a mnemonic-category histogram. No
|
||||||
|
//! addresses or immediates enter the vector (those change every build); only structure does.
|
||||||
|
|
||||||
|
use crate::elf::CodeImage;
|
||||||
|
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
pub const NCATS: usize = 18;
|
||||||
|
/// Hash buckets for the referenced-string content sketch.
|
||||||
|
pub const NREF: usize = 32;
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct Fingerprint {
|
||||||
|
pub size_bytes: u32,
|
||||||
|
pub insns: u32,
|
||||||
|
pub blocks: u32,
|
||||||
|
pub calls: u32,
|
||||||
|
pub distinct_callees: u32,
|
||||||
|
pub cond_branches: u32,
|
||||||
|
pub uncond_branches: u32,
|
||||||
|
pub rets: u32,
|
||||||
|
pub data_refs: u32,
|
||||||
|
pub indirect: u32,
|
||||||
|
// call-graph context: aggregate shape of this function's callees (a callee's structure is
|
||||||
|
// build-invariant), which distinguishes otherwise-identical thunks by which function they hit.
|
||||||
|
pub ctx_ins: u32,
|
||||||
|
pub ctx_calls: u32,
|
||||||
|
pub ctx_br: u32,
|
||||||
|
pub cats: [u32; NCATS],
|
||||||
|
// content sketch: hash-bucket histogram of the printable rodata strings this function
|
||||||
|
// references. The string *content* is build-invariant and highly function-specific, so it adds
|
||||||
|
// discriminative signal the pure structural counts lack.
|
||||||
|
pub refs: [u32; NREF],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fingerprint {
|
||||||
|
pub fn to_vec(&self) -> Vec<u32> {
|
||||||
|
let mut v = vec![
|
||||||
|
self.size_bytes,
|
||||||
|
self.insns,
|
||||||
|
self.blocks,
|
||||||
|
self.calls,
|
||||||
|
self.distinct_callees,
|
||||||
|
self.cond_branches,
|
||||||
|
self.uncond_branches,
|
||||||
|
self.rets,
|
||||||
|
self.data_refs,
|
||||||
|
self.indirect,
|
||||||
|
self.ctx_ins,
|
||||||
|
self.ctx_calls,
|
||||||
|
self.ctx_br,
|
||||||
|
];
|
||||||
|
v.extend_from_slice(&self.cats);
|
||||||
|
v.extend_from_slice(&self.refs);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If `target` points at a printable C string in read-only data, hash it into a `[0, NREF)` bucket.
|
||||||
|
/// Only strings of a few printable chars count — this skips jump tables and pointer arrays (whose
|
||||||
|
/// bytes are addresses that move across builds), keeping the sketch build-invariant.
|
||||||
|
fn string_bucket(img: &CodeImage, target: u64) -> Option<usize> {
|
||||||
|
let bytes = img.rodata_at(target, 32)?;
|
||||||
|
let run: &[u8] = {
|
||||||
|
let end = bytes
|
||||||
|
.iter()
|
||||||
|
.position(|&c| !(0x20..0x7f).contains(&c))
|
||||||
|
.unwrap_or(bytes.len());
|
||||||
|
&bytes[..end]
|
||||||
|
};
|
||||||
|
if run.len() < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// FNV-1a over the printable run
|
||||||
|
let mut h: u64 = 0xcbf29ce484222325;
|
||||||
|
for &b in run {
|
||||||
|
h = (h ^ b as u64).wrapping_mul(0x100000001b3);
|
||||||
|
}
|
||||||
|
Some((h % NREF as u64) as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn category(insn: &Instruction) -> usize {
|
||||||
|
match insn.flow_control() {
|
||||||
|
FlowControl::Call | FlowControl::IndirectCall => return 10,
|
||||||
|
FlowControl::UnconditionalBranch | FlowControl::IndirectBranch => return 11,
|
||||||
|
FlowControl::ConditionalBranch => return 12,
|
||||||
|
FlowControl::Return => return 13,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if insn.op0_register().is_xmm()
|
||||||
|
|| insn.op0_register().is_ymm()
|
||||||
|
|| insn.op1_register().is_xmm()
|
||||||
|
|| insn.op1_register().is_ymm()
|
||||||
|
{
|
||||||
|
return 17;
|
||||||
|
}
|
||||||
|
use Mnemonic::*;
|
||||||
|
match insn.mnemonic() {
|
||||||
|
Mov | Movzx | Movsx | Movsxd | Xchg => 0,
|
||||||
|
Lea => 1,
|
||||||
|
Push => 2,
|
||||||
|
Pop => 3,
|
||||||
|
Add | Adc | Sub | Sbb | Inc | Dec | Neg => 4,
|
||||||
|
Imul | Mul | Idiv | Div => 5,
|
||||||
|
And | Or | Xor | Not => 6,
|
||||||
|
Shl | Shr | Sar | Rol | Ror | Shld | Shrd => 7,
|
||||||
|
Cmp => 8,
|
||||||
|
Test => 9,
|
||||||
|
Nop | Int3 => 14,
|
||||||
|
Leave => 15,
|
||||||
|
_ => 16,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn near_target(insn: &Instruction) -> Option<u64> {
|
||||||
|
match insn.op0_kind() {
|
||||||
|
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64 => {
|
||||||
|
Some(insn.near_branch_target())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_span(t: u64, entry: u64, cap: usize) -> bool {
|
||||||
|
t >= entry && (t - entry) as usize <= cap
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Base {
|
||||||
|
f: Fingerprint,
|
||||||
|
targets: Vec<u64>, // outgoing call + tail-jump destinations
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap, build-invariant summary of a callee — (instructions, calls, branches) over a bounded
|
||||||
|
/// linear scan from its entry. Enough to tell one thunk's target from another's.
|
||||||
|
fn light_summary(img: &CodeImage, addr: u64) -> (u32, u32, u32) {
|
||||||
|
let Some(code) = img.code_at(addr) else {
|
||||||
|
return (0, 0, 0);
|
||||||
|
};
|
||||||
|
let mut dec = Decoder::with_ip(64, code, addr, DecoderOptions::NONE);
|
||||||
|
let (mut ins, mut calls, mut br) = (0u32, 0u32, 0u32);
|
||||||
|
while dec.can_decode() && ins < 48 {
|
||||||
|
let insn = dec.decode();
|
||||||
|
if insn.len() == 0 || insn.is_invalid() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ins += 1;
|
||||||
|
match insn.flow_control() {
|
||||||
|
FlowControl::Call | FlowControl::IndirectCall => calls += 1,
|
||||||
|
FlowControl::ConditionalBranch
|
||||||
|
| FlowControl::UnconditionalBranch
|
||||||
|
| FlowControl::IndirectBranch => br += 1,
|
||||||
|
FlowControl::Return => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(ins, calls, br)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the fingerprint of `entry` — base structure plus one hop of call-graph context.
|
||||||
|
pub fn extract(img: &CodeImage, entry: u64) -> Option<Fingerprint> {
|
||||||
|
let base = extract_base(img, entry)?;
|
||||||
|
let mut f = base.f;
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for &t in &base.targets {
|
||||||
|
if t != entry && img.is_code(t) && seen.insert(t) {
|
||||||
|
let (ins, calls, br) = light_summary(img, t);
|
||||||
|
f.ctx_ins = f.ctx_ins.wrapping_add(ins);
|
||||||
|
f.ctx_calls = f.ctx_calls.wrapping_add(calls);
|
||||||
|
f.ctx_br = f.ctx_br.wrapping_add(br);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base structural features + the function's outgoing call/tail-jump targets.
|
||||||
|
fn extract_base(img: &CodeImage, entry: u64) -> Option<Base> {
|
||||||
|
let code = img.code_at(entry)?;
|
||||||
|
const MAX_SPAN: usize = 96 * 1024;
|
||||||
|
const MAX_INSNS: u32 = 8000;
|
||||||
|
let cap = code.len().min(MAX_SPAN);
|
||||||
|
|
||||||
|
let mut f = Fingerprint::default();
|
||||||
|
let mut visited: HashSet<u64> = HashSet::new();
|
||||||
|
let mut leaders: HashSet<u64> = HashSet::new();
|
||||||
|
let mut callees: HashSet<u64> = HashSet::new();
|
||||||
|
let mut targets: Vec<u64> = Vec::new();
|
||||||
|
let mut hi = entry;
|
||||||
|
let mut work = vec![entry];
|
||||||
|
leaders.insert(entry);
|
||||||
|
|
||||||
|
while let Some(start) = work.pop() {
|
||||||
|
let mut ip = start;
|
||||||
|
loop {
|
||||||
|
if ip < entry || (ip - entry) as usize >= cap || f.insns >= MAX_INSNS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !visited.insert(ip) {
|
||||||
|
break; // this path merged into already-decoded code
|
||||||
|
}
|
||||||
|
let off = (ip - entry) as usize;
|
||||||
|
let mut dec = Decoder::with_ip(64, &code[off..], ip, DecoderOptions::NONE);
|
||||||
|
if !dec.can_decode() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let insn = dec.decode();
|
||||||
|
let ilen = insn.len() as u64;
|
||||||
|
if ilen == 0 || insn.is_invalid() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
f.insns += 1;
|
||||||
|
f.cats[category(&insn)] += 1;
|
||||||
|
if insn.is_ip_rel_memory_operand() {
|
||||||
|
f.data_refs += 1;
|
||||||
|
if let Some(bucket) = string_bucket(img, insn.memory_displacement64()) {
|
||||||
|
f.refs[bucket] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ip + ilen > hi {
|
||||||
|
hi = ip + ilen;
|
||||||
|
}
|
||||||
|
let next = ip + ilen;
|
||||||
|
match insn.flow_control() {
|
||||||
|
FlowControl::Return => {
|
||||||
|
f.rets += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
FlowControl::Call => {
|
||||||
|
f.calls += 1;
|
||||||
|
match near_target(&insn) {
|
||||||
|
Some(t) => {
|
||||||
|
callees.insert(t);
|
||||||
|
targets.push(t);
|
||||||
|
}
|
||||||
|
None => f.indirect += 1,
|
||||||
|
}
|
||||||
|
ip = next;
|
||||||
|
}
|
||||||
|
FlowControl::IndirectCall => {
|
||||||
|
f.calls += 1;
|
||||||
|
f.indirect += 1;
|
||||||
|
ip = next;
|
||||||
|
}
|
||||||
|
FlowControl::ConditionalBranch => {
|
||||||
|
f.cond_branches += 1;
|
||||||
|
if let Some(t) = near_target(&insn)
|
||||||
|
&& in_span(t, entry, cap)
|
||||||
|
{
|
||||||
|
leaders.insert(t);
|
||||||
|
work.push(t);
|
||||||
|
}
|
||||||
|
leaders.insert(next);
|
||||||
|
ip = next;
|
||||||
|
}
|
||||||
|
FlowControl::UnconditionalBranch => {
|
||||||
|
f.uncond_branches += 1;
|
||||||
|
match near_target(&insn) {
|
||||||
|
Some(t) if in_span(t, entry, cap) => {
|
||||||
|
leaders.insert(t);
|
||||||
|
ip = t;
|
||||||
|
}
|
||||||
|
Some(t) => {
|
||||||
|
targets.push(t); // tail call (thunk target)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
None => break, // indirect jump
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlowControl::IndirectBranch => {
|
||||||
|
f.indirect += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => ip = next,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.insns == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
f.blocks = leaders.len() as u32;
|
||||||
|
f.distinct_callees = callees.len() as u32;
|
||||||
|
f.size_bytes = (hi - entry) as u32;
|
||||||
|
Some(Base { f, targets })
|
||||||
|
}
|
||||||
47
src/lib.rs
Normal file
47
src/lib.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
//! source2rosetta — library crate: the reusable derivation/verification engine behind the CLI.
|
||||||
|
//!
|
||||||
|
//! The `source2rosetta` binary (`src/main.rs`) is a thin clap front-end over these modules. Keeping the
|
||||||
|
//! logic in a library lets the CI pipeline (and tests) link and call it directly instead of shelling
|
||||||
|
//! out and scraping stdout.
|
||||||
|
//!
|
||||||
|
//! # Supported API surface
|
||||||
|
//! A fork or embedder calls into these. Every engine entry point takes an explicit `&profile::GameProfile`
|
||||||
|
//! (there is NO process-global — CS2 and Dota can be derived in the same process):
|
||||||
|
//! - [`pipeline`] — the pure OFFLINE derivation engine (nothing here attaches to a running server):
|
||||||
|
//! `corpus_model_cmd` (distill the corpus model), `fold_model_cmd` (roll model N → N+1), `backfill_cmd`
|
||||||
|
//! (cross-build name/offset timelines), plus the `ClassScope` / `CorpusSource` inputs.
|
||||||
|
//! - [`produce`] — CI orchestration + the LIVE half (everything that drives a running server): `produce_cmd`
|
||||||
|
//! (the whole per-game build — boots its own bots server for validate-live + typed netvars when a game is
|
||||||
|
//! given), `integration_test_cmd` (the standalone live oracle), `classify_change_cmd` / `filter_corpus_cmd`
|
||||||
|
//! (the CI branch primitives), `unpack_seed`.
|
||||||
|
//! - [`profile`] — the per-game knobs: [`profile::GameProfile`] plus the `CS2` / `DOTA` consts. Adding a game is a const here.
|
||||||
|
//! - [`model`] / [`render`] (re-exported from `source2rosetta-core`) — the canonical derived-gamedata model and its
|
||||||
|
//! format emitters; the standalone `source2rosetta-gen` binary links just `core`.
|
||||||
|
//!
|
||||||
|
//! # Low-level engine (implementation detail)
|
||||||
|
//! The modules below are the building blocks the API composes (ELF/RTTI/SchemaSystem readers, the fingerprint
|
||||||
|
//! metric, the sig/abi machinery, the data-parallel primitive, the name taxonomy). They stay `pub` for the fuzz
|
||||||
|
//! harness and advanced embedders, but carry NO stability promise — treat them as internal.
|
||||||
|
|
||||||
|
// ---- supported API ----
|
||||||
|
pub mod pipeline;
|
||||||
|
pub mod produce;
|
||||||
|
pub mod profile;
|
||||||
|
|
||||||
|
// ---- low-level engine (implementation detail; `pub` only for the fuzz harness, not a stable surface) ----
|
||||||
|
pub mod abi;
|
||||||
|
pub mod elf;
|
||||||
|
pub mod emit;
|
||||||
|
pub mod fingerprint;
|
||||||
|
pub mod live;
|
||||||
|
pub mod locate;
|
||||||
|
pub mod par;
|
||||||
|
pub mod rtti;
|
||||||
|
pub mod schema;
|
||||||
|
pub mod sig;
|
||||||
|
pub mod taxonomy;
|
||||||
|
pub mod xref;
|
||||||
|
|
||||||
|
// The canonical model + emitters live in the deriver-free `source2rosetta-core` crate; re-export them so
|
||||||
|
// existing `source2rosetta::{model, render}` paths keep resolving.
|
||||||
|
pub use source2rosetta_core::{model, render};
|
||||||
310
src/live.rs
Normal file
310
src/live.rs
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
//! Read-only window into a *running* CS2 server's memory — the runtime oracle that verifies the
|
||||||
|
//! offline derivations against ground truth. No injection, no debugger: just `/proc/<pid>/mem` (needs
|
||||||
|
//! ptrace access — same-user with `yama/ptrace_scope=0`, or `CAP_SYS_PTRACE`).
|
||||||
|
//!
|
||||||
|
//! 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
|
||||||
|
//! confirms both our relocation logic and the struct layout — and, because runtime-populated fields
|
||||||
|
//! (e.g. `m_pSchemaBinding`) are non-null live but zero on disk, proves we are reading live state.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::os::unix::fs::FileExt;
|
||||||
|
|
||||||
|
pub struct LiveProcess {
|
||||||
|
mem: File,
|
||||||
|
bases: HashMap<String, u64>, // library filename -> load base (lowest mapping address)
|
||||||
|
paths: HashMap<String, String>, // library filename -> the FULL path the process actually mapped
|
||||||
|
writable: Vec<(u64, u64)>, // rw anonymous regions (heap etc.) — where live objects live
|
||||||
|
executable: Vec<(u64, u64)>, // r-x regions — where valid code/vtable-slot targets must land
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LiveProcess {
|
||||||
|
pub fn attach(pid: u32) -> Result<Self> {
|
||||||
|
let maps = std::fs::read_to_string(format!("/proc/{pid}/maps"))
|
||||||
|
.with_context(|| format!("read /proc/{pid}/maps (is pid {pid} running?)"))?;
|
||||||
|
let mut bases: HashMap<String, u64> = HashMap::new();
|
||||||
|
let mut paths: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut writable: Vec<(u64, u64)> = Vec::new();
|
||||||
|
let mut executable: Vec<(u64, u64)> = Vec::new();
|
||||||
|
for line in maps.lines() {
|
||||||
|
// format: START-END perms offset dev inode path
|
||||||
|
let (range, rest) = match line.split_once(' ') {
|
||||||
|
Some(x) => x,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let perms = rest.split(' ').next().unwrap_or("");
|
||||||
|
let path = line.rsplit_once(char::is_whitespace).map_or("", |(_, p)| p);
|
||||||
|
let Some((start, end)) = range.split_once('-').and_then(|(a, b)| {
|
||||||
|
Some((
|
||||||
|
u64::from_str_radix(a, 16).ok()?,
|
||||||
|
u64::from_str_radix(b, 16).ok()?,
|
||||||
|
))
|
||||||
|
}) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if path.ends_with(".so") && path.starts_with('/') {
|
||||||
|
let fname = path.rsplit('/').next().unwrap_or(path).to_string();
|
||||||
|
paths
|
||||||
|
.entry(fname.clone())
|
||||||
|
.or_insert_with(|| path.to_string());
|
||||||
|
bases
|
||||||
|
.entry(fname)
|
||||||
|
.and_modify(|b| *b = (*b).min(start))
|
||||||
|
.or_insert(start);
|
||||||
|
}
|
||||||
|
// writable anonymous memory = the heap where runtime objects (entities) are allocated
|
||||||
|
if perms.starts_with("rw") && (path.is_empty() || path == "[heap]") {
|
||||||
|
writable.push((start, end));
|
||||||
|
}
|
||||||
|
if perms.starts_with('r') && perms.contains('x') {
|
||||||
|
executable.push((start, end));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
executable.sort_unstable();
|
||||||
|
let mem = File::open(format!("/proc/{pid}/mem")).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"open /proc/{pid}/mem — needs ptrace access (yama ptrace_scope=0 or run as root)"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
mem,
|
||||||
|
bases,
|
||||||
|
paths,
|
||||||
|
writable,
|
||||||
|
executable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `addr` inside an executable mapping? A valid function pointer / vtable slot target must be.
|
||||||
|
pub fn is_exec(&self, addr: u64) -> bool {
|
||||||
|
self.executable
|
||||||
|
.binary_search_by(|&(s, e)| {
|
||||||
|
if addr < s {
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
} else if addr >= e {
|
||||||
|
std::cmp::Ordering::Less
|
||||||
|
} else {
|
||||||
|
std::cmp::Ordering::Equal
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort read of `n` bytes at runtime `addr` (short/empty on an unmapped page).
|
||||||
|
pub fn read_bytes(&self, addr: u64, n: usize) -> Vec<u8> {
|
||||||
|
let mut buf = vec![0u8; n];
|
||||||
|
let got = self.mem.read_at(&mut buf, addr).unwrap_or(0);
|
||||||
|
buf.truncate(got);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the writable/heap regions for object instances whose vtable pointer is `vtable` — i.e.
|
||||||
|
/// live instances of the class that owns that vtable. Returns the object base addresses (an
|
||||||
|
/// object's first qword is its vtable pointer). Stops at `max` hits.
|
||||||
|
pub fn find_instances(&self, vtable: u64, max: usize) -> Vec<u64> {
|
||||||
|
let mut hits = Vec::new();
|
||||||
|
let mut buf = vec![0u8; 1 << 20]; // 1 MiB window
|
||||||
|
let needle = vtable.to_le_bytes();
|
||||||
|
'outer: for &(start, end) in &self.writable {
|
||||||
|
let mut addr = start;
|
||||||
|
while addr < end {
|
||||||
|
let n = ((end - addr) as usize).min(buf.len());
|
||||||
|
// `buf` is reused across windows, so scanning past a SHORT read matches stale bytes from the
|
||||||
|
// previous window and reports addresses that hold nothing of the sort. Bind both the scan and
|
||||||
|
// the advance to what was actually read.
|
||||||
|
let got = self.mem.read_at(&mut buf[..n], addr).unwrap_or(0);
|
||||||
|
if got < 8 {
|
||||||
|
addr += n as u64;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// objects are pointer-aligned, so only 8-aligned positions can be a vtable slot
|
||||||
|
let mut i = 0;
|
||||||
|
while i + 8 <= got {
|
||||||
|
if buf[i..i + 8] == needle {
|
||||||
|
hits.push(addr + i as u64);
|
||||||
|
if hits.len() >= max {
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 8;
|
||||||
|
}
|
||||||
|
// advance by the 8-aligned prefix consumed: a persistently short-reading region still makes
|
||||||
|
// progress (never re-reads the same bytes), and the skipped tail is retried on the next pass.
|
||||||
|
addr += (got & !7) as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load base (slide) of a library — its lowest mapping address. Since CS2 `.so` files link at
|
||||||
|
/// vaddr 0, the runtime address of a file vaddr `v` is simply `base + v`.
|
||||||
|
pub fn base(&self, lib: &str) -> Option<u64> {
|
||||||
|
self.bases.get(lib).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full path the process actually mapped for `lib` (a basename). The authority on WHICH file of a
|
||||||
|
/// given name is loaded when several exist on disk — a game tree can hold the engine's own
|
||||||
|
/// `libserver.so` and a loader shim of the same name several directories away.
|
||||||
|
pub fn mapped_path(&self, lib: &str) -> Option<&str> {
|
||||||
|
self.paths.get(lib).map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self, addr: u64, buf: &mut [u8]) -> Result<()> {
|
||||||
|
self.mem
|
||||||
|
.read_exact_at(buf, addr)
|
||||||
|
.with_context(|| format!("read {} bytes at {addr:#x}", buf.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_u64(&self, addr: u64) -> Result<u64> {
|
||||||
|
let mut b = [0u8; 8];
|
||||||
|
self.read(addr, &mut b)?;
|
||||||
|
Ok(u64::from_le_bytes(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_i32(&self, addr: u64) -> Result<i32> {
|
||||||
|
let mut b = [0u8; 4];
|
||||||
|
self.read(addr, &mut b)?;
|
||||||
|
Ok(i32::from_le_bytes(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_u16(&self, addr: u64) -> Result<u16> {
|
||||||
|
let mut b = [0u8; 2];
|
||||||
|
self.read(addr, &mut b)?;
|
||||||
|
Ok(u16::from_le_bytes(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NUL-terminated string at runtime `addr` (bounded). Reads may land near an unmapped page, so a
|
||||||
|
/// short read is fine — we take whatever came back up to the terminator.
|
||||||
|
pub fn read_cstr(&self, addr: u64) -> Result<String> {
|
||||||
|
let mut buf = [0u8; 256];
|
||||||
|
let n = self.mem.read_at(&mut buf, addr).unwrap_or(0);
|
||||||
|
let end = buf[..n].iter().position(|&c| c == 0).unwrap_or(n);
|
||||||
|
Ok(String::from_utf8_lossy(&buf[..end]).into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a remote call: the return value (RAX) and whether the function returned cleanly to our
|
||||||
|
/// trap (vs faulting internally on a bad argument).
|
||||||
|
pub struct CallResult {
|
||||||
|
pub rax: u64,
|
||||||
|
pub clean_return: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call the function at runtime address `func` inside process `pid` with `args` (SysV: up to 6 in
|
||||||
|
/// registers), via ptrace. Attaches, saves the main thread's registers, sets up a call frame whose
|
||||||
|
/// return address is 0 (so the function traps on return, where we read RAX), runs it, then restores
|
||||||
|
/// the thread exactly — the SIGSEGV from the return trap is suppressed. Needs ptrace permission
|
||||||
|
/// (owned child, or same-user with ptrace_scope=0). UNSAFE: only call leaf-ish functions with valid args.
|
||||||
|
pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
|
||||||
|
use anyhow::bail;
|
||||||
|
let dbg = std::env::var("SOURCE2ROSETTA_DBG").is_ok();
|
||||||
|
unsafe {
|
||||||
|
if libc::ptrace(libc::PTRACE_ATTACH, pid, 0usize, 0usize) < 0 {
|
||||||
|
bail!(
|
||||||
|
"PTRACE_ATTACH {pid} failed (errno {}) — need ptrace permission",
|
||||||
|
errno()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut status = 0i32;
|
||||||
|
if libc::waitpid(pid, &mut status, 0) < 0 {
|
||||||
|
libc::ptrace(libc::PTRACE_DETACH, pid, 0usize, 0usize);
|
||||||
|
bail!("waitpid(attach) failed");
|
||||||
|
}
|
||||||
|
if dbg {
|
||||||
|
eprintln!(
|
||||||
|
"[call] attached; stop status {status:#x} (stopped={})",
|
||||||
|
libc::WIFSTOPPED(status)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut saved: libc::user_regs_struct = std::mem::zeroed();
|
||||||
|
if libc::ptrace(libc::PTRACE_GETREGS, pid, 0usize, &mut saved as *mut _) < 0 {
|
||||||
|
libc::ptrace(libc::PTRACE_DETACH, pid, 0usize, 0usize);
|
||||||
|
bail!("PTRACE_GETREGS failed");
|
||||||
|
}
|
||||||
|
let restore = |saved: &libc::user_regs_struct| {
|
||||||
|
libc::ptrace(libc::PTRACE_SETREGS, pid, 0usize, saved as *const _);
|
||||||
|
libc::ptrace(libc::PTRACE_DETACH, pid, 0usize, 0usize);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut regs = saved;
|
||||||
|
// If we attached mid-syscall, orig_rax holds the syscall number and the kernel would run its
|
||||||
|
// syscall-restart logic on our injected rip. Setting it to -1 says "no syscall in progress".
|
||||||
|
regs.orig_rax = u64::MAX;
|
||||||
|
let slots = [
|
||||||
|
&mut regs.rdi as *mut u64,
|
||||||
|
&mut regs.rsi,
|
||||||
|
&mut regs.rdx,
|
||||||
|
&mut regs.rcx,
|
||||||
|
&mut regs.r8,
|
||||||
|
&mut regs.r9,
|
||||||
|
];
|
||||||
|
for (i, &a) in args.iter().take(6).enumerate() {
|
||||||
|
*slots[i] = a;
|
||||||
|
}
|
||||||
|
// Scratch stack BELOW the 128-byte redzone so we never corrupt the interrupted frame; write a
|
||||||
|
// return address of 0 and keep SysV's `rsp % 16 == 8` at function entry.
|
||||||
|
let mut sp = (saved.rsp - 512) & !0xfu64;
|
||||||
|
sp -= 8;
|
||||||
|
if libc::ptrace(libc::PTRACE_POKEDATA, pid, sp as usize, 0usize) < 0 {
|
||||||
|
restore(&saved);
|
||||||
|
bail!(
|
||||||
|
"POKEDATA(return addr) at {sp:#x} failed (errno {})",
|
||||||
|
errno()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let wrote = libc::ptrace(libc::PTRACE_PEEKDATA, pid, sp as usize, 0usize);
|
||||||
|
regs.rsp = sp;
|
||||||
|
regs.rip = func;
|
||||||
|
if libc::ptrace(libc::PTRACE_SETREGS, pid, 0usize, ®s as *const _) < 0 {
|
||||||
|
restore(&saved);
|
||||||
|
bail!("PTRACE_SETREGS failed");
|
||||||
|
}
|
||||||
|
if dbg {
|
||||||
|
eprintln!(
|
||||||
|
"[call] rip={func:#x} rsp={sp:#x} rdi={:#x} retaddr-slot={wrote:#x} (want 0)",
|
||||||
|
regs.rdi
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run, absorbing any spurious signals, until the function returns into our null trap.
|
||||||
|
loop {
|
||||||
|
libc::ptrace(libc::PTRACE_CONT, pid, 0usize, 0usize);
|
||||||
|
if libc::waitpid(pid, &mut status, 0) < 0 || !libc::WIFSTOPPED(status) {
|
||||||
|
restore(&saved);
|
||||||
|
bail!("target vanished mid-call (status {status:#x})");
|
||||||
|
}
|
||||||
|
let sig = libc::WSTOPSIG(status);
|
||||||
|
let mut cur: libc::user_regs_struct = std::mem::zeroed();
|
||||||
|
libc::ptrace(libc::PTRACE_GETREGS, pid, 0usize, &mut cur as *mut _);
|
||||||
|
if dbg {
|
||||||
|
eprintln!(
|
||||||
|
"[call] stop sig={sig} rip={:#x} rax={:#x}",
|
||||||
|
cur.rip, cur.rax
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if cur.rip == 0 {
|
||||||
|
let r = CallResult {
|
||||||
|
rax: cur.rax,
|
||||||
|
clean_return: true,
|
||||||
|
};
|
||||||
|
restore(&saved);
|
||||||
|
return Ok(r);
|
||||||
|
}
|
||||||
|
if sig == libc::SIGSEGV || sig == libc::SIGILL || sig == libc::SIGBUS {
|
||||||
|
let r = CallResult {
|
||||||
|
rax: cur.rax,
|
||||||
|
clean_return: false,
|
||||||
|
};
|
||||||
|
restore(&saved);
|
||||||
|
return Ok(r);
|
||||||
|
}
|
||||||
|
// any other signal (SIGSTOP/timer/…): swallow it and keep running the call
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn errno() -> i32 {
|
||||||
|
unsafe { *libc::__errno_location() }
|
||||||
|
}
|
||||||
95
src/locate.rs
Normal file
95
src/locate.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
//! Locating primitives: where a library file lives ON DISK, and where the function entries live
|
||||||
|
//! INSIDE an image.
|
||||||
|
//!
|
||||||
|
//! On disk: `find_file` resolves a lib by name under a build tree (nearest-depth-wins, so a Metamod
|
||||||
|
//! shim can't shadow the real engine lib) and `load_lib` turns a build dir *or* a bare `.so` into a
|
||||||
|
//! loaded [`CodeImage`]. Both are leaf primitives (they touch only `elf` + the filesystem), so the
|
||||||
|
//! low-level readers — `schema` especially — depend on THIS module rather than up on the engine.
|
||||||
|
//!
|
||||||
|
//! In an image: [`candidate_entries`] enumerates plausible function starts without symbols — relocation
|
||||||
|
//! values that point into code (vtable slots + function pointers — covers virtual functions) unioned
|
||||||
|
//! with the targets of direct near `call`s found by a linear sweep. `xref` unions this with `.eh_frame`
|
||||||
|
//! starts to index the whole binary.
|
||||||
|
|
||||||
|
use crate::elf::CodeImage;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use iced_x86::{Decoder, DecoderOptions, FlowControl, OpKind};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Every plausible function entry address in `img`: relocation values that point into code, plus
|
||||||
|
/// the targets of direct near `call`s found by a linear sweep. Sorted, de-duplicated.
|
||||||
|
pub fn candidate_entries(img: &CodeImage) -> Vec<u64> {
|
||||||
|
let mut set: BTreeSet<u64> = img.code_pointer_targets().into_iter().collect();
|
||||||
|
for (va, code) in img.exec_blocks() {
|
||||||
|
let mut dec = Decoder::with_ip(64, code, va, DecoderOptions::NONE);
|
||||||
|
while dec.can_decode() {
|
||||||
|
let insn = dec.decode(); // iced advances one byte on invalid, so the sweep self-resyncs
|
||||||
|
if insn.flow_control() == FlowControl::Call
|
||||||
|
&& matches!(
|
||||||
|
insn.op0_kind(),
|
||||||
|
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
||||||
|
)
|
||||||
|
{
|
||||||
|
let t = insn.near_branch_target();
|
||||||
|
if img.is_code(t) {
|
||||||
|
set.insert(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shallowest file named `name` under `dir` (bounded depth), ties broken by sorted path.
|
||||||
|
///
|
||||||
|
/// NEAREST-DEPTH-WINS, not first-`read_dir`-hit: a game install legitimately holds several files of the same
|
||||||
|
/// basename, and the shallowest is the real one. A CS2 tree has the engine's own
|
||||||
|
/// `csgo/bin/linuxsteamrt64/libserver.so` at depth 3 and Metamod's ~300 KB loader shim of the SAME name at
|
||||||
|
/// `csgo/addons/metamod/bin/linuxsteamrt64/libserver.so` (depth 5, plus any `bin.*.bak` siblings). Depth-first
|
||||||
|
/// order made which one you derive from a property of directory-entry order — deriving against the shim would
|
||||||
|
/// yield garbage — and plain sorting is WORSE, since `addons` sorts before `bin`. Sorting is only the tie-break
|
||||||
|
/// among equally-shallow candidates, so the result never depends on filesystem enumeration order.
|
||||||
|
pub(crate) fn find_file(dir: &Path, name: &str, depth: usize) -> Option<PathBuf> {
|
||||||
|
let mut level = vec![dir.to_path_buf()];
|
||||||
|
for _ in 0..depth {
|
||||||
|
let (mut hits, mut next) = (Vec::new(), Vec::new());
|
||||||
|
for d in &level {
|
||||||
|
let Ok(rd) = std::fs::read_dir(d) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for e in rd.flatten() {
|
||||||
|
let p = e.path();
|
||||||
|
if p.is_dir() {
|
||||||
|
next.push(p);
|
||||||
|
} else if p.file_name().and_then(|s| s.to_str()) == Some(name) {
|
||||||
|
hits.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hits.is_empty() {
|
||||||
|
hits.sort();
|
||||||
|
return hits.into_iter().next();
|
||||||
|
}
|
||||||
|
if next.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
next.sort();
|
||||||
|
level = next;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate `lib` under `dir` (depth 8) and load it as a `CodeImage` — the `find_file` + load pattern the
|
||||||
|
/// command entry points share.
|
||||||
|
pub(crate) fn load_lib(path: &Path, lib: &str) -> Result<CodeImage> {
|
||||||
|
// Accept a build DIR (find `lib` within, depth 8) or a direct `.so` FILE (load as-is), so callers can
|
||||||
|
// pass `path/to/build_dir` or `path/to/libserver.so` interchangeably (e.g. `classify-change --prev`).
|
||||||
|
let file = if path.is_file() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
find_file(path, lib, 8)
|
||||||
|
.with_context(|| format!("{lib} not found under {}", path.display()))?
|
||||||
|
};
|
||||||
|
CodeImage::load(&file)
|
||||||
|
}
|
||||||
482
src/main.rs
Normal file
482
src/main.rs
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
//! source2rosetta — CLI front-end. A thin clap layer over `source2rosetta::pipeline`: parse args,
|
||||||
|
//! select the game profile, dispatch to the engine.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use source2rosetta::pipeline::{
|
||||||
|
ClassScope, backfill_cmd, corpus_model_cmd, fold_model_cmd, load_model,
|
||||||
|
};
|
||||||
|
use source2rosetta::produce::{
|
||||||
|
ProduceArgs, SeedInputs, classify_change_cmd, filter_corpus_cmd, integration_test_cmd,
|
||||||
|
produce_cmd, unpack_seed,
|
||||||
|
};
|
||||||
|
use source2rosetta::profile;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// The game whose profile drives lib/pawn/launch/dead-weight knobs. Adding a game = a profile const + an arm.
|
||||||
|
#[derive(Clone, Copy, clap::ValueEnum)]
|
||||||
|
enum Game {
|
||||||
|
#[value(alias = "csgo")]
|
||||||
|
Cs2,
|
||||||
|
#[value(alias = "dota")]
|
||||||
|
Dota2,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "source2rosetta",
|
||||||
|
about = "Locate Source-2 engine functions across builds"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// Which game's profile to use — selects lib/pawn/launch/game-key/dead-weight knobs. Source-2-generic
|
||||||
|
/// behavior is unaffected; only the game-specific paths read the selected profile. (Known: cs2, dota2.)
|
||||||
|
#[arg(long, global = true, value_enum, default_value = "cs2")]
|
||||||
|
game: Game,
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Cmd,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Own the process end-to-end for CI — no human, no mod: LAUNCH a VANILLA dedicated server for the
|
||||||
|
/// selected `--game`, populate it (CS2: bots on an empty deathmatch; a pawn-less game like Dota waits
|
||||||
|
/// for its `ready_class` proxy instead), then verify the derived gamedata against it — the schema oracle,
|
||||||
|
/// a semantic ptrace CALL on a live pawn (pawn games only), and (with --gamedata) a full validate-live.
|
||||||
|
/// (Disable metamod in the game's `gameinfo.gi` for a truly vanilla run — no hooks, clean pass/fail.)
|
||||||
|
IntegrationTest {
|
||||||
|
/// Game root (contains `bin/linuxsteamrt64/<executable>` and the content dir).
|
||||||
|
#[arg(long = "game-dir")]
|
||||||
|
game_dir: PathBuf,
|
||||||
|
/// Dir holding the on-disk libserver.so for the offline reference (defaults to --game).
|
||||||
|
#[arg(long)]
|
||||||
|
build: Option<PathBuf>,
|
||||||
|
/// Server library to derive from; defaults to the active game's server lib.
|
||||||
|
#[arg(long)]
|
||||||
|
lib: Option<String>,
|
||||||
|
/// Seconds to wait for the server to come up and bots to spawn alive.
|
||||||
|
#[arg(long, default_value_t = 60)]
|
||||||
|
wait: u64,
|
||||||
|
#[arg(long)] // default resolved from the active game profile at dispatch
|
||||||
|
map: Option<String>,
|
||||||
|
/// Number of bots to fill the server with.
|
||||||
|
#[arg(long, default_value_t = 9)]
|
||||||
|
bots: u32,
|
||||||
|
/// Optional gamedata json to also validate-live against the running server.
|
||||||
|
#[arg(long)]
|
||||||
|
gamedata: Option<PathBuf>,
|
||||||
|
/// Write the validated (kept) gamedata here (with --gamedata) — so this one command owns the
|
||||||
|
/// server AND persists the live-validated result, no separate validate-live needed.
|
||||||
|
#[arg(long)]
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
/// Leave the launched server running instead of killing it after the test.
|
||||||
|
#[arg(long)]
|
||||||
|
keep: bool,
|
||||||
|
/// With --gamedata, also run the LIVE fuzzer against this same server for N randomized probes
|
||||||
|
/// (0 = off). Reuses the launched server — no separate `fuzz-live` run needed for CI.
|
||||||
|
#[arg(long, default_value_t = 500)]
|
||||||
|
fuzz_iterations: usize,
|
||||||
|
},
|
||||||
|
/// The whole per-game build in ONE in-memory command: derive → fold → (if `--game-dir` is given)
|
||||||
|
/// validate-live + typed netvars → fold model, writing the release set (`gamedata-`/`netvars-`/`model-`/
|
||||||
|
/// `manifest`) into --out-dir. No per-stage intermediate files. **Pass `--game-dir` for a full,
|
||||||
|
/// live-validated build; omit it for a fast OFFLINE build (gamedata + model only, no server).**
|
||||||
|
Produce {
|
||||||
|
/// A launchable game install → the FULL build (boots a server for validate-live + typed netvars).
|
||||||
|
/// OMIT for an offline build (gamedata + model only). The offline/full switch — no separate flag.
|
||||||
|
#[arg(long = "game-dir")]
|
||||||
|
game_dir: Option<PathBuf>,
|
||||||
|
/// Dir holding the on-disk libs for make-sig + live validation (defaults to --game-dir, else --target).
|
||||||
|
#[arg(long)]
|
||||||
|
build: Option<PathBuf>,
|
||||||
|
/// Server library to derive from; defaults to the active game's server lib.
|
||||||
|
#[arg(long)]
|
||||||
|
lib: Option<String>,
|
||||||
|
/// One bundled seed (catalogue + naming sections) — the release form. Replaces the loose
|
||||||
|
/// --catalogue/--promotable/--candidates/--full-names/--extra-offsets/--extra-sigs flags.
|
||||||
|
#[arg(long)]
|
||||||
|
seed: Option<PathBuf>,
|
||||||
|
/// Function catalogue (loose form; omit when using --seed).
|
||||||
|
#[arg(long)]
|
||||||
|
catalogue: Option<PathBuf>,
|
||||||
|
/// Corpus-signal source A: the raw build binaries to fingerprint on the fly. Exactly ONE of
|
||||||
|
/// --corpus / --corpus-model is required (--corpus-model is the production forward-derive path).
|
||||||
|
#[arg(long)]
|
||||||
|
corpus: Option<PathBuf>,
|
||||||
|
/// Corpus-signal source B: a distilled `model-<game>.json` — forward-derives from the model + only the
|
||||||
|
/// target binary (no corpus). Also triggers the sidecar fold (model N → N+1). See --corpus.
|
||||||
|
#[arg(long)]
|
||||||
|
corpus_model: Option<PathBuf>,
|
||||||
|
/// The build DIRECTORY to DERIVE gamedata from — the primary input (its libs are searched by name).
|
||||||
|
/// A bare `.so` path is not searched; pass the directory that contains it. REQUIRED.
|
||||||
|
#[arg(long)]
|
||||||
|
target: PathBuf,
|
||||||
|
/// Optional: names eligible for promotion into high_confidence (from the naming producer flow).
|
||||||
|
/// Omit to promote nothing — the catalogue still derives in full.
|
||||||
|
#[arg(long)]
|
||||||
|
promotable: Option<PathBuf>,
|
||||||
|
/// Optional: prefiltered per-address context for those names (`{"candidates": [...]}`). Omit for none.
|
||||||
|
#[arg(long)]
|
||||||
|
candidates: Option<PathBuf>,
|
||||||
|
/// Optional: the full-slice name universe. When set, the monolith also carries an `experimental`
|
||||||
|
/// tier — the least-filtered inclusion band (every name guess, graded, each with a resolvable
|
||||||
|
/// locator but an UNVERIFIED name).
|
||||||
|
#[arg(long)]
|
||||||
|
full_names: Option<PathBuf>,
|
||||||
|
/// Multilib ground-truth vtable offsets to fold as high_confidence — `{lib: [{name,class,slot}]}`
|
||||||
|
/// (e.g. the macOS symbol transfer). Folded directly, bypassing the candidate gate.
|
||||||
|
#[arg(long)]
|
||||||
|
extra_offsets: Option<PathBuf>,
|
||||||
|
/// Multilib non-virtual names to fold as sigs — `{lib: [{name,addr}]}`; `make_sig` runs per lib.
|
||||||
|
#[arg(long)]
|
||||||
|
extra_sigs: Option<PathBuf>,
|
||||||
|
/// Byte budget for signatures the FOLD generates (the extrapolated tiers). The derive's own
|
||||||
|
/// `core` sigs use a separate fixed budget — this flag does not widen those.
|
||||||
|
#[arg(long, default_value_t = 400)]
|
||||||
|
sig_cap: usize,
|
||||||
|
#[arg(long, default_value = "vX")]
|
||||||
|
version: String,
|
||||||
|
#[arg(long)]
|
||||||
|
out_dir: PathBuf,
|
||||||
|
/// Class scope for the sidecar model fold — must match the scope the input model was distilled with.
|
||||||
|
#[arg(long, value_enum, default_value = "clean")]
|
||||||
|
class_scope: ClassScope,
|
||||||
|
#[arg(long, default_value_t = 90)]
|
||||||
|
wait: u64,
|
||||||
|
#[arg(long)] // default resolved from the active game profile at dispatch
|
||||||
|
map: Option<String>,
|
||||||
|
#[arg(long, default_value_t = 9)]
|
||||||
|
bots: u32,
|
||||||
|
},
|
||||||
|
/// Distill the whole corpus into a shippable model (vtable-alignment hops + reference fingerprints
|
||||||
|
/// + slot timelines) so derivation needs only the model + the target binary, not the 86 GB corpus.
|
||||||
|
CorpusModel {
|
||||||
|
/// One bundled seed — the release form; its catalogue section is what gets distilled. Replaces the
|
||||||
|
/// loose --catalogue (naming sections are ignored here — the model tracks catalogue names only).
|
||||||
|
#[arg(long)]
|
||||||
|
seed: Option<PathBuf>,
|
||||||
|
/// Function catalogue (loose form; omit when using --seed).
|
||||||
|
#[arg(long)]
|
||||||
|
catalogue: Option<PathBuf>,
|
||||||
|
#[arg(long)]
|
||||||
|
corpus: PathBuf,
|
||||||
|
/// Which classes get vtable-slot hops: `clean` (every real game class — the default; enough for any
|
||||||
|
/// modding offset to derive model-only), `all` (also template/protobuf/NetworkVar junk), or
|
||||||
|
/// `catalogue` (only what the catalogue names). CI compresses the model, so on-disk size isn't shipped.
|
||||||
|
#[arg(long, value_enum, default_value = "clean")]
|
||||||
|
class_scope: ClassScope,
|
||||||
|
#[arg(long)]
|
||||||
|
out: PathBuf,
|
||||||
|
},
|
||||||
|
/// Incrementally fold ONE new build into an existing model: `model N + build → model N+1`, equal to a
|
||||||
|
/// full re-distill over the same builds but reading only the model + the one binary (no corpus). The
|
||||||
|
/// production update path — keeps the model fresh per build without re-reading history.
|
||||||
|
FoldModel {
|
||||||
|
/// The existing model N (carries the `abi_obs` window the fold re-windows).
|
||||||
|
#[arg(long)]
|
||||||
|
model: PathBuf,
|
||||||
|
/// One bundled seed — the release form; its catalogue section is folded. Replaces the loose --catalogue.
|
||||||
|
#[arg(long)]
|
||||||
|
seed: Option<PathBuf>,
|
||||||
|
/// Function catalogue (loose form; omit when using --seed). Must match the model's distill catalogue.
|
||||||
|
#[arg(long)]
|
||||||
|
catalogue: Option<PathBuf>,
|
||||||
|
/// The one new build dir to fold in (holds the just-updated libserver.so etc.).
|
||||||
|
#[arg(long)]
|
||||||
|
build: PathBuf,
|
||||||
|
/// Must match the scope the model was distilled with (`clean` default).
|
||||||
|
#[arg(long, value_enum, default_value = "clean")]
|
||||||
|
class_scope: ClassScope,
|
||||||
|
#[arg(long)]
|
||||||
|
out: PathBuf,
|
||||||
|
},
|
||||||
|
/// Back-fill cross-build history for extrapolated (T3) names: for each {name, anchor} pair, resolve
|
||||||
|
/// the anchor STRING uniquely in every corpus build (the same string-anchor locator as `anchor`), so
|
||||||
|
/// a name that was a single-build guess gains a real timeline. Reports per-name history depth +
|
||||||
|
/// consistency-since-first-appearance — the measure of how many T3 names graduate to first-class
|
||||||
|
/// (a function anchored across hundreds of builds is high-confidence regardless of its T3 origin).
|
||||||
|
Backfill {
|
||||||
|
/// Raw build binaries — needed for the string-anchor half (locating a sig/self-named function in
|
||||||
|
/// each historical build). Omit to run only the model-only offset half.
|
||||||
|
#[arg(long)]
|
||||||
|
corpus: Option<PathBuf>,
|
||||||
|
/// Distilled corpus model — its `hops` back-fill an OFFSET function's vtable-slot timeline with
|
||||||
|
/// NO binaries (the community-PR-of-a-vtable-method path). Omit to run only the string half.
|
||||||
|
#[arg(long)]
|
||||||
|
corpus_model: Option<PathBuf>,
|
||||||
|
/// Server library to derive from; defaults to the active game's server lib.
|
||||||
|
#[arg(long)]
|
||||||
|
lib: Option<String>,
|
||||||
|
/// JSON array of {name, tier?, anchor?, class?, slot?}: `anchor` (a distinctive string it
|
||||||
|
/// references — its own name for self-named) drives the string half; `class`+`slot` drive the
|
||||||
|
/// model-hops half.
|
||||||
|
#[arg(long)]
|
||||||
|
names: PathBuf,
|
||||||
|
#[arg(long)]
|
||||||
|
threads: Option<usize>,
|
||||||
|
/// Write the per-name timeline report here.
|
||||||
|
#[arg(long)]
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
/// Classify how much a library changed between two builds — the CI branch primitive. Enumerates every
|
||||||
|
/// function (`.eh_frame`) in each build and compares their bodies with the position-dependent bytes
|
||||||
|
/// (RIP-relative displacements + near-branch targets) masked out, so the verdict is shift-invariant:
|
||||||
|
/// a pure layout move (bodies unchanged, addresses shifted) reads as UNCHANGED, unlike a raw byte diff.
|
||||||
|
/// Prints `skip` (nothing meaningful changed → no release), `normal` (an ordinary patch → re-derive) or
|
||||||
|
/// `shift` (a toolchain/compiler change moved ~every function's codegen at once → re-derive, and the
|
||||||
|
/// derive leans harder on the string-anchor/vtable recovery paths) plus the exact % of the new build's
|
||||||
|
/// functions whose body isn't byte-identical to the
|
||||||
|
/// previous build's. The thresholds are heuristic defaults — calibrate `--skip-below`/`--shift-above`
|
||||||
|
/// against real adjacent-vs-toolchain-jump pairs.
|
||||||
|
ClassifyChange {
|
||||||
|
/// Previous build: a `.so` file directly, or a build dir to find `--lib` under.
|
||||||
|
#[arg(long)]
|
||||||
|
prev: PathBuf,
|
||||||
|
/// New build: a `.so` file directly, or a build dir to find `--lib` under.
|
||||||
|
#[arg(long)]
|
||||||
|
new: PathBuf,
|
||||||
|
/// Server library to derive from; defaults to the active game's server lib.
|
||||||
|
#[arg(long)]
|
||||||
|
lib: Option<String>,
|
||||||
|
/// Extra `skip` tolerance: a changed-fraction below this also counts as `skip`. Default 0 —
|
||||||
|
/// only a code-IDENTICAL build (0 functions changed) skips, so any real patch re-derives. Raise
|
||||||
|
/// it (e.g. 0.01) to also skip changes under N%. (Calibration on 339 CS2 pairs: 311 are
|
||||||
|
/// code-identical, real patches touch <=6 functions / <=0.08%, the 2 toolchain jumps are 34%/53%.)
|
||||||
|
#[arg(long, default_value_t = 0.0)]
|
||||||
|
skip_below: f64,
|
||||||
|
/// changed-fraction at or above this = `shift`. Default 0.20 — the CS2 corpus's real patches top
|
||||||
|
/// out near 0.08% while its two toolchain jumps are 34%/53%, so 20% cleanly separates them with
|
||||||
|
/// wide margin and (unlike 40%) doesn't misclassify the 34% jump as an ordinary patch.
|
||||||
|
#[arg(long, default_value_t = 0.20)]
|
||||||
|
shift_above: f64,
|
||||||
|
/// Emit a machine-readable JSON object instead of the human summary.
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Stage-1 change-aware corpus filter: walk a game's builds chronologically, collapse runs of
|
||||||
|
/// code-identical builds (bodies unchanged, only relocations moved) to ONE representative, label each
|
||||||
|
/// surviving transition normal/shift, and segment the timeline into toolchain ERAS (cut at shifts).
|
||||||
|
/// Writes a selection manifest (code-distinct kept builds + era/drift each). Lossless for the per-game
|
||||||
|
/// facts; the distinct-build set `corpus-model` distills. Digests each build once.
|
||||||
|
FilterCorpus {
|
||||||
|
#[arg(long)]
|
||||||
|
corpus: PathBuf,
|
||||||
|
/// Server library to derive from; defaults to the active game's server lib.
|
||||||
|
#[arg(long)]
|
||||||
|
lib: Option<String>,
|
||||||
|
/// changed-fraction below this collapses a build as code-identical. Default 0 = only exact
|
||||||
|
/// code-identity collapses (any real change keeps the build code-distinct).
|
||||||
|
#[arg(long, default_value_t = 0.0)]
|
||||||
|
skip_below: f64,
|
||||||
|
/// changed-fraction at or above this marks a toolchain shift = an era boundary (default 0.20).
|
||||||
|
#[arg(long, default_value_t = 0.20)]
|
||||||
|
shift_above: f64,
|
||||||
|
#[arg(long)]
|
||||||
|
threads: Option<usize>,
|
||||||
|
/// Write the selection manifest here (JSON); prints to stdout if omitted.
|
||||||
|
#[arg(long)]
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command's `--lib`, defaulting to the game's server library when unset.
|
||||||
|
fn lib_or_default(prof: &profile::GameProfile, lib: Option<String>) -> String {
|
||||||
|
lib.unwrap_or_else(|| prof.server_lib.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the catalogue for the model commands (`corpus-model`/`fold-model`) from either a `--seed` bundle
|
||||||
|
/// (release form) or a loose `--catalogue` file. The seed's catalogue section parses to the same functions as
|
||||||
|
/// the loose `needed-functions.json`, so the distilled/folded model is identical either way. When a seed is
|
||||||
|
/// given, its sections unpack under a `.seed` dir beside `out` (as `produce` does beside its out-dir).
|
||||||
|
fn model_catalogue(
|
||||||
|
prof: &profile::GameProfile,
|
||||||
|
seed: Option<PathBuf>,
|
||||||
|
catalogue: Option<PathBuf>,
|
||||||
|
out: &std::path::Path,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
match seed {
|
||||||
|
Some(s) => {
|
||||||
|
let work = out
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| std::path::Path::new("."))
|
||||||
|
.join(".seed");
|
||||||
|
Ok(unpack_seed(prof, &s, &work)?.catalogue)
|
||||||
|
}
|
||||||
|
None => catalogue.context("pass --seed <bundle> or --catalogue <file>"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
// Thread the resolved profile as an explicit parameter rather than a process-wide global, so the
|
||||||
|
// engine stays reusable per call.
|
||||||
|
let profile = match cli.game {
|
||||||
|
Game::Cs2 => &profile::CS2,
|
||||||
|
Game::Dota2 => &profile::DOTA,
|
||||||
|
};
|
||||||
|
match cli.cmd {
|
||||||
|
Cmd::IntegrationTest {
|
||||||
|
game_dir,
|
||||||
|
build,
|
||||||
|
lib,
|
||||||
|
wait,
|
||||||
|
map,
|
||||||
|
bots,
|
||||||
|
gamedata,
|
||||||
|
out,
|
||||||
|
keep,
|
||||||
|
fuzz_iterations,
|
||||||
|
} => {
|
||||||
|
let map = map.unwrap_or_else(|| profile.default_map.to_string());
|
||||||
|
let lib = lib_or_default(profile, lib);
|
||||||
|
integration_test_cmd(
|
||||||
|
profile,
|
||||||
|
&game_dir,
|
||||||
|
build.as_deref(),
|
||||||
|
&lib,
|
||||||
|
wait,
|
||||||
|
&map,
|
||||||
|
bots,
|
||||||
|
gamedata.as_deref(),
|
||||||
|
out.as_deref(),
|
||||||
|
keep,
|
||||||
|
fuzz_iterations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Cmd::Produce {
|
||||||
|
game_dir,
|
||||||
|
build,
|
||||||
|
lib,
|
||||||
|
seed,
|
||||||
|
catalogue,
|
||||||
|
corpus,
|
||||||
|
corpus_model,
|
||||||
|
target,
|
||||||
|
promotable,
|
||||||
|
candidates,
|
||||||
|
full_names,
|
||||||
|
extra_offsets,
|
||||||
|
extra_sigs,
|
||||||
|
sig_cap,
|
||||||
|
version,
|
||||||
|
out_dir,
|
||||||
|
class_scope,
|
||||||
|
wait,
|
||||||
|
map,
|
||||||
|
bots,
|
||||||
|
} => {
|
||||||
|
// derive inputs come from a single --seed bundle (release form) or the loose flags (dev/verify).
|
||||||
|
let inputs = match seed {
|
||||||
|
Some(s) => unpack_seed(profile, &s, &out_dir.join(".seed"))?,
|
||||||
|
None => SeedInputs {
|
||||||
|
catalogue: catalogue.context("pass --seed <bundle> or --catalogue <file>")?,
|
||||||
|
promotable,
|
||||||
|
candidates,
|
||||||
|
full_names,
|
||||||
|
extra_offsets,
|
||||||
|
extra_sigs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let map = map.unwrap_or_else(|| profile.default_map.to_string());
|
||||||
|
let lib = lib_or_default(profile, lib);
|
||||||
|
produce_cmd(ProduceArgs {
|
||||||
|
prof: profile,
|
||||||
|
game: game_dir.as_deref(),
|
||||||
|
build: build.as_deref(),
|
||||||
|
lib: &lib,
|
||||||
|
catalogue: &inputs.catalogue,
|
||||||
|
corpus: corpus.as_deref(),
|
||||||
|
corpus_model: corpus_model.as_deref(),
|
||||||
|
class_scope,
|
||||||
|
target: &target,
|
||||||
|
promotable: inputs.promotable.as_deref(),
|
||||||
|
candidates: inputs.candidates.as_deref(),
|
||||||
|
full_names: inputs.full_names.as_deref(),
|
||||||
|
extra_offsets: inputs.extra_offsets.as_deref(),
|
||||||
|
extra_sigs: inputs.extra_sigs.as_deref(),
|
||||||
|
sig_cap,
|
||||||
|
version: &version,
|
||||||
|
out_dir: &out_dir,
|
||||||
|
wait,
|
||||||
|
map: &map,
|
||||||
|
bots,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Cmd::CorpusModel {
|
||||||
|
seed,
|
||||||
|
catalogue,
|
||||||
|
corpus,
|
||||||
|
class_scope,
|
||||||
|
out,
|
||||||
|
} => {
|
||||||
|
let cat = model_catalogue(profile, seed, catalogue, &out)?;
|
||||||
|
corpus_model_cmd(profile, &cat, &corpus, class_scope, &out)
|
||||||
|
}
|
||||||
|
Cmd::FoldModel {
|
||||||
|
model,
|
||||||
|
seed,
|
||||||
|
catalogue,
|
||||||
|
build,
|
||||||
|
class_scope,
|
||||||
|
out,
|
||||||
|
} => {
|
||||||
|
let cat = model_catalogue(profile, seed, catalogue, &out)?;
|
||||||
|
fold_model_cmd(
|
||||||
|
profile,
|
||||||
|
load_model(&model)?,
|
||||||
|
&cat,
|
||||||
|
&build,
|
||||||
|
class_scope,
|
||||||
|
&out,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Cmd::Backfill {
|
||||||
|
corpus,
|
||||||
|
corpus_model,
|
||||||
|
lib,
|
||||||
|
names,
|
||||||
|
threads,
|
||||||
|
out,
|
||||||
|
} => {
|
||||||
|
let lib = lib_or_default(profile, lib);
|
||||||
|
backfill_cmd(
|
||||||
|
profile,
|
||||||
|
corpus.as_deref(),
|
||||||
|
corpus_model.as_deref(),
|
||||||
|
&lib,
|
||||||
|
&names,
|
||||||
|
threads,
|
||||||
|
out.as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Cmd::ClassifyChange {
|
||||||
|
prev,
|
||||||
|
new,
|
||||||
|
lib,
|
||||||
|
skip_below,
|
||||||
|
shift_above,
|
||||||
|
json,
|
||||||
|
} => {
|
||||||
|
let lib = lib_or_default(profile, lib);
|
||||||
|
classify_change_cmd(&prev, &new, &lib, skip_below, shift_above, json)
|
||||||
|
}
|
||||||
|
Cmd::FilterCorpus {
|
||||||
|
corpus,
|
||||||
|
lib,
|
||||||
|
skip_below,
|
||||||
|
shift_above,
|
||||||
|
threads,
|
||||||
|
out,
|
||||||
|
} => {
|
||||||
|
let lib = lib_or_default(profile, lib);
|
||||||
|
filter_corpus_cmd(
|
||||||
|
profile,
|
||||||
|
&corpus,
|
||||||
|
&lib,
|
||||||
|
skip_below,
|
||||||
|
shift_above,
|
||||||
|
out.as_deref(),
|
||||||
|
threads,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/par.rs
Normal file
48
src/par.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
//! Tiny data-parallel primitive shared across the crate — no work-stealing dependency, just scoped
|
||||||
|
//! threads pulling work by an atomic index. Lives in the library (not the binary) so lib modules
|
||||||
|
//! (`xref`, …) and the CI pipeline can parallelise directly, not only the CLI front-end.
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
/// Run `f` over `items` across `nthreads` scoped threads, pulling work by atomic index
|
||||||
|
/// (dynamic load-balancing without a work-stealing dep). Results returned in input order, so callers
|
||||||
|
/// that merge them stay deterministic regardless of which thread finished which item.
|
||||||
|
pub fn parallel_map<T, R, F>(items: &[T], nthreads: usize, f: F) -> Vec<R>
|
||||||
|
where
|
||||||
|
T: Sync,
|
||||||
|
R: Send,
|
||||||
|
F: Fn(&T) -> R + Sync,
|
||||||
|
{
|
||||||
|
let len = items.len();
|
||||||
|
let nthreads = nthreads.clamp(1, len.max(1));
|
||||||
|
let next = AtomicUsize::new(0);
|
||||||
|
let out: Mutex<Vec<(usize, R)>> = Mutex::new(Vec::with_capacity(len));
|
||||||
|
std::thread::scope(|s| {
|
||||||
|
for _ in 0..nthreads {
|
||||||
|
s.spawn(|| {
|
||||||
|
let mut local = Vec::new();
|
||||||
|
loop {
|
||||||
|
let i = next.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if i >= len {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
local.push((i, f(&items[i])));
|
||||||
|
}
|
||||||
|
out.lock().unwrap().extend(local);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut v = out.into_inner().unwrap();
|
||||||
|
v.sort_by_key(|(i, _)| *i);
|
||||||
|
v.into_iter().map(|(_, r)| r).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requested thread count, or the machine's available parallelism.
|
||||||
|
pub fn default_threads(threads: Option<usize>) -> usize {
|
||||||
|
threads.unwrap_or_else(|| {
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(4)
|
||||||
|
})
|
||||||
|
}
|
||||||
3722
src/pipeline.rs
Normal file
3722
src/pipeline.rs
Normal file
File diff suppressed because it is too large
Load diff
1811
src/produce.rs
Normal file
1811
src/produce.rs
Normal file
File diff suppressed because it is too large
Load diff
367
src/profile.rs
Normal file
367
src/profile.rs
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
//! Per-game knobs — the only Source-2-*title*-specific constants, gathered in one place so a second
|
||||||
|
//! game (Deadlock, Dota 2) is a data change, not a code hunt. CS2 and Dota 2 are registered today; the
|
||||||
|
//! schema/RTTI/xref/oracle machinery around them is already game-generic.
|
||||||
|
//!
|
||||||
|
//! Everything a second game varies lives here: library names, the output game-key, the live-oracle
|
||||||
|
//! launch spec, the player-pawn anchor, and the class/field/message-prefix literals the derivation and
|
||||||
|
//! validation paths reference. (The SchemaSystem struct layout is deliberately NOT here — it tracks the
|
||||||
|
//! engine BUILD ERA, not the game, so it lives as a per-binary `schema::SchemaLayout`.)
|
||||||
|
|
||||||
|
/// How to launch a vanilla server populated with alive units, for the live oracle. Structured (not a
|
||||||
|
/// flat arg string) because a second Source-2 game selects its mode and fills its world completely
|
||||||
|
/// differently (Dota 2 has no game_type/game_mode deathmatch, no bot_quota). The `args` builder
|
||||||
|
/// reproduces the exact CS2 arg order, so a byte-identical launch is assertable.
|
||||||
|
pub struct LaunchSpec {
|
||||||
|
/// Cvars set before `-maxplayers`/`+map`, in order (CS2: the deathmatch `game_type 1` / `game_mode 2`).
|
||||||
|
pub pre_map_cvars: &'static [(&'static str, &'static str)],
|
||||||
|
/// Cvars set after `+map <map>`, in order. A value of `"{bots}"` is substituted with the runtime bot
|
||||||
|
/// count (CS2's `+bot_quota <n>`); every other value is passed through verbatim.
|
||||||
|
pub post_map_cvars: &'static [(&'static str, &'static str)],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LaunchSpec {
|
||||||
|
/// The vanilla dedicated-server args to spawn `bots` alive units on `map`, in the exact order the
|
||||||
|
/// live launch requires.
|
||||||
|
pub fn args(&self, map: &str, bots: u32) -> Vec<String> {
|
||||||
|
let mut a: Vec<String> = vec!["-dedicated".into(), "-insecure".into()];
|
||||||
|
for (k, v) in self.pre_map_cvars {
|
||||||
|
a.push(format!("+{k}"));
|
||||||
|
a.push((*v).into());
|
||||||
|
}
|
||||||
|
a.push("-maxplayers".into());
|
||||||
|
a.push((bots + 4).to_string());
|
||||||
|
a.push("+map".into());
|
||||||
|
a.push(map.to_string());
|
||||||
|
for (k, v) in self.post_map_cvars {
|
||||||
|
a.push(format!("+{k}"));
|
||||||
|
a.push(if *v == "{bots}" {
|
||||||
|
bots.to_string()
|
||||||
|
} else {
|
||||||
|
(*v).into()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live-oracle player-pawn anchor: a pawn RTTI class, a liveness netvar, and the IsPlayerPawn vtable
|
||||||
|
/// slot. Used to find an alive instance in a running server, prove a derived offset is really callable,
|
||||||
|
/// and sweep this-only query methods. `Copy` so it round-trips out of a `const` profile cheaply.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct PawnAnchor {
|
||||||
|
pub pawn_class: &'static str, // player-pawn RTTI class — the live-oracle instance anchor
|
||||||
|
pub health_field: &'static str, // a reliable "is this instance alive" netvar
|
||||||
|
pub is_player_pawn_slot: u64, // gamedata vtable offset of IsPlayerPawn (call-live smoke test)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GameProfile {
|
||||||
|
pub server_lib: &'static str, // the gameplay library (schema classes, most signatures)
|
||||||
|
pub engine_lib: &'static str, // engine2 (entity system, networking)
|
||||||
|
/// Every server-mapped Source-2 library the corpus model spans, ORDERED by resolution precedence
|
||||||
|
/// (an earlier lib wins a class name present in more than one). `server_lib`/`engine_lib` are the first
|
||||||
|
/// two and remain the "primary lib" for command defaults + the live-oracle readiness anchor. The
|
||||||
|
/// client-render stack (libclient/panorama/rendersystemvulkan/cairo/…) is deliberately absent: the
|
||||||
|
/// dedicated server never maps it (confirmed against `/proc/<pid>/maps` of a running server).
|
||||||
|
pub libs: &'static [&'static str],
|
||||||
|
/// How many vtable slots to read per class. A hard stop, not a hint: `rtti::read_slots` returns what it
|
||||||
|
/// read with no truncation marker, so a class with more slots than this is INDISTINGUISHABLE from one
|
||||||
|
/// that genuinely ends here — its tail silently vanishes, and `validate_offset` reports a legitimate
|
||||||
|
/// slot past the cap as out-of-bounds. `extract_build_vtables` WARNS when a class lands exactly on the
|
||||||
|
/// cap, which is how a game that needs a bigger one is discovered.
|
||||||
|
///
|
||||||
|
/// At 2048: CS2's deepest class is ~464 and Dota's ~541 (the `CDOTA_BaseNPC_*` / `CDOTA_Unit_Hero_*`
|
||||||
|
/// family). 2048 is deliberately far above need: `read_slots` stops at the first slot that isn't
|
||||||
|
/// executable code, so a normal class costs nothing extra and only genuinely deep vtables scan further.
|
||||||
|
///
|
||||||
|
/// **Raising this invalidates that game's model.** Slot counts and per-slot fingerprints are recorded
|
||||||
|
/// under the cap in force at distill time; a derive that reads deeper vtables than the model was built
|
||||||
|
/// from is comparing different objects. A raise is a re-distill, not a config tweak — change it and the
|
||||||
|
/// model together.
|
||||||
|
pub max_vtable_slots: usize,
|
||||||
|
/// Output game-key the game-keyed emitters use (Metamod `Games { <key> {..} }`, Plugify `{ "<key>": {..} }`).
|
||||||
|
pub game_key: &'static str,
|
||||||
|
/// The `--game` CLI token / per-release filename suffix (`cs2`, `dota2`) — distinct from `game_key` (the
|
||||||
|
/// content-dir token `csgo`/`dota` that framework formats key on). Names the artifacts
|
||||||
|
/// `gamedata-<token>.json` / `model-<token>.json` / `netvars-<token>.json`.
|
||||||
|
pub token: &'static str,
|
||||||
|
/// Dedicated-server launcher binary under `bin/linuxsteamrt64/` (CS2: `cs2`).
|
||||||
|
pub executable: &'static str,
|
||||||
|
/// Default map for the live-oracle server.
|
||||||
|
pub default_map: &'static str,
|
||||||
|
/// This game's user-message class prefix, dropped as wire/serializer dead weight (CS2: `CCSUsrMsg`).
|
||||||
|
pub usermsg_prefix: &'static str,
|
||||||
|
/// Dead-weight / name-classification vocabulary — the retunable taxonomy a fork edits per game.
|
||||||
|
/// `foreign_namespaces`: RTTI namespaces that are never gameplay (the C++ runtime, Steam GC SDK, Valve
|
||||||
|
/// container templates, the V8 vscript backend). `proto_prefixes`: protobuf message-class name prefixes
|
||||||
|
/// (wire/GC protocol). Most values are Source-2-universal, but they ride the profile so a fork retunes
|
||||||
|
/// one const block instead of hunting a second file.
|
||||||
|
pub foreign_namespaces: &'static [&'static str],
|
||||||
|
pub proto_prefixes: &'static [&'static str],
|
||||||
|
/// Protobuf serializer method names: a lone HARD one decisively marks its class generated wire plumbing;
|
||||||
|
/// SOFT ones can be legit game methods, so they only count toward the ≥3-method protobuf-class cluster.
|
||||||
|
pub hard_serializer: &'static [&'static str],
|
||||||
|
pub soft_serializer: &'static [&'static str],
|
||||||
|
/// Method-name prefixes for a this-only blind-callable boolean query — the live call-smoke-test gate.
|
||||||
|
pub query_prefixes: &'static [&'static str],
|
||||||
|
/// Live-oracle "famous field" spotlight: per class, the netvars whose live offsets `verify-live` prints
|
||||||
|
/// field-by-field (the ones mods actually read). CS2 gameplay fields on the generic `CBaseEntity`.
|
||||||
|
pub spotlight_fields: &'static [(&'static str, &'static [&'static str])],
|
||||||
|
/// Human-readable game name for the shipped gamedata banner.
|
||||||
|
pub display_name: &'static str,
|
||||||
|
/// How the live oracle spawns alive units.
|
||||||
|
pub launch: LaunchSpec,
|
||||||
|
/// RTTI class of the always-present gamerules proxy. A pawn-less game (Dota) uses a live instance of it
|
||||||
|
/// as the live-oracle readiness signal (a live one means the map loaded and libserver is ready) in
|
||||||
|
/// place of an alive pawn; set for every game though pawn games use the alive-pawn poll.
|
||||||
|
pub ready_class: &'static str,
|
||||||
|
/// The live-oracle player-pawn anchor, or `None` for a pawn-less game (Dota 2 units are
|
||||||
|
/// CDOTA_BaseNPC/heroes, not a spawned CCSPlayerPawn). `Some` runs the alive-pawn poll + IsPlayerPawn
|
||||||
|
/// call test + callable-method sweep; `None` skips them — the pawn-less live flow (an alternate
|
||||||
|
/// entity anchor, or a bot-match-with-no-players readiness signal) is a placeholder TBD at bring-up.
|
||||||
|
pub pawn_anchor: Option<PawnAnchor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counter-Strike 2.
|
||||||
|
pub const CS2: GameProfile = GameProfile {
|
||||||
|
server_lib: "libserver.so",
|
||||||
|
engine_lib: "libengine2.so",
|
||||||
|
// Every Valve Source-2 library the dedicated server maps (confirmed from /proc/<pid>/maps), ordered
|
||||||
|
// by resolution precedence: gameplay (server) then engine2 win shared-infra class-name collisions,
|
||||||
|
// then the systems by rough dependency depth. Excludes the client-render stack (never server-mapped)
|
||||||
|
// and the V8/Steam vendored runtimes (foreign code, filtered by taxonomy).
|
||||||
|
libs: &[
|
||||||
|
"libserver.so",
|
||||||
|
"libengine2.so",
|
||||||
|
"libtier0.so",
|
||||||
|
"libnetworksystem.so",
|
||||||
|
"libschemasystem.so",
|
||||||
|
"libresourcesystem.so",
|
||||||
|
"libscenesystem.so",
|
||||||
|
"libsoundsystem.so",
|
||||||
|
"libanimationsystem.so",
|
||||||
|
"libvphysics2.so",
|
||||||
|
"libmeshsystem.so",
|
||||||
|
"libparticles.so",
|
||||||
|
"libworldrenderer.so",
|
||||||
|
"libmaterialsystem2.so",
|
||||||
|
"libscenefilecache.so",
|
||||||
|
"libfilesystem_stdio.so",
|
||||||
|
"liblocalize.so",
|
||||||
|
"libhost.so",
|
||||||
|
"libmatchmaking.so",
|
||||||
|
"libpulse_system.so",
|
||||||
|
"librendersystemempty.so",
|
||||||
|
"libvscript.so",
|
||||||
|
],
|
||||||
|
max_vtable_slots: 2048,
|
||||||
|
game_key: "csgo",
|
||||||
|
token: "cs2",
|
||||||
|
executable: "cs2",
|
||||||
|
default_map: "de_dust2",
|
||||||
|
usermsg_prefix: "CCSUsrMsg",
|
||||||
|
foreign_namespaces: &[
|
||||||
|
"google::protobuf",
|
||||||
|
"std::",
|
||||||
|
"__gnu_cxx",
|
||||||
|
"__cxxabiv1",
|
||||||
|
"GCSDK::",
|
||||||
|
"CUtl",
|
||||||
|
"v8::",
|
||||||
|
],
|
||||||
|
proto_prefixes: &[
|
||||||
|
"CMsg", "CSVCMsg", "CNETMsg", "CCLCMsg", "CMsgGC", "CDataGC", "CGC", "CSO", "PB_",
|
||||||
|
],
|
||||||
|
hard_serializer: &[
|
||||||
|
"GetCachedSize",
|
||||||
|
"ByteSizeLong",
|
||||||
|
"IsInitialized",
|
||||||
|
"GetMetadata",
|
||||||
|
"MergePartialFromCodedStream",
|
||||||
|
"SerializeWithCachedSizes",
|
||||||
|
"InternalSerialize",
|
||||||
|
"GetClassData",
|
||||||
|
"MergeImpl",
|
||||||
|
"_InternalParse",
|
||||||
|
],
|
||||||
|
soft_serializer: &[
|
||||||
|
"New",
|
||||||
|
"Clear",
|
||||||
|
"CopyFrom",
|
||||||
|
"MergeFrom",
|
||||||
|
"SharedCtor",
|
||||||
|
"SharedDtor",
|
||||||
|
],
|
||||||
|
query_prefixes: &["Is", "Has", "Can", "Should", "Are", "Will"],
|
||||||
|
spotlight_fields: &[(
|
||||||
|
"CBaseEntity",
|
||||||
|
&["m_iHealth", "m_iTeamNum", "m_hOwnerEntity"],
|
||||||
|
)],
|
||||||
|
display_name: "CS2",
|
||||||
|
launch: LaunchSpec {
|
||||||
|
pre_map_cvars: &[("game_type", "1"), ("game_mode", "2")],
|
||||||
|
post_map_cvars: &[
|
||||||
|
("sv_hibernate_when_empty", "0"),
|
||||||
|
("bot_join_after_player", "0"),
|
||||||
|
("bot_quota", "{bots}"),
|
||||||
|
("bot_quota_mode", "fill"),
|
||||||
|
("bot_difficulty", "2"),
|
||||||
|
("mp_warmuptime", "0"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ready_class: "CCSGameRulesProxy",
|
||||||
|
pawn_anchor: Some(PawnAnchor {
|
||||||
|
pawn_class: "CCSPlayerPawn",
|
||||||
|
health_field: "m_iHealth",
|
||||||
|
is_player_pawn_slot: 168,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Dota 2. Dota has no deathmatch `game_type`/`game_mode` or `bot_quota`, so the live oracle needs a
|
||||||
|
/// different keep-alive combination than CS2; the `launch` cvars are the real bot-match cvars found in
|
||||||
|
/// `libserver.so` (see `launch`).
|
||||||
|
pub const DOTA: GameProfile = GameProfile {
|
||||||
|
server_lib: "libserver.so", // generic Source-2 (same filename as CS2)
|
||||||
|
engine_lib: "libengine2.so",
|
||||||
|
// The full Source-2 server lib set (same names as CS2 — shared engine; Dota's build supplies its own
|
||||||
|
// versions). Superset-safe: `load_build_images` skips any lib absent from Dota's build. Confirm against a
|
||||||
|
// running Dota server's /proc/maps if a Dota-specific server lib ever appears outside this set.
|
||||||
|
libs: &[
|
||||||
|
"libserver.so",
|
||||||
|
"libengine2.so",
|
||||||
|
"libtier0.so",
|
||||||
|
"libnetworksystem.so",
|
||||||
|
"libschemasystem.so",
|
||||||
|
"libresourcesystem.so",
|
||||||
|
"libscenesystem.so",
|
||||||
|
"libsoundsystem.so",
|
||||||
|
"libanimationsystem.so",
|
||||||
|
"libvphysics2.so",
|
||||||
|
"libmeshsystem.so",
|
||||||
|
"libparticles.so",
|
||||||
|
"libworldrenderer.so",
|
||||||
|
"libmaterialsystem2.so",
|
||||||
|
"libscenefilecache.so",
|
||||||
|
"libfilesystem_stdio.so",
|
||||||
|
"liblocalize.so",
|
||||||
|
"libhost.so",
|
||||||
|
"libmatchmaking.so",
|
||||||
|
"libpulse_system.so",
|
||||||
|
"librendersystemempty.so",
|
||||||
|
"libvscript.so",
|
||||||
|
],
|
||||||
|
max_vtable_slots: 2048,
|
||||||
|
game_key: "dota",
|
||||||
|
token: "dota2",
|
||||||
|
executable: "dota2", // bin/linuxsteamrt64/dota2
|
||||||
|
default_map: "dota",
|
||||||
|
usermsg_prefix: "CDOTAUserMsg",
|
||||||
|
// Same Source-2-universal dead-weight vocabulary as CS2; only `usermsg_prefix` above is genuinely
|
||||||
|
// per-game. A Dota-specific tune would edit here.
|
||||||
|
foreign_namespaces: &[
|
||||||
|
"google::protobuf",
|
||||||
|
"std::",
|
||||||
|
"__gnu_cxx",
|
||||||
|
"__cxxabiv1",
|
||||||
|
"GCSDK::",
|
||||||
|
"CUtl",
|
||||||
|
"v8::",
|
||||||
|
],
|
||||||
|
proto_prefixes: &[
|
||||||
|
"CMsg", "CSVCMsg", "CNETMsg", "CCLCMsg", "CMsgGC", "CDataGC", "CGC", "CSO", "PB_",
|
||||||
|
],
|
||||||
|
hard_serializer: &[
|
||||||
|
"GetCachedSize",
|
||||||
|
"ByteSizeLong",
|
||||||
|
"IsInitialized",
|
||||||
|
"GetMetadata",
|
||||||
|
"MergePartialFromCodedStream",
|
||||||
|
"SerializeWithCachedSizes",
|
||||||
|
"InternalSerialize",
|
||||||
|
"GetClassData",
|
||||||
|
"MergeImpl",
|
||||||
|
"_InternalParse",
|
||||||
|
],
|
||||||
|
soft_serializer: &[
|
||||||
|
"New",
|
||||||
|
"Clear",
|
||||||
|
"CopyFrom",
|
||||||
|
"MergeFrom",
|
||||||
|
"SharedCtor",
|
||||||
|
"SharedDtor",
|
||||||
|
],
|
||||||
|
query_prefixes: &["Is", "Has", "Can", "Should", "Are", "Will"],
|
||||||
|
spotlight_fields: &[(
|
||||||
|
"CBaseEntity",
|
||||||
|
&["m_iHealth", "m_iTeamNum", "m_hOwnerEntity"],
|
||||||
|
)],
|
||||||
|
display_name: "Dota 2",
|
||||||
|
// Dota 2 has no deathmatch `game_type`/`game_mode` or `bot_quota`; a headless AI/bot match uses these
|
||||||
|
// cvars (all present in libserver.so). `sv_hibernate_when_empty 0` is REQUIRED — without it an empty
|
||||||
|
// dedicated server hibernates and quits immediately (exit 0). The pawn-only stages gate on `pawn_anchor`
|
||||||
|
// being `Some`, so Dota (whose `pawn_anchor` is `None`) validates through the schema + sig oracles
|
||||||
|
// without a pawn, polling `ready_class` (a live CDOTAGamerulesProxy = map loaded) in place of an alive
|
||||||
|
// pawn.
|
||||||
|
launch: LaunchSpec {
|
||||||
|
// Keep an empty headless Dota server ALIVE long enough to attach + validate: sv_cheats enables dev
|
||||||
|
// commands, hibernate-off stops it quitting when empty, and the huge auto-surrender timeout defeats
|
||||||
|
// the empty-match abandon that otherwise closes it after a few minutes. Bots are NOT populated here
|
||||||
|
// (that needs a post-map-load stdin command and is only for the entity oracle); sig validation needs
|
||||||
|
// only libserver loaded + a map.
|
||||||
|
pre_map_cvars: &[("sv_cheats", "1"), ("dota_force_gamemode", "1")],
|
||||||
|
post_map_cvars: &[
|
||||||
|
("sv_hibernate_when_empty", "0"),
|
||||||
|
("dota_auto_surrender_all_disconnected_timeout", "999999"),
|
||||||
|
("dota_local_bot_match_difficulty", "1"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ready_class: "CDOTAGamerulesProxy",
|
||||||
|
// Dota 2 units are `CDOTA_BaseNPC_Hero` NPCs, not a spawned `CCSPlayerPawn` — no player-pawn anchor. The
|
||||||
|
// pawn-based oracle (alive-pawn poll + IsPlayerPawn call + this-only method sweep) is skipped; the SCHEMA
|
||||||
|
// oracle (attach + read SchemaSystem, no pawn) is the portable core. A hero-NPC anchor is the pawn-less
|
||||||
|
// extension, TBD at bring-up.
|
||||||
|
pawn_anchor: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// (There is deliberately NO process-wide "active profile" global. `main` resolves `--game` to a
|
||||||
|
// `&'static GameProfile` and threads it explicitly through every engine entry point, so the library is
|
||||||
|
// reusable per-call — CS2 and Dota can be derived in the same process — and no code path can silently
|
||||||
|
// run one game's assumptions on another.)
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cs2_launch_args_are_byte_identical_to_the_old_hand_synced_vec() {
|
||||||
|
// The exact arg vec the live launch requires for map="de_dust2", bots=9 — pins the LaunchSpec
|
||||||
|
// builder to a byte-identical launch.
|
||||||
|
let expected: Vec<String> = [
|
||||||
|
"-dedicated",
|
||||||
|
"-insecure",
|
||||||
|
"+game_type",
|
||||||
|
"1",
|
||||||
|
"+game_mode",
|
||||||
|
"2",
|
||||||
|
"-maxplayers",
|
||||||
|
"13",
|
||||||
|
"+map",
|
||||||
|
"de_dust2",
|
||||||
|
"+sv_hibernate_when_empty",
|
||||||
|
"0",
|
||||||
|
"+bot_join_after_player",
|
||||||
|
"0",
|
||||||
|
"+bot_quota",
|
||||||
|
"9",
|
||||||
|
"+bot_quota_mode",
|
||||||
|
"fill",
|
||||||
|
"+bot_difficulty",
|
||||||
|
"2",
|
||||||
|
"+mp_warmuptime",
|
||||||
|
"0",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(CS2.launch.args("de_dust2", 9), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
276
src/rtti.rs
Normal file
276
src/rtti.rs
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
//! Offline Itanium C++ RTTI: locate a class's vtable in an ELF `.so` and read its slot array.
|
||||||
|
//!
|
||||||
|
//! Chain (Itanium ABI, LP64): the class name is stored length-prefixed+mangled (e.g.
|
||||||
|
//! "11CBaseEntity") as a `_ZTS` string in `.rodata`; the `_ZTI` typeinfo points to that string
|
||||||
|
//! at +8; the `_ZTV` vtable points to the typeinfo at +8, with `offset-to-top` at +0, so virtual
|
||||||
|
//! slots start at vtable+16. Those slot pointers live in `.data.rel.ro` and are supplied by
|
||||||
|
//! relocations, which `CodeImage::read_ptr` already resolves.
|
||||||
|
//!
|
||||||
|
//! This is the ELF/Itanium half; a Windows fork would add an MSVC-RTTI sibling behind the same
|
||||||
|
//! `find_vtable` shape (COL at vftable-8, TypeDescriptor `.?AV<name>@@`).
|
||||||
|
|
||||||
|
use crate::elf::{CodeImage, KindTag};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
pub struct VTable {
|
||||||
|
pub slot0: u64, // vaddr of virtual slot index 0
|
||||||
|
pub slots: Vec<u64>, // function vaddrs; gamedata offset of a method == its index here
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One vtable discovered by the whole-binary sweep — the class inventory row.
|
||||||
|
pub struct ClassVtable {
|
||||||
|
pub mangled: String, // the raw `_ZTS` type name, e.g. "11CBaseEntity"
|
||||||
|
pub name: String, // demangled, e.g. "CBaseEntity"
|
||||||
|
pub vtable_va: u64, // vaddr of slot index 0
|
||||||
|
pub offset_to_top: i64, // 0 for the primary (complete-object) vtable; <0 for sub-object tables
|
||||||
|
pub typeinfo: u64, // vaddr of the Itanium typeinfo struct
|
||||||
|
pub slots: Vec<u64>, // method vaddrs; a method's gamedata offset == its index here
|
||||||
|
pub bases: Vec<BaseClass>, // direct base classes (the is-a graph edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A direct base class of a type, from its Itanium typeinfo.
|
||||||
|
pub struct BaseClass {
|
||||||
|
pub name: String, // demangled base class name
|
||||||
|
pub offset: i64, // this-pointer adjustment to the base subobject (0 for the primary base)
|
||||||
|
pub virtual_base: bool, // true if inherited virtually
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The three Itanium `type_info` "kind" vtables (their in-object `+16` slot0 pointers). libc++abi is
|
||||||
|
/// statically bundled in CS2 libraries, so these resolve as WEAK symbols and let us classify each
|
||||||
|
/// typeinfo *exactly* — no heuristic guess of `__class` vs `__si` vs `__vmi`.
|
||||||
|
struct RttiKinds {
|
||||||
|
class: u64, // __class_type_info — no bases
|
||||||
|
si: u64, // __si_class_type_info — single public base at offset 0
|
||||||
|
vmi: u64, // __vmi_class_type_info — multiple / virtual / non-public bases
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RttiKinds {
|
||||||
|
fn detect(img: &CodeImage) -> Self {
|
||||||
|
let k = |n: &str| img.symbol_addr(n).map_or(0, |a| a.wrapping_add(16));
|
||||||
|
Self {
|
||||||
|
class: k("_ZTVN10__cxxabiv117__class_type_infoE"),
|
||||||
|
si: k("_ZTVN10__cxxabiv120__si_class_type_infoE"),
|
||||||
|
vmi: k("_ZTVN10__cxxabiv121__vmi_class_type_infoE"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Is `p` (a typeinfo's `+0` field) one of the three kind vtables? When the kind symbols are
|
||||||
|
/// stripped (all zero) we can't tell, so accept any pointer the caller already range-checked.
|
||||||
|
fn is_kind(&self, p: u64) -> bool {
|
||||||
|
if self.class == 0 && self.si == 0 && self.vmi == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
p == self.class || p == self.si || p == self.vmi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Itanium length-prefixed name for a flat class, e.g. `CBaseEntity` -> `11CBaseEntity`.
|
||||||
|
/// (Namespaced/templated names need full mangling; our targets are flat class names.)
|
||||||
|
fn mangle(class: &str) -> String {
|
||||||
|
format!("{}{}", class.len(), class)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the class's primary (complete-object) vtable and read its function-pointer slots.
|
||||||
|
pub fn find_vtable(img: &CodeImage, class: &str, max_slots: usize) -> Option<VTable> {
|
||||||
|
let mut candidates: Vec<u64> = Vec::new();
|
||||||
|
|
||||||
|
// Fast path: an exported `_ZTV` symbol (uncommon for gameplay classes, but cheap).
|
||||||
|
if let Some(ztv) = img.symbol_addr(&format!("_ZTV{}", mangle(class))) {
|
||||||
|
candidates.push(ztv.wrapping_add(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
// General path: name string -> typeinfo (points to name at +8) -> vtable (points to TI at +8).
|
||||||
|
let mut needle = mangle(class).into_bytes();
|
||||||
|
needle.push(0);
|
||||||
|
for name_str in img.find_bytes(&needle) {
|
||||||
|
for &ti_name_slot in img.ptrs_to(name_str) {
|
||||||
|
if ti_name_slot < 8 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let typeinfo = ti_name_slot - 8;
|
||||||
|
for &vt_ti_slot in img.ptrs_to(typeinfo) {
|
||||||
|
candidates.push(vt_ti_slot.wrapping_add(8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort_unstable();
|
||||||
|
candidates.dedup();
|
||||||
|
|
||||||
|
for slot0 in candidates {
|
||||||
|
// primary vtable has offset-to-top == 0 at slot0-16; filters typeinfo base-class lists
|
||||||
|
if img.read_ptr(slot0.wrapping_sub(16)) != Some(0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let slots = read_slots(img, slot0, max_slots);
|
||||||
|
// Higher floor than `enumerate_vtables` (which admits `>= 2`): a 2-slot stub is too thin to trust
|
||||||
|
// as the TARGET's real vtable when matching by name. A class whose primary vtable has exactly 2
|
||||||
|
// code slots is still catalogued in the model but not re-located here, so its offsets flag
|
||||||
|
// unresolved — a missed derivation for a rare class, never a wrong value.
|
||||||
|
if slots.len() >= 3 {
|
||||||
|
return Some(VTable { slot0, slots });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consecutive slot pointers that land in executable code; stops at the first that doesn't.
|
||||||
|
///
|
||||||
|
/// Returning exactly `max` slots is AMBIGUOUS — the vtable may genuinely end there, or may continue past
|
||||||
|
/// the cap with the tail silently dropped. Callers that care (the ones recording slot counts into the
|
||||||
|
/// model) should compare `len() == max` and warn; see `GameProfile::max_vtable_slots`.
|
||||||
|
fn read_slots(img: &CodeImage, slot0: u64, max: usize) -> Vec<u64> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for i in 0..max {
|
||||||
|
match img.read_ptr(slot0.wrapping_add((i as u64).wrapping_mul(8))) {
|
||||||
|
Some(v) if img.is_code(v) => out.push(v),
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Demangle an Itanium *type* name (the bare `_ZTS` payload, e.g. "11CBaseEntity") to a readable
|
||||||
|
/// class name. cpp_demangle wants a whole symbol, so we re-attach the `_ZTS` prefix and strip the
|
||||||
|
/// "typeinfo name for " decoration it produces. Falls back to the mangled form.
|
||||||
|
fn demangle_type(mangled: &str) -> String {
|
||||||
|
let sym = format!("_ZTS{mangled}");
|
||||||
|
cpp_demangle::Symbol::new(sym.as_bytes())
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.demangle().ok())
|
||||||
|
.map(|d| {
|
||||||
|
d.strip_prefix("typeinfo name for ")
|
||||||
|
.unwrap_or(&d)
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| mangled.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If `ti` addresses a valid Itanium typeinfo, return its `(mangled, demangled)` class name.
|
||||||
|
/// A typeinfo is `[kind_vtable_ptr][name_ptr][ base-class data … ]`: `+0` points at one of the
|
||||||
|
/// C++ runtime's type_info-kind vtables, `+8` at the `_ZTS` name string.
|
||||||
|
fn typeinfo_name(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Option<(String, String)> {
|
||||||
|
// +0 must be one of the three kind vtables. Prefer the symbol-name-derived tag (the only signal that
|
||||||
|
// survives a DYNAMICALLY-linked C++ runtime, where the three kinds all resolve to the same offline
|
||||||
|
// value); else fall back to the in-image value check (statically-linked / stripped builds).
|
||||||
|
if img.kind_at(ti).is_none() {
|
||||||
|
let kind = img.read_ptr(ti)?;
|
||||||
|
if kind == 0 || !img.contains(kind) || !kinds.is_kind(kind) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let name_ptr = img.read_ptr(ti.wrapping_add(8))?;
|
||||||
|
let mangled = img.read_c_string(name_ptr)?;
|
||||||
|
// Itanium type names start with a length digit (flat class) or a mangling sigil.
|
||||||
|
let c0 = *mangled.as_bytes().first()?;
|
||||||
|
if !(c0.is_ascii_digit() || matches!(c0, b'N' | b'I' | b'P' | b'K' | b'S')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((mangled.clone(), demangle_type(&mangled)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct base classes of the typeinfo at `ti`, dispatched on its exact Itanium kind.
|
||||||
|
fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass> {
|
||||||
|
// Classify the kind: prefer the symbol-name tag (dynamically-linked runtime), else compare the resolved
|
||||||
|
// +0 pointer to the in-image kind vtables (statically-linked). Without the tag, an old build can't tell
|
||||||
|
// __si from __vmi at all, and the base graph would silently come back empty.
|
||||||
|
let tag = img.kind_at(ti).or_else(|| {
|
||||||
|
let kind = img.read_ptr(ti).unwrap_or(0);
|
||||||
|
if kind == 0 {
|
||||||
|
None
|
||||||
|
} else if kind == kinds.si {
|
||||||
|
Some(KindTag::Si)
|
||||||
|
} else if kind == kinds.vmi {
|
||||||
|
Some(KindTag::Vmi)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
match tag {
|
||||||
|
Some(KindTag::Si) => {
|
||||||
|
// __si_class_type_info: one public, non-virtual base at offset 0; its typeinfo ptr at +16.
|
||||||
|
if let Some(bp) = img.read_ptr(ti.wrapping_add(16))
|
||||||
|
&& let Some((_, name)) = typeinfo_name(img, bp, kinds)
|
||||||
|
{
|
||||||
|
return vec![BaseClass {
|
||||||
|
name,
|
||||||
|
offset: 0,
|
||||||
|
virtual_base: false,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Some(KindTag::Vmi) => {
|
||||||
|
// __vmi_class_type_info: flags@+16, base_count@+20, then 16-byte {typeinfo_ptr, offset_flags}.
|
||||||
|
let Some(count) = img.read_u32(ti + 20) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if count == 0 || count > 128 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut bases = Vec::new();
|
||||||
|
for i in 0..count as u64 {
|
||||||
|
let e = ti.wrapping_add(24).wrapping_add(i.wrapping_mul(16));
|
||||||
|
let Some(bp) = img.read_ptr(e) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if let Some((_, name)) = typeinfo_name(img, bp, kinds) {
|
||||||
|
let of = img.read_i64(e.wrapping_add(8)).unwrap_or(0);
|
||||||
|
bases.push(BaseClass {
|
||||||
|
name,
|
||||||
|
offset: of >> 8, // Itanium: high bits = this-pointer adjustment
|
||||||
|
virtual_base: of & 0x1 != 0, // low byte: 0x1 = virtual, 0x2 = public
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bases
|
||||||
|
}
|
||||||
|
_ => Vec::new(), // __class_type_info (no bases) or a kind we can't classify
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enumerate EVERY class vtable in the image via Itanium RTTI — the whole-binary class inventory.
|
||||||
|
///
|
||||||
|
/// Reloc-driven (not a raw byte sweep): each vtable's typeinfo field at `vtable-8` is a relocation,
|
||||||
|
/// so we walk the reloc map, keep slots that point at a valid typeinfo, and recover the vtable just
|
||||||
|
/// above. Every pointer is read through the `.rela.dyn`-resolved `read_ptr`, so `.data.rel.ro` slots
|
||||||
|
/// (zero on disk) come back as their true as-loaded values.
|
||||||
|
pub fn enumerate_vtables(img: &CodeImage, max_slots: usize) -> Vec<ClassVtable> {
|
||||||
|
let kinds = RttiKinds::detect(img);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for (slot, val) in img.reloc_slots() {
|
||||||
|
if slot < 8 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((mangled, name)) = typeinfo_name(img, val, &kinds) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let vtable_va = slot.wrapping_add(8);
|
||||||
|
if !seen.insert(vtable_va) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// offset-to-top sits at vtable-16 (just below the typeinfo field): a plain, non-relocated,
|
||||||
|
// pointer-aligned int, 0 for a primary table and a small negative for sub-object tables.
|
||||||
|
let Some(ott) = img.read_i64(slot.wrapping_sub(8)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !(-(1 << 24)..=0).contains(&ott) || ott % 8 != 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let slots = read_slots(img, vtable_va, max_slots);
|
||||||
|
if slots.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bases = typeinfo_bases(img, val, &kinds);
|
||||||
|
out.push(ClassVtable {
|
||||||
|
mangled,
|
||||||
|
name,
|
||||||
|
vtable_va,
|
||||||
|
offset_to_top: ott,
|
||||||
|
typeinfo: val,
|
||||||
|
slots,
|
||||||
|
bases,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort_by_key(|c| c.vtable_va);
|
||||||
|
out
|
||||||
|
}
|
||||||
353
src/schema.rs
Normal file
353
src/schema.rs
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
//! Offline Source-2 **SchemaSystem** reader: recover class instance sizes, field offsets and base
|
||||||
|
//! classes straight from Valve's own reflection tables in a stripped `.so` — making the field-offset
|
||||||
|
//! half of gamedata *deterministic* (no fingerprint carry-forward, no "verify this guess").
|
||||||
|
//!
|
||||||
|
//! Source 2 emits, as static data, a `SchemaClassInfoData_t` per registered class (its name, size,
|
||||||
|
//! field array, base array) plus a `SchemaClassFieldData_t` per field (name, type, offset). The
|
||||||
|
//! struct layouts here are the authoritative LP64 layouts from hl2sdk-cs2
|
||||||
|
//! `public/schemasystem/schematypes.h`.
|
||||||
|
//!
|
||||||
|
//! Root discovery is reloc-driven, mirroring `rtti::enumerate_vtables`: every class name pointer is
|
||||||
|
//! a relocation, so we treat each reloc slot as a candidate `m_pszName` field, read the struct just
|
||||||
|
//! below it, and validate (sane size + field count, a real fields pointer, and a first field named
|
||||||
|
//! `m_…` — Source 2's universal member-prefix, which alone rejects essentially all false positives).
|
||||||
|
//! Every pointer is read through `CodeImage::read_ptr`, so `.data.rel.ro` slots (zero on disk) come
|
||||||
|
//! back as their true as-loaded values.
|
||||||
|
|
||||||
|
use crate::elf::CodeImage;
|
||||||
|
use crate::profile::GameProfile;
|
||||||
|
use crate::{live, model};
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::collections::{BTreeMap, HashSet};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Byte offsets of the SchemaSystem reflection structs (SchemaClassInfoData_t / SchemaClassFieldData_t /
|
||||||
|
/// SchemaBaseClassInfoData_t, LP64 — hl2sdk-cs2 public/schemasystem/schematypes.h). Grouped into one
|
||||||
|
/// swappable value because this layout tracks the engine BUILD ERA (Valve reshapes these structs across
|
||||||
|
/// engine updates), NOT the game. That makes it orthogonal to `GameProfile`: a future per-era detector
|
||||||
|
/// ships several `SchemaLayout`s and picks one per binary. Today there is exactly one — `CURRENT_LAYOUT`,
|
||||||
|
/// the source of truth the rest of this module and the live oracle read through.
|
||||||
|
pub struct SchemaLayout {
|
||||||
|
pub ci_binding: u64, // CSchemaClassInfo* m_pSchemaBinding (0 on disk, populated at runtime)
|
||||||
|
pub ci_name: u64, // const char* m_pszName
|
||||||
|
pub ci_size: u64, // int m_nSize
|
||||||
|
pub ci_field_count: u64, // uint16 m_nFieldCount
|
||||||
|
pub ci_base_count: u64, // uint8 m_nBaseClassCount
|
||||||
|
pub ci_fields: u64, // SchemaClassFieldData_t* m_pFields
|
||||||
|
pub ci_bases: u64, // SchemaBaseClassInfoData_t* m_pBaseClasses
|
||||||
|
pub f_name: u64, // SchemaClassFieldData_t::m_pszName
|
||||||
|
pub f_offset: u64, // SchemaClassFieldData_t::m_nSingleInheritanceOffset
|
||||||
|
pub f_stride: u64, // sizeof(SchemaClassFieldData_t)
|
||||||
|
pub b_offset: u64, // SchemaBaseClassInfoData_t::m_nOffset
|
||||||
|
pub b_class: u64, // SchemaBaseClassInfoData_t::m_pClass
|
||||||
|
pub b_stride: u64, // sizeof(SchemaBaseClassInfoData_t)
|
||||||
|
// ---- CSchemaType: a SECOND runtime struct, reachable only from a live process ----
|
||||||
|
// `SchemaClassFieldData_t::m_pType` points at it, and the typed-netvars walk reads the type's name and
|
||||||
|
// category through it. It belongs here for the same reason the rest does: this is engine-ERA layout
|
||||||
|
// Valve reshapes across builds — kept beside the offline offsets so a reshape can't pass every offline
|
||||||
|
// check and still ship a netvars file full of empty types.
|
||||||
|
pub f_type: u64, // SchemaClassFieldData_t::m_pType
|
||||||
|
pub ty_name: u64, // CSchemaType::m_pszName
|
||||||
|
pub ty_category: u64, // CSchemaType::m_eTypeCategory (low byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one layout in service — current CS2/Source-2 engine era.
|
||||||
|
pub const CURRENT_LAYOUT: SchemaLayout = SchemaLayout {
|
||||||
|
ci_binding: 0,
|
||||||
|
ci_name: 8,
|
||||||
|
ci_size: 32,
|
||||||
|
ci_field_count: 36,
|
||||||
|
ci_base_count: 41,
|
||||||
|
ci_fields: 48,
|
||||||
|
ci_bases: 56,
|
||||||
|
f_name: 0,
|
||||||
|
f_offset: 16,
|
||||||
|
f_stride: 32,
|
||||||
|
b_offset: 0,
|
||||||
|
b_class: 8,
|
||||||
|
b_stride: 16,
|
||||||
|
f_type: 8,
|
||||||
|
ty_name: 8,
|
||||||
|
ty_category: 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The offsets projected as module consts — the stable interface the parser (below) and the runtime
|
||||||
|
// oracle (`produce::verify_live_cmd` via `schema::CI_*` / `F_*`) read. Sourced from `CURRENT_LAYOUT` so it stays the
|
||||||
|
// single source of truth; a per-era swap changes only the const above.
|
||||||
|
pub const CI_BINDING: u64 = CURRENT_LAYOUT.ci_binding;
|
||||||
|
pub const CI_NAME: u64 = CURRENT_LAYOUT.ci_name;
|
||||||
|
pub const CI_SIZE: u64 = CURRENT_LAYOUT.ci_size;
|
||||||
|
pub const CI_FIELD_COUNT: u64 = CURRENT_LAYOUT.ci_field_count;
|
||||||
|
const CI_BASE_COUNT: u64 = CURRENT_LAYOUT.ci_base_count;
|
||||||
|
pub const CI_FIELDS: u64 = CURRENT_LAYOUT.ci_fields;
|
||||||
|
const CI_BASES: u64 = CURRENT_LAYOUT.ci_bases;
|
||||||
|
const F_NAME: u64 = CURRENT_LAYOUT.f_name;
|
||||||
|
pub const F_OFFSET: u64 = CURRENT_LAYOUT.f_offset;
|
||||||
|
pub const F_STRIDE: u64 = CURRENT_LAYOUT.f_stride;
|
||||||
|
pub const F_TYPE: u64 = CURRENT_LAYOUT.f_type;
|
||||||
|
pub const TY_NAME: u64 = CURRENT_LAYOUT.ty_name;
|
||||||
|
pub const TY_CATEGORY: u64 = CURRENT_LAYOUT.ty_category;
|
||||||
|
const B_OFFSET: u64 = CURRENT_LAYOUT.b_offset;
|
||||||
|
const B_CLASS: u64 = CURRENT_LAYOUT.b_class;
|
||||||
|
const B_STRIDE: u64 = CURRENT_LAYOUT.b_stride;
|
||||||
|
|
||||||
|
pub struct SchemaField {
|
||||||
|
pub name: String,
|
||||||
|
pub offset: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SchemaBase {
|
||||||
|
pub name: String,
|
||||||
|
pub offset: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One registered Source-2 class recovered from the schema tables.
|
||||||
|
pub struct SchemaClass {
|
||||||
|
pub name: String,
|
||||||
|
pub class_info: u64, // vaddr of the SchemaClassInfoData_t
|
||||||
|
pub name_ptr: u64, // reloc-resolved vaddr of the name string (for live cross-check)
|
||||||
|
pub size: i32, // instance size in bytes
|
||||||
|
pub bases: Vec<SchemaBase>,
|
||||||
|
pub fields: Vec<SchemaField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SchemaClass {
|
||||||
|
/// The primary (offset-0) base class name, if any — for cross-checking against the RTTI chain.
|
||||||
|
pub fn primary_base(&self) -> Option<&str> {
|
||||||
|
self.bases
|
||||||
|
.iter()
|
||||||
|
.find(|b| b.offset == 0)
|
||||||
|
.map(|b| b.name.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A schema type name: an identifier plus the template/namespace punctuation Source 2 uses.
|
||||||
|
fn is_type_name(s: &str) -> bool {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
if b.is_empty() || b.len() >= 256 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !(b[0].is_ascii_alphabetic() || b[0] == b'_') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
s.chars().all(|c| {
|
||||||
|
c.is_ascii_alphanumeric()
|
||||||
|
|| matches!(c, '_' | ':' | '<' | '>' | ',' | ' ' | '*' | '&' | '[' | ']')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enumerate every registered class in `img` via the SchemaSystem tables — the whole-binary schema
|
||||||
|
/// inventory. Sorted by class name.
|
||||||
|
pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for (slot, val) in img.reloc_slots() {
|
||||||
|
if slot < 8 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Candidate: `slot` is a class's m_pszName field, so `val` -> the class name string.
|
||||||
|
let Some(name) = img.read_c_string(val) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !is_type_name(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let base = slot - 8;
|
||||||
|
if !seen.insert(base) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(cls) = parse_class(img, base, &name, val) {
|
||||||
|
out.push(cls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<SchemaClass> {
|
||||||
|
let size = img.read_i32(base.wrapping_add(CI_SIZE))?;
|
||||||
|
if size <= 0 || size >= (1 << 23) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let field_count = img.read_u16(base.wrapping_add(CI_FIELD_COUNT))?;
|
||||||
|
if field_count == 0 || field_count >= 6000 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let fields_ptr = img.read_ptr(base.wrapping_add(CI_FIELDS))?;
|
||||||
|
if fields_ptr == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Discriminator: a real schema class's first field is `m_…`. This alone rejects the stray reloc
|
||||||
|
// slots that happen to point at an identifier-shaped string but aren't class bindings.
|
||||||
|
let first = img
|
||||||
|
.read_ptr(fields_ptr)
|
||||||
|
.and_then(|p| img.read_c_string(p))?;
|
||||||
|
if !first.starts_with("m_") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut fields = Vec::with_capacity(field_count as usize);
|
||||||
|
for i in 0..field_count as u64 {
|
||||||
|
let fe = fields_ptr.wrapping_add(i.wrapping_mul(F_STRIDE));
|
||||||
|
let Some(fname) = img.read_ptr(fe + F_NAME).and_then(|p| img.read_c_string(p)) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let offset = img.read_i32(fe + F_OFFSET).unwrap_or(0);
|
||||||
|
fields.push(SchemaField {
|
||||||
|
name: fname,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_count = img.read_u8(base.wrapping_add(CI_BASE_COUNT)).unwrap_or(0);
|
||||||
|
let bases_ptr = img.read_ptr(base.wrapping_add(CI_BASES)).unwrap_or(0);
|
||||||
|
let mut bases = Vec::new();
|
||||||
|
if bases_ptr != 0 {
|
||||||
|
for i in 0..base_count as u64 {
|
||||||
|
let be = bases_ptr.wrapping_add(i.wrapping_mul(B_STRIDE));
|
||||||
|
let offset = img.read_u32(be + B_OFFSET).unwrap_or(0);
|
||||||
|
let bcls = img.read_ptr(be + B_CLASS).unwrap_or(0);
|
||||||
|
if bcls == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(bn) = img
|
||||||
|
.read_ptr(bcls + CI_NAME)
|
||||||
|
.and_then(|p| img.read_c_string(p))
|
||||||
|
{
|
||||||
|
bases.push(SchemaBase { name: bn, offset });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(SchemaClass {
|
||||||
|
name: name.to_string(),
|
||||||
|
class_info: base,
|
||||||
|
name_ptr,
|
||||||
|
size,
|
||||||
|
bases,
|
||||||
|
fields,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Live type walk (the typed schema) ──────────────────────────────────────────────────────────
|
||||||
|
// The offline reader above recovers class layouts (names + field offsets) from the static reflection
|
||||||
|
// tables. Field *types* are runtime-resolved (each record's `m_pType` is a null pointer on disk), so
|
||||||
|
// `live_schema` attaches to a running process, reads the types back, and builds the typed
|
||||||
|
// `netvars-<game>.json` (`model::Schema`) directly — no `sdk.json` intermediate.
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// against swiftlys2's own generated hashes.
|
||||||
|
fn fnv1a32(s: &str) -> u32 {
|
||||||
|
let mut h: u32 = 0x811c9dc5;
|
||||||
|
for b in s.bytes() {
|
||||||
|
h = (h ^ b as u32).wrapping_mul(0x0100_0193);
|
||||||
|
}
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort byte size of a builtin schema type (codegen doesn't require it, but it's cheap).
|
||||||
|
fn builtin_size(t: &str) -> i32 {
|
||||||
|
match t {
|
||||||
|
"int8" | "uint8" | "char" | "bool" => 1,
|
||||||
|
"int16" | "uint16" => 2,
|
||||||
|
"int32" | "uint32" | "float32" => 4,
|
||||||
|
"int64" | "uint64" | "float64" | "double" => 8,
|
||||||
|
_ if t.ends_with('*') => 8,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk a running process's schema across every server-mapped library (`profile.libs`) and build the typed
|
||||||
|
/// netvars (`model::Schema`) DIRECTLY — no `sdk.json` round-trip: field layout is read offline from each
|
||||||
|
/// image, the runtime `m_pType` from the live process. Shared classes (compiled into many libs) de-dupe
|
||||||
|
/// precedence-first (the earlier lib in `libs` wins). This is `netvars-<game>.json` — the shipped SDK
|
||||||
|
/// material (`source2rosetta-gen` renders it on demand).
|
||||||
|
pub(crate) fn live_schema(
|
||||||
|
prof: &GameProfile,
|
||||||
|
pid: u32,
|
||||||
|
dir: &Path,
|
||||||
|
source_build: &str,
|
||||||
|
) -> Result<model::Schema> {
|
||||||
|
use model::{Field, Schema, SchemaMeta};
|
||||||
|
let live = live::LiveProcess::attach(pid)?;
|
||||||
|
let mut classes: BTreeMap<String, BTreeMap<String, Field>> = BTreeMap::new();
|
||||||
|
let (mut typed, mut untyped) = (0usize, 0usize);
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
let mut nlibs = 0usize;
|
||||||
|
for &lib in prof.libs {
|
||||||
|
let Ok(img) = crate::locate::load_lib(dir, lib) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(base) = live.base(lib) else { continue }; // lib not mapped in the process -> skip
|
||||||
|
nlibs += 1;
|
||||||
|
for c in &enumerate_schema(&img) {
|
||||||
|
// a shared class already taken from an earlier (higher-precedence) lib — identical layout, skip
|
||||||
|
if !seen.insert(c.name.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// fields array is static; each record's m_pType is runtime-resolved -> read it from the process
|
||||||
|
let fields_ptr = img.read_ptr(c.class_info + CI_FIELDS).unwrap_or(0);
|
||||||
|
let mut fmap: BTreeMap<String, Field> = BTreeMap::new();
|
||||||
|
for (i, f) in c.fields.iter().enumerate() {
|
||||||
|
let rec = base
|
||||||
|
.wrapping_add(fields_ptr)
|
||||||
|
.wrapping_add((i as u64).wrapping_mul(F_STRIDE));
|
||||||
|
let mptype = live.read_u64(rec.wrapping_add(F_TYPE)).unwrap_or(0);
|
||||||
|
// a resolved type is a real pointer; on-disk placeholders are tiny/tagged values
|
||||||
|
let (type_name, cat) = if mptype > 0x10000 {
|
||||||
|
let name = live
|
||||||
|
.read_u64(mptype.wrapping_add(TY_NAME))
|
||||||
|
.ok()
|
||||||
|
.and_then(|q| live.read_cstr(q).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let cat = live
|
||||||
|
.read_u16(mptype.wrapping_add(TY_CATEGORY))
|
||||||
|
.map(|v| (v & 0xff) as u8)
|
||||||
|
.unwrap_or(0xff);
|
||||||
|
(name, cat)
|
||||||
|
} else {
|
||||||
|
(String::new(), 0xffu8)
|
||||||
|
};
|
||||||
|
let ty = type_name.replace(' ', ""); // codegen strips spaces anyway
|
||||||
|
// count typed/untyped on the SPACE-STRIPPED type (what the netvars meta reflects), so a
|
||||||
|
// whitespace-only runtime name counts as untyped.
|
||||||
|
if ty.is_empty() {
|
||||||
|
untyped += 1;
|
||||||
|
} else {
|
||||||
|
typed += 1;
|
||||||
|
}
|
||||||
|
let kind = match cat {
|
||||||
|
1 => model::FieldKind::Ptr,
|
||||||
|
3 => model::FieldKind::FixedArray,
|
||||||
|
_ => model::FieldKind::Ref, // builtin / atomic / declared class / declared enum
|
||||||
|
};
|
||||||
|
let name_hash = ((fnv1a32(&c.name) as u64) << 32) | fnv1a32(&f.name) as u64;
|
||||||
|
fmap.insert(
|
||||||
|
f.name.clone(),
|
||||||
|
Field {
|
||||||
|
offset: f.offset,
|
||||||
|
ty: ty.clone(),
|
||||||
|
kind,
|
||||||
|
size: builtin_size(&ty) as usize,
|
||||||
|
name_hash,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
classes.insert(c.name.clone(), fmap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"typed netvars: {} classes across {nlibs} libs, {typed} typed fields, {untyped} unresolved",
|
||||||
|
classes.len()
|
||||||
|
);
|
||||||
|
Ok(Schema {
|
||||||
|
meta: SchemaMeta {
|
||||||
|
game_key: prof.game_key.to_string(),
|
||||||
|
source_build: source_build.to_string(),
|
||||||
|
typed,
|
||||||
|
untyped,
|
||||||
|
},
|
||||||
|
classes,
|
||||||
|
})
|
||||||
|
}
|
||||||
87
src/sig.rs
Normal file
87
src/sig.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
//! Byte-pattern signatures: parse "55 48 89 ? E5" and scan a byte haystack for matches.
|
||||||
|
|
||||||
|
/// A signature pattern; `None` entries are wildcards (`?` / `??`).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Pattern {
|
||||||
|
bytes: Vec<Option<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pattern {
|
||||||
|
pub fn parse(s: &str) -> anyhow::Result<Self> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
for tok in s.split_whitespace() {
|
||||||
|
match tok {
|
||||||
|
"?" | "??" | "*" => bytes.push(None),
|
||||||
|
hex => {
|
||||||
|
let b = u8::from_str_radix(hex, 16)
|
||||||
|
.map_err(|_| anyhow::anyhow!("bad signature token {tok:?}"))?;
|
||||||
|
bytes.push(Some(b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyhow::ensure!(!bytes.is_empty(), "empty signature");
|
||||||
|
anyhow::ensure!(bytes.iter().any(Option::is_some), "all-wildcard signature");
|
||||||
|
Ok(Self { bytes })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First concrete (non-wildcard) byte and its index — used as a cheap scan prefilter.
|
||||||
|
fn anchor(&self) -> (usize, u8) {
|
||||||
|
self.bytes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find_map(|(i, b)| b.map(|v| (i, v)))
|
||||||
|
.expect("parse() guarantees at least one concrete byte")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte offsets in `hay` where this pattern matches.
|
||||||
|
pub fn find_all(&self, hay: &[u8]) -> Vec<usize> {
|
||||||
|
let n = self.bytes.len();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if hay.len() < n {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let (ai, av) = self.anchor();
|
||||||
|
// SIMD-scan for the anchor byte (memchr), full-match only at candidate starts.
|
||||||
|
// A match starting at `pos` puts its anchor at `pos + ai`, so valid anchor indices
|
||||||
|
// are [ai, hay.len()-n+ai]; scanning [..=hi] finds every match's anchor, none missed.
|
||||||
|
let hi = hay.len() - n + ai;
|
||||||
|
for apos in memchr::memchr_iter(av, &hay[..=hi]) {
|
||||||
|
if apos < ai {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pos = apos - ai;
|
||||||
|
if self
|
||||||
|
.bytes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.all(|(i, b)| b.is_none_or(|v| hay[pos + i] == v))
|
||||||
|
{
|
||||||
|
out.push(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_and_match() {
|
||||||
|
let p = Pattern::parse("55 48 ? E5").unwrap();
|
||||||
|
let hay = [0x00, 0x55, 0x48, 0x99, 0xE5, 0x55, 0x48, 0x11, 0xE5];
|
||||||
|
assert_eq!(p.find_all(&hay), vec![1, 5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_question_is_wildcard() {
|
||||||
|
let p = Pattern::parse("90 ?? 90").unwrap();
|
||||||
|
assert_eq!(p.find_all(&[0x90, 0xAB, 0x90]), vec![0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_wildcard_rejected() {
|
||||||
|
assert!(Pattern::parse("? ??").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
265
src/taxonomy.rs
Normal file
265
src/taxonomy.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
//! Name / dead-weight taxonomy — the classification predicates that decide which resolved names are
|
||||||
|
//! real gameplay functions vs. generated plumbing, and how much to trust a name guess. Every item here is
|
||||||
|
//! a pure `&str`-in / verdict-out predicate over the name/class vocabulary, with no engine or IO
|
||||||
|
//! dependency — each takes the game's `&GameProfile` for its retunable vocabulary (dead-weight namespaces,
|
||||||
|
//! serializer method names, query prefixes). This is the primary knob a fork retunes for a different game
|
||||||
|
//! (the vocabulary is data on the profile). Shared by the fold (`build_gamedata_cmd`), the experimental band
|
||||||
|
//! (`emit_experimental_band`), the live semantic sweep, and the corpus-model class scope.
|
||||||
|
|
||||||
|
use crate::model::Tier;
|
||||||
|
use crate::profile::GameProfile;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
/// A candidate whose RTTI class is not CS2 gameplay at all — foreign runtime/library code that leaked
|
||||||
|
/// into `libserver.so`, or a protobuf-generated message type whose whole vtable is serializer boilerplate
|
||||||
|
/// (`GetMetadata`/`New`/`Clear`/`MergeFrom`/…, zero hook value). Excluded at dump time so naming agents
|
||||||
|
/// never spend time (~64% of the CS2 candidate pool) on functions we already know are dead
|
||||||
|
/// weight — and so the same junk never enters a per-game run for Dota2/Deadlock.
|
||||||
|
pub(crate) fn is_dead_weight_class(prof: &GameProfile, class: &str) -> bool {
|
||||||
|
// foreign namespaces (C++ runtime, libstdc++, Steam GC SDK, Valve container templates, the V8 vscript
|
||||||
|
// backend whose `v8::` classes leak into libvscript's RTTI) — all retunable per game on the profile.
|
||||||
|
if prof.foreign_namespaces.iter().any(|p| class.starts_with(p)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// protobuf RPC message shape — a `_Response`/`_Request` class is always a wire message.
|
||||||
|
if class.contains("_Response") || class.contains("_Request") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// protobuf-generated message classes (the wire/GC protocol) — every method is serializer plumbing. The
|
||||||
|
// per-game user-message prefix (CS2: CCSUsrMsg) plus the shared Source-2 / Steam-GC message prefixes.
|
||||||
|
let leaf = class.rsplit("::").next().unwrap_or(class);
|
||||||
|
leaf.starts_with(prof.usermsg_prefix) || prof.proto_prefixes.iter().any(|p| leaf.starts_with(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resolved NAME that is not CS2 gameplay — its owning class (or the whole name) is foreign/protobuf.
|
||||||
|
/// Complements the class-based dump-candidates prefilter for NON-virtual dead weight that has no RTTI
|
||||||
|
/// vtable class to filter on at dump time (free `GCSDK::*` / `google::protobuf::*` functions), caught
|
||||||
|
/// here once naming has resolved the class.
|
||||||
|
pub(crate) fn is_dead_weight_name(prof: &GameProfile, name: &str) -> bool {
|
||||||
|
let cls = name.rsplit_once("::").map(|(c, _)| c).unwrap_or(name);
|
||||||
|
is_dead_weight_class(prof, cls) || is_dead_weight_class(prof, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classes with ≥3 serializer methods among `names` — protobuf message types whose class name matches no
|
||||||
|
/// foreign/CMsg prefix (e.g. `AccountActivity`, `CGCToGCMsgMasterAck`), detectable ONLY by their generated
|
||||||
|
/// method surface. The shared cluster detector both the fold and the experimental band flag plumbing with.
|
||||||
|
/// A HARD serializer method (`GetMetadata`/…) is decisive on its own; SOFT ones (`New`/`Clear`/…) can be
|
||||||
|
/// legit game methods, so both count toward the ≥3 cluster here but only HARD is a lone verdict elsewhere
|
||||||
|
/// (see [`is_serializer_plumbing`]). Both sets ride the `GameProfile` passed in, so a fork retunes them.
|
||||||
|
pub(crate) fn protobuf_message_classes<'a>(
|
||||||
|
prof: &GameProfile,
|
||||||
|
names: impl Iterator<Item = &'a str>,
|
||||||
|
) -> HashSet<String> {
|
||||||
|
let mut ser_count: HashMap<&str, usize> = HashMap::new();
|
||||||
|
for name in names {
|
||||||
|
if let Some((cls, leaf)) = name.rsplit_once("::")
|
||||||
|
&& (prof.hard_serializer.contains(&leaf) || prof.soft_serializer.contains(&leaf))
|
||||||
|
{
|
||||||
|
*ser_count.entry(cls).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ser_count
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, c)| *c >= 3)
|
||||||
|
.map(|(k, _)| k.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `name` protobuf serializer plumbing — a lone HARD serializer method, or a member of a class flagged
|
||||||
|
/// as a protobuf message by [`protobuf_message_classes`]? Complements the prefix/namespace test in
|
||||||
|
/// [`is_dead_weight_name`], which can't see method-name-only protobuf classes.
|
||||||
|
pub(crate) fn is_serializer_plumbing(
|
||||||
|
prof: &GameProfile,
|
||||||
|
name: &str,
|
||||||
|
pb_classes: &HashSet<String>,
|
||||||
|
) -> bool {
|
||||||
|
let hard = prof.hard_serializer;
|
||||||
|
name.rsplit_once("::")
|
||||||
|
.is_some_and(|(cls, leaf)| hard.contains(&leaf) || pb_classes.contains(cls))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A class clean enough to key a vtable-OFFSET entry: a real gameplay class (not dead weight), not a
|
||||||
|
/// `NetworkVar_`/template/alias chainer, and a bare name (the RTTI ground-truth class, no `::`).
|
||||||
|
pub(crate) fn clean_offset_class(prof: &GameProfile, cls: &str) -> bool {
|
||||||
|
!is_dead_weight_class(prof, cls)
|
||||||
|
&& !cls.contains("NetworkVar_")
|
||||||
|
&& !cls.contains('<')
|
||||||
|
&& !cls.contains("Alias_")
|
||||||
|
&& !cls.contains("::")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The class portion of a fully-qualified function name — everything before the last `::`, or before the
|
||||||
|
/// last `_` for the flat `Class_Method` form, or the whole name if neither. The one name-vocabulary splitter
|
||||||
|
/// the gamedata offsets and the live sweep share, so callers don't re-derive the class inline.
|
||||||
|
pub(crate) fn class_of(name: &str) -> &str {
|
||||||
|
if let Some(i) = name.rfind("::") {
|
||||||
|
&name[..i]
|
||||||
|
} else if let Some(i) = name.rfind('_') {
|
||||||
|
&name[..i]
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `ret=` class word out of an abi describe string ("int=1 float=0 ret=int" -> "int").
|
||||||
|
pub(crate) fn parse_ret(abi: &str) -> Option<&str> {
|
||||||
|
abi.split_whitespace().find_map(|t| t.strip_prefix("ret="))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the full-slice name universe (`candidates-names-cs2-full.json`): an address + the
|
||||||
|
/// AI/heuristic name guess for it, plus the signals that grade the guess. Distinct from `PromoName` —
|
||||||
|
/// this reads the UN-filtered set (promoted AND un-promoted), the raw material of the experimental band.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(crate) struct FullName {
|
||||||
|
pub(crate) addr: String,
|
||||||
|
pub(crate) name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) confidence: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) corroboration: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) self_named: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) promote: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The confidence LADDER for a name guess — a composite honesty tier, stronger than the model's own
|
||||||
|
/// confidence word: a name literally present in the function's bytes (`self-named`) is near-certain; a
|
||||||
|
/// dictionary-corroborated leaf is next; then the model's own high/medium/low. Returns `(rank, label)`,
|
||||||
|
/// lower rank = more trustworthy. This is the primary grouping key of the experimental band.
|
||||||
|
pub(crate) fn guess_tier(r: &FullName) -> (u8, Tier) {
|
||||||
|
if r.self_named {
|
||||||
|
(0, Tier::SelfNamed)
|
||||||
|
} else if matches!(r.corroboration.as_str(), "exact" | "exact-free") {
|
||||||
|
(1, Tier::Corroborated)
|
||||||
|
} else if r.confidence == "high" {
|
||||||
|
(2, Tier::High)
|
||||||
|
} else if r.confidence == "medium" {
|
||||||
|
(3, Tier::Medium)
|
||||||
|
} else {
|
||||||
|
(4, Tier::Low)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A method name safe to blind-CALL with only `this` — a boolean predicate that returns a bool in RAX.
|
||||||
|
/// Deliberately EXCLUDES `Get*`: a getter can return a value BY VALUE (a string/struct), whose ABI
|
||||||
|
/// hides an output-buffer pointer in RDI (RVO) with `this` shifted to RSI — so calling it with the
|
||||||
|
/// object in RDI makes it WRITE the return value into the object. That is memory CORRUPTION, not a
|
||||||
|
/// faulting read, so `call_remote`'s signal-suppression can't catch it and the server dies later. The ABI-shape
|
||||||
|
/// lower bound can't distinguish this (a constant-returner reads no args and shows `int=0`), so the
|
||||||
|
/// gate is name-based: only the boolean predicates, which by convention return a bool and take no
|
||||||
|
/// output parameter. Fewer methods get the call-smoke-test, but the harness never corrupts the server.
|
||||||
|
pub(crate) fn is_query_method(prof: &GameProfile, name: &str) -> bool {
|
||||||
|
let leaf = name.rsplit("::").next().unwrap_or(name);
|
||||||
|
prof.query_prefixes.iter().any(|p| leaf.starts_with(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::profile::{CS2, DOTA};
|
||||||
|
|
||||||
|
// This module is documented as "the primary knob a fork retunes for a different game", and nothing
|
||||||
|
// else gates a retune: live validation only ever sees entries that SURVIVED classification, so an
|
||||||
|
// over-broad predicate silently shrinks the output with no count to compare against. These pin the
|
||||||
|
// decisions against both shipped profiles.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_gameplay_classes_are_not_dead_weight() {
|
||||||
|
for prof in [&CS2, &DOTA] {
|
||||||
|
for cls in [
|
||||||
|
"CBaseEntity",
|
||||||
|
"CCSPlayerPawn",
|
||||||
|
"CGameRules",
|
||||||
|
"CDOTA_BaseNPC",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!is_dead_weight_class(prof, cls),
|
||||||
|
"{cls} misclassified as dead weight"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protobuf_and_foreign_namespaces_are_dead_weight() {
|
||||||
|
for prof in [&CS2, &DOTA] {
|
||||||
|
for cls in ["CMsgVector", "v8::internal::Object", "std::vector<int>"] {
|
||||||
|
assert!(
|
||||||
|
is_dead_weight_class(prof, cls),
|
||||||
|
"{cls} should be dead weight"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_hard_serializer_marks_plumbing_but_one_soft_does_not() {
|
||||||
|
let prof = &CS2;
|
||||||
|
let none = HashSet::new();
|
||||||
|
let hard = format!("CFoo::{}", prof.hard_serializer[0]);
|
||||||
|
let soft = format!("CFoo::{}", prof.soft_serializer[0]);
|
||||||
|
// a lone HARD serializer method is decisive on its own
|
||||||
|
assert!(is_serializer_plumbing(prof, &hard, &none));
|
||||||
|
// a lone SOFT one is not — those names also occur on legitimate game classes
|
||||||
|
assert!(!is_serializer_plumbing(prof, &soft, &none));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protobuf_clustering_needs_three_serializer_methods() {
|
||||||
|
let prof = &CS2;
|
||||||
|
let soft = prof.soft_serializer;
|
||||||
|
assert!(
|
||||||
|
soft.len() >= 3,
|
||||||
|
"profile needs >=3 soft serializers for this rule to be reachable"
|
||||||
|
);
|
||||||
|
let two: Vec<String> = soft.iter().take(2).map(|m| format!("CTwo::{m}")).collect();
|
||||||
|
let three: Vec<String> = soft
|
||||||
|
.iter()
|
||||||
|
.take(3)
|
||||||
|
.map(|m| format!("CThree::{m}"))
|
||||||
|
.collect();
|
||||||
|
let all: Vec<&str> = two.iter().chain(three.iter()).map(String::as_str).collect();
|
||||||
|
let flagged = protobuf_message_classes(prof, all.into_iter());
|
||||||
|
assert!(
|
||||||
|
flagged.contains("CThree"),
|
||||||
|
"3 serializer methods should cluster as protobuf"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!flagged.contains("CTwo"),
|
||||||
|
"2 methods is below the >=3 cluster threshold"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn class_of_prefers_scope_then_underscore_then_whole_name() {
|
||||||
|
assert_eq!(class_of("CBaseEntity::TakeDamage"), "CBaseEntity");
|
||||||
|
// a templated class keeps its template arguments
|
||||||
|
assert_eq!(
|
||||||
|
class_of("CHandle<CBaseEntity>::Get"),
|
||||||
|
"CHandle<CBaseEntity>"
|
||||||
|
);
|
||||||
|
// no `::` falls back to the last underscore — this is how the ecosystem's flat
|
||||||
|
// `CClass_Method` names still key a class
|
||||||
|
assert_eq!(class_of("CCSPlayerPawn_Respawn"), "CCSPlayerPawn");
|
||||||
|
// and with neither separator the whole name IS the class key
|
||||||
|
assert_eq!(class_of("FreeFunction"), "FreeFunction");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_methods_need_a_profile_prefix_not_just_get() {
|
||||||
|
let prof = &CS2;
|
||||||
|
// `is_query_method` gates the live CALL sweep — a false positive means blind-calling a method
|
||||||
|
// that really takes arguments, so it must not fire on every `Get*`.
|
||||||
|
let any_prefix_hit = prof
|
||||||
|
.query_prefixes
|
||||||
|
.iter()
|
||||||
|
.any(|p| is_query_method(prof, &format!("CBaseEntity::{p}Something")));
|
||||||
|
assert!(
|
||||||
|
any_prefix_hit,
|
||||||
|
"no profile query prefix matched its own pattern"
|
||||||
|
);
|
||||||
|
assert!(!is_query_method(prof, "CBaseEntity::Teleport"));
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/xref.rs
Normal file
135
src/xref.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
//! Whole-binary cross-reference index — the layer that lets us *name and locate non-virtual
|
||||||
|
//! functions*, which have no vtable slot and (mostly) no symbol.
|
||||||
|
//!
|
||||||
|
//! Decode every function and record, for each referenced address, the instructions that reference
|
||||||
|
//! it: near `call`/`jmp` targets (code) and RIP-relative memory operands (`lea`/`mov` into `.rodata`
|
||||||
|
//! strings, globals, …). Each reference is attributed to its containing function, so we can answer
|
||||||
|
//! "which function uses this string?" (string-anchored location) and "who calls this function?".
|
||||||
|
//!
|
||||||
|
//! Function entries come from `locate::candidate_entries` (relocation code-pointers — every vtable
|
||||||
|
//! slot — ∪ decoded `call` targets) unioned with `.eh_frame` starts. This matters: CS2 strips
|
||||||
|
//! `.eh_frame` from the *game* code (unwind info survives only for the statically-linked runtime
|
||||||
|
//! tail), so an eh_frame-only index misses the entire gameplay region. Decoding from each entry to
|
||||||
|
//! the next avoids the misalignment a blind section-wide linear sweep suffers on data/padding.
|
||||||
|
|
||||||
|
use crate::elf::CodeImage;
|
||||||
|
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, OpKind};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct XrefIndex {
|
||||||
|
entries: Vec<u64>, // sorted, de-duped function entry addresses
|
||||||
|
refs: HashMap<u64, Vec<u64>>, // referenced VA -> source instruction VAs
|
||||||
|
call_targets: Vec<u64>, // sorted, de-duped near-call targets
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XrefIndex {
|
||||||
|
pub fn build(img: &CodeImage) -> Self {
|
||||||
|
// Reliable gameplay entries (vtable slots + fn-pointers via relocations, plus call targets),
|
||||||
|
// then add the eh_frame starts (the runtime tail). Union = coverage of the whole binary.
|
||||||
|
let mut entries = crate::locate::candidate_entries(img);
|
||||||
|
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
|
||||||
|
entries.sort_unstable();
|
||||||
|
entries.dedup();
|
||||||
|
|
||||||
|
// Disassemble each function's [start, next) range independently across threads — this is the
|
||||||
|
// single biggest decode in the tool and the ranges vary wildly in size, so the atomic work
|
||||||
|
// scheduler load-balances them. Each task returns its (ref-pair, call-target) deltas; merging
|
||||||
|
// them in entry order (parallel_map preserves input order) reproduces the serial build
|
||||||
|
// byte-for-byte: refs[t] receives its srcs in the same (ascending entry, then instruction)
|
||||||
|
// order and call_targets is sorted afterwards.
|
||||||
|
type EntryData = (Vec<(u64, u64)>, Vec<u64>);
|
||||||
|
let idxs: Vec<usize> = (0..entries.len()).collect();
|
||||||
|
let per_entry: Vec<EntryData> =
|
||||||
|
crate::par::parallel_map(&idxs, crate::par::default_threads(None), |&i| {
|
||||||
|
let start = entries[i];
|
||||||
|
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
|
||||||
|
let Some(code) = img.code_range(start, end) else {
|
||||||
|
return (Vec::new(), Vec::new());
|
||||||
|
};
|
||||||
|
let mut ref_pairs: Vec<(u64, u64)> = Vec::new();
|
||||||
|
let mut call_targets: Vec<u64> = Vec::new();
|
||||||
|
let mut insn = Instruction::default();
|
||||||
|
let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE);
|
||||||
|
while dec.can_decode() {
|
||||||
|
dec.decode_out(&mut insn);
|
||||||
|
let src = insn.ip();
|
||||||
|
// Near call/jmp: the target is code; call targets double as function entries.
|
||||||
|
if matches!(
|
||||||
|
insn.op0_kind(),
|
||||||
|
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
||||||
|
) {
|
||||||
|
let t = insn.near_branch_target();
|
||||||
|
ref_pairs.push((t, src));
|
||||||
|
if insn.flow_control() == FlowControl::Call {
|
||||||
|
call_targets.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// RIP-relative memory operand: a reference to a string / global / code pointer.
|
||||||
|
if insn.is_ip_rel_memory_operand() {
|
||||||
|
let t = insn.ip_rel_memory_address();
|
||||||
|
ref_pairs.push((t, src));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(ref_pairs, call_targets)
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut refs: HashMap<u64, Vec<u64>> = HashMap::new();
|
||||||
|
let mut call_targets = Vec::new();
|
||||||
|
for (ref_pairs, cts) in per_entry {
|
||||||
|
for (t, src) in ref_pairs {
|
||||||
|
refs.entry(t).or_default().push(src);
|
||||||
|
}
|
||||||
|
call_targets.extend(cts);
|
||||||
|
}
|
||||||
|
call_targets.sort_unstable();
|
||||||
|
call_targets.dedup();
|
||||||
|
Self {
|
||||||
|
entries,
|
||||||
|
refs,
|
||||||
|
call_targets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The entry (function start) that contains `va`: the nearest entry at or below `va`.
|
||||||
|
pub fn containing_func(&self, va: u64) -> Option<u64> {
|
||||||
|
let i = self.entries.partition_point(|&s| s <= va);
|
||||||
|
(i > 0).then(|| self.entries[i - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source instruction addresses that reference `target`.
|
||||||
|
pub fn refs_to(&self, target: u64) -> &[u64] {
|
||||||
|
self.refs.get(&target).map_or(&[], |v| v.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distinct functions that reference `target` (each referring instruction mapped to its
|
||||||
|
/// containing function, so one function referencing `target` N times counts once).
|
||||||
|
pub fn referrers(&self, target: u64) -> Vec<u64> {
|
||||||
|
let mut fs: Vec<u64> = self
|
||||||
|
.refs_to(target)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&s| self.containing_func(s))
|
||||||
|
.collect();
|
||||||
|
fs.sort_unstable();
|
||||||
|
fs.dedup();
|
||||||
|
fs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call_targets(&self) -> &[u64] {
|
||||||
|
&self.call_targets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Functions that reference the string `s` anywhere in read-only data — the canonical "find the
|
||||||
|
/// function by a string it uses" primitive. A string referenced by exactly one function names that
|
||||||
|
/// function unambiguously. We match `s` as a substring (a code `lea`/`mov` points at the string's
|
||||||
|
/// start whatever follows it — a trailing `\n`, format args, or a longer literal), so callers pass a
|
||||||
|
/// distinctive fragment without needing the whole literal.
|
||||||
|
pub fn funcs_using_string(img: &CodeImage, xref: &XrefIndex, s: &str) -> Vec<u64> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for str_va in img.find_bytes(s.as_bytes()) {
|
||||||
|
out.extend(xref.referrers(str_va));
|
||||||
|
}
|
||||||
|
out.sort_unstable();
|
||||||
|
out.dedup();
|
||||||
|
out
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue