initial commit
All checks were successful
CI / fuzz (push) Successful in 1m41s
CI / lint (push) Successful in 16s
CI / test (push) Successful in 22s

This commit is contained in:
Kamal Tufekcic 2026-07-27 10:12:04 +03:00
commit a2922b8bad
59 changed files with 2684583 additions and 0 deletions

View 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
View 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."

View 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."

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.

View 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"] }

View 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).

View 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(())
}

View 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;

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

View 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(&divider);
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
View 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
View file

@ -0,0 +1,6 @@
target
corpus
artifacts
coverage
Cargo.lock
logs

67
fuzz/Cargo.toml Normal file
View 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

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

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

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

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

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

View 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

View 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" }
]

File diff suppressed because it is too large Load diff

3616
mappings/naming/ai-sigs.json Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
[]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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

File diff suppressed because one or more lines are too long

731
src/abi.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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, &regs 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

1811
src/produce.rs Normal file

File diff suppressed because it is too large Load diff

367
src/profile.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
}