ship one record per function: merge the release set, gen reads it, descriptions as doc comments, gates for what was only claimed; v3.0
This commit is contained in:
parent
71ce34edd2
commit
3410a79b6a
28 changed files with 30596 additions and 955 deletions
|
|
@ -1,13 +1,14 @@
|
|||
# source2rosetta-gen
|
||||
|
||||
Render a published [source2rosetta](../../README.md) gamedata release into whatever format your framework
|
||||
reads. `source2rosetta` does the hard part — deriving CS2 / Dota 2 gamedata from the stripped engine and
|
||||
validating it on a live server — and publishes a small set of JSON files per game. `source2rosetta-gen` turns those into
|
||||
CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, or a typed C# SDK, locally, in a second.
|
||||
Render a published [source2rosetta](../../README.md) release into whatever format your framework reads.
|
||||
`source2rosetta` does the hard part — deriving CS2 / Dota 2 gamedata from the stripped engine and
|
||||
validating it on a live server — and publishes **one file per game**, `rosetta-<game>.json`.
|
||||
`source2rosetta-gen` turns that into CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, a
|
||||
typed C# SDK, or the Dota script API, locally, in a second.
|
||||
|
||||
It's deliberately tiny: it links only `source2rosetta-core` (serde + the format emitters) — **no** ELF reader,
|
||||
no disassembler, no ptrace. So a consumer who "just wants the files" downloads one release + this small binary
|
||||
and generates exactly what they need, instead of every format being pre-baked into the release.
|
||||
no disassembler, no ptrace. So a consumer who "just wants the files" downloads one release + this small
|
||||
binary and generates exactly what they need, instead of every format being pre-baked into the release.
|
||||
|
||||
## Get it
|
||||
|
||||
|
|
@ -23,109 +24,140 @@ does **not** build it — use `-p source2rosetta-core` or `--workspace`.)
|
|||
|
||||
## Use it
|
||||
|
||||
Three of the published artifacts are `gen` inputs, one per `--` flag:
|
||||
|
||||
- `gamedata-<game>.json` (`--from`) — the derived gamedata (function signatures + vtable offsets), tiered by confidence.
|
||||
- `netvars-<game>.json` (`--netvars`) — the typed schema (field offsets + runtime types, plus the class base
|
||||
graph and per-type sizes).
|
||||
- `abi-<game>.json` (`--abi`) — declared parameter and return types, each re-judged against the footprint
|
||||
measured in that build. This is what a function TAKES, as opposed to where it is. See
|
||||
[Call shapes](#call-shapes----abi-abi-gamejson).
|
||||
|
||||
`bindings-<game>.json` ships beside them and `gen` does **not** render it — it is not locator data. It is
|
||||
what the binary declares about itself, in five sections: Pulse bindings (display name, description, call
|
||||
policy, and each binding's typed signature), entity-IO inputs and outputs, map-classname → C++ class, and
|
||||
console commands. Plain JSON, readable as-is.
|
||||
|
||||
Then point `gen` at whichever you need and pick a `--format`. Output goes to `--out`, or stdout if omitted.
|
||||
One input, one flag. `--format` says **who the output is for**; `--out` is a **directory**, because most
|
||||
formats write more than one file.
|
||||
|
||||
```sh
|
||||
# CounterStrikeSharp combined gamedata (the default)
|
||||
source2rosetta-gen --from gamedata-cs2.json --format cssharp --out gamedata.json
|
||||
R=https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest
|
||||
curl -fsSLO $R/rosetta-cs2.json
|
||||
|
||||
# Metamod / SourceMod gamedata VDF (one .games.txt)
|
||||
source2rosetta-gen --from gamedata-cs2.json --format metamod --out csgo.games.txt
|
||||
# CounterStrikeSharp: the combined gamedata + typed call sites for the same functions
|
||||
source2rosetta-gen --from rosetta-cs2.json --format cssharp --out ./csharp
|
||||
|
||||
# Swiftly / ModSharp / Plugify gamedata
|
||||
source2rosetta-gen --from gamedata-cs2.json --format swiftly --out gamedata.json
|
||||
# Metamod:Source / SourceMod: the gamedata VDF + a C++ prototype header
|
||||
source2rosetta-gen --from rosetta-cs2.json --format metamod --out ./mm
|
||||
|
||||
# Typed C# SDK from the schema — one `static class` per engine class, `const` field offsets + types
|
||||
source2rosetta-gen --netvars netvars-cs2.json --format cs-sdk --out Schema.cs
|
||||
# A typed C# SDK from the schema — one `static class` per engine class, `const` offsets + types
|
||||
source2rosetta-gen --from rosetta-cs2.json --format cs-sdk --out ./sdk
|
||||
|
||||
# Flat netvar offset map (class -> field -> offset)
|
||||
source2rosetta-gen --netvars netvars-cs2.json --format netvars --out netvars.json
|
||||
# The Dota script API: ModDota's dota-data shape AND the TypeScript declarations
|
||||
source2rosetta-gen --from rosetta-dota2.json --format moddota --out ./dota
|
||||
```
|
||||
|
||||
Each run prints what it wrote.
|
||||
|
||||
## Formats
|
||||
|
||||
| `--format` | needs | output |
|
||||
**A framework gets two files, and it needs both.** The gamedata says *where* a function is; the call sites say
|
||||
*how to call it*. They were separate inputs when the release was four files; one artifact makes them one
|
||||
command.
|
||||
|
||||
| `--format` | writes | notes |
|
||||
|---|---|---|
|
||||
| `cssharp` *(default)* | `--from` | CounterStrikeSharp combined gamedata — **JSONC**: banner comments mean a strict JSON parser will reject it |
|
||||
| `metamod` | `--from` | Metamod:Source / SourceMod gamedata VDF (`.games.txt`) |
|
||||
| `modsharp` | `--from` | ModSharp gamedata JSON |
|
||||
| `swiftly` | `--from` | Swiftly gamedata JSON — **signature entries only**; vtable-offset entries are omitted, because that framework takes offsets through a separate file |
|
||||
| `plugify` | `--from` | Plugify gamedata JSON |
|
||||
| `model` | `--from` | the selected tiers flattened to one name → locator map (format-neutral; not a re-readable monolith) |
|
||||
| `cs-sdk` | `--netvars` | typed C# SDK — `static class` per schema class, `const int` field offsets tagged with their type |
|
||||
| `netvars` | `--netvars` | flat schema map, `{ class: { field: offset } }` |
|
||||
| `cssharp` *(default)* | `gamedata.json` + `RosettaFunctions.cs` | the gamedata is **JSONC** — banner comments, so a strict JSON parser will reject it. The `.cs` is `MemoryFunction*` fields / `VirtualFunction*` factories |
|
||||
| `metamod` | `<game_key>.games.txt` + `rosetta_prototypes.h` | the VDF also covers SourceMod. Metamod plugins are C++, so the prototypes are a header of `using X_t = RET (*)(…)` plus an `X_vtidx` constant per slot |
|
||||
| `modsharp` | `gamedata.json` + `RosettaCalls.cs` | `[AddressKey]` interface for its Roslyn generator, plus a vtable-dispatch class |
|
||||
| `swiftly` | `gamedata.json` + `prototypes.json` | **signature entries only** in the gamedata; that framework takes offsets through a separate file |
|
||||
| `plugify` | `gamedata.json` + `prototypes.json` | runtime type arrays (`{"paramTypes":["pointer","string"],"retType":"void"}`) |
|
||||
| `cs-sdk` | `Schema.cs` | typed C# SDK: `static class` per schema class, `const int` field offsets tagged with their type, plus the engine's own enums at their real width |
|
||||
| `netvars` | `netvars.json` | flat schema map, `{ class: { field: offset } }` |
|
||||
| `moddota` | `api.json` + `api.d.ts` | the VScript API in ModDota `dota-data`'s shape (their toolchain renders from it), plus TypeScript declarations for authors using the published packages as-is — one `interface` per class, Valve's own description as the doc comment |
|
||||
| `flat` | `gamedata-flat.json` | the selected tiers as one name → locator map, format-neutral |
|
||||
|
||||
### Call shapes — `--abi abi-<game>.json`
|
||||
**Every published artifact renders every format on this list.** The releases are always derived against a
|
||||
running server, so nothing here is conditional on how the artifact was made. (If you derive your own, that
|
||||
changes — see [below](#if-you-derived-the-artifact-yourself).)
|
||||
|
||||
The same framework ids, a different input: `--abi` renders **how to call** a function rather than where it
|
||||
is. The input picks the family, so `--abi … --format cssharp` emits typed call sites while
|
||||
`--from … --format cssharp` emits the gamedata those calls resolve through.
|
||||
### Which games a format covers
|
||||
|
||||
All five framework ids work here, exactly as they do for `--from`:
|
||||
**The artifact states its own game** (`meta.game_key`, `csgo` or `dota`) and the output follows it — there is
|
||||
no `--game` flag, because a second place to state one fact is a second place for it to be wrong.
|
||||
|
||||
```sh
|
||||
source2rosetta-gen --abi abi-cs2.json --format cssharp --out RosettaFunctions.cs
|
||||
source2rosetta-gen --abi abi-cs2.json --format metamod --out rosetta_prototypes.h
|
||||
source2rosetta-gen --abi abi-cs2.json --format modsharp --out RosettaCalls.cs
|
||||
source2rosetta-gen --abi abi-cs2.json --format swiftly --out prototypes.json
|
||||
source2rosetta-gen --abi abi-cs2.json --format plugify --out prototypes.json
|
||||
```
|
||||
Two formats are **game-keyed**, and for both the key is the game DIRECTORY the server runs out of, which is
|
||||
what `game_key` already holds:
|
||||
|
||||
| `--format` | output |
|
||||
|---|---|
|
||||
| `cssharp` | C# `MemoryFunction*` fields (signature) / `VirtualFunction*` factories (vtable slot) |
|
||||
| `metamod` | C++ header of `using X_t = RET (*)(…)`, plus an `X_vtidx` constant for a slot — Metamod plugins are C++ and take the **declared** types verbatim |
|
||||
| `modsharp` | C# `[AddressKey]` interface for its Roslyn generator (signature) + a vtable-dispatch class (slot) |
|
||||
| `swiftly` | JSON per-function type descriptors (`{"args":"ppf","ret":"v","call":"address"}`) |
|
||||
| `plugify` | JSON runtime type arrays (`{"paramTypes":["pointer","string","float"],"retType":"void"}`) |
|
||||
- `metamod` writes `Games { <game_key> { … } }`. The consuming plugin looks that section up by the engine's
|
||||
own `GetGameDir()` — see [cs2kz-metamod's reader][kz] — so `dota` is what a Dota 2 plugin will look for.
|
||||
Metamod takes Dota 2 as a first-class SDK target ([`dota.json`][mm], `define: DOTA`, `source2: true`).
|
||||
- `plugify` writes `{ "<game_key>": { … } }`, matched against the `S2SDK_GAME_NAME` its s2sdk plugin was
|
||||
BUILT with (default `csgo`).
|
||||
|
||||
Source for the two C# targets and for C++ because their type lists are **compile-time**; data for Swiftly
|
||||
and Plugify because theirs are resolved at runtime.
|
||||
The rest are game-neutral in shape: `modsharp` and `cssharp` carry no game key at all (flat, keyed only by
|
||||
platform), and `flat` / `cs-sdk` / `netvars` / `moddota` are plain data.
|
||||
|
||||
**The two locator forms are not interchangeable, and every output distinguishes them.** A signature
|
||||
resolves to one address; a vtable slot is entered through the object, so the framework reaches it by a
|
||||
different call entirely — `VirtualFunctionVoid(instance, slot)` rather than `GameData.GetSignature(key)`,
|
||||
`GetVFuncIndex` rather than `GetAddress`, `(*(void***)self)[idx]` rather than a scanned pointer. Roughly
|
||||
a quarter of a LIVE-derived manifest's call sites are vtable-located, so binding them all through the
|
||||
signature path would look up keys that live in the gamedata's `offsets` section and never in its
|
||||
`signatures` one.
|
||||
**Two consumers cannot run on Dota 2 at all**, and `gen` declines rather than write a file that can never
|
||||
load: CounterStrikeSharp resolves its binaries out of `<dir>/csgo/bin/`, and Swiftly initialises against the
|
||||
`csgo` game directory. `--force` renders anyway. It is a warning rather than a rule on purpose — that is a
|
||||
claim about somebody else's project, read out of their source at one point in time, and projects add games.
|
||||
|
||||
An **offline**-derived manifest emits none through the vtable path at all: a slot is recorded only once
|
||||
live validation has confirmed it is really a vtable slot and not a carried member offset, so an offline
|
||||
run states no slot rather than guess one. Same artifact shape, fewer vtable call sites — worth knowing
|
||||
before diffing two manifests produced different ways.
|
||||
**ModSharp is CS2-first but not excluded.** Its own paths are hardcoded (`../../csgo/steam.inf`), yet its
|
||||
gamedata carries no game key whatsoever — flat `Addresses` / `VFuncs`, platform-keyed — so the file rendered
|
||||
here is the same one whatever game the build targets.
|
||||
|
||||
**The receiver is always in the type list.** Where the declaration came from an Itanium-mangled symbol
|
||||
`this` is invisible, so it is prepended, spelled from the function's own class (`CBaseEntity*`, not
|
||||
`void*`) and marked `[this]` in the C++ header. It is a real register in the call frame — leaving it out
|
||||
shifts every argument by one.
|
||||
[kz]: https://github.com/KZGlobalTeam/cs2kz-metamod/blob/dev/src/utils/gameconfig.cpp
|
||||
[mm]: https://github.com/alliedmodders/hl2sdk-manifests/blob/master/manifests/dota.json
|
||||
|
||||
Only functions the deriver could stand behind are emitted: `status: verified` **or `lower-bound`** (the
|
||||
declaration passes registers the callee never reads and contradicts it in none — safe to call, and
|
||||
marked as such in every output), a receiver settled by evidence (the declaration names it, a
|
||||
live-validated vtable slot proves it, or the measurement independently agrees), and every parameter
|
||||
mappable onto an ABI class. A function whose return **nobody
|
||||
declared** is still emitted — otherwise Dota would lose 2,304 of its 3,732 call sites — but it is marked as such in every
|
||||
output (prose in the generated source, `ret_declared` / `retDeclared` in the data), and the value is
|
||||
documented as the raw return register rather than a typed result.
|
||||
### Descriptions
|
||||
|
||||
**Every call site is emitted with a sentence saying what the function is FOR**, wherever that target's
|
||||
readers hover: a C# XML `<summary>`, so IntelliSense shows it; a comment above the C++ typedef; a
|
||||
`description` field in the data formats. The prototype keeps a home of its own — a `<remarks>` in C#, the
|
||||
identity line in the header — so nothing is lost to make room. That is every emittable call site: 2,073 on
|
||||
CS2, 2,326 on Dota.
|
||||
|
||||
**Each one says whose sentence it is, and that is not decoration.** Some are Valve's own, read out of a
|
||||
registry in the binary (717 CS2 / 774 Dota); the rest are this project's reading of the build, and the two
|
||||
carry very different weight. So every generated source prints the origin beside the text, the data formats
|
||||
carry it as an id (`valve` / `derived` / `generated`), and `moddota` — which deliberately mirrors a shape the
|
||||
Dota ecosystem already publishes — keeps ours under keys of our own name rather than in the `description`
|
||||
field their toolchain renders as Valve's word.
|
||||
|
||||
**Valve's text always wins.** Where the binary documents a function, that is what ships; a generated
|
||||
description only ever fills a gap, so the two can never disagree in an output. Between Valve's own two
|
||||
registries the script one wins: a console registration's help text documents the COMMAND an operator types,
|
||||
while a script binding documents the function. In the `.d.ts`, a member Valve documents reads exactly as it
|
||||
did before — bare, the way the published types do — and only a gap Valve left is filled and marked.
|
||||
|
||||
`cs-sdk` and `netvars` render the schema, which is classes and field offsets. There are no functions in them
|
||||
to describe, so they carry none.
|
||||
|
||||
### What the call sites will and won't emit
|
||||
|
||||
Only functions the deriver could stand behind: `status: verified` **or `lower-bound`** (the declaration passes
|
||||
registers the callee never reads and contradicts it in none — safe to call, and marked as such in every
|
||||
output), a receiver settled by evidence (the declaration names it, a live-validated vtable slot proves it, or
|
||||
the measurement independently agrees), and every parameter mappable onto an ABI class. A function whose return
|
||||
**nobody declared** is still emitted — otherwise Dota would lose 2,304 of its 3,732 call sites — but it is
|
||||
marked as such in every output (`ret_declared` / `retDeclared` in the data formats, prose in the generated
|
||||
source), and the value is documented as the raw return register rather than a typed result.
|
||||
|
||||
**The two locator forms are not interchangeable, and every output distinguishes them.** A signature resolves to
|
||||
one address; a vtable slot is entered through the object, so the framework reaches it by a different call
|
||||
entirely — `VirtualFunctionVoid(instance, slot)` rather than `GameData.GetSignature(key)`, `GetVFuncIndex`
|
||||
rather than `GetAddress`, `(*(void***)self)[idx]` rather than a scanned pointer. Roughly a quarter of the
|
||||
call sites are vtable-located.
|
||||
|
||||
**A few entries carry BOTH**, which is worth knowing if you consume the gamedata rather than these call
|
||||
sites: `model::Entry` allows it and five CS2 `core` entries use it. Both locators are live-validated
|
||||
independently, and `validated: true` means both passed — an entry whose signature checked out but whose slot
|
||||
could not be reached ships `null`, never `true`. The call-site emitters here pick one form per function, so
|
||||
this only affects what you read out of the artifact directly.
|
||||
|
||||
**The receiver is always in the type list.** Where the declaration came from an Itanium-mangled symbol `this`
|
||||
is invisible, so it is prepended, spelled from the function's own class (`CBaseEntity*`, not `void*`) and
|
||||
marked `[this]` in the C++ header. It is a real register in the call frame — leaving it out shifts every
|
||||
argument by one.
|
||||
|
||||
**Two things `moddota` states honestly rather than guesses.** Parameters are declared `...args: any[]`,
|
||||
because the registry does not carry them — types appear only inside Valve's prose descriptions, inconsistently,
|
||||
in about a fifth of entries. It is visibly ugly on purpose: nobody should mistake these for complete
|
||||
declarations, and inventing plausible arity would emit declarations that lie rather than abstain. And
|
||||
`available` is always `server`, because a dedicated server never maps `libclient`, so this derivation cannot
|
||||
see the client side at all.
|
||||
|
||||
## Confidence tier
|
||||
|
||||
The gamedata formats (the `--from` ones) take a `--tier`, cumulative and defaulting to `high_confidence`:
|
||||
The locator half takes a `--tier`, cumulative and defaulting to `high_confidence`:
|
||||
|
||||
| `--tier` | includes |
|
||||
|---|---|
|
||||
|
|
@ -135,10 +167,40 @@ The gamedata formats (the `--from` ones) take a `--tier`, cumulative and default
|
|||
|
||||
```sh
|
||||
# only the rock-solid set:
|
||||
source2rosetta-gen --from gamedata-cs2.json --format cssharp --tier core --out gamedata.json
|
||||
source2rosetta-gen --from rosetta-cs2.json --format cssharp --tier core --out ./csharp
|
||||
```
|
||||
|
||||
The schema formats (`cs-sdk`, `netvars`) ignore `--tier`.
|
||||
The schema and script-API formats ignore `--tier`.
|
||||
|
||||
## What is in the artifact that `gen` does not render
|
||||
|
||||
`rosetta-<game>.json` is plain JSON and readable as-is, and it carries more than these formats consume: the
|
||||
Pulse binding registry (typed signatures, call policy, and a callable shim per binding), entity outputs,
|
||||
map classname → C++ class, ConVars with decoded flags, and the declared rows that belong to no function here
|
||||
(`surfaces.unjoined`). Those have no loader to render them into — read them directly. The per-function
|
||||
descriptions are in there too, and they DO reach the outputs, but only for the ~2,000 functions that become
|
||||
call sites; the artifact describes about twice as many. See the [artifacts section](../../README.md#artifacts-schemas--output-formats).
|
||||
|
||||
## If you derived the artifact yourself
|
||||
|
||||
Skip this if you downloaded the artifact — it is about `source2rosetta produce`, not about `gen`.
|
||||
|
||||
The releases are derived against a **running server**, which is where two whole surfaces come from. Run
|
||||
`produce` without `--game-dir` and it derives what it can offline, honestly, and states the rest as absent —
|
||||
so `gen` gets an artifact that is real but smaller, and three things follow:
|
||||
|
||||
- **`cs-sdk`, `netvars` and `moddota` have nothing to render**, and say so rather than writing an empty file.
|
||||
The schema's field TYPES are runtime-resolved, and a VScript binding's owning class is reached through a
|
||||
register loaded from memory, so neither is readable from the file alone. All three group by one or the
|
||||
other.
|
||||
- **No call site is reached through a vtable.** A slot is only recorded once live validation has confirmed it
|
||||
is really a vtable slot and not a carried member offset, so an offline run states no slot rather than guess
|
||||
one. Roughly a quarter of a live manifest's call sites are vtable-located; an offline one has none. Same
|
||||
shape, fewer entries — worth knowing before diffing two outputs made different ways.
|
||||
- **Nothing has been checked against a running server.** `validated` is `null` on every record rather than a
|
||||
verdict, and the locator half drops only what live validation confidently REJECTED — so an offline artifact
|
||||
keeps entries a live one would have thrown out. The tiers still mean what they mean; they just have not
|
||||
been tested.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,107 +1,211 @@
|
|||
//! `source2rosetta-gen` — the standalone generator. Reads the published monolith (`gamedata-<game>.json`
|
||||
//! from `source2rosetta produce`) and renders it into any framework's gamedata format at a chosen confidence
|
||||
//! tier.
|
||||
//! `source2rosetta-gen` — the standalone generator. Reads the published `rosetta-<game>.json` and writes
|
||||
//! the files one consumer needs: a framework's gamedata plus the typed call sites that resolve through it,
|
||||
//! a typed schema SDK, or the script API the Dota ecosystem publishes.
|
||||
//!
|
||||
//! It touches only the `model` + `render` layers — no ELF reader, no ptrace, no disassembler — so a consumer
|
||||
//! who "just wants the files" downloads one monolith + this small, rarely-changing binary and generates
|
||||
//! who "just wants the files" downloads one artifact and this small, rarely-changing binary and generates
|
||||
//! whatever their framework needs locally, instead of every format being pre-baked into releases. It lives in
|
||||
//! the `source2rosetta-core` crate (serde-only), so it stays genuinely lean.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser;
|
||||
use source2rosetta_core::{model, render};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "source2rosetta-gen",
|
||||
version,
|
||||
about = "Render a source2rosetta monolith into a framework gamedata format"
|
||||
about = "Render a source2rosetta release into the files your framework reads"
|
||||
)]
|
||||
struct Cli {
|
||||
/// The monolith `gamedata-<game>.json` (for a GAMEDATA --format: cssharp/metamod/modsharp/swiftly/plugify/model).
|
||||
/// The published `rosetta-<game>.json` — one artifact holding every surface.
|
||||
#[arg(long)]
|
||||
from: Option<PathBuf>,
|
||||
/// The typed `netvars-<game>.json` (for a SCHEMA --format: cs-sdk/netvars).
|
||||
#[arg(long)]
|
||||
netvars: Option<PathBuf>,
|
||||
/// The prototype manifest `abi-<game>.json` — renders CALL SHAPES (declared parameter and return
|
||||
/// types, verified against the build) instead of locators. Reuses the framework --format ids: the
|
||||
/// INPUT chooses what is rendered, so `--abi … --format cssharp` emits typed call sites while
|
||||
/// `--from … --format cssharp` emits the gamedata those calls resolve through.
|
||||
#[arg(long)]
|
||||
abi: Option<PathBuf>,
|
||||
/// Output format. GAMEDATA (needs --from): cssharp | metamod | modsharp | swiftly | plugify | model.
|
||||
/// SCHEMA (needs --netvars): cs-sdk (typed C# SDK) | netvars (flat offset map). ABI (needs --abi):
|
||||
/// cssharp | metamod | modsharp | swiftly | plugify. cssharp = the `//`-bannered CS# combined file;
|
||||
/// the metamod gamedata format also covers SourceMod (the VDF `.games.txt`), while its ABI format is
|
||||
/// a C++ header, because Metamod plugins are C++ and declare prototypes in source.
|
||||
from: PathBuf,
|
||||
/// Who the output is for, and it writes every file that consumer reads. FRAMEWORKS get a gamedata
|
||||
/// file and the typed call sites that resolve through it: cssharp | metamod (also SourceMod's VDF) |
|
||||
/// modsharp | swiftly | plugify. SCHEMA: cs-sdk (typed C# SDK) | netvars (flat offset map). SCRIPT
|
||||
/// API: moddota, which writes both shapes that ecosystem publishes (`api.json` + `api.d.ts`). Plus
|
||||
/// `flat`, a format-neutral name -> locator map.
|
||||
#[arg(long, default_value = "cssharp")]
|
||||
format: String,
|
||||
/// Confidence tier for a gamedata format (cumulative): core | high_confidence | experimental. Defaults to
|
||||
/// `high_confidence` (core + the promoted names). Ignored by schema formats.
|
||||
/// Confidence tier for the locator half, cumulative: core | high_confidence | experimental. Defaults to
|
||||
/// `high_confidence` (core + the promoted names). Ignored by the schema and script-API formats.
|
||||
#[arg(long, default_value = "high_confidence")]
|
||||
tier: String,
|
||||
/// Write here (default: stdout).
|
||||
/// Directory to write into (default: the working directory). A format writes more than one file, so
|
||||
/// this names a DIRECTORY rather than a file — the names are the ones each framework expects.
|
||||
#[arg(long, default_value = ".")]
|
||||
out: PathBuf,
|
||||
/// Render even where the consumer is not known to run on this artifact's game. What that claim rests
|
||||
/// on is in `render::GAME_SUPPORT`; it is read out of somebody else's source and they do add games,
|
||||
/// so it declines by default rather than refusing outright.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
force: bool,
|
||||
}
|
||||
|
||||
/// One file to write: the name the consuming framework expects, and what goes in it.
|
||||
struct Out {
|
||||
name: String,
|
||||
text: String,
|
||||
/// What this file is, for the line printed after writing it.
|
||||
what: &'static str,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let fmt = cli.format.as_str();
|
||||
|
||||
// The INPUT selects the emitter family, which is why `cssharp` can name three different outputs.
|
||||
let text = if let Some(path) = cli.abi.as_ref() {
|
||||
let man: model::AbiManifest = serde_json::from_str(&std::fs::read_to_string(path)?)
|
||||
.with_context(|| format!("parse abi manifest json {}", path.display()))?;
|
||||
let e = render::abi_by_id(fmt).with_context(|| {
|
||||
format!(
|
||||
"unknown ABI --format `{fmt}` (known: {})",
|
||||
render::ABI_FORMAT_IDS.join(" | ")
|
||||
)
|
||||
})?;
|
||||
e.render(&man)
|
||||
} else if render::SCHEMA_FORMAT_IDS.contains(&fmt) {
|
||||
// schema formats render the typed netvars (class -> field -> offset/type), not the gamedata monolith.
|
||||
let path = cli.netvars.as_ref().context(
|
||||
"a schema --format (cs-sdk | netvars) requires --netvars <netvars-<game>.json>",
|
||||
)?;
|
||||
let schema: model::Schema = serde_json::from_str(&std::fs::read_to_string(path)?)
|
||||
.with_context(|| format!("parse netvars json {}", path.display()))?;
|
||||
render::schema_by_id(fmt)
|
||||
.expect("known schema format")
|
||||
.render(&schema)
|
||||
} else {
|
||||
let path = cli
|
||||
.from
|
||||
.as_ref()
|
||||
.context("a gamedata --format requires --from <gamedata-<game>.json>")?;
|
||||
let tier = model::TierSelect::from_id(&cli.tier).with_context(|| {
|
||||
format!(
|
||||
"unknown --tier {:?} (want one of: {})",
|
||||
cli.tier,
|
||||
model::TIER_IDS.join(" | ")
|
||||
)
|
||||
})?;
|
||||
let mono: model::Monolith = serde_json::from_str(&std::fs::read_to_string(path)?)
|
||||
.with_context(|| format!("parse monolith json {}", path.display()))?;
|
||||
match fmt {
|
||||
// cssharp is the bannered combined file (guaranteed + extrapolated sections), not a flat map.
|
||||
"cssharp" => render::render_monolith_cssharp(&mono, tier),
|
||||
f @ ("metamod" | "modsharp" | "swiftly" | "plugify" | "model") => render::by_id(f)
|
||||
.expect("known flat format")
|
||||
.render(&mono.select(tier)),
|
||||
other => bail!(
|
||||
"unknown --format {other:?} (gamedata: cssharp|metamod|modsharp|swiftly|plugify|model; \
|
||||
schema: cs-sdk|netvars)"
|
||||
),
|
||||
let text = std::fs::read_to_string(&cli.from)
|
||||
.with_context(|| format!("read {}", cli.from.display()))?;
|
||||
let r: model::Rosetta = serde_json::from_str(&text)
|
||||
.with_context(|| format!("parse {} as a rosetta artifact", cli.from.display()))?;
|
||||
let tier = model::TierSelect::from_id(&cli.tier).with_context(|| {
|
||||
format!(
|
||||
"unknown --tier {:?} (want one of: {})",
|
||||
cli.tier,
|
||||
model::TIER_IDS.join(" | ")
|
||||
)
|
||||
})?;
|
||||
|
||||
// Checked before anything is rendered: a file that cannot load on the game it was made for is worse
|
||||
// than no file, and the one thing worse than that is one written silently.
|
||||
if let Some(mismatch) = render::game_mismatch(fmt, &r.meta.game_key) {
|
||||
if !cli.force {
|
||||
bail!("{mismatch}\n Pass --force to render it anyway.");
|
||||
}
|
||||
eprintln!("warning: {mismatch}\n Rendering anyway (--force).");
|
||||
}
|
||||
|
||||
let outputs = match fmt {
|
||||
// A framework gets the pair: WHERE the functions are, and HOW to call them. They were two
|
||||
// invocations against two files; one artifact makes them one command, and a consumer who has the
|
||||
// locators without the call sites has half of what it takes to make a call.
|
||||
f @ ("cssharp" | "metamod" | "modsharp" | "swiftly" | "plugify") => {
|
||||
let gd = match f {
|
||||
// cssharp is the bannered combined file (guaranteed + extrapolated sections), not a flat map.
|
||||
"cssharp" => render::render_monolith_cssharp(&r.to_monolith(), tier),
|
||||
_ => render::by_id(f)
|
||||
.expect("known flat format")
|
||||
.render(&r.gamedata(tier)),
|
||||
};
|
||||
let calls = render::abi_by_id(f)
|
||||
.expect("known abi format")
|
||||
.render(&r.abi_manifest());
|
||||
let (gd_name, calls_name) = match f {
|
||||
"cssharp" => ("gamedata.json".into(), "RosettaFunctions.cs"),
|
||||
"metamod" => (
|
||||
format!("{}.games.txt", r.meta.game_key),
|
||||
"rosetta_prototypes.h",
|
||||
),
|
||||
"modsharp" => ("gamedata.json".into(), "RosettaCalls.cs"),
|
||||
_ => ("gamedata.json".into(), "prototypes.json"),
|
||||
};
|
||||
vec![
|
||||
Out {
|
||||
name: gd_name,
|
||||
text: gd,
|
||||
what: "locators",
|
||||
},
|
||||
Out {
|
||||
name: calls_name.into(),
|
||||
text: calls,
|
||||
what: "typed call sites",
|
||||
},
|
||||
]
|
||||
}
|
||||
"flat" => vec![Out {
|
||||
name: "gamedata-flat.json".into(),
|
||||
text: render::by_id("model")
|
||||
.expect("known flat format")
|
||||
.render(&r.gamedata(tier)),
|
||||
what: "locators, format-neutral",
|
||||
}],
|
||||
f @ ("cs-sdk" | "netvars") => {
|
||||
// Field types are runtime-resolved, so an offline build states no schema at all. Saying so is
|
||||
// better than writing an empty SDK that compiles and describes nothing.
|
||||
let schema = r.typed_schema().context(
|
||||
"this artifact's `schema` is null, so the schema formats have nothing to render — field \
|
||||
TYPES are resolved at runtime and are not in the file. Every PUBLISHED artifact carries \
|
||||
one, so this is a local OFFLINE derive: re-run `produce` with --game-dir, or take the \
|
||||
artifact from a release.",
|
||||
)?;
|
||||
let text = render::schema_by_id(f)
|
||||
.expect("known schema format")
|
||||
.render(&schema);
|
||||
vec![Out {
|
||||
name: if f == "cs-sdk" {
|
||||
"Schema.cs".into()
|
||||
} else {
|
||||
"netvars.json".into()
|
||||
},
|
||||
text,
|
||||
what: "typed schema",
|
||||
}]
|
||||
}
|
||||
// One consumer, two files, for the same reason a framework gets two: the ModDota ecosystem's
|
||||
// toolchain renders from the JSON, while an author working against the published packages reads
|
||||
// the declarations. Emitting one of them is answering half the question.
|
||||
"moddota" => {
|
||||
let vscript = r.vscript();
|
||||
// Both shapes group members by owning class, and the class is only readable from a running
|
||||
// server — so an offline artifact renders nothing, and an empty file would look like an
|
||||
// answer rather than a missing input. The two ways of having nothing to render are worth
|
||||
// telling apart: a game with no script VM at all is not a build that was run offline.
|
||||
if vscript.is_empty() {
|
||||
bail!(
|
||||
"this artifact carries no VScript bindings at all, so there is no script API to \
|
||||
render. Only Dota 2 and CS2 expose one — check you passed the right artifact."
|
||||
);
|
||||
}
|
||||
if vscript.iter().all(|v| v.class.is_none()) {
|
||||
bail!(
|
||||
"this artifact has {} VScript bindings and no owning class on any of them, and both \
|
||||
shapes group members BY class. The owning class is only readable from a running \
|
||||
server, and every PUBLISHED artifact carries it, so this is a local OFFLINE derive: \
|
||||
re-run `produce` with --game-dir, or take the artifact from a release.",
|
||||
vscript.len()
|
||||
);
|
||||
}
|
||||
let schema = r.typed_schema();
|
||||
let render_as = |id: &str| {
|
||||
render::bindings_by_id(id)
|
||||
.expect("known bindings format")
|
||||
.render(&vscript, schema.as_ref())
|
||||
};
|
||||
vec![
|
||||
Out {
|
||||
name: "api.json".into(),
|
||||
text: render_as("api-json"),
|
||||
what: "script API, ModDota `dota-data` shape",
|
||||
},
|
||||
Out {
|
||||
name: "api.d.ts".into(),
|
||||
text: render_as("dts"),
|
||||
what: "script API, TypeScript declarations",
|
||||
},
|
||||
]
|
||||
}
|
||||
other => bail!(
|
||||
"unknown --format {other:?} (want one of: {})",
|
||||
render::FORMAT_IDS.join(" | "),
|
||||
),
|
||||
};
|
||||
|
||||
match cli.out {
|
||||
Some(p) => std::fs::write(&p, text).with_context(|| format!("write {}", p.display()))?,
|
||||
None => println!("{text}"),
|
||||
write_all(&cli.out, &outputs)
|
||||
}
|
||||
|
||||
fn write_all(dir: &Path, outputs: &[Out]) -> Result<()> {
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
for o in outputs {
|
||||
let p = dir.join(&o.name);
|
||||
std::fs::write(&p, &o.text).with_context(|| format!("write {}", p.display()))?;
|
||||
println!(
|
||||
"{} ({}, {} KB)",
|
||||
p.display(),
|
||||
o.what,
|
||||
o.text.len().div_ceil(1024)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,8 +3,11 @@
|
|||
//! derivation never changes. This module depends only on `model` + serde, NOT on the deriver, so a
|
||||
//! standalone `source2rosetta-gen` binary can link just this to turn a published model JSON into files.
|
||||
|
||||
use crate::model::{Entry, Gamedata, MonoEntry, Monolith, Schema, Tier, TierSelect};
|
||||
use crate::model::{
|
||||
self, Entry, Gamedata, MonoEntry, Monolith, Schema, Tier, TierSelect, VScriptBinding, one_line,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Render the monolith as the CounterStrikeSharp combined `gamedata.json`: the guaranteed `core` section, a
|
||||
/// `//` banner, then the extrapolated section (the tiers `select` includes beyond core), both key-sorted with
|
||||
|
|
@ -177,8 +180,9 @@ pub fn by_id(id: &str) -> Option<Box<dyn GamedataEmitter>> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Every `--format` id `by_id` accepts — for help text and error messages (keep in sync with `by_id`).
|
||||
pub const FORMAT_IDS: &[&str] = &[
|
||||
/// Every gamedata emitter id `by_id` accepts (keep in sync with it), named like its three sibling
|
||||
/// families. NOT the `--format` list — see [`FORMAT_IDS`], which names CONSUMERS rather than emitters.
|
||||
pub const GAMEDATA_FORMAT_IDS: &[&str] = &[
|
||||
"cssharp", "metamod", "modsharp", "swiftly", "plugify", "model",
|
||||
];
|
||||
|
||||
|
|
@ -432,10 +436,17 @@ impl SchemaEmitter for CsSdk {
|
|||
let m = &s.meta;
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
// The artifact is named by the GAME TOKEN (`cs2`), which the schema does not carry — it knows
|
||||
// its content key (`csgo`). So the command is spelled generically rather than interpolated
|
||||
// into something that does not exist.
|
||||
"// <auto-generated> source2rosetta — {} build {}. Source-2 SchemaSystem field offsets.\n\
|
||||
// {} classes, {} typed fields, {} enums. Regenerate: source2rosetta-gen --netvars netvars-{}.json --format cs-sdk\n\
|
||||
// {} classes, {} typed fields, {} enums. Regenerate: source2rosetta-gen --from rosetta-<game>.json --format cs-sdk\n\
|
||||
namespace Source2.Schema;\n",
|
||||
m.game_key, m.source_build, s.classes.len(), m.typed, s.enums.len(), m.game_key
|
||||
m.game_key,
|
||||
m.source_build,
|
||||
s.classes.len(),
|
||||
m.typed,
|
||||
s.enums.len()
|
||||
));
|
||||
// Enums first: a field offset is only half the story, and the C# side wants the type in scope
|
||||
// before the classes that reference it. Emitted with the engine's own underlying width, so a
|
||||
|
|
@ -754,6 +765,10 @@ pub struct CallShape<'a> {
|
|||
/// the signatures section at all.
|
||||
pub vtable: Option<i64>,
|
||||
pub provenance: &'a [String],
|
||||
/// What the function is FOR, where anything says — see [`model::FunctionRecord::doc`]. The one
|
||||
/// field here that is not about the call frame, and the reason a generated call site is readable:
|
||||
/// an author hovering `CBaseEntity_Kill` sees a sentence rather than a type list.
|
||||
pub doc: Option<&'a model::Doc>,
|
||||
}
|
||||
|
||||
impl CallShape<'_> {
|
||||
|
|
@ -877,6 +892,7 @@ pub fn callable_shapes(m: &crate::model::AbiManifest) -> Vec<CallShape<'_>> {
|
|||
lower_bound,
|
||||
vtable: e.vtable,
|
||||
provenance: &e.provenance,
|
||||
doc: e.doc.as_ref(),
|
||||
});
|
||||
}
|
||||
out
|
||||
|
|
@ -913,6 +929,24 @@ const LOWER_BOUND: &str = "ARGUMENTS ARE A LOWER BOUND — the callee reads fewe
|
|||
passes, so a trailing argument may be ignored; calling through it is safe, the extra register is \
|
||||
simply unread";
|
||||
|
||||
/// What every emitter says about where a description came from. One wording per source, for the same
|
||||
/// reason as the two constants above: five outputs must not describe one fact differently.
|
||||
///
|
||||
/// The marker is load-bearing, not decoration. Most of a shipped artifact's prose is this project's own
|
||||
/// reading of the build rather than Valve's, and the two carry very different weight — an author acting
|
||||
/// on a sentence has to know which they are reading. An id this does not recognise is printed VERBATIM
|
||||
/// rather than quietly presented as Valve's.
|
||||
fn doc_origin(source: &str) -> String {
|
||||
match source {
|
||||
model::Doc::VALVE => "Valve's own text, read from the registry in the binary".into(),
|
||||
"derived" => {
|
||||
"source2rosetta's, DERIVED — fixed by the surrounding facts, not Valve's".into()
|
||||
}
|
||||
"generated" => "source2rosetta's, GENERATED — a reading of this build, not Valve's".into(),
|
||||
other => format!("source2rosetta's, source `{other}` — not Valve's"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape text destined for a C# XML doc comment.
|
||||
///
|
||||
/// Not cosmetic: the prototypes this carries are full of characters XML reserves. A `CAI_Concept&`
|
||||
|
|
@ -940,6 +974,46 @@ fn source_banner(m: &crate::model::AbiManifest, comment: &str) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
/// The head of the C# XML doc block above one generated call site — everything above the caveats.
|
||||
///
|
||||
/// **The description takes the `<summary>` and the prototype moves down to a `<remarks>`**, because an
|
||||
/// editor shows the summary first and "what does this thing do" is what an author hovering a
|
||||
/// `MemoryFunctionVoid<nint, nint>` is asking. With no description the shape is exactly what it was
|
||||
/// before descriptions existed: the prototype IS the summary, and the block is one line.
|
||||
///
|
||||
/// Shared with ModSharp rather than written twice — the two targets differ in what they generate and
|
||||
/// not in how a function is documented, and two copies of an XML doc block is how they come to disagree.
|
||||
fn cs_doc_head(c: &CallShape) -> String {
|
||||
match c.doc {
|
||||
Some(d) => format!(
|
||||
" /// <summary>{}</summary>\n \
|
||||
/// <remarks><c>{}</c></remarks>\n \
|
||||
/// <remarks>Description: {}.</remarks>",
|
||||
xml(&d.text),
|
||||
xml(&c.prototype()),
|
||||
// Escaped like everything else here: the fixed wordings need none, but an artifact's own
|
||||
// `source` id ends up in this line verbatim, and one `<` in it is a malformed comment.
|
||||
xml(&doc_origin(&d.source)),
|
||||
),
|
||||
None => format!(" /// <summary><c>{}</c></summary>", xml(&c.prototype())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The caveat remarks that follow it: what this artifact will not let a call site pretend about itself.
|
||||
fn cs_doc_caveats(c: &CallShape) -> String {
|
||||
let mut s = String::new();
|
||||
if c.ret == Ret::Undeclared {
|
||||
s.push_str(&format!(
|
||||
"\n /// <remarks>{UNDECLARED_RETURN} (measured class: <c>{}</c>).</remarks>",
|
||||
xml(c.ret_source)
|
||||
));
|
||||
}
|
||||
if c.lower_bound {
|
||||
s.push_str(&format!("\n /// <remarks>{LOWER_BOUND}.</remarks>"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// CounterStrikeSharp: a C# source file of `MemoryFunction*` fields bound to the gamedata key.
|
||||
///
|
||||
/// It has to be SOURCE, not data: the argument list is the generic parameter list of
|
||||
|
|
@ -979,17 +1053,6 @@ impl AbiEmitter for CsSharpAbi {
|
|||
"FunctionWithReturn"
|
||||
}
|
||||
};
|
||||
let mut caveat = if c.ret == Ret::Undeclared {
|
||||
format!(
|
||||
"\n /// <remarks>{UNDECLARED_RETURN} (measured class: <c>{}</c>).</remarks>",
|
||||
xml(c.ret_source)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if c.lower_bound {
|
||||
caveat.push_str(&format!("\n /// <remarks>{LOWER_BOUND}.</remarks>"));
|
||||
}
|
||||
// The two binding forms are not interchangeable. A signature resolves to ONE address and
|
||||
// can be a static field; a vtable slot is per-object — `VirtualFunction*` takes the
|
||||
// instance and reads the slot out of that object's own vtable — so it has to be a factory
|
||||
|
|
@ -1026,11 +1089,10 @@ impl AbiEmitter for CsSharpAbi {
|
|||
None => "signature".to_string(),
|
||||
};
|
||||
s.push_str(&format!(
|
||||
" /// <summary><c>{}</c></summary>\n \
|
||||
/// <remarks>verified · {how} · {}</remarks>{}\n{}",
|
||||
xml(&c.prototype()),
|
||||
"{}\n /// <remarks>verified · {how} · {}</remarks>{}\n{}",
|
||||
cs_doc_head(&c),
|
||||
c.provenance.join(", "),
|
||||
caveat,
|
||||
cs_doc_caveats(&c),
|
||||
binding,
|
||||
));
|
||||
}
|
||||
|
|
@ -1097,6 +1159,16 @@ impl AbiEmitter for MetamodAbi {
|
|||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
// The prose goes ABOVE the identity line, which is where a C++ reader expects a member's
|
||||
// documentation and where a `//` comment is safe: the text is one line by construction
|
||||
// (`model::one_line`), so nothing after it can fall out of the comment and into the header.
|
||||
if let Some(d) = c.doc {
|
||||
s.push_str(&format!(
|
||||
"// {}\n// Description: {}.\n",
|
||||
d.text,
|
||||
doc_origin(&d.source)
|
||||
));
|
||||
}
|
||||
s.push_str(&format!(
|
||||
"// {} · verified · {}{}\nusing {}_t = {} (*)({});\n",
|
||||
c.key,
|
||||
|
|
@ -1141,24 +1213,7 @@ impl AbiEmitter for ModSharpAbi {
|
|||
s.push_str("using Sharp.Shared;\nusing Sharp.Shared.Attributes;\nusing Sharp.Shared.Calls;\n\nnamespace Sharp.Generated;\n\n");
|
||||
|
||||
let shapes = callable_shapes(m);
|
||||
let doc = |c: &CallShape| {
|
||||
let mut caveat = if c.ret == Ret::Undeclared {
|
||||
format!(
|
||||
"\n /// <remarks>{UNDECLARED_RETURN} (measured class: <c>{}</c>).</remarks>",
|
||||
xml(c.ret_source)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if c.lower_bound {
|
||||
caveat.push_str(&format!("\n /// <remarks>{LOWER_BOUND}.</remarks>"));
|
||||
}
|
||||
format!(
|
||||
" /// <summary><c>{}</c></summary>{}\n",
|
||||
xml(&c.prototype()),
|
||||
caveat
|
||||
)
|
||||
};
|
||||
let doc = |c: &CallShape| format!("{}{}\n", cs_doc_head(c), cs_doc_caveats(c));
|
||||
let ret_cs = |c: &CallShape| match c.ret {
|
||||
Ret::Void => "void",
|
||||
Ret::Declared(r) => r.cs(),
|
||||
|
|
@ -1278,6 +1333,11 @@ impl AbiEmitter for SwiftlyAbi {
|
|||
doc.insert(
|
||||
c.key.to_string(),
|
||||
json!({
|
||||
// The prose, and the id saying whose it is — the machine-readable form of the
|
||||
// marker the generated-source targets print in words. A consumer rendering this
|
||||
// into its own docs needs to be able to attribute it.
|
||||
"description": c.doc.map(|d| d.text.clone()),
|
||||
"description_source": c.doc.map(|d| d.source.clone()),
|
||||
"args": c.params.iter().map(|t| t.swiftly()).collect::<String>(),
|
||||
"ret": match c.ret {
|
||||
Ret::Void => 'v',
|
||||
|
|
@ -1338,6 +1398,9 @@ impl AbiEmitter for PlugifyAbi {
|
|||
fns.insert(
|
||||
c.key.to_string(),
|
||||
json!({
|
||||
// As for Swiftly: the text plus whose it is, so a consumer can attribute it.
|
||||
"description": c.doc.map(|d| d.text.clone()),
|
||||
"descriptionSource": c.doc.map(|d| d.source.clone()),
|
||||
"paramTypes": c.params.iter().map(|t| plg(*t)).collect::<Vec<_>>(),
|
||||
"retType": match c.ret {
|
||||
Ret::Void => "void",
|
||||
|
|
@ -1361,6 +1424,294 @@ impl AbiEmitter for PlugifyAbi {
|
|||
}
|
||||
}
|
||||
|
||||
// ============================ VSCRIPT / BINDINGS emitters ============================
|
||||
|
||||
/// An emitter over `bindings-<game>.json`'s VScript section.
|
||||
///
|
||||
/// A separate family from the gamedata, schema and ABI emitters because it renders a different INPUT:
|
||||
/// the script-facing surface, grouped by the class that owns it. Both targets here exist to feed the
|
||||
/// Dota custom-game ecosystem, which already has a toolchain for exactly this shape.
|
||||
pub trait BindingsEmitter {
|
||||
fn id(&self) -> &'static str;
|
||||
/// `schema` supplies the class BASE CHAIN, which the binding registry does not carry. With it, `.d.ts`
|
||||
/// emits `interface X extends Y` the way the ecosystem's published types do; without it, every
|
||||
/// interface is flat but still correct — an offline artifact has no schema, and that is the only case
|
||||
/// where it is absent.
|
||||
fn render(&self, vscript: &[VScriptBinding], schema: Option<&Schema>) -> String;
|
||||
}
|
||||
|
||||
/// The base class to declare an `extends` against, but ONLY when we also emit an interface for it.
|
||||
///
|
||||
/// A dangling `extends` would make the file unusable on its own, and self-contained is the safer default:
|
||||
/// an author merging this beside the ecosystem's packages loses nothing, while an author using it alone
|
||||
/// would otherwise get an unresolved reference. The base graph comes from the typed schema, so it is
|
||||
/// present exactly when the artifact carries a schema, i.e. when it came from a full build.
|
||||
fn vs_base<'a>(
|
||||
cls: &str,
|
||||
schema: Option<&'a Schema>,
|
||||
emitted: &BTreeMap<&str, Vec<&VScriptBinding>>,
|
||||
) -> Option<&'a str> {
|
||||
schema?
|
||||
.bases
|
||||
.get(cls)?
|
||||
.first()
|
||||
.map(|b| b.name.as_str())
|
||||
.filter(|b| emitted.contains_key(b))
|
||||
}
|
||||
|
||||
/// Group the VScript bindings by owning class, dropping the ones that have none.
|
||||
///
|
||||
/// `class` is live-only (see `VScriptBinding::class`), so an OFFLINE build renders nothing here. That is
|
||||
/// the honest outcome rather than a bug: both output formats declare members under their interface, and a
|
||||
/// flat list of function names is not a thing either consumer can use.
|
||||
fn vscript_by_class(vscript: &[VScriptBinding]) -> BTreeMap<&str, Vec<&VScriptBinding>> {
|
||||
let mut out: BTreeMap<&str, Vec<&VScriptBinding>> = BTreeMap::new();
|
||||
for v in vscript {
|
||||
if let Some(c) = v.class.as_deref() {
|
||||
out.entry(c).or_default().push(v);
|
||||
}
|
||||
}
|
||||
for v in out.values_mut() {
|
||||
// By the C++ name as well as the script one: a script name is NOT unique on a class —
|
||||
// `CBodyComponent::SetMaterialGroup` is two different implementations, and `ConnectOutput` is
|
||||
// three — so sorting on the script name alone leaves their order decided by the order the
|
||||
// registry happened to be read in, and the rendered API stops being reproducible.
|
||||
v.sort_by(|a, b| (&a.name, &a.cpp).cmp(&(&b.name, &b.cpp)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One member per script-facing name, preferring the row Valve documented — see the call site.
|
||||
fn dedup_by_script_name<'a>(members: &[&'a VScriptBinding]) -> Vec<&'a VScriptBinding> {
|
||||
let mut out: Vec<&VScriptBinding> = Vec::with_capacity(members.len());
|
||||
for m in members {
|
||||
match out.last_mut() {
|
||||
Some(prev) if prev.name == m.name => {
|
||||
if prev.description.is_empty() && !m.description.is_empty() {
|
||||
*prev = m;
|
||||
}
|
||||
}
|
||||
_ => out.push(m),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Escape text destined for a `/** … */` doc comment.
|
||||
///
|
||||
/// Two hazards, both present in shipped data: a `*/` inside the text ENDS the comment early (3 Dota
|
||||
/// descriptions contain one), and a newline drops the rest of the sentence out of the `*`-continued
|
||||
/// block the format expects (82 do).
|
||||
fn jsdoc(s: &str) -> String {
|
||||
one_line(s).replace("*/", "*\\/")
|
||||
}
|
||||
|
||||
/// The script return type as its TypeScript spelling.
|
||||
///
|
||||
/// Lua has one number type, so every numeric `ScriptDataType_t` collapses to `number` — the distinction
|
||||
/// between `int`, `float` and `uint` is real in the binary and meaningless in the target language, and
|
||||
/// preserving it would produce declarations that do not typecheck against the ecosystem's own types.
|
||||
fn vs_ts_type(ret: Option<&str>) -> &'static str {
|
||||
match ret {
|
||||
Some("void") | None => "void",
|
||||
Some("bool") => "boolean",
|
||||
Some("int") | Some("uint") | Some("float") => "number",
|
||||
Some("string") => "string",
|
||||
Some("Vector") => "Vector",
|
||||
Some("QAngle") => "QAngle",
|
||||
Some("handle") | Some("ehandle") => "CBaseEntity",
|
||||
Some("table") => "object",
|
||||
_ => "any",
|
||||
}
|
||||
}
|
||||
|
||||
/// `api.json` — the shape ModDota's `dota-data` publishes and its `TypeScriptDeclarations` renders from.
|
||||
///
|
||||
/// Emitting THIS rather than declarations directly is deliberate: their toolchain already turns this
|
||||
/// shape into typed `.d.ts` packages, so matching it means the ecosystem's existing pipeline produces
|
||||
/// up-to-date output instead of a parallel artifact competing with it.
|
||||
///
|
||||
/// Two fields are honestly absent. Parameter lists, because the registry does not carry them — types
|
||||
/// appear only inside Valve's prose descriptions, inconsistently, in about a fifth of entries. And
|
||||
/// `available`, because a dedicated server never maps `libclient`, so this derivation cannot see the
|
||||
/// client side at all and claiming `both` would be an assertion about something never read.
|
||||
pub struct VScriptApiJson;
|
||||
|
||||
impl BindingsEmitter for VScriptApiJson {
|
||||
fn id(&self) -> &'static str {
|
||||
"api-json"
|
||||
}
|
||||
fn render(&self, vscript: &[VScriptBinding], _schema: Option<&Schema>) -> String {
|
||||
let mut classes: Vec<Value> = Vec::new();
|
||||
for (cls, members) in vscript_by_class(vscript) {
|
||||
let ms: Vec<Value> = members
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert("kind".into(), json!("function"));
|
||||
o.insert("name".into(), json!(m.name));
|
||||
o.insert("available".into(), json!("server"));
|
||||
if !m.description.is_empty() {
|
||||
o.insert("description".into(), json!(m.description));
|
||||
} else if let Some(d) = &m.doc {
|
||||
// Under a key of OUR name, never theirs. `description` is Valve's field in
|
||||
// their shape and their toolchain renders it as Valve's word; putting this
|
||||
// project's reading of the build there would launder it into their published
|
||||
// types as something Valve wrote.
|
||||
o.insert("rosetta_description".into(), json!(d.text));
|
||||
o.insert("rosetta_description_source".into(), json!(d.source));
|
||||
}
|
||||
o.insert("returns".into(), json!([m.ret.clone().unwrap_or_default()]));
|
||||
o.insert("args".into(), json!([]));
|
||||
// Ours and not theirs: the C++ binding this resolves to, which is the key the same
|
||||
// function appears under in `gamedata-<game>.json`.
|
||||
o.insert("cpp".into(), json!(m.cpp));
|
||||
Value::Object(o)
|
||||
})
|
||||
.collect();
|
||||
classes.push(json!({ "kind": "class", "name": cls, "members": ms }));
|
||||
}
|
||||
serde_json::to_string_pretty(&Value::Array(classes)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `.d.ts` — TypeScript declarations in the style the Dota ecosystem's published types use.
|
||||
///
|
||||
/// For authors working against the packages as shipped rather than regenerating them. Members are
|
||||
/// declared on an interface per class, with Valve's own description as the doc comment — which is the
|
||||
/// point: hovering a function in an editor shows what Valve wrote about it.
|
||||
///
|
||||
/// Parameters are declared `...args: any[]` because the registry does not state them. That is deliberately
|
||||
/// ugly: it is visible in every signature, so nobody mistakes this for a complete declaration, and it
|
||||
/// still typechecks. Inventing plausible parameter lists would be worse than saying nothing.
|
||||
pub struct VScriptDts;
|
||||
|
||||
impl BindingsEmitter for VScriptDts {
|
||||
fn id(&self) -> &'static str {
|
||||
"dts"
|
||||
}
|
||||
fn render(&self, vscript: &[VScriptBinding], schema: Option<&Schema>) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("/** @noSelfInFile */\n");
|
||||
s.push_str("// Generated by source2rosetta-gen from rosetta-<game>.json — do not edit.\n");
|
||||
s.push_str(
|
||||
"// Return types are Valve's own, read from the script VM's registry. Parameter lists are\n\
|
||||
// NOT in that registry, so every member takes `...args: any[]`: the arity is unknown, and\n\
|
||||
// guessing it would produce declarations that lie rather than declarations that abstain.\n\n",
|
||||
);
|
||||
let grouped = vscript_by_class(vscript);
|
||||
for (cls, members) in &grouped {
|
||||
match vs_base(cls, schema, &grouped) {
|
||||
Some(base) => s.push_str(&format!("declare interface {cls} extends {base} {{\n")),
|
||||
None => s.push_str(&format!("declare interface {cls} {{\n")),
|
||||
}
|
||||
// One line per SCRIPT-facing name. Where a class registers a name twice — two C++
|
||||
// implementations behind one script member — an author still sees one member, so declaring
|
||||
// it twice adds a redundant overload and no information. The row carrying Valve's
|
||||
// description wins; `api-json` keeps both, because it states the `cpp` that tells them apart.
|
||||
for m in dedup_by_script_name(members) {
|
||||
// Valve's own description if the registration carried one — that is the point of this
|
||||
// format, and it renders bare, the way the ecosystem's published types do. Where Valve
|
||||
// documents nothing, this project's own reading fills the gap and SAYS SO: a hover
|
||||
// tooltip is exactly where an unmarked sentence would be taken for Valve's word.
|
||||
if !m.description.is_empty() {
|
||||
s.push_str(&format!(" /** {} */\n", jsdoc(&m.description)));
|
||||
} else if let Some(d) = &m.doc {
|
||||
s.push_str(&format!(
|
||||
" /** {}\n * ({}.) */\n",
|
||||
jsdoc(&d.text),
|
||||
doc_origin(&d.source)
|
||||
));
|
||||
}
|
||||
s.push_str(&format!(
|
||||
" {}(...args: any[]): {};\n",
|
||||
m.name,
|
||||
vs_ts_type(m.ret.as_deref())
|
||||
));
|
||||
}
|
||||
s.push_str("}\n\n");
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a bindings emitter by its `--format` id.
|
||||
pub fn bindings_by_id(id: &str) -> Option<Box<dyn BindingsEmitter>> {
|
||||
match id {
|
||||
"api-json" => Some(Box::new(VScriptApiJson)),
|
||||
"dts" => Some(Box::new(VScriptDts)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every bindings emitter id `bindings_by_id` accepts (keep in sync with it).
|
||||
///
|
||||
/// NOT a `--format` list: both of these are rendered by the single `moddota` format, because they are
|
||||
/// one consumer's two files — their toolchain reads the JSON and their authors read the declarations.
|
||||
pub const BINDINGS_FORMAT_IDS: &[&str] = &["api-json", "dts"];
|
||||
|
||||
/// Every `--format` `gen` accepts, in the order its help lists them.
|
||||
///
|
||||
/// A format names WHO the output is for, not which file it is, so one of them can write several files:
|
||||
/// a framework gets the gamedata its loader resolves through plus the typed call sites that go through
|
||||
/// it, and `moddota` gets both of the shapes that ecosystem publishes.
|
||||
pub const FORMAT_IDS: &[&str] = &[
|
||||
"cssharp", "metamod", "modsharp", "swiftly", "plugify", "cs-sdk", "netvars", "moddota", "flat",
|
||||
];
|
||||
|
||||
/// Where a format's consumer is known not to run on a game, and the evidence for saying so.
|
||||
///
|
||||
/// **A warning rather than a rule.** Each entry is a claim about somebody else's project, read out of
|
||||
/// their source at one point in time, and third-party projects add games. So `gen` declines by default
|
||||
/// and takes `--force`: being wrong here costs a flag, never an outcome.
|
||||
///
|
||||
/// What earns an entry is a framework that resolves its own binaries through a FIXED game directory,
|
||||
/// which is a thing its own source states rather than a thing this project infers. Two do:
|
||||
/// CounterStrikeSharp and Swiftly.
|
||||
///
|
||||
/// **ModSharp is deliberately absent**, though it is equally CS2-first in its paths
|
||||
/// (`../../csgo/steam.inf`): its gamedata carries no game key at all — flat `Addresses` / `VFuncs`,
|
||||
/// keyed only by platform — so the file rendered here is byte-for-byte the same whatever game the build
|
||||
/// targets. The two multi-game targets are absent for the opposite reason: Metamod takes Dota 2 as a
|
||||
/// first-class SDK and Plugify is built per game (`S2SDK_GAME_NAME`), and BOTH key their gamedata by the
|
||||
/// game directory, which is what `meta.game_key` already carries.
|
||||
pub const GAME_SUPPORT: &[(&str, &[&str], &str)] = &[
|
||||
(
|
||||
"cssharp",
|
||||
&["csgo"],
|
||||
"CounterStrikeSharp resolves its binaries out of `<dir>/csgo/bin/` (src/core/memory.h)",
|
||||
),
|
||||
(
|
||||
"swiftly",
|
||||
&["csgo"],
|
||||
"Swiftly initialises against the `csgo` game directory (src/core/entrypoint.cpp)",
|
||||
),
|
||||
];
|
||||
|
||||
/// What is wrong with rendering `format` from a `game_key` artifact, or `None` where nothing is.
|
||||
///
|
||||
/// Names the formats that DO cover the game, because the useful thing to tell someone holding a Dota
|
||||
/// artifact is not that this one is a dead end but which ones are not.
|
||||
pub fn game_mismatch(format: &str, game_key: &str) -> Option<String> {
|
||||
let (_, _, why) = GAME_SUPPORT
|
||||
.iter()
|
||||
.find(|(f, games, _)| *f == format && !games.contains(&game_key))?;
|
||||
let covered: Vec<&str> = FORMAT_IDS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| {
|
||||
!GAME_SUPPORT
|
||||
.iter()
|
||||
.any(|(id, games, _)| id == f && !games.contains(&game_key))
|
||||
})
|
||||
.collect();
|
||||
Some(format!(
|
||||
"`{format}` is for a consumer that does not run on this game. {why}, and this artifact is \
|
||||
`{game_key}`.\n Formats that do cover `{game_key}`: {}",
|
||||
covered.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -1625,6 +1976,138 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_description_reaches_every_call_site_format_and_says_whose_it_is() {
|
||||
let doc = |text: &str, source: &str| crate::model::Doc {
|
||||
text: text.into(),
|
||||
source: source.into(),
|
||||
};
|
||||
let mut e = abi_entry(&["A*", "int"], true, "void", 2);
|
||||
e.doc = Some(doc("Kills the entity.", "generated"));
|
||||
let m = manifest(&[("A::M", e)]);
|
||||
|
||||
for out in [CsSharpAbi.render(&m), ModSharpAbi.render(&m)] {
|
||||
// The prose takes the summary — an editor shows that first, and it is what an author
|
||||
// hovering a generic type list is asking for — and the prototype keeps a home of its own.
|
||||
assert!(
|
||||
out.contains("<summary>Kills the entity.</summary>"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("<remarks><c>A::M(A*, int) -> void</c></remarks>"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("GENERATED — a reading of this build, not Valve's"));
|
||||
}
|
||||
let mm = MetamodAbi.render(&m);
|
||||
assert!(mm.contains("// Kills the entity.\n// Description: source2rosetta's, GENERATED"));
|
||||
assert!(
|
||||
SwiftlyAbi
|
||||
.render(&m)
|
||||
.contains("\"description_source\": \"generated\"")
|
||||
);
|
||||
assert!(
|
||||
PlugifyAbi
|
||||
.render(&m)
|
||||
.contains("\"descriptionSource\": \"generated\"")
|
||||
);
|
||||
|
||||
// Valve's own text is attributed to Valve, in the same place, by the same one wording.
|
||||
let mut e = abi_entry(&["A*", "int"], true, "void", 2);
|
||||
e.doc = Some(doc("Valve wrote this.", crate::model::Doc::VALVE));
|
||||
let valve = CsSharpAbi.render(&manifest(&[("A::M", e)]));
|
||||
assert!(valve.contains("<summary>Valve wrote this.</summary>"));
|
||||
assert!(
|
||||
valve.contains("Description: Valve's own text, read from the registry in the binary.")
|
||||
);
|
||||
|
||||
// An id nothing here knows is printed verbatim rather than passed off as Valve's — the same
|
||||
// rule `flags_raw` follows for a bit whose meaning this build cannot state.
|
||||
let mut e = abi_entry(&["A*", "int"], true, "void", 2);
|
||||
e.doc = Some(doc("t", "handwritten"));
|
||||
let odd = CsSharpAbi.render(&manifest(&[("A::M", e)]));
|
||||
assert!(odd.contains("source `handwritten` — not Valve's"), "{odd}");
|
||||
|
||||
// And with nothing to say, the block is exactly what it was before descriptions existed.
|
||||
let bare = CsSharpAbi.render(&manifest(&[("A::M", abi_entry(&["A*"], true, "void", 1))]));
|
||||
assert!(bare.contains("<summary><c>A::M(A*) -> void</c></summary>"));
|
||||
assert!(!bare.contains("Description:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_description_cannot_break_out_of_the_comment_it_is_printed_in() {
|
||||
// Real shipped text carries all three hazards: XML metacharacters (~100 descriptions per
|
||||
// game), a newline (78), and `*/` (3 in Dota).
|
||||
let mut e = abi_entry(&["A*"], true, "void", 1);
|
||||
e.doc = Some(crate::model::Doc {
|
||||
text: crate::model::one_line("a <b> & c\nsecond line */ end"),
|
||||
source: "generated".into(),
|
||||
});
|
||||
let m = manifest(&[("A::M", e)]);
|
||||
for out in [CsSharpAbi.render(&m), ModSharpAbi.render(&m)] {
|
||||
assert!(
|
||||
out.contains("a <b> & c second line */ end"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
// A `//` comment ends at a newline, so every prose line in the C++ header must be one line.
|
||||
for line in MetamodAbi.render(&m).lines() {
|
||||
assert!(
|
||||
line.starts_with("//") || !line.contains("second line"),
|
||||
"{line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_script_api_fills_a_gap_valve_left_and_never_overwrites_one() {
|
||||
let member = |name: &str, valve: &str, doc: Option<&str>| VScriptBinding {
|
||||
name: name.into(),
|
||||
class: Some("CBaseEntity".into()),
|
||||
cpp: format!("Script_{name}"),
|
||||
library: "server".into(),
|
||||
description: valve.into(),
|
||||
ret: Some("void".into()),
|
||||
ret_raw: 0,
|
||||
addr: None,
|
||||
vtable_slot: None,
|
||||
doc: doc.map(|t| crate::model::Doc {
|
||||
text: t.into(),
|
||||
source: "generated".into(),
|
||||
}),
|
||||
};
|
||||
let vs = vec![
|
||||
member("Kill", "Valve's own.", Some("ours, and outranked")),
|
||||
member("Quiet", "", Some("Ours: it does a thing. */")),
|
||||
member("Silent", "", None),
|
||||
];
|
||||
|
||||
let dts = VScriptDts.render(&vs, None);
|
||||
assert!(dts.contains("/** Valve's own. */"), "{dts}");
|
||||
assert!(!dts.contains("outranked"), "{dts}");
|
||||
// Ours is marked where it lands, because a hover tooltip is exactly where an unattributed
|
||||
// sentence gets taken for Valve's word. And `*/` inside it must not end the comment.
|
||||
assert!(
|
||||
dts.contains("/** Ours: it does a thing. *\\/\n * (source2rosetta's, GENERATED")
|
||||
);
|
||||
// A member nothing describes gets no comment at all — an empty one would read as "documented,
|
||||
// and it says nothing". (Counting the indented form: the file's own `@noSelfInFile` banner is
|
||||
// a doc comment too.)
|
||||
assert!(dts.contains(" Silent(...args: any[]): void;"));
|
||||
assert_eq!(dts.matches(" /**").count(), 2);
|
||||
|
||||
// `api.json` is ModDota's shape: `description` is Valve's field, so ours goes under a key of
|
||||
// our own name rather than being laundered into their published types as Valve's word.
|
||||
let api: Value = serde_json::from_str(&VScriptApiJson.render(&vs, None)).unwrap();
|
||||
let ms = &api[0]["members"];
|
||||
assert_eq!(ms[0]["description"], "Valve's own.");
|
||||
assert!(ms[0].get("rosetta_description").is_none());
|
||||
assert!(ms[1].get("description").is_none());
|
||||
assert_eq!(ms[1]["rosetta_description"], "Ours: it does a thing. */");
|
||||
assert_eq!(ms[1]["rosetta_description_source"], "generated");
|
||||
assert!(ms[2].get("rosetta_description").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_return_that_cannot_be_represented_drops_the_function() {
|
||||
// `Vector` by value is a real declaration this cannot express — dropping it is not the same as
|
||||
|
|
@ -1738,10 +2221,60 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn every_format_id_resolves_and_renders() {
|
||||
for id in FORMAT_IDS {
|
||||
for id in GAMEDATA_FORMAT_IDS {
|
||||
let em = by_id(id).unwrap_or_else(|| panic!("by_id({id}) is None"));
|
||||
assert!(!em.render(&sample()).is_empty(), "{id} rendered empty");
|
||||
}
|
||||
// Every `--format` has to be reachable through one of the four emitter families, or `gen`
|
||||
// advertises an id it then rejects. `moddota` is the one that maps to a family rather than to
|
||||
// an emitter: it is a CONSUMER with two files, which is why the CLI list is not a registry.
|
||||
for id in FORMAT_IDS {
|
||||
let known = by_id(id).is_some()
|
||||
|| schema_by_id(id).is_some()
|
||||
|| abi_by_id(id).is_some()
|
||||
|| *id == "moddota"
|
||||
|| *id == "flat";
|
||||
assert!(known, "--format {id} resolves to no emitter");
|
||||
}
|
||||
for id in BINDINGS_FORMAT_IDS {
|
||||
assert!(bindings_by_id(id).is_some(), "bindings_by_id({id}) is None");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_consumer_that_does_not_run_on_this_game_is_named_and_so_are_the_ones_that_do() {
|
||||
// CounterStrikeSharp resolves its own binaries out of `<dir>/csgo/bin/`, so Dota locators in
|
||||
// its shape describe a file that can never load. Saying which formats DO cover the game is the
|
||||
// useful half: someone holding a Dota artifact needs a way forward, not just a closed door.
|
||||
let m = game_mismatch("cssharp", "dota").expect("cssharp does not run on dota");
|
||||
assert!(m.contains("CounterStrikeSharp"), "{m}");
|
||||
assert!(
|
||||
m.contains("metamod") && m.contains("moddota") && m.contains("plugify"),
|
||||
"{m}"
|
||||
);
|
||||
assert!(
|
||||
!m.contains("swiftly"),
|
||||
"swiftly does not cover dota either: {m}"
|
||||
);
|
||||
assert!(game_mismatch("cssharp", "csgo").is_none());
|
||||
|
||||
// Multi-game by evidence: Metamod takes Dota 2 as a first-class SDK, Plugify is built per game,
|
||||
// and both key their gamedata by the game directory — which is what `game_key` already carries.
|
||||
for f in ["metamod", "plugify", "moddota", "flat", "cs-sdk", "netvars"] {
|
||||
assert!(game_mismatch(f, "dota").is_none(), "{f} should cover dota");
|
||||
}
|
||||
// ModSharp is CS2-FIRST but not CS2-only in the shape rendered here: its gamedata carries no
|
||||
// game key at all, so the file is the same whatever game the build targets.
|
||||
assert!(game_mismatch("modsharp", "dota").is_none());
|
||||
|
||||
// Every id the table constrains has to be a format that exists, or the constraint silently
|
||||
// applies to nothing.
|
||||
for (id, _, _) in GAME_SUPPORT {
|
||||
assert!(
|
||||
FORMAT_IDS.contains(id),
|
||||
"GAME_SUPPORT names unknown format {id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue