Compare commits
6 commits
gen-v3.0.0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bac78eb8fa | ||
|
|
43e37679ed | ||
|
|
36441687ff | ||
|
|
a9665e55f9 | ||
|
|
22ab973f0c | ||
|
|
fb652c39ae |
16 changed files with 340 additions and 190 deletions
|
|
@ -20,8 +20,9 @@ jobs:
|
||||||
runs-on: s2-runner
|
runs-on: s2-runner
|
||||||
env:
|
env:
|
||||||
GAME: ${{ github.event.inputs.game }}
|
GAME: ${{ github.event.inputs.game }}
|
||||||
STEAM_APPS: /home/cs2/.steam/SteamApps
|
|
||||||
STEAM_USER: source2rosetta
|
STEAM_USER: source2rosetta
|
||||||
|
STEAM_HOME_ANON: /home/cs2
|
||||||
|
STEAM_HOME_AUTH: /home/cs2/steam-auth
|
||||||
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
|
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
|
||||||
OVERRIDE_DIR: /home/cs2/rosetta-override
|
OVERRIDE_DIR: /home/cs2/rosetta-override
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -30,12 +31,36 @@ jobs:
|
||||||
- name: Update the install to the current build
|
- name: Update the install to the current build
|
||||||
run: |
|
run: |
|
||||||
case "$GAME" in
|
case "$GAME" in
|
||||||
cs2) APPID=730 ;;
|
cs2) APPID=730; LOGIN=anonymous; STEAM_HOME="$STEAM_HOME_ANON" ;;
|
||||||
dota2) APPID=570 ;;
|
dota2) APPID=570; LOGIN="$STEAM_USER"; STEAM_HOME="$STEAM_HOME_AUTH" ;;
|
||||||
*) echo "unknown game '$GAME' (expected cs2 or dota2)"; exit 1 ;;
|
*) echo "unknown game '$GAME' (expected cs2 or dota2)"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
echo "APPID=$APPID" >> "$GITHUB_ENV"
|
env HOME="$STEAM_HOME" steamcmd +login "$LOGIN" +app_update "$APPID" +quit
|
||||||
steamcmd +login "$STEAM_USER" +app_update "$APPID" +quit
|
|
||||||
|
STEAM_APPS=""; SEEN=""; BUILD=""
|
||||||
|
for cand in "$STEAM_HOME/Steam/steamapps" "$STEAM_HOME/.steam/steam/steamapps" \
|
||||||
|
"$STEAM_HOME/.steam/SteamApps"; do
|
||||||
|
m="$cand/appmanifest_$APPID.acf"
|
||||||
|
[ -f "$m" ] || continue
|
||||||
|
# The same tree reached twice through a symlink is ONE tree, not a disagreement.
|
||||||
|
key=$(stat -Lc '%d:%i' "$m")
|
||||||
|
case " $SEEN " in *" $key "*) continue ;; esac
|
||||||
|
SEEN="$SEEN $key"
|
||||||
|
b=$(grep -oP '"buildid"[[:space:]]+"\K[0-9]+' "$m")
|
||||||
|
echo " candidate $cand -> buildid $b"
|
||||||
|
if [ -z "$STEAM_APPS" ]; then
|
||||||
|
STEAM_APPS="$cand"; BUILD="$b"
|
||||||
|
elif [ "$b" != "$BUILD" ]; then
|
||||||
|
echo "::error::two Steam app trees under $STEAM_HOME disagree — $STEAM_APPS says" \
|
||||||
|
"$BUILD, $cand says $b. One is stale; deriving from it would publish gamedata for" \
|
||||||
|
"a build nothing is running. Remove the stale tree or symlink it to the live one."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ -n "$STEAM_APPS" ] || {
|
||||||
|
echo "::error::no appmanifest_$APPID.acf under $STEAM_HOME — did the update run?"; exit 1; }
|
||||||
|
echo "using $STEAM_APPS (buildid $BUILD)"
|
||||||
|
{ echo "APPID=$APPID"; echo "STEAM_APPS=$STEAM_APPS"; } >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Resolve the game paths + the new buildid
|
- name: Resolve the game paths + the new buildid
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -248,7 +248,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "source2rosetta-core"
|
name = "source2rosetta-core"
|
||||||
version = "2.1.0"
|
version = "3.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
|
|
||||||
53
README.md
53
README.md
|
|
@ -28,18 +28,18 @@ The output is framework-neutral; `source2rosetta-gen` renders it into whatever y
|
||||||
|
|
||||||
## Results
|
## Results
|
||||||
|
|
||||||
Ballpark from a recent build, on a 16-core desktop. These move build-to-build — treat them as orders of magnitude, not guarantees.
|
Measured on CS2 build `24537688` and Dota 2 build `24541331`, on a 16-core desktop. These move build-to-build — treat them as orders of magnitude, not guarantees.
|
||||||
|
|
||||||
| | derived functions | declared surface | typed prototypes | typed schema | model | one-time distill |
|
| | derived functions | declared surface | typed prototypes | typed schema | model | one-time distill |
|
||||||
|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| **CS2** | ~1,125 `core` + ~2,865 `high_confidence`, plus ~4,375 `experimental` name guesses | **300 VScript bindings (246 located)**, 580 Pulse bindings (127 host-callable), 784 commands, 1,551 ConVars, 715 entity inputs / 226 outputs, 474 classnames | ~2,055 `verified` + ~80 `lower-bound`, 55 `mismatch`, 261 `return-only` | ~1,900 classes / ~12,300 fields | ~48 MB (a few MB gzipped) | ~15 min |
|
| **CS2** | 1,086 `core` + 2,894 `high_confidence`, plus 4,374 `experimental` name guesses | **300 VScript bindings (247 located)**, 580 Pulse bindings (127 host-callable), 784 commands, 1,551 ConVars, 715 entity inputs / 226 outputs, 474 classnames | 2,158 `verified` + 92 `lower-bound`, 84 `mismatch`, 261 `return-only` | 1,899 classes / 12,331 fields | ~46 MB (a few MB gzipped) | ~15 min |
|
||||||
| **Dota 2** | ~1,100 `core` + ~4,045 `high_confidence`, plus ~5,740 `experimental` | **1,841 VScript bindings (1,599 located)**, 500 Pulse bindings (99 host-callable), 855 commands, 1,171 ConVars, 624 entity inputs / 187 outputs, 3,528 classnames | ~2,800 `verified` + ~99 `lower-bound`, 44 `mismatch`, 1,594 `return-only` | ~2,960 classes / ~17,700 fields | ~570 MB | ~1 hr |
|
| **Dota 2** | 1,097 `core` + 4,047 `high_confidence`, plus 5,817 `experimental` | **1,841 VScript bindings (1,599 located)**, 500 Pulse bindings (99 host-callable), 855 commands, 1,171 ConVars, 624 entity inputs / 187 outputs, 3,528 classnames | 2,837 `verified` + 99 `lower-bound`, 45 `mismatch`, 1,594 `return-only` | 2,962 classes / 17,695 fields | ~735 MB | ~1 hr |
|
||||||
|
|
||||||
**Declared surface** is what the binary states about itself, and it is a different kind of fact from the rest: no inference, no cross-build chaining, no confidence tier. Two counts in it are subsets worth reading precisely. *Host-callable* is the Pulse bindings invocable with an argument array alone — verified by calling each on a live server of both games. *Located* is the VScript bindings whose implementation folds onto a function record as a real locator; the rest are documented but not addressable, and a C++ name registered at two addresses is dropped rather than guessed.
|
**Declared surface** is what the binary states about itself, and it is a different kind of fact from the rest: no inference, no cross-build chaining, no confidence tier. Two counts in it are subsets worth reading precisely. *Host-callable* is the Pulse bindings invocable with an argument array alone — verified by calling each on a live server of both games. *Located* is the VScript bindings whose implementation folds onto a function record as a real locator; the rest are documented but not addressable, and a C++ name registered at two addresses is dropped rather than guessed.
|
||||||
|
|
||||||
The VScript surface is the newest and it moves the `high_confidence` count more than anything else has: **+246 on CS2 and +1,599 on Dota**, every one a name Valve states in the binary alongside a declared return type. On Dota that is a 65% increase in the named surface, and it reaches gameplay verbs no other source in this project locates — `AddNewModifier`, `AddItemByName`, `CastAbilityOnTarget` and `ChangeTeam` are all absent from every tier of the previous release.
|
The VScript surface is the newest and it moves the `high_confidence` count more than anything else has: **+247 on CS2 and +1,599 on Dota**, every one a name Valve states in the binary alongside a declared return type. On Dota that is a 65% increase in the named surface, and it reaches gameplay verbs no other source in this project locates — `AddNewModifier`, `AddItemByName`, `CastAbilityOnTarget` and `ChangeTeam` are all absent from every tier of the previous release.
|
||||||
|
|
||||||
A full run live-validates what it ships and reports **0 dropped** on both games — for CS2 that is ~2,610 signatures and ~1,120 vtable offsets checked against a running server. Distilling the model is a one-time cost; after that each build's re-derive is minutes of compute, and the half hour in the headline is the whole loop: notice, update, derive, validate, publish.
|
A full run live-validates what it ships and reports **0 dropped** on both games — for CS2 that is 3,972 entries carrying 2,848 signatures and 1,129 vtable offsets, all checked against a running server (Dota: 5,138 entries, 4,309 signatures, 829 offsets). Distilling the model is a one-time cost; after that each build's re-derive is minutes of compute, and the half hour in the headline is the whole loop: notice, update, derive, validate, publish.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -74,9 +74,9 @@ The artifacts answer four different questions, and most useful work joins two or
|
||||||
|
|
||||||
Three of those are newer than the rest and worth calling out, because they change what a plugin can do:
|
Three of those are newer than the rest and worth calling out, because they change what a plugin can do:
|
||||||
|
|
||||||
**The VScript registry closes the biggest gap in the Dota surface.** 1,841 bindings on Dota and 300 on CS2, each pairing the name a script author types with the C++ name, Valve's own English description, and a declared return type — and 1,599 / 246 of them fold into the gamedata as real locators. It is the only source here that supplies gameplay VERBS on Dota: `AddNewModifier`, `AddItemByName`, `CastAbilityOnTarget`, `ChangeTeam`, `ModifyGold` and `AddExperience` are absent from every tier of the previous release and present now, which is why the [Dota section below](#dota-2) reads differently from how it did. They are script-facing wrappers rather than the underlying methods, and for a caller that is a feature: the wrapper's argument shape is the one Valve declared for a content author to use safely, and the wrapper is what the engine itself invokes.
|
**The VScript registry closes the biggest gap in the Dota surface.** 1,841 bindings on Dota and 300 on CS2, each pairing the name a script author types with the C++ name, Valve's own English description, and a declared return type — and 1,599 / 247 of them fold into the gamedata as real locators. It is the only source here that supplies gameplay VERBS on Dota: `AddNewModifier`, `AddItemByName`, `CastAbilityOnTarget`, `ChangeTeam`, `ModifyGold` and `AddExperience` are absent from every tier of the previous release and present now, which is why the [Dota section below](#dota-2) reads differently from how it did. They are script-facing wrappers rather than the underlying methods, and for a caller that is a feature: the wrapper's argument shape is the one Valve declared for a content author to use safely, and the wrapper is what the engine itself invokes.
|
||||||
|
|
||||||
**ConVars ship with their flags.** 1,551 on CS2 across four libraries, 781 in Dota's `libserver` — with `cheat`, `replicated`, `archive` and `notify` decoded, and the raw word beside them. The names are not the point: a consumer finds a convar by name at runtime with no gamedata at all. The *flags* are, because they are engine-declared authority. A host that wants to say "this module may change gameplay settings but not cheat-protected ones" can key that on what the engine itself declares instead of maintaining an allowlist by hand.
|
**ConVars ship with their flags.** 1,551 on CS2 across four libraries, 782 in Dota's `libserver` — with `cheat`, `replicated`, `archive` and `notify` decoded, and the raw word beside them. The names are not the point: a consumer finds a convar by name at runtime with no gamedata at all. The *flags* are, because they are engine-declared authority. A host that wants to say "this module may change gameplay settings but not cheat-protected ones" can key that on what the engine itself declares instead of maintaining an allowlist by hand.
|
||||||
|
|
||||||
**Most of the Pulse surface is callable.** Each binding carries a `shim` address and a `call.needs` verdict; the `args-only` tier — roughly 110 on CS2, 82 on Dota within `libserver` — is invocable with an argument array and nothing else, through Valve's own marshalling, which enforces the binding's declared types. Those are *actions* (teleport, ignite, change team, start a mover, spawn a template), which is the half no field write can do; reading state remains the schema's job and is better served there.
|
**Most of the Pulse surface is callable.** Each binding carries a `shim` address and a `call.needs` verdict; the `args-only` tier — roughly 110 on CS2, 82 on Dota within `libserver` — is invocable with an argument array and nothing else, through Valve's own marshalling, which enforces the binding's declared types. Those are *actions* (teleport, ignite, change team, start a mover, spawn a template), which is the half no field write can do; reading state remains the schema's job and is better served there.
|
||||||
|
|
||||||
|
|
@ -98,7 +98,7 @@ What you do not get: `TryPlayerMove`, `WalkMove`, `Accelerate` and `TracePlayerB
|
||||||
|
|
||||||
#### Combat, damage and tracing
|
#### Combat, damage and tracing
|
||||||
|
|
||||||
`CBaseEntity::TakeDamage` is the funnel and `CCSPlayerPawn::OnTakeDamage_Alive` the player-specific override, but the interesting part is that you do not need a constructor to build a damage packet: `CTakeDamageInfo` is laid out completely — 22 fields over 280 bytes — and `CTakeDamageResult` (15 fields) tells you what the engine actually did, including `m_flPreModifiedDamage` beside `m_flDamageDealt` and a `m_bWasDamageSuppressed` flag. `DamageTypes_t`, `HitGroup_t` and the 21-flag `TakeDamageFlags_t` (`DFLAG_PREVENT_DEATH`, `DFLAG_IGNORE_ARMOR`, …) give you the switchboard. Two ABI notes. `CBaseEntity::Event_Killed` is `verified` and measures as the CS2-shaped `(CCSPlayerPawn*, CTakeDamageResult*)`, not the Source-1 `CTakeDamageInfo const&` everyone assumes; `abi:CBaseEntity::TakeDamage` is tier `core` but verdict **`unverified`** — the declaration was never checked against this build. Build the struct by offsets and prefer the verified entry points.
|
`CBaseEntity::TakeDamage` is the funnel and `CCSPlayerPawn::OnTakeDamage_Alive` the player-specific override, but the interesting part is that you do not need a constructor to build a damage packet: `CTakeDamageInfo` is laid out completely — 22 fields over 280 bytes — and `CTakeDamageResult` (15 fields) tells you what the engine actually did, including `m_flPreModifiedDamage` beside `m_flDamageDealt` and a `m_bWasDamageSuppressed` flag. `DamageTypes_t`, `HitGroup_t` and the 21-flag `TakeDamageFlags_t` (`DFLAG_PREVENT_DEATH`, `DFLAG_IGNORE_ARMOR`, …) give you the switchboard. Two prototype notes, and the second is the sharpest example in this file of why the locator and the prototype are separate facts. `CBaseEntity::Event_Killed` is `verified` and measures as the CS2-shaped `(CCSPlayerPawn*, CTakeDamageResult*)`, not the Source-1 `CTakeDamageInfo const&` everyone assumes. And `CBaseEntity::TakeDamage` — the funnel itself — is tier `core` with `validated: true`, and its prototype verdict is **`mismatch`**: the circulated declaration `(CTakeDamageInfo&)` accounts for two integer registers and this build's callee reads **three**. The address is right and hooking it is fine; *calling through that declaration* would load the wrong registers. Build the struct by offsets and prefer the verified entry points.
|
||||||
|
|
||||||
**Do not use `CBaseEntity::DispatchTraceAttack`. Earlier revisions of this section recommended it, and it is mislocated** — the entry resolves to `CLogicRelay::Trigger`, which is a different function entirely. It is the clearest example in this file of why a locator that passes every check can still be wrong, so it is worth reading rather than just avoiding: its shipped pattern is a bare compiler prologue with no distinguishing content, so it is unique in the library by luck rather than by identity; the address holds real executable code, so live validation passed it; and `Trigger(hActivator, hCaller)` on a relay measures the same `int=3, ret=int` footprint as the declared `(CBaseEntity*, CTakeDamageInfo*, CTakeDamageResult*)`, so the ABI check called it `verified`. Three independent guards, none of which is an identity check. What caught it was **Valve's VScript registry naming that same address `Trigger`, with the description "Triggers the logic_relay"** — and the disassembly agreeing, every offset it touches being a named `CLogicRelay` field (`m_OnTrigger` at `+0x7a0`, `m_bDisabled`, `m_bPassthoughCaller`). Found 2026-08-01 by the [alias grouping](#functions--one-record-each), which is what made two sources' accounts of one address comparable at all.
|
**Do not use `CBaseEntity::DispatchTraceAttack`. Earlier revisions of this section recommended it, and it is mislocated** — the entry resolves to `CLogicRelay::Trigger`, which is a different function entirely. It is the clearest example in this file of why a locator that passes every check can still be wrong, so it is worth reading rather than just avoiding: its shipped pattern is a bare compiler prologue with no distinguishing content, so it is unique in the library by luck rather than by identity; the address holds real executable code, so live validation passed it; and `Trigger(hActivator, hCaller)` on a relay measures the same `int=3, ret=int` footprint as the declared `(CBaseEntity*, CTakeDamageInfo*, CTakeDamageResult*)`, so the ABI check called it `verified`. Three independent guards, none of which is an identity check. What caught it was **Valve's VScript registry naming that same address `Trigger`, with the description "Triggers the logic_relay"** — and the disassembly agreeing, every offset it touches being a named `CLogicRelay` field (`m_OnTrigger` at `+0x7a0`, `m_bDisabled`, `m_bPassthoughCaller`). Found 2026-08-01 by the [alias grouping](#functions--one-record-each), which is what made two sources' accounts of one address comparable at all.
|
||||||
|
|
||||||
|
|
@ -170,7 +170,7 @@ Because command flags are decoded, the client-reachable attack surface is exactl
|
||||||
|
|
||||||
### Dota 2
|
### Dota 2
|
||||||
|
|
||||||
Dota's surface is materially larger, and the difference is structural rather than incidental: **3,528 registered entity classnames against CS2's 474**, and 2,958 schema classes / 17,668 fields against 1,899 / 12,330. The reason is that in Dota every ability and every item is a networked entity with its own class — 2,155 `CDOTA_Ability*` classnames (795 of them `special_bonus_*` talents, 1,360 regular abilities), 660 `CDOTA_Item*`, 231 unit types, 130 heroes. What that buys is identification: given any script name a mod author types, you get the exact C++ class. What it does not buy is per-ability hooking — only a minority of those classes carry fields or functions of their own; the shared bases (`CDOTABaseAbility` 54 fields, `CDOTA_Item` 63, `CDOTA_BaseNPC` 269) are where the data lives.
|
Dota's surface is materially larger, and the difference is structural rather than incidental: **3,528 registered entity classnames against CS2's 474**, and 2,962 schema classes / 17,695 fields against 1,899 / 12,331. The reason is that in Dota every ability and every item is a networked entity with its own class — 2,155 `CDOTA_Ability*` classnames (795 of them `special_bonus_*` talents, 1,360 regular abilities), 660 `CDOTA_Item*`, 231 unit types, 130 heroes. What that buys is identification: given any script name a mod author types, you get the exact C++ class. What it does not buy is per-ability hooking — only a minority of those classes carry fields or functions of their own; the shared bases (`CDOTABaseAbility` 54 fields, `CDOTA_Item` 63, `CDOTA_BaseNPC` 269) are where the data lives.
|
||||||
|
|
||||||
The shape of Dota's coverage is also different from CS2's. Its `core` tier is narrow: 919 `CModifierFactory<…>` entries and several hundred game-system factories account for most of it, and the classic gameplay verbs a Dota modder expects are not in *that* tier.
|
The shape of Dota's coverage is also different from CS2's. Its `core` tier is narrow: 919 `CModifierFactory<…>` entries and several hundred game-system factories account for most of it, and the classic gameplay verbs a Dota modder expects are not in *that* tier.
|
||||||
|
|
||||||
|
|
@ -220,13 +220,13 @@ A flat offset dump cannot do any of the following, and each one is a real failur
|
||||||
|
|
||||||
**`bases` is also the only place multiple inheritance is expressed.** Treating a `CEconEntity` as `IHasAttributes` requires adding 3,136 bytes; for `CChicken` it is 3,728. In Dota, 16 ability classes carry a second base at +2144 — `CDOTA_Ability_Morphling_Waveform` and friends inherit `CHorizontalMotionController` there, `CDOTA_Ability_DataDriven` inherits `CDOTA_ActionRunner`. A naive `(Base*)ptr` cast at any of these sites corrupts memory silently.
|
**`bases` is also the only place multiple inheritance is expressed.** Treating a `CEconEntity` as `IHasAttributes` requires adding 3,136 bytes; for `CChicken` it is 3,728. In Dota, 16 ability classes carry a second base at +2144 — `CDOTA_Ability_Morphling_Waveform` and friends inherit `CHorizontalMotionController` there, `CDOTA_Ability_DataDriven` inherits `CDOTA_ActionRunner`. A naive `(Base*)ptr` cast at any of these sites corrupts memory silently.
|
||||||
|
|
||||||
**`enums` recovers field width, not just readability.** 812 CS2 fields report `size: 0`; the enum's own size is what makes them decodable. `CBaseEntity::m_MoveType`, `m_nPreviouslySetMoveType` and `m_nActualMoveType` sit at 1491/1492/1493 and are only three consecutive `u8`s because `MoveType_t` is one byte wide. Beyond that, 555 CS2 enums / 743 Dota give you the legal-value tables — damage-type bitmasks, hit groups, observer modes, and on Dota the entire gameplay vocabulary.
|
**`enums` recovers field width, not just readability.** 812 CS2 fields report `size: 0`; the enum's own size is what makes them decodable. `CBaseEntity::m_MoveType`, `m_nPreviouslySetMoveType` and `m_nActualMoveType` sit at 1491/1492/1493 and are only three consecutive `u8`s because `MoveType_t` is one byte wide. Beyond that, 524 CS2 enums / 710 Dota give you the legal-value tables — damage-type bitmasks, hit groups, observer modes, and on Dota the entire gameplay vocabulary.
|
||||||
|
|
||||||
**`types` gives size and SysV class.** Size turns every generated accessor into a bounds check (12,330/12,330 CS2 fields pass). SysV class is what stops a struct-return call from corrupting the stack: a 12-byte `Vector` comes back in XMM registers (`sse`), a 48-byte `matrix3x4_t` through a hidden pointer (`memory`). That is what makes `CBaseEntity::GetEyePosition` callable correctly.
|
**`types` gives size and SysV class.** Size turns every generated accessor into a bounds check (12,331/12,331 CS2 fields pass). SysV class is what stops a struct-return call from corrupting the stack: a 12-byte `Vector` comes back in XMM registers (`sse`), a 48-byte `matrix3x4_t` through a hidden pointer (`memory`). That is what makes `CBaseEntity::GetEyePosition` callable correctly.
|
||||||
|
|
||||||
**A field's `name_hash` is stable across builds *and* across games.** 10,363 `Class::field` pairs exist in both artifacts; all 10,363 have identical hashes, and 2,589 of them sit at different offsets. So ship one hash-keyed table of the fields your plugin touches and bind offsets per build and per game at load. A hash that vanishes means a rename; a hash that moves means a rebind.
|
**A field's `name_hash` is stable across builds *and* across games.** 10,363 `Class::field` pairs exist in both artifacts; all 10,363 have identical hashes, and 2,589 of them sit at different offsets. So ship one hash-keyed table of the fields your plugin touches and bind offsets per build and per game at load. A hash that vanishes means a rename; a hash that moves means a rebind.
|
||||||
|
|
||||||
**A checked prototype is worth more than a declared one, and the verdict is the product.** `verified` (2,054 CS2 / 3,633 Dota) means declared arity matches the footprint measured in this build. `lower-bound` (82/99) means the declaration passes registers the callee never reads — compatible, but not the same claim. **`mismatch` (55/44) is the most immediately useful of the six**: it names community-circulated prototypes that are wrong for this binary and will load the wrong registers. `ambiguous` lists the surviving overloads for you to separate; `return-only` gives a return type and no arity claim; `unverified` means nothing checked it.
|
**A checked prototype is worth more than a declared one, and the verdict is the product.** `verified` (2,158 CS2 / 2,837 Dota) means declared arity matches the footprint measured in this build. `lower-bound` (92/99) means the declaration passes registers the callee never reads — compatible, but not the same claim. **`mismatch` (84/45) is the most immediately useful of the six**: it names community-circulated prototypes that are wrong for this binary and will load the wrong registers. `ambiguous` lists the surviving overloads for you to separate; `return-only` gives a return type and no arity claim; `unverified` means nothing checked it.
|
||||||
|
|
||||||
Two structural cross-checks come for free: all **226 CS2 entity outputs agree exactly with netvars** on class, member and byte offset, independently derived; and for all 759 CS2 commands present in both files, the dispatch form in `bindings` agrees with the prototype in `abi` — 674 `direct`, 81 `member` (extra leading `this`), 4 `interface`, zero disagreements. Hooking a member-form command with the free-function signature shifts every argument by one, and nothing in the command's name tells you which it is.
|
Two structural cross-checks come for free: all **226 CS2 entity outputs agree exactly with netvars** on class, member and byte offset, independently derived; and for all 759 CS2 commands present in both files, the dispatch form in `bindings` agrees with the prototype in `abi` — 674 `direct`, 81 `member` (extra leading `this`), 4 `interface`, zero disagreements. Hooking a member-form command with the free-function signature shifts every argument by one, and nothing in the command's name tells you which it is.
|
||||||
|
|
||||||
|
|
@ -234,13 +234,13 @@ Two structural cross-checks come for free: all **226 CS2 entity outputs agree ex
|
||||||
|
|
||||||
### The experimental band — read this before using any of it
|
### The experimental band — read this before using any of it
|
||||||
|
|
||||||
`experimental` is 4,374 entries on CS2 and 5,947 on Dota, and it is a different kind of artifact from everything above.
|
`experimental` is 4,374 entries on CS2 and 5,817 on Dota, and it is a different kind of artifact from everything above.
|
||||||
|
|
||||||
**Resolvable locator. Unverified name. Never live-validated.** Every entry has `validated: null`, `corroboration: bare` (one source, nothing independently agreed) and `self_named: false`. What is real is the *locator* — an RTTI class plus vtable slot, or a byte signature — and the *measured register footprint*, which every entry carries. What is a guess is the label. 270 CS2 / 357 Dota entries carry `collision: true` (another guessed name resolved to the same target) and 42 / 150 carry `dead_weight: true` (the target is a stub).
|
**Resolvable locator. Unverified name. Never live-validated.** Every entry has `validated: null`, `corroboration: bare` (one source, nothing independently agreed) and `self_named: false`. What is real is the *locator* — an RTTI class plus vtable slot, or a byte signature — and the *measured register footprint*, which every entry carries. What is a guess is the label. 270 CS2 / 355 Dota entries carry `collision: true` (another guessed name resolved to the same target) and 42 / 130 carry `dead_weight: true` (the target is a stub).
|
||||||
|
|
||||||
The two games' bands are not the same product. CS2's is 3,230 vtable locators across 914 RTTI classes plus 1,144 byte signatures across 21 libraries — and **zero in `libserver`**. It is engine infrastructure: `CPhysicsBody`, `CVPhys2World`, `CEngineServer`, `CServerSideClient`, `CNetChan`, `CCvar`, `CSchemaSystem`. If the names are right, that is a whole telemetry, physics and cvar surface — `CNetChan::GetAvgLatency` at slot 11 measures `{int:1, ret=float}`, which is at least the shape of a `float GetX() const`. If they are wrong, you have called a numbered slot with the wrong idea of what it does. Anyone hunting there for an unnamed `CCSPlayerPawn` method will not find it.
|
The two games' bands are not the same product. CS2's is 3,230 vtable locators across 914 RTTI classes plus 1,144 byte signatures across 21 libraries — and **zero in `libserver`**. It is engine infrastructure: `CPhysicsBody`, `CVPhys2World`, `CEngineServer`, `CServerSideClient`, `CNetChan`, `CCvar`, `CSchemaSystem`. If the names are right, that is a whole telemetry, physics and cvar surface — `CNetChan::GetAvgLatency` at slot 11 measures `{int:1, ret=float}`, which is at least the shape of a `float GetX() const`. If they are wrong, you have called a numbered slot with the wrong idea of what it does. Anyone hunting there for an unnamed `CCSPlayerPawn` method will not find it.
|
||||||
|
|
||||||
Dota's band *does* reach gameplay: 1,402 byte signatures in `libserver`, roughly 350 of them DOTA-named — `CDOTAGameRules::KillCreeps`, `CDOTATurboGameMode::FilterModifyGold`, `CDOTA_Ability_*::OnSpellStart`. If those names are right it is a gold mine for custom-game work. Treat every one as a hypothesis.
|
Dota's band *does* reach gameplay: 2,040 byte signatures in `libserver`, roughly 350 of them DOTA-named — `CDOTAGameRules::KillCreeps`, `CDOTATurboGameMode::FilterModifyGold`, `CDOTA_Ability_*::OnSpellStart`. If those names are right it is a gold mine for custom-game work. Treat every one as a hypothesis.
|
||||||
|
|
||||||
One sub-band is self-checking, which makes it usable on different terms: the `CNetMessagePB<id, MessageType, (SignonGroup_t)g, …>` template instantiations bake a wire id, a protobuf class name, a signon group and a reliability flag into the mangled name. Unlike a bare `CFoo::Bar` guess, that is structured data you can falsify against live traffic in one command (`net_listallmessages`, `net_messageinfo`). Note that for Dota the *authoritative* message-id source is not this band at all — it is the schema enums `EDotaUserMessages`, `EBaseUserMessages` and `EDotaClientMessages`, which are deterministic. Use those for ids and the templates as corroboration.
|
One sub-band is self-checking, which makes it usable on different terms: the `CNetMessagePB<id, MessageType, (SignonGroup_t)g, …>` template instantiations bake a wire id, a protobuf class name, a signon group and a reliability flag into the mangled name. Unlike a bare `CFoo::Bar` guess, that is structured data you can falsify against live traffic in one command (`net_listallmessages`, `net_messageinfo`). Note that for Dota the *authoritative* message-id source is not this band at all — it is the schema enums `EDotaUserMessages`, `EBaseUserMessages` and `EDotaClientMessages`, which are deterministic. Use those for ids and the templates as corroboration.
|
||||||
|
|
||||||
|
|
@ -453,7 +453,7 @@ A dozen free, mostly two-sided checks run on **every** derive and are reported.
|
||||||
- **entity-output ↔ schema join** — 226/226 CS2, 186/187 Dota.
|
- **entity-output ↔ schema join** — 226/226 CS2, 186/187 Dota.
|
||||||
- **EHANDLE class grouping** — Valve's naming vs the binary's destructor addresses: 0 of 44 CS2 / 41 Dota groups carry two classes.
|
- **EHANDLE class grouping** — Valve's naming vs the binary's destructor addresses: 0 of 44 CS2 / 41 Dota groups carry two classes.
|
||||||
- **Pulse element stride** — derived by consensus per image, unanimous across six libraries in both games.
|
- **Pulse element stride** — derived by consensus per image, unanimous across six libraries in both games.
|
||||||
- **live schema oracle** — offline layout vs the running process: 852/852 CS2, 1,912/1,912 Dota.
|
- **live schema oracle** — offline layout vs the running process: 852/852 CS2, 1,916/1,916 Dota. Note the population: this reads `libserver` alone, where the release floor counts the union across every mapped library (1,899 / 2,962). Two different numbers for two different questions.
|
||||||
- **Pulse shim invocation** — the only *behavioural* oracle here: every binding the artifact calls `args-only` is invoked on the live server with a sentinel entity handle, which the engine's own resolve rejects before touching anything. CS2 **67/67 clean**. It verifies a claim the artifact makes rather than a value it reports, and it is safe to run in CI precisely because the sentinel path mutates nothing — every argument slot the measurement calls unused is passed as null, so a slot that is actually used faults, and a fault is caught and the thread restored.
|
- **Pulse shim invocation** — the only *behavioural* oracle here: every binding the artifact calls `args-only` is invoked on the live server with a sentinel entity handle, which the engine's own resolve rejects before touching anything. CS2 **67/67 clean**. It verifies a claim the artifact makes rather than a value it reports, and it is safe to run in CI precisely because the sentinel path mutates nothing — every argument slot the measurement calls unused is passed as null, so a slot that is actually used faults, and a fault is caught and the thread restored.
|
||||||
- **Pulse descriptors, against the live ones** — the reconstruction check. A binding's typed signature is *constant-propagated out of an initialiser*, not read from data: the elements are written at runtime and are zeroes on disk. So the shipped `params` were, until this landed, an unverified inference. The oracle reads what the running server actually holds and compares: **383/383 on `libserver` and 155/155 on `libpulse_system`, with returns 139/139, zero disagreements.** The trick is that the regions are lazy-init singletons a normal match never populates — a standard game executes no Pulse graph — so the oracle *calls the accessor first*. Those are the same `+24`/`+32` accessors the fold refuses to treat as locators: nullary, `int=0`, body builds a static once. Worthless as locators, and exactly what makes this check possible.
|
- **Pulse descriptors, against the live ones** — the reconstruction check. A binding's typed signature is *constant-propagated out of an initialiser*, not read from data: the elements are written at runtime and are zeroes on disk. So the shipped `params` were, until this landed, an unverified inference. The oracle reads what the running server actually holds and compares: **383/383 on `libserver` and 155/155 on `libpulse_system`, with returns 139/139, zero disagreements.** The trick is that the regions are lazy-init singletons a normal match never populates — a standard game executes no Pulse graph — so the oracle *calls the accessor first*. Those are the same `+24`/`+32` accessors the fold refuses to treat as locators: nullary, `int=0`, body builds a static once. Worthless as locators, and exactly what makes this check possible.
|
||||||
- **field-gap size calibration**, the semantic call sweep, and a 500-iteration live fuzz.
|
- **field-gap size calibration**, the semantic call sweep, and a 500-iteration live fuzz.
|
||||||
|
|
@ -467,7 +467,7 @@ So one check asks the other question, from two things the binary states about an
|
||||||
|
|
||||||
**Both must hold, and the conjunction is the whole design.** Either alone rejects good entries, measured rather than supposed: a dozen CS2 bindings are bound *straight* to the native method instead of through a script wrapper, so `SetAbsOrigin` and `CBaseEntity::SetAbsOrigin` legitimately share an address (as do `ScriptSetSize` and `CBaseModelEntity::SetCollisionBounds`, whose names do not even resemble each other); and separately, six entries reach past their class because their NAME carries the wrong prefix while the locator is fine — four `CPathMover::` entries that are really `CFuncMover` setters, two `CBasePlayerController::` that are really `CCSPlayerController`. All eight of those still ship.
|
**Both must hold, and the conjunction is the whole design.** Either alone rejects good entries, measured rather than supposed: a dozen CS2 bindings are bound *straight* to the native method instead of through a script wrapper, so `SetAbsOrigin` and `CBaseEntity::SetAbsOrigin` legitimately share an address (as do `ScriptSetSize` and `CBaseModelEntity::SetCollisionBounds`, whose names do not even resemble each other); and separately, six entries reach past their class because their NAME carries the wrong prefix while the locator is fine — four `CPathMover::` entries that are really `CFuncMover` setters, two `CBasePlayerController::` that are really `CCSPlayerController`. All eight of those still ship.
|
||||||
|
|
||||||
Across the 3,988 CS2 entries that resolved before it ran, the conjunction fires **once**, and that one had shipped in a release: `CBaseEntity::DispatchTraceAttack` resolved to `CLogicRelay::Trigger`. It now ships as `name-contradicted` instead of as a locator. Because n=1, it refuses the entry rather than failing the release.
|
Across the ~3,980 CS2 entries that resolved before it ran, the conjunction fires **once**, and that one had shipped in a release: `CBaseEntity::DispatchTraceAttack` resolved to `CLogicRelay::Trigger`. It now ships as `name-contradicted` instead of as a locator. Because n=1, it refuses the entry rather than failing the release.
|
||||||
|
|
||||||
**It runs where the model LEARNS, not only where the artifact is written**, and that placement is the point. The same locate step feeds the incremental fold and the distill, so a check applied only at emit time would leave the model recording the impostor's fingerprint — and the strict fingerprint check would then *confirm* the wrong address on the next build. That is exactly how this entry survived: the model had learned the decoy, so the guard that should have caught it vouched for it instead.
|
**It runs where the model LEARNS, not only where the artifact is written**, and that placement is the point. The same locate step feeds the incremental fold and the distill, so a check applied only at emit time would leave the model recording the impostor's fingerprint — and the strict fingerprint check would then *confirm* the wrong address on the next build. That is exactly how this entry survived: the model had learned the decoy, so the guard that should have caught it vouched for it instead.
|
||||||
|
|
||||||
|
|
@ -546,7 +546,7 @@ Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/re
|
||||||
|
|
||||||
- `--target <dir>` — the build **directory** to derive from; `produce` requires a directory and its libraries are searched by name. (Other subcommands accept a bare `.so` as well, which is how `classify-change --prev` is used.)
|
- `--target <dir>` — the build **directory** to derive from; `produce` requires a directory and its libraries are searched by name. (Other subcommands accept a bare `.so` as well, which is how `classify-change --prev` is used.)
|
||||||
- `--game-dir <install>` — must be the **`game/` subtree** of the install, the same directory layout the dedicated server is launched from.
|
- `--game-dir <install>` — must be the **`game/` subtree** of the install, the same directory layout the dedicated server is launched from.
|
||||||
- `--seed <bundle>` — one file bundling every derive input. The loose equivalent is `--catalogue <file>` plus the optional `--promotable` / `--candidates` / `--full-names` / `--extra-offsets` / `--extra-sigs`, all defaulting to empty — **so a brand-new game needs only a catalogue to start deriving.**
|
- `--seed <bundle>` — one file bundling every derive input. The loose equivalent is `--catalogue <file>` plus the optional `--promotable` / `--candidates` / `--full-names` / `--extra-offsets` / `--extra-sigs`, all defaulting to empty — **so a brand-new game needs only a catalogue to start deriving.** The two forms are mutually exclusive and the CLI says so: `--seed` supplies all six, so passing one alongside it is a usage error rather than an input silently dropped.
|
||||||
- Corpus signal — exactly one of `--corpus-model <model.json>` (the normal path: forward-derive from the model + target binary, rolling the model N→N+1 as a sidecar) or `--corpus <dir>` (fingerprint raw build binaries on the fly).
|
- Corpus signal — exactly one of `--corpus-model <model.json>` (the normal path: forward-derive from the model + target binary, rolling the model N→N+1 as a sidecar) or `--corpus <dir>` (fingerprint raw build binaries on the fly).
|
||||||
|
|
||||||
Model-based derives are **forward-only**: the model describes history up to its newest build, so pointing one at an *older* target is not supported.
|
Model-based derives are **forward-only**: the model describes history up to its newest build, so pointing one at an *older* target is not supported.
|
||||||
|
|
@ -669,16 +669,19 @@ configuration rather than code.
|
||||||
A **`FunctionRecord`** opens with its `tier` (`core` / `high_confidence` / `experimental`) and the locator
|
A **`FunctionRecord`** opens with its `tier` (`core` / `high_confidence` / `experimental`) and the locator
|
||||||
flattened to the top level. A virtual method ships as a bare integer `offset` (its RTTI vtable slot index); a
|
flattened to the top level. A virtual method ships as a bare integer `offset` (its RTTI vtable slot index); a
|
||||||
non-virtual function as a `signature` object with the `library` it scans and a space-hex `linux` pattern with
|
non-virtual function as a `signature` object with the `library` it scans and a space-hex `linux` pattern with
|
||||||
`?` wildcards. By deriver convention an entry carries one or the other.
|
`?` wildcards. Usually one or the other — but **a record may carry both**, and a handful of `core` CS2 records
|
||||||
|
do, so a consumer (and the live oracle) has to judge every locator a record holds rather than the first it
|
||||||
|
finds.
|
||||||
|
|
||||||
Two further locator keys appear where they were established:
|
Two further locator keys appear where they were established:
|
||||||
|
|
||||||
- **`class`** — for an `offset` entry, the class whose vtable the slot was measured on. A slot index alone
|
- **`class`** — for an `offset` entry, the class whose vtable the slot was measured on. A slot index alone
|
||||||
locates nothing, since it only means anything relative to a particular vtable. Taken from the class the
|
locates nothing, since it only means anything relative to a particular vtable. It is the record's own name
|
||||||
derivation actually chained the offset through, never parsed out of the entry name: a base-declared method
|
class, stated explicitly: the deriver keys its slot timelines and alignment hops by that class and chains
|
||||||
routinely sits in a derived class's vtable, so those are different facts and only the measured one locates.
|
through it, so "the class chained through" and "the class in the name" are one fact. Directly folded
|
||||||
|
offsets (the multilib ground-truth path) omit it and leave the consumer to split the name.
|
||||||
- **`anchors`** — distinctive string literals the function references, each unique to it within its library.
|
- **`anchors`** — distinctive string literals the function references, each unique to it within its library.
|
||||||
Not a third locator competing with the sig-XOR-offset pair, but a supplement with a *different failure
|
Not a locator competing with the signature and the offset, but a supplement with a *different failure
|
||||||
mode*: a byte signature is a snapshot of one build's codegen, while a string survives a recompile that
|
mode*: a byte signature is a snapshot of one build's codegen, while a string survives a recompile that
|
||||||
moves instructions. Emitted alongside the signature, never instead of it.
|
moves instructions. Emitted alongside the signature, never instead of it.
|
||||||
|
|
||||||
|
|
@ -703,8 +706,8 @@ already uses, so all four ship and each one names the other three. On CS2 that i
|
||||||
names, 4.3% of the resolved surface**; on Dota it is a single group, because that catalogue is far less of a
|
names, 4.3% of the resolved surface**; on Dota it is a single group, because that catalogue is far less of a
|
||||||
merge.
|
merge.
|
||||||
|
|
||||||
Read it before treating a tier count as a function count. `counts.core + counts.high_confidence` = 3,987 is
|
Read it before treating a tier count as a function count. `counts.core + counts.high_confidence` = 3,980 is
|
||||||
exactly right about NAMES and describes **3,895 distinct functions**. And read it before hooking: two names on
|
exactly right about NAMES and describes **3,888 distinct functions**. And read it before hooking: two names on
|
||||||
one address detoured independently is one trampoline chain claimed twice.
|
one address detoured independently is one trampoline chain claimed twice.
|
||||||
|
|
||||||
Grouping is by locator identity across `core` + `high_confidence` — a shipped pattern is generated at the
|
Grouping is by locator identity across `core` + `high_confidence` — a shipped pattern is generated at the
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "source2rosetta-core"
|
name = "source2rosetta-core"
|
||||||
version = "2.1.0"
|
version = "3.0.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "source2rosetta's deriver-free core: canonical gamedata model + format emitters (serde-only)"
|
description = "source2rosetta's deriver-free core: canonical gamedata model + format emitters (serde-only)"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
|
|
|
||||||
|
|
@ -23,15 +23,18 @@ pub struct Entry {
|
||||||
/// For a vtable-OFFSET locator: the class whose vtable the slot was measured on.
|
/// For a vtable-OFFSET locator: the class whose vtable the slot was measured on.
|
||||||
///
|
///
|
||||||
/// Part of the locator, not decoration — a slot index alone locates nothing, since it is only meaningful
|
/// Part of the locator, not decoration — a slot index alone locates nothing, since it is only meaningful
|
||||||
/// relative to a particular class's vtable. Taken from the class whose vtable the derivation actually
|
/// relative to a particular class's vtable.
|
||||||
/// chained the offset through, never parsed out of the entry name: a method declared on a base class
|
///
|
||||||
/// routinely sits in a derived class's vtable, so the name's class and the measured class are different
|
/// It is the entry name's own class, stated explicitly: the deriver keys its slot timelines and
|
||||||
/// facts and only the second one locates anything.
|
/// alignment hops by the name's class and chains through that one, so "the class the offset was
|
||||||
|
/// chained through" and "the class in the name" are one fact rather than two. Directly folded offsets
|
||||||
|
/// (the multilib ground-truth path) carry no class at all and leave the consumer to split the name.
|
||||||
|
/// So a reader may treat this as a convenience copy — never as a second, independent attribution.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub class: Option<String>,
|
pub class: Option<String>,
|
||||||
/// Distinctive string literals this function references, each unique to it within its library.
|
/// Distinctive string literals this function references, each unique to it within its library.
|
||||||
///
|
///
|
||||||
/// NOT a third locator competing with the sig-XOR-offset pair — a supplement with a DIFFERENT failure
|
/// NOT a locator competing with the signature and the offset — a supplement with a DIFFERENT failure
|
||||||
/// mode. A byte signature is a snapshot of one build's codegen; a string survives a recompile that
|
/// mode. A byte signature is a snapshot of one build's codegen; a string survives a recompile that
|
||||||
/// moves instructions. So a consumer that can resolve anchors (ModSharp's `refs.strings`) has a
|
/// moves instructions. So a consumer that can resolve anchors (ModSharp's `refs.strings`) has a
|
||||||
/// locator that keeps working across the window between Valve shipping a build and us republishing,
|
/// locator that keeps working across the window between Valve shipping a build and us republishing,
|
||||||
|
|
@ -44,7 +47,9 @@ pub struct Entry {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Entry {
|
impl Entry {
|
||||||
/// A signature-only locator (the deriver's sig-XOR-offset invariant as a constructor).
|
/// A signature-only locator. A convenience for the common shape, NOT an invariant — an entry may carry
|
||||||
|
/// a signature and an offset at once (the struct doc says so, and real `core` entries do), so anything
|
||||||
|
/// judging an entry must check every locator it holds rather than the first one it finds.
|
||||||
pub fn signature(library: impl Into<String>, linux: impl Into<String>) -> Entry {
|
pub fn signature(library: impl Into<String>, linux: impl Into<String>) -> Entry {
|
||||||
Entry {
|
Entry {
|
||||||
signature: Some(Sig {
|
signature: Some(Sig {
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ fuzz_target!(|data: &[u8]| {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let xr = xref::XrefIndex::build(&img);
|
let xr = xref::XrefIndex::build(&img);
|
||||||
// Exercise the lookups over a bounded set of the discovered call targets — none may panic.
|
// Exercise the lookups over a bounded set of the discovered function entries — none may panic.
|
||||||
for &t in xr.call_targets().iter().take(64) {
|
for &t in xr.entries().iter().take(64) {
|
||||||
let _ = xr.referrers(t);
|
let _ = xr.referrers(t);
|
||||||
let _ = xr.refs_to(t);
|
let _ = xr.refs_to(t);
|
||||||
let _ = xr.containing_func(t);
|
let _ = xr.containing_func(t);
|
||||||
|
|
|
||||||
35
src/abi.rs
35
src/abi.rs
|
|
@ -609,9 +609,12 @@ pub(crate) fn gp_slot(r: Register) -> Option<usize> {
|
||||||
/// Registers a `call` destroys — every caller-saved GPR. A pointer that SURVIVES a call is in a
|
/// Registers a `call` destroys — every caller-saved GPR. A pointer that SURVIVES a call is in a
|
||||||
/// callee-saved register, which is exactly how a real `this` is kept across one.
|
/// callee-saved register, which is exactly how a real `this` is kept across one.
|
||||||
///
|
///
|
||||||
/// One list, three shapes: this array, [`caller_saved_mask`]'s bitmask, and the slot indices `concmd`
|
/// ONE list, and every shape of it is derived from this array: [`caller_saved_mask`]'s bitmask, the slot
|
||||||
/// clears after a call. They must agree — a register missing from one and present in another is a
|
/// indices [`caller_saved_slots`] hands the `concmd` and `vscript` value trackers, and `pulse`'s two
|
||||||
/// tracker that forgets a value the machine kept, or keeps one the machine destroyed.
|
/// invalidation loops, which read it directly. Nothing transcribes it, because a register present in one
|
||||||
|
/// copy and missing from another is a tracker that forgets a value the machine kept, or keeps one the
|
||||||
|
/// machine destroyed — and a fork retargeting this (Windows/MSVC makes RSI and RDI callee-saved) has to
|
||||||
|
/// change exactly one place.
|
||||||
pub(crate) const CALLER_SAVED: [Register; 9] = [
|
pub(crate) const CALLER_SAVED: [Register; 9] = [
|
||||||
Register::RAX,
|
Register::RAX,
|
||||||
Register::RCX,
|
Register::RCX,
|
||||||
|
|
@ -631,6 +634,16 @@ fn caller_saved_mask() -> u32 {
|
||||||
.fold(0u32, |m, s| m | (1 << s))
|
.fold(0u32, |m, s| m | (1 << s))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`CALLER_SAVED`] as the `[_; 16]` slot indices the instruction readers clear after a call — the shape
|
||||||
|
/// `concmd` and `vscript` need, derived once here instead of transcribed into each.
|
||||||
|
pub(crate) fn caller_saved_slots() -> [usize; 9] {
|
||||||
|
let mut out = [0usize; 9];
|
||||||
|
for (i, &r) in CALLER_SAVED.iter().enumerate() {
|
||||||
|
out[i] = gp_slot(r).expect("every caller-saved register is a GPR");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// The largest displacement the function reaches through the pointer it was handed in RDI — for a
|
/// The largest displacement the function reaches through the pointer it was handed in RDI — for a
|
||||||
/// member function, how far into `this` it touches.
|
/// member function, how far into `this` it touches.
|
||||||
///
|
///
|
||||||
|
|
@ -766,6 +779,22 @@ pub fn this_reach(img: &CodeImage, entry: u64) -> Option<u64> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_shape_of_the_caller_saved_list_agrees_with_the_array() {
|
||||||
|
// The invariant `CALLER_SAVED` documents, checked rather than asserted. Both derived shapes are
|
||||||
|
// computed from the array here, so this can only fail if someone reintroduces a hand-written
|
||||||
|
// copy — which is exactly the drift that put a raw index list in `vscript` and a second register
|
||||||
|
// array in `pulse`.
|
||||||
|
let mask = caller_saved_mask();
|
||||||
|
let slots = caller_saved_slots();
|
||||||
|
assert_eq!(mask.count_ones() as usize, CALLER_SAVED.len());
|
||||||
|
assert_eq!(slots.len(), CALLER_SAVED.len());
|
||||||
|
for (&r, &s) in CALLER_SAVED.iter().zip(slots.iter()) {
|
||||||
|
assert_eq!(gp_slot(r), Some(s), "{r:?} lost its slot index");
|
||||||
|
assert_ne!(mask & (1 << s), 0, "{r:?} is missing from the bitmask");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Decode a tiny hand-assembled straight-line function and recover its shape through the REAL
|
// Decode a tiny hand-assembled straight-line function and recover its shape through the REAL
|
||||||
// per-instruction helper (`insn_effect`) + the real liveness formula — so a test can't pass while
|
// per-instruction helper (`insn_effect`) + the real liveness formula — so a test can't pass while
|
||||||
// the production path is wrong. (A single-successor chain; the fixpoint isn't exercised here.)
|
// the production path is wrong. (A single-successor chain; the fixpoint isn't exercised here.)
|
||||||
|
|
|
||||||
|
|
@ -71,21 +71,6 @@ const RSI: usize = 6;
|
||||||
const RDI: usize = 7;
|
const RDI: usize = 7;
|
||||||
const R8: usize = 8;
|
const R8: usize = 8;
|
||||||
const R9: usize = 9;
|
const R9: usize = 9;
|
||||||
/// Caller-saved under SysV: a call destroys any constant we were tracking in these. The `this` a
|
|
||||||
/// constructor threads through its registrations is callee-saved (rbx, r12-r15), so it survives — which
|
|
||||||
/// is what makes the member-callback form readable at all.
|
|
||||||
///
|
|
||||||
/// DERIVED from `abi::CALLER_SAVED` rather than re-listed. It is a fixed SysV fact and was spelled out
|
|
||||||
/// three times across two readers and the ABI measurer; a register present in one list and missing from
|
|
||||||
/// another is a tracker that either forgets a value the machine kept or keeps one it destroyed.
|
|
||||||
fn clobbered() -> [usize; 9] {
|
|
||||||
let mut out = [0usize; 9];
|
|
||||||
for (i, &r) in crate::abi::CALLER_SAVED.iter().enumerate() {
|
|
||||||
out[i] = crate::abi::gp_slot(r).expect("every caller-saved register is a GPR");
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Longest string accepted as a command name. Names are identifiers; anything longer is not one, so the
|
/// Longest string accepted as a command name. Names are identifiers; anything longer is not one, so the
|
||||||
/// cap doubles as a validity gate.
|
/// cap doubles as a validity gate.
|
||||||
const MAX_NAME: usize = 64;
|
const MAX_NAME: usize = 64;
|
||||||
|
|
@ -386,7 +371,11 @@ fn collect_sites(
|
||||||
// symbolic base for any unknown-valued register, and the store arm keys that base as
|
// symbolic base for any unknown-valued register, and the store arm keys that base as
|
||||||
// `(reg, epoch, disp)`: without the bump, `rax` after two successive calls is ONE key
|
// `(reg, epoch, disp)`: without the bump, `rax` after two successive calls is ONE key
|
||||||
// space shared by two objects, where same-displacement stores overwrite each other.
|
// space shared by two objects, where same-displacement stores overwrite each other.
|
||||||
for c in clobbered() {
|
//
|
||||||
|
// Only the caller-saved nine. The `this` a constructor threads through its registrations
|
||||||
|
// is callee-saved (rbx, r12-r15) and SURVIVES, which is what makes the member-callback
|
||||||
|
// form readable at all — so the list comes from `abi`, never from a local transcription.
|
||||||
|
for c in crate::abi::caller_saved_slots() {
|
||||||
end_life(&mut epoch, c as u8);
|
end_life(&mut epoch, c as u8);
|
||||||
val[c] = V::Unknown;
|
val[c] = V::Unknown;
|
||||||
}
|
}
|
||||||
|
|
@ -883,9 +872,9 @@ mod tests {
|
||||||
// made through the FIRST could be read back as a slot of the SECOND — and the member-callback
|
// made through the FIRST could be read back as a slot of the SECOND — and the member-callback
|
||||||
// recovery ships whatever executable pointer that merged window holds.
|
// recovery ships whatever executable pointer that merged window holds.
|
||||||
let mut epoch = [0u32; 16];
|
let mut epoch = [0u32; 16];
|
||||||
let clobber = clobbered();
|
let clobber = crate::abi::caller_saved_slots();
|
||||||
let before: Vec<u32> = clobber.iter().map(|&c| epoch[c]).collect();
|
let before: Vec<u32> = clobber.iter().map(|&c| epoch[c]).collect();
|
||||||
for c in clobbered() {
|
for c in clobber {
|
||||||
end_life(&mut epoch, c as u8);
|
end_life(&mut epoch, c as u8);
|
||||||
}
|
}
|
||||||
for (i, &c) in clobber.iter().enumerate() {
|
for (i, &c) in clobber.iter().enumerate() {
|
||||||
|
|
|
||||||
19
src/lib.rs
19
src/lib.rs
|
|
@ -11,9 +11,10 @@
|
||||||
//! compares two builds of one named library and reads nothing game-specific.)
|
//! compares two builds of one named library and reads nothing game-specific.)
|
||||||
//! - [`pipeline`] — the pure OFFLINE derivation engine (nothing here attaches to a running server):
|
//! - [`pipeline`] — the pure OFFLINE derivation engine (nothing here attaches to a running server):
|
||||||
//! `corpus_model_cmd` (distill the corpus model, taking a [`pipeline::ClassScope`]), `fold_model_cmd`
|
//! `corpus_model_cmd` (distill the corpus model, taking a [`pipeline::ClassScope`]), `fold_model_cmd`
|
||||||
//! (roll model N → N+1), `backfill_cmd` (cross-build name/offset timelines). The derive that consumes
|
//! (roll model N → N+1, over a [`pipeline::CorpusModel`] that [`pipeline::load_model`] reads off disk —
|
||||||
//! a corpus source is reached through `produce::produce_cmd`, which builds one internally from its
|
//! the only way to build its first argument), `backfill_cmd` (cross-build name/offset timelines). The
|
||||||
//! `--corpus` / `--corpus-model` arguments — `CorpusSource` itself is crate-private.
|
//! derive that consumes a corpus source is reached through `produce::produce_cmd`, which builds one
|
||||||
|
//! internally from its `--corpus` / `--corpus-model` arguments — `CorpusSource` itself is crate-private.
|
||||||
//! - [`produce`] — CI orchestration + the LIVE half (everything that drives a running server): `produce_cmd`
|
//! - [`produce`] — CI orchestration + the LIVE half (everything that drives a running server): `produce_cmd`
|
||||||
//! (the whole per-game build — boots its own bots server for validate-live + typed netvars when a game is
|
//! (the whole per-game build — boots its own bots server for validate-live + typed netvars when a game is
|
||||||
//! given), `integration_test_cmd` (the standalone live oracle), `classify_change_cmd` / `filter_corpus_cmd`
|
//! given), `integration_test_cmd` (the standalone live oracle), `classify_change_cmd` / `filter_corpus_cmd`
|
||||||
|
|
@ -24,8 +25,10 @@
|
||||||
//!
|
//!
|
||||||
//! # Low-level engine (implementation detail)
|
//! # Low-level engine (implementation detail)
|
||||||
//! The modules below are the building blocks the API composes (ELF/RTTI/SchemaSystem readers, the fingerprint
|
//! The modules below are the building blocks the API composes (ELF/RTTI/SchemaSystem readers, the fingerprint
|
||||||
//! metric, the sig/abi machinery, the data-parallel primitive, the name taxonomy). They stay `pub` for the fuzz
|
//! metric, the sig/abi machinery, the data-parallel primitive). They stay `pub` for the fuzz harness and
|
||||||
//! harness and advanced embedders, but carry NO stability promise — treat them as internal.
|
//! advanced embedders, but carry NO stability promise — treat them as internal. The name taxonomy is NOT
|
||||||
|
//! among them: it is crate-private, because the knob a fork retunes is the `GameProfile` vocabulary block
|
||||||
|
//! those predicates read, not the predicates.
|
||||||
|
|
||||||
// ---- supported API ----
|
// ---- supported API ----
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
|
@ -46,11 +49,15 @@ pub mod pulse;
|
||||||
pub mod rtti;
|
pub mod rtti;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod sig;
|
pub mod sig;
|
||||||
pub mod taxonomy;
|
|
||||||
pub mod valvetab;
|
pub mod valvetab;
|
||||||
pub mod vscript;
|
pub mod vscript;
|
||||||
pub mod xref;
|
pub mod xref;
|
||||||
|
|
||||||
|
// ---- crate-private ----
|
||||||
|
// The name taxonomy: every item is `pub(crate)`, so publishing the module published an empty page. The
|
||||||
|
// per-game vocabulary it reads is the fork-retunable part, and that is already `pub` on `GameProfile`.
|
||||||
|
mod taxonomy;
|
||||||
|
|
||||||
// The canonical model + emitters live in the deriver-free `source2rosetta-core` crate; re-export them so
|
// The canonical model + emitters live in the deriver-free `source2rosetta-core` crate; re-export them so
|
||||||
// existing `source2rosetta::{model, render}` paths keep resolving.
|
// existing `source2rosetta::{model, render}` paths keep resolving.
|
||||||
pub use source2rosetta_core::{model, render};
|
pub use source2rosetta_core::{model, render};
|
||||||
|
|
|
||||||
94
src/main.rs
94
src/main.rs
|
|
@ -84,9 +84,11 @@ enum Cmd {
|
||||||
},
|
},
|
||||||
/// The whole per-game build in ONE in-memory command: derive → fold → (if `--game-dir` is given)
|
/// The whole per-game build in ONE in-memory command: derive → fold → (if `--game-dir` is given)
|
||||||
/// validate-live + typed netvars → merge → fold model, writing the release set
|
/// validate-live + typed netvars → merge → fold model, writing the release set
|
||||||
/// (`rosetta-<game>.json` + `model-<game>.json` + `manifest.json`) into --out-dir. No per-stage
|
/// (`rosetta-<game>.json` + `manifest.json`, plus `model-<game>.json` when `--corpus-model` was the
|
||||||
/// intermediate files. **Pass `--game-dir` for a full, live-validated build; omit it for a fast
|
/// source — the sidecar is that model rolled N → N+1, so a `--corpus` genesis run writes two files,
|
||||||
/// OFFLINE build (no server, so no live validation and a `null` schema).**
|
/// not three) into --out-dir. No per-stage intermediate files. **Pass `--game-dir` for a full,
|
||||||
|
/// live-validated build; omit it for a fast OFFLINE build (no server, so no live validation and a
|
||||||
|
/// `null` schema).**
|
||||||
Produce {
|
Produce {
|
||||||
/// A launchable game install → the FULL build (boots a server for validate-live + typed netvars).
|
/// A launchable game install → the FULL build (boots a server for validate-live + typed netvars).
|
||||||
/// OMIT for an offline build. The offline/full switch — no separate flag.
|
/// OMIT for an offline build. The offline/full switch — no separate flag.
|
||||||
|
|
@ -98,12 +100,13 @@ enum Cmd {
|
||||||
/// Server library to derive from; defaults to the active game's server lib.
|
/// Server library to derive from; defaults to the active game's server lib.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
lib: Option<String>,
|
lib: Option<String>,
|
||||||
/// One bundled seed (catalogue + naming sections) — the release form. Replaces the loose
|
/// One bundled seed (catalogue + naming sections) — the release form. Carries everything the loose
|
||||||
/// --catalogue/--promotable/--candidates/--full-names/--extra-offsets/--extra-sigs flags.
|
/// --catalogue/--promotable/--candidates/--full-names/--extra-offsets/--extra-sigs flags carry, and
|
||||||
|
/// CONFLICTS with each of them: pass one form or the other, never a mix.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
seed: Option<PathBuf>,
|
seed: Option<PathBuf>,
|
||||||
/// Function catalogue (loose form; omit when using --seed).
|
/// Function catalogue (loose form; omit when using --seed).
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
catalogue: Option<PathBuf>,
|
catalogue: Option<PathBuf>,
|
||||||
/// Corpus-signal source A: the raw build binaries to fingerprint on the fly. Exactly ONE of
|
/// Corpus-signal source A: the raw build binaries to fingerprint on the fly. Exactly ONE of
|
||||||
/// --corpus / --corpus-model is required (--corpus-model is the production forward-derive path).
|
/// --corpus / --corpus-model is required (--corpus-model is the production forward-derive path).
|
||||||
|
|
@ -119,22 +122,22 @@ enum Cmd {
|
||||||
target: PathBuf,
|
target: PathBuf,
|
||||||
/// Optional: names eligible for promotion into high_confidence (from the naming producer flow).
|
/// Optional: names eligible for promotion into high_confidence (from the naming producer flow).
|
||||||
/// Omit to promote nothing — the catalogue still derives in full.
|
/// Omit to promote nothing — the catalogue still derives in full.
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
promotable: Option<PathBuf>,
|
promotable: Option<PathBuf>,
|
||||||
/// Optional: prefiltered per-address context for those names (`{"candidates": [...]}`). Omit for none.
|
/// Optional: prefiltered per-address context for those names (`{"candidates": [...]}`). Omit for none.
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
candidates: Option<PathBuf>,
|
candidates: Option<PathBuf>,
|
||||||
/// Optional: the full-slice name universe. When set, the monolith also carries an `experimental`
|
/// Optional: the full-slice name universe. When set, the monolith also carries an `experimental`
|
||||||
/// tier — the least-filtered inclusion band (every name guess, graded, each with a resolvable
|
/// tier — the least-filtered inclusion band (every name guess, graded, each with a resolvable
|
||||||
/// locator but an UNVERIFIED name).
|
/// locator but an UNVERIFIED name).
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
full_names: Option<PathBuf>,
|
full_names: Option<PathBuf>,
|
||||||
/// Multilib ground-truth vtable offsets to fold as high_confidence — `{lib: [{name,class,slot}]}`
|
/// Multilib ground-truth vtable offsets to fold as high_confidence — `{lib: [{name,class,slot}]}`
|
||||||
/// (e.g. the macOS symbol transfer). Folded directly, bypassing the candidate gate.
|
/// (e.g. the macOS symbol transfer). Folded directly, bypassing the candidate gate.
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
extra_offsets: Option<PathBuf>,
|
extra_offsets: Option<PathBuf>,
|
||||||
/// Multilib non-virtual names to fold as sigs — `{lib: [{name,addr}]}`; `make_sig` runs per lib.
|
/// Multilib non-virtual names to fold as sigs — `{lib: [{name,addr}]}`; `make_sig` runs per lib.
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
extra_sigs: Option<PathBuf>,
|
extra_sigs: Option<PathBuf>,
|
||||||
/// Declared C++ prototypes (`mappings/prototypes.json`) to judge against this build's measured
|
/// Declared C++ prototypes (`mappings/prototypes.json`) to judge against this build's measured
|
||||||
/// register footprints. Static repo input — omit and no function carries a declared prototype.
|
/// register footprints. Static repo input — omit and no function carries a declared prototype.
|
||||||
|
|
@ -171,12 +174,12 @@ enum Cmd {
|
||||||
/// Distill the whole corpus into a shippable model (vtable-alignment hops + reference fingerprints
|
/// Distill the whole corpus into a shippable model (vtable-alignment hops + reference fingerprints
|
||||||
/// + slot timelines) so derivation needs only the model + the target binary, not the 86 GB corpus.
|
/// + slot timelines) so derivation needs only the model + the target binary, not the 86 GB corpus.
|
||||||
CorpusModel {
|
CorpusModel {
|
||||||
/// One bundled seed — the release form; its catalogue section is what gets distilled. Replaces the
|
/// One bundled seed — the release form; its catalogue section is what gets distilled. CONFLICTS with
|
||||||
/// loose --catalogue (naming sections are ignored here — the model tracks catalogue names only).
|
/// the loose --catalogue (naming sections are ignored here — the model tracks catalogue names only).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
seed: Option<PathBuf>,
|
seed: Option<PathBuf>,
|
||||||
/// Function catalogue (loose form; omit when using --seed).
|
/// Function catalogue (loose form; omit when using --seed).
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
catalogue: Option<PathBuf>,
|
catalogue: Option<PathBuf>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
corpus: PathBuf,
|
corpus: PathBuf,
|
||||||
|
|
@ -195,11 +198,12 @@ enum Cmd {
|
||||||
/// The existing model N (carries the `abi_obs` window the fold re-windows).
|
/// The existing model N (carries the `abi_obs` window the fold re-windows).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
model: PathBuf,
|
model: PathBuf,
|
||||||
/// One bundled seed — the release form; its catalogue section is folded. Replaces the loose --catalogue.
|
/// One bundled seed — the release form; its catalogue section is folded. CONFLICTS with the loose
|
||||||
|
/// --catalogue.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
seed: Option<PathBuf>,
|
seed: Option<PathBuf>,
|
||||||
/// Function catalogue (loose form; omit when using --seed). Must match the model's distill catalogue.
|
/// Function catalogue (loose form; omit when using --seed). Must match the model's distill catalogue.
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with = "seed")]
|
||||||
catalogue: Option<PathBuf>,
|
catalogue: Option<PathBuf>,
|
||||||
/// The one new build dir to fold in (holds the just-updated libserver.so etc.).
|
/// The one new build dir to fold in (holds the just-updated libserver.so etc.).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
|
@ -309,9 +313,11 @@ fn lib_or_default(prof: &profile::GameProfile, lib: Option<String>) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the catalogue for the model commands (`corpus-model`/`fold-model`) from either a `--seed` bundle
|
/// Resolve the catalogue for the model commands (`corpus-model`/`fold-model`) from either a `--seed` bundle
|
||||||
/// (release form) or a loose `--catalogue` file. The seed's catalogue section parses to the same functions as
|
/// (release form) or a loose `--catalogue` file — never both; `catalogue` declares the conflict, so the
|
||||||
/// the loose `needed-functions.json`, so the distilled/folded model is identical either way. When a seed is
|
/// `None` arm here means the flag was genuinely absent. The seed's catalogue section parses to the same
|
||||||
/// given, its sections unpack under a `.seed` dir beside `out` (as `produce` does beside its out-dir).
|
/// functions as the loose `needed-functions.json`, so the distilled/folded model is identical either way.
|
||||||
|
/// When a seed is given, its sections unpack under a `.seed` dir beside `out` (as `produce` does beside
|
||||||
|
/// its out-dir).
|
||||||
fn model_catalogue(
|
fn model_catalogue(
|
||||||
prof: &profile::GameProfile,
|
prof: &profile::GameProfile,
|
||||||
seed: Option<PathBuf>,
|
seed: Option<PathBuf>,
|
||||||
|
|
@ -393,6 +399,9 @@ fn main() -> Result<()> {
|
||||||
bots,
|
bots,
|
||||||
} => {
|
} => {
|
||||||
// derive inputs come from a single --seed bundle (release form) or the loose flags (dev/verify).
|
// derive inputs come from a single --seed bundle (release form) or the loose flags (dev/verify).
|
||||||
|
// The bundle arm reads NONE of the loose bindings, which is only honest because each of them
|
||||||
|
// declares `conflicts_with = "seed"` — clap rejects the mix before dispatch rather than letting
|
||||||
|
// this arm drop an explicitly passed input on the floor.
|
||||||
let inputs = match seed {
|
let inputs = match seed {
|
||||||
Some(s) => unpack_seed(profile, &s, &out_dir.join(".seed"))?,
|
Some(s) => unpack_seed(profile, &s, &out_dir.join(".seed"))?,
|
||||||
None => SeedInputs {
|
None => SeedInputs {
|
||||||
|
|
@ -511,3 +520,50 @@ fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use clap::CommandFactory;
|
||||||
|
|
||||||
|
/// A dropped input is a silent skip, and this is the one place the CLI could produce one: the `--seed`
|
||||||
|
/// arms unpack every loose input themselves and never read the loose bindings, so an undeclared
|
||||||
|
/// conflict means `produce --seed s.json --full-names f.json` runs to exit 0 with `--full-names`
|
||||||
|
/// ignored — and a monolith with no experimental tier is exactly what a game with no naming harvest
|
||||||
|
/// legitimately ships, so no collapse floor downstream can tell the two apart.
|
||||||
|
#[test]
|
||||||
|
fn a_seed_bundle_refuses_the_loose_inputs_rather_than_ignoring_them() {
|
||||||
|
Cli::command().debug_assert();
|
||||||
|
let parse = |argv: &[&str]| {
|
||||||
|
let full: Vec<&str> = std::iter::once("source2rosetta")
|
||||||
|
.chain(argv.iter().copied())
|
||||||
|
.collect();
|
||||||
|
Cli::try_parse_from(&full)
|
||||||
|
};
|
||||||
|
let refused = |argv: &[&str]| {
|
||||||
|
assert!(
|
||||||
|
parse(argv).is_err(),
|
||||||
|
"accepted, so one of these inputs is silently dropped: {argv:?}"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let produce = ["produce", "--target", "t", "--out-dir", "o", "--seed", "s"];
|
||||||
|
for flag in [
|
||||||
|
"--catalogue",
|
||||||
|
"--promotable",
|
||||||
|
"--candidates",
|
||||||
|
"--full-names",
|
||||||
|
"--extra-offsets",
|
||||||
|
"--extra-sigs",
|
||||||
|
] {
|
||||||
|
refused(&[&produce[..], &[flag, "x"]].concat());
|
||||||
|
}
|
||||||
|
let model = ["--out", "o", "--seed", "s", "--catalogue", "c"];
|
||||||
|
refused(&[&["corpus-model", "--corpus", "c"][..], &model].concat());
|
||||||
|
refused(&[&["fold-model", "--model", "m", "--build", "b"][..], &model].concat());
|
||||||
|
// Either form ALONE still parses — the conflict must not have made the loose form unusable.
|
||||||
|
assert!(parse(&produce).is_ok());
|
||||||
|
let loose = ["--catalogue", "c", "--full-names", "f"];
|
||||||
|
let bare = &produce[..produce.len() - 2]; // the same command minus `--seed s`
|
||||||
|
assert!(parse(&[bare, &loose[..]].concat()).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -336,14 +336,16 @@ impl Identity {
|
||||||
/// was a genuine defect that had shipped in a release (`CBaseEntity::DispatchTraceAttack`, in fact
|
/// was a genuine defect that had shipped in a release (`CBaseEntity::DispatchTraceAttack`, in fact
|
||||||
/// `CLogicRelay::Trigger`). n=1, so this rejects one entry rather than aborting a release.
|
/// `CLogicRelay::Trigger`). n=1, so this rejects one entry rather than aborting a release.
|
||||||
fn contradiction(&self, lib: &str, img: &CodeImage, addr: u64, name: &str) -> Option<String> {
|
fn contradiction(&self, lib: &str, img: &CodeImage, addr: u64, name: &str) -> Option<String> {
|
||||||
// `Identity::of` gives every loaded image an entry, so a miss here does NOT mean "a library with
|
// `Identity::of` gives every loaded image an entry, and both call sites key from a file in that
|
||||||
// no evidence" — it means the caller holds the wrong FORM of the key, and the `?` below then
|
// same map, so a miss here does NOT mean "a library with no evidence" — it means the caller holds
|
||||||
// disables this entire check without a word. That is precisely how it sat dead on the fold path.
|
// the wrong FORM of the key, and the `?` this used to be then disabled the entire check without a
|
||||||
debug_assert!(
|
// word. That is precisely how it sat dead on the fold path.
|
||||||
self.0.contains_key(lib),
|
//
|
||||||
"Identity is keyed by SHORT library name (`server`), got `{lib}`"
|
// NOT a `debug_assert!`: that compiles out in release, and the shipped binary is a release build,
|
||||||
);
|
// so the guard would have gone on silently passing in the one configuration that matters.
|
||||||
let lib = self.0.get(lib)?;
|
let Some(lib) = self.0.get(lib) else {
|
||||||
|
panic!("Identity is keyed by SHORT library name (`server`), got `{lib}`");
|
||||||
|
};
|
||||||
let (class, method) = name.split_once("::")?;
|
let (class, method) = name.split_once("::")?;
|
||||||
let registered = lib.vscript_at.get(&addr)?;
|
let registered = lib.vscript_at.get(&addr)?;
|
||||||
if names_correspond(registered, method) {
|
if names_correspond(registered, method) {
|
||||||
|
|
@ -4848,9 +4850,11 @@ fn derive_offsets(
|
||||||
match chain_and_vote(&anchors, hv, target_idx) {
|
match chain_and_vote(&anchors, hv, target_idx) {
|
||||||
Some((pred, conf)) if conf >= 80 => {
|
Some((pred, conf)) if conf >= 80 => {
|
||||||
gd.set_offset(f.name.clone(), pred as i64);
|
gd.set_offset(f.name.clone(), pred as i64);
|
||||||
// The class the slot was chained THROUGH — the only one that makes the index meaningful.
|
// The class whose vtable this slot indexes — half the locator, since an index alone locates
|
||||||
// Recorded here rather than reconstructed later from the name, which would be a different
|
// nothing. It is `class_of(f.name)`: `vtable_offset_timelines` builds `VtFunc::class` that
|
||||||
// (and sometimes wrong) fact: a base-declared method sits in a derived class's vtable.
|
// way and both `hops` and `bv.fps` are keyed by it, so the class the chain walked and the
|
||||||
|
// class in the name are one fact, not two. Emitting it saves the consumer a name split; it
|
||||||
|
// does not add information the name lacks.
|
||||||
gd.set_class(f.name.clone(), f.class.clone());
|
gd.set_class(f.name.clone(), f.class.clone());
|
||||||
off_ok += 1;
|
off_ok += 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2124,17 +2124,30 @@ fn validate_live_cmd(
|
||||||
fn verify_live_cmd(prof: &GameProfile, pid: u32, dir: &Path, lib: &str) -> Result<OracleCounts> {
|
fn verify_live_cmd(prof: &GameProfile, pid: u32, dir: &Path, lib: &str) -> Result<OracleCounts> {
|
||||||
let img = load_lib(dir, lib)?;
|
let img = load_lib(dir, lib)?;
|
||||||
let classes = schema::enumerate_schema(&img);
|
let classes = schema::enumerate_schema(&img);
|
||||||
// The SAME floor `produce` applies, not merely non-empty: this half compares an offline read against a
|
// A floor, not merely non-empty: this half compares an offline read against a live one through the same
|
||||||
// live one through the same `CI_*` constants on the same bytes, so a reshape's survivors agree with
|
// `CI_*` constants on the same bytes, so a reshape's survivors agree with themselves at ~1.0 and a
|
||||||
// themselves at ~1.0 and a handful of classes looks like a clean run. See
|
// handful of classes looks like a clean run.
|
||||||
// `GameProfile::min_schema_classes`.
|
//
|
||||||
|
// It is `min_schema_classes_LIB`, because this enumerates ONE library while `produce`'s floor counts the
|
||||||
|
// union across all of them — see `GameProfile::min_schema_classes_lib`. Only `server_lib` has a
|
||||||
|
// calibrated count, so any other library is enumerated and reported rather than judged against a number
|
||||||
|
// that does not describe it.
|
||||||
|
if lib == prof.server_lib {
|
||||||
ensure!(
|
ensure!(
|
||||||
classes.len() >= prof.min_schema_classes,
|
classes.len() >= prof.min_schema_classes_lib,
|
||||||
"offline schema derivation found {} classes in {lib} (floor {}) — refusing to verify a schema \
|
"offline schema derivation found {} classes in {lib} (floor {}) — refusing to verify a schema \
|
||||||
whose class table collapsed",
|
whose class table collapsed",
|
||||||
classes.len(),
|
classes.len(),
|
||||||
prof.min_schema_classes
|
prof.min_schema_classes_lib
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"NOTE: {lib} is not {}, which is the only library with a calibrated class floor — \
|
||||||
|
enumerated {} classes, collapse check SKIPPED",
|
||||||
|
prof.server_lib,
|
||||||
|
classes.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let live = live::LiveProcess::attach(pid)?;
|
let live = live::LiveProcess::attach(pid)?;
|
||||||
let base = live
|
let base = live
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,16 @@ pub struct GameProfile {
|
||||||
/// offline/live layout comparison reads the same bytes through the same `CI_*` constants, so whatever
|
/// offline/live layout comparison reads the same bytes through the same `CI_*` constants, so whatever
|
||||||
/// survives a reshape agrees with itself.
|
/// survives a reshape agrees with itself.
|
||||||
pub min_schema_classes: usize,
|
pub min_schema_classes: usize,
|
||||||
|
/// Collapse floor for the schema CLASS table read from a SINGLE library — the live oracle's population.
|
||||||
|
///
|
||||||
|
/// Distinct from [`min_schema_classes`](Self::min_schema_classes), and the two may never be shared: that
|
||||||
|
/// one counts the union across every mapped library, this one counts `server_lib` alone, and the union is
|
||||||
|
/// roughly twice as large. A floor calibrated on the union rejects every healthy build when applied here,
|
||||||
|
/// because the honest single-library count sits below it by construction.
|
||||||
|
///
|
||||||
|
/// Calibrated the same way as its sibling — well under the observed count, a collapse detector rather
|
||||||
|
/// than a tight bound — and it only applies to `server_lib`, the one library whose count is calibrated.
|
||||||
|
pub min_schema_classes_lib: usize,
|
||||||
/// Collapse floor for the DERIVED function tiers — `core + high_confidence`.
|
/// Collapse floor for the DERIVED function tiers — `core + high_confidence`.
|
||||||
///
|
///
|
||||||
/// Every table read out of the binary has one of these; the tool's headline product did not, and the
|
/// Every table read out of the binary has one of these; the tool's headline product did not, and the
|
||||||
|
|
@ -252,9 +262,11 @@ pub const CS2: GameProfile = GameProfile {
|
||||||
// observed live: 271 of 300 bindings attributed across 24 classes
|
// observed live: 271 of 300 bindings attributed across 24 classes
|
||||||
min_vscript_classed: 150,
|
min_vscript_classed: 150,
|
||||||
min_schema_enums: 250,
|
min_schema_enums: 250,
|
||||||
// CS2 recovers 1,899. A floor at 1,200 is well clear of build-to-build drift and nowhere near
|
// CS2 recovers 1,899 across every mapped library. A floor at 1,200 is well clear of build-to-build
|
||||||
// the range a `SchemaClassInfoData_t` reshape would leave.
|
// drift and nowhere near the range a `SchemaClassInfoData_t` reshape would leave.
|
||||||
min_schema_classes: 1_200,
|
min_schema_classes: 1_200,
|
||||||
|
// libserver.so alone holds 852 of those; the live oracle reads that library only.
|
||||||
|
min_schema_classes_lib: 550,
|
||||||
// CS2 ships 1,086 core + 2,899 high-confidence = 3,985.
|
// CS2 ships 1,086 core + 2,899 high-confidence = 3,985.
|
||||||
min_core_functions: 2_500,
|
min_core_functions: 2_500,
|
||||||
game_key: "csgo",
|
game_key: "csgo",
|
||||||
|
|
@ -365,8 +377,10 @@ pub const DOTA: GameProfile = GameProfile {
|
||||||
// observed live: 1,638 of 1,841 bindings attributed across 63 classes
|
// observed live: 1,638 of 1,841 bindings attributed across 63 classes
|
||||||
min_vscript_classed: 900,
|
min_vscript_classed: 900,
|
||||||
min_schema_enums: 350,
|
min_schema_enums: 350,
|
||||||
// Dota recovers 2,962.
|
// Dota recovers 2,962 across every mapped library.
|
||||||
min_schema_classes: 2_000,
|
min_schema_classes: 2_000,
|
||||||
|
// libserver.so alone holds 1,916 of those; the live oracle reads that library only.
|
||||||
|
min_schema_classes_lib: 1_250,
|
||||||
// Dota ships 1,096 + 4,047 = 5,143.
|
// Dota ships 1,096 + 4,047 = 5,143.
|
||||||
min_core_functions: 3_000,
|
min_core_functions: 3_000,
|
||||||
game_key: "dota",
|
game_key: "dota",
|
||||||
|
|
@ -450,6 +464,23 @@ pub const DOTA: GameProfile = GameProfile {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The two class floors count DIFFERENT populations — the all-library union and `server_lib` alone —
|
||||||
|
/// so a profile that gives them the same value has calibrated one of them against the other's
|
||||||
|
/// population, which rejects every healthy build on whichever site got the larger number.
|
||||||
|
#[test]
|
||||||
|
fn the_single_library_class_floor_is_strictly_below_the_all_library_one() {
|
||||||
|
for prof in [&CS2, &DOTA] {
|
||||||
|
assert!(
|
||||||
|
prof.min_schema_classes_lib < prof.min_schema_classes,
|
||||||
|
"{}: single-library floor {} must sit below the all-library floor {} — one library \
|
||||||
|
cannot hold more classes than every library",
|
||||||
|
prof.token,
|
||||||
|
prof.min_schema_classes_lib,
|
||||||
|
prof.min_schema_classes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cs2_launch_args_are_byte_identical_to_the_old_hand_synced_vec() {
|
fn cs2_launch_args_are_byte_identical_to_the_old_hand_synced_vec() {
|
||||||
// The exact arg vec the live launch requires for map="de_dust2", bots=9 — pins the LaunchSpec
|
// The exact arg vec the live launch requires for map="de_dust2", bots=9 — pins the LaunchSpec
|
||||||
|
|
|
||||||
93
src/pulse.rs
93
src/pulse.rs
|
|
@ -24,6 +24,9 @@
|
||||||
//! declares. A layout change yields FEWER signatures, never wrong ones, and the profile floor turns
|
//! declares. A layout change yields FEWER signatures, never wrong ones, and the profile floor turns
|
||||||
//! "fewer" into a failed release.
|
//! "fewer" into a failed release.
|
||||||
|
|
||||||
|
// Registers whose value a call destroys. The ONE list in `abi`, not a second copy of it — both loops
|
||||||
|
// below that invalidate across a call read it directly.
|
||||||
|
use crate::abi::CALLER_SAVED;
|
||||||
use crate::elf::CodeImage;
|
use crate::elf::CodeImage;
|
||||||
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
|
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
|
@ -83,20 +86,6 @@ struct Trace {
|
||||||
ret: Option<(u64, u64)>,
|
ret: Option<(u64, u64)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers whose value a call destroys. Anything else the pass cannot evaluate is invalidated as the
|
|
||||||
/// instruction that writes it is seen, so the default is always "unknown" rather than "stale".
|
|
||||||
const CALLER_SAVED: [Register; 9] = [
|
|
||||||
Register::RAX,
|
|
||||||
Register::RCX,
|
|
||||||
Register::RDX,
|
|
||||||
Register::RSI,
|
|
||||||
Register::RDI,
|
|
||||||
Register::R8,
|
|
||||||
Register::R9,
|
|
||||||
Register::R10,
|
|
||||||
Register::R11,
|
|
||||||
];
|
|
||||||
|
|
||||||
fn full(r: Register) -> Register {
|
fn full(r: Register) -> Register {
|
||||||
if r.is_gpr() { r.full_register() } else { r }
|
if r.is_gpr() { r.full_register() } else { r }
|
||||||
}
|
}
|
||||||
|
|
@ -362,26 +351,6 @@ fn record(img: &CodeImage, accessor: u64) -> Option<Record> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every CODE pointer an accessor's initializer stores into its record region, with the region base:
|
|
||||||
/// `(base, [(address written, code address written)])`.
|
|
||||||
///
|
|
||||||
/// A DIAGNOSTIC, and deliberately not part of any shipped artifact. The parameter records carry a
|
|
||||||
/// function pointer whose ROLE is not established — the record reader already has to look at these in
|
|
||||||
/// order to reject them as parameter names, so exposing them costs nothing and lets that question be
|
|
||||||
/// settled against evidence collected elsewhere (a runtime call-edge trace) rather than guessed. Nothing
|
|
||||||
/// here interprets them; they are raw measurements.
|
|
||||||
pub fn code_stores(img: &CodeImage, accessor: u64) -> Option<(u64, Vec<(u64, u64)>)> {
|
|
||||||
let r = record(img, accessor)?;
|
|
||||||
let stores =
|
|
||||||
r.t.writes
|
|
||||||
.iter()
|
|
||||||
.filter(|&(a, _)| *a >= r.base)
|
|
||||||
.filter(|&(_, p)| img.is_code(*p))
|
|
||||||
.map(|(&a, &p)| (a, p))
|
|
||||||
.collect();
|
|
||||||
Some((r.base, stores))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The spacings at which this record's `count` names could sit, given that element 0's name is at
|
/// The spacings at which this record's `count` names could sit, given that element 0's name is at
|
||||||
/// `base + 8` and the array is contiguous. Usually one; a record carrying a second identifier-shaped
|
/// `base + 8` and the array is contiguous. Usually one; a record carrying a second identifier-shaped
|
||||||
/// string of its own offers more, which is why the stride is settled per IMAGE and not per record.
|
/// string of its own offers more, which is why the stride is settled per IMAGE and not per record.
|
||||||
|
|
@ -582,10 +551,10 @@ const SHIM_SLOTS: [Register; 6] = [
|
||||||
/// What an invocation shim was measured to read, and therefore what a caller has to supply.
|
/// What an invocation shim was measured to read, and therefore what a caller has to supply.
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
pub struct ShimReads {
|
pub struct ShimReads {
|
||||||
/// The argument slots actually read, named — `rcx`, `r8`, `stack0`.
|
/// The argument slots actually read, named — `rcx`, `r8`, `stack0`. The argument array (`r8`) is
|
||||||
|
/// stated here and nowhere else: reading it is the ordinary case and constrains a caller in no way,
|
||||||
|
/// so it needs no flag of its own beside the three that do.
|
||||||
pub reads: Vec<&'static str>,
|
pub reads: Vec<&'static str>,
|
||||||
/// Does it read the argument array (`r8`)?
|
|
||||||
pub args: bool,
|
|
||||||
/// Does it read the output sink (the first stack slot)? True for exactly the bindings that declare a
|
/// Does it read the output sink (the first stack slot)? True for exactly the bindings that declare a
|
||||||
/// return, measured across both games with no exceptions.
|
/// return, measured across both games with no exceptions.
|
||||||
pub sink: bool,
|
pub sink: bool,
|
||||||
|
|
@ -634,6 +603,24 @@ pub fn record_region(img: &CodeImage, accessor: u64) -> Option<(u64, u64)> {
|
||||||
(r.base != 0).then_some((r.base, r.count))
|
(r.base != 0).then_some((r.base, r.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a read of one argument slot demands of a HOST caller.
|
||||||
|
///
|
||||||
|
/// The argument array (`r8`) demands nothing — the caller builds it, so reading it is the ordinary case
|
||||||
|
/// and `reads` already states it. The Pulse context (`rcx`) is VM-owned and cannot be supplied at all.
|
||||||
|
/// Everything else is a slot the caller would otherwise pass null.
|
||||||
|
///
|
||||||
|
/// A named arm rather than a fall-through for `r8` specifically: dropping it into the `_` catch-all would
|
||||||
|
/// mark every ordinary binding as needing a slot no host can fill, retiring the entire `args-only`
|
||||||
|
/// callable tier — a collapse that reads as "this build has no callable bindings", which is a legitimate
|
||||||
|
/// answer for a game and therefore invisible.
|
||||||
|
fn slot_need(r: Register, out: &mut ShimReads) {
|
||||||
|
match r {
|
||||||
|
Register::RCX => out.context = true,
|
||||||
|
Register::R8 => {}
|
||||||
|
_ => out.other = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Measure which of a shim's seven arguments it reads.
|
/// Measure which of a shim's seven arguments it reads.
|
||||||
///
|
///
|
||||||
/// Reachable instructions in ADDRESS order, which needs two guards that cost real time to find:
|
/// Reachable instructions in ADDRESS order, which needs two guards that cost real time to find:
|
||||||
|
|
@ -725,11 +712,7 @@ pub fn shim_reads(img: &CodeImage, entry: u64) -> Option<ShimReads> {
|
||||||
{
|
{
|
||||||
if live.contains_key(r) {
|
if live.contains_key(r) {
|
||||||
out.reads.push(name);
|
out.reads.push(name);
|
||||||
match *r {
|
slot_need(*r, &mut out);
|
||||||
Register::RCX => out.context = true,
|
|
||||||
Register::R8 => out.args = true,
|
|
||||||
_ => out.other = true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sink {
|
if sink {
|
||||||
|
|
@ -802,26 +785,26 @@ mod tests {
|
||||||
let ctx = ShimReads {
|
let ctx = ShimReads {
|
||||||
context: true,
|
context: true,
|
||||||
sink: true,
|
sink: true,
|
||||||
args: true,
|
reads: vec!["rcx", "r8", "stack0"],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(ctx.needs(), "pulse-context");
|
assert_eq!(ctx.needs(), "pulse-context");
|
||||||
let other = ShimReads {
|
let other = ShimReads {
|
||||||
other: true,
|
other: true,
|
||||||
sink: true,
|
sink: true,
|
||||||
args: true,
|
reads: vec!["rdi", "r8", "stack0"],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(other.needs(), "other-slots");
|
assert_eq!(other.needs(), "other-slots");
|
||||||
let sink = ShimReads {
|
let sink = ShimReads {
|
||||||
sink: true,
|
sink: true,
|
||||||
args: true,
|
reads: vec!["r8", "stack0"],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(sink.needs(), "output-sink");
|
assert_eq!(sink.needs(), "output-sink");
|
||||||
// The callable tier: the argument array and nothing else.
|
// The callable tier: the argument array and nothing else.
|
||||||
let only = ShimReads {
|
let only = ShimReads {
|
||||||
args: true,
|
reads: vec!["r8"],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(only.needs(), "args-only");
|
assert_eq!(only.needs(), "args-only");
|
||||||
|
|
@ -829,6 +812,24 @@ mod tests {
|
||||||
assert_eq!(ShimReads::default().needs(), "args-only");
|
assert_eq!(ShimReads::default().needs(), "args-only");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reading_the_argument_array_leaves_a_shim_host_callable() {
|
||||||
|
// Asserted against the shipped rule rather than a copy of it. `r8` is the argument array the
|
||||||
|
// CALLER builds, so a read of it must impose nothing; the arm exists only to keep it out of the
|
||||||
|
// catch-all, where it would mark every ordinary binding uncallable at once.
|
||||||
|
let mut r8 = ShimReads::default();
|
||||||
|
slot_need(Register::R8, &mut r8);
|
||||||
|
assert_eq!(r8.needs(), "args-only");
|
||||||
|
let mut rcx = ShimReads::default();
|
||||||
|
slot_need(Register::RCX, &mut rcx);
|
||||||
|
assert_eq!(rcx.needs(), "pulse-context");
|
||||||
|
for r in [Register::RDI, Register::RSI, Register::RDX, Register::R9] {
|
||||||
|
let mut o = ShimReads::default();
|
||||||
|
slot_need(r, &mut o);
|
||||||
|
assert_eq!(o.needs(), "other-slots", "{r:?} is a slot a host must fill");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pval_void_is_negative_one_and_still_a_type() {
|
fn pval_void_is_negative_one_and_still_a_type() {
|
||||||
assert!(valid_pval(0)); // PVAL_BOOL
|
assert!(valid_pval(0)); // PVAL_BOOL
|
||||||
|
|
|
||||||
|
|
@ -188,9 +188,6 @@ fn xmm(r: Register) -> Option<u8> {
|
||||||
.filter(|i| *i < 16)
|
.filter(|i| *i < 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers a call clobbers, so a value cannot survive across one and be attributed to the wrong record.
|
|
||||||
const CLOBBER: [usize; 9] = [0, 1, 2, 6, 7, 8, 9, 10, 11];
|
|
||||||
|
|
||||||
/// A field of a particular record: which record, and the displacement within it.
|
/// A field of a particular record: which record, and the displacement within it.
|
||||||
type Slot = (u32, i64);
|
type Slot = (u32, i64);
|
||||||
|
|
||||||
|
|
@ -278,7 +275,10 @@ pub fn vscript_functions(img: &CodeImage) -> Vec<VScriptFunc> {
|
||||||
dec.decode_out(&mut insn);
|
dec.decode_out(&mut insn);
|
||||||
|
|
||||||
if insn.flow_control() == FlowControl::Call {
|
if insn.flow_control() == FlowControl::Call {
|
||||||
for c in CLOBBER {
|
// Registers a call clobbers, so a value cannot survive one and be attributed to the wrong
|
||||||
|
// record. Taken from `abi`, not transcribed as raw GPR indices — a second copy of a fixed
|
||||||
|
// SysV fact is a copy that can drift.
|
||||||
|
for c in crate::abi::caller_saved_slots() {
|
||||||
val[c] = V::Unknown;
|
val[c] = V::Unknown;
|
||||||
scaled[c] = false;
|
scaled[c] = false;
|
||||||
recid[c] = None;
|
recid[c] = None;
|
||||||
|
|
|
||||||
45
src/xref.rs
45
src/xref.rs
|
|
@ -13,13 +13,12 @@
|
||||||
//! the next avoids the misalignment a blind section-wide linear sweep suffers on data/padding.
|
//! the next avoids the misalignment a blind section-wide linear sweep suffers on data/padding.
|
||||||
|
|
||||||
use crate::elf::CodeImage;
|
use crate::elf::CodeImage;
|
||||||
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, OpKind};
|
use iced_x86::{Decoder, DecoderOptions, Instruction, OpKind};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub struct XrefIndex {
|
pub struct XrefIndex {
|
||||||
entries: Vec<u64>, // sorted, de-duped function entry addresses
|
entries: Vec<u64>, // sorted, de-duped function entry addresses
|
||||||
refs: HashMap<u64, Vec<u64>>, // referenced VA -> source instruction VAs
|
refs: HashMap<u64, Vec<u64>>, // referenced VA -> source instruction VAs
|
||||||
call_targets: Vec<u64>, // sorted, de-duped near-call targets
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl XrefIndex {
|
impl XrefIndex {
|
||||||
|
|
@ -30,36 +29,29 @@ impl XrefIndex {
|
||||||
|
|
||||||
// Disassemble each function's [start, next) range independently across threads — this is the
|
// Disassemble each function's [start, next) range independently across threads — this is the
|
||||||
// single biggest decode in the tool and the ranges vary wildly in size, so the atomic work
|
// single biggest decode in the tool and the ranges vary wildly in size, so the atomic work
|
||||||
// scheduler load-balances them. Each task returns its (ref-pair, call-target) deltas; merging
|
// scheduler load-balances them. Each task returns its ref-pair deltas; merging them in entry
|
||||||
// them in entry order (parallel_map preserves input order) reproduces the serial build
|
// order (parallel_map preserves input order) reproduces the serial build byte-for-byte, because
|
||||||
// byte-for-byte: refs[t] receives its srcs in the same (ascending entry, then instruction)
|
// refs[t] receives its srcs in the same (ascending entry, then instruction) order.
|
||||||
// order and call_targets is sorted afterwards.
|
|
||||||
type EntryData = (Vec<(u64, u64)>, Vec<u64>);
|
|
||||||
let idxs: Vec<usize> = (0..entries.len()).collect();
|
let idxs: Vec<usize> = (0..entries.len()).collect();
|
||||||
let per_entry: Vec<EntryData> =
|
let per_entry: Vec<Vec<(u64, u64)>> =
|
||||||
crate::par::parallel_map(&idxs, crate::par::default_threads(None), |&i| {
|
crate::par::parallel_map(&idxs, crate::par::default_threads(None), |&i| {
|
||||||
let start = entries[i];
|
let start = entries[i];
|
||||||
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
|
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
|
||||||
let Some(code) = img.code_range(start, end) else {
|
let Some(code) = img.code_range(start, end) else {
|
||||||
return (Vec::new(), Vec::new());
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let mut ref_pairs: Vec<(u64, u64)> = Vec::new();
|
let mut ref_pairs: Vec<(u64, u64)> = Vec::new();
|
||||||
let mut call_targets: Vec<u64> = Vec::new();
|
|
||||||
let mut insn = Instruction::default();
|
let mut insn = Instruction::default();
|
||||||
let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE);
|
let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE);
|
||||||
while dec.can_decode() {
|
while dec.can_decode() {
|
||||||
dec.decode_out(&mut insn);
|
dec.decode_out(&mut insn);
|
||||||
let src = insn.ip();
|
let src = insn.ip();
|
||||||
// Near call/jmp: the target is code; call targets double as function entries.
|
// Near call/jmp: the target is code.
|
||||||
if matches!(
|
if matches!(
|
||||||
insn.op0_kind(),
|
insn.op0_kind(),
|
||||||
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
||||||
) {
|
) {
|
||||||
let t = insn.near_branch_target();
|
ref_pairs.push((insn.near_branch_target(), src));
|
||||||
ref_pairs.push((t, src));
|
|
||||||
if insn.flow_control() == FlowControl::Call {
|
|
||||||
call_targets.push(t);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// RIP-relative memory operand: a reference to a string / global / code pointer.
|
// RIP-relative memory operand: a reference to a string / global / code pointer.
|
||||||
if insn.is_ip_rel_memory_operand() {
|
if insn.is_ip_rel_memory_operand() {
|
||||||
|
|
@ -67,24 +59,16 @@ impl XrefIndex {
|
||||||
ref_pairs.push((t, src));
|
ref_pairs.push((t, src));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(ref_pairs, call_targets)
|
ref_pairs
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut refs: HashMap<u64, Vec<u64>> = HashMap::new();
|
let mut refs: HashMap<u64, Vec<u64>> = HashMap::new();
|
||||||
let mut call_targets = Vec::new();
|
for ref_pairs in per_entry {
|
||||||
for (ref_pairs, cts) in per_entry {
|
|
||||||
for (t, src) in ref_pairs {
|
for (t, src) in ref_pairs {
|
||||||
refs.entry(t).or_default().push(src);
|
refs.entry(t).or_default().push(src);
|
||||||
}
|
}
|
||||||
call_targets.extend(cts);
|
|
||||||
}
|
|
||||||
call_targets.sort_unstable();
|
|
||||||
call_targets.dedup();
|
|
||||||
Self {
|
|
||||||
entries,
|
|
||||||
refs,
|
|
||||||
call_targets,
|
|
||||||
}
|
}
|
||||||
|
Self { entries, refs }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The entry (function start) that contains `va`: the nearest entry at or below `va`.
|
/// The entry (function start) that contains `va`: the nearest entry at or below `va`.
|
||||||
|
|
@ -111,8 +95,11 @@ impl XrefIndex {
|
||||||
fs
|
fs
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn call_targets(&self) -> &[u64] {
|
/// The function entries this index was built over, ascending — the union `locate::function_entries`
|
||||||
&self.call_targets
|
/// computes. Exposed because it is the domain of `containing_func`: a caller enumerating functions
|
||||||
|
/// should read it here rather than recompute the union and risk a different one.
|
||||||
|
pub fn entries(&self) -> &[u64] {
|
||||||
|
&self.entries
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue