553 lines
22 KiB
Rust
553 lines
22 KiB
Rust
//! The canonical derived-gamedata model — the single in-memory representation the derivation produces
|
|
//! and the emitters consume. Deliberately format-agnostic (no serde_json shapes here): `render::*`
|
|
//! turns it into CSSharp JSON, Metamod VDF, etc. Keeping the shape here (not smeared through `json!`
|
|
//! call sites) is what lets one derivation feed every output format and a standalone generator.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
/// A byte-pattern signature located in a specific library.
|
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
pub struct Sig {
|
|
pub library: String, // "server", "engine2", … (the module the pattern scans)
|
|
pub linux: String, // space-hex pattern with `?` wildcards, e.g. "55 48 89 ? E5"
|
|
}
|
|
|
|
/// One gamedata function: a vtable-method offset, a scan signature, or (rarely) both.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
pub struct Entry {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub signature: Option<Sig>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub offset: Option<i64>, // vtable slot index (or a carried member offset)
|
|
}
|
|
|
|
impl Entry {
|
|
/// A signature-only locator (the deriver's sig-XOR-offset invariant as a constructor).
|
|
pub fn signature(library: impl Into<String>, linux: impl Into<String>) -> Entry {
|
|
Entry {
|
|
signature: Some(Sig {
|
|
library: library.into(),
|
|
linux: linux.into(),
|
|
}),
|
|
offset: None,
|
|
}
|
|
}
|
|
|
|
/// A vtable-offset-only locator.
|
|
pub fn offset(linux: i64) -> Entry {
|
|
Entry {
|
|
signature: None,
|
|
offset: Some(linux),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The default output game-key when none is set — CS2's Steam content-dir token. Games-keyed emitters
|
|
/// (Metamod/Plugify) fall back to this so an older model JSON (no `game_key`) still renders as CS2.
|
|
fn default_game_key() -> String {
|
|
"csgo".to_string()
|
|
}
|
|
|
|
/// The derived gamedata for one build: function name -> entry. BTreeMap so iteration/output is
|
|
/// deterministically key-sorted, matching serde_json's Map key ordering byte-for-byte.
|
|
///
|
|
/// Serializing this IS the canonical "model JSON" — the single per-build artifact the deriver
|
|
/// publishes and the standalone `source2rosetta-gen` reads back to produce every framework's format.
|
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct Gamedata {
|
|
pub entries: BTreeMap<String, Entry>,
|
|
/// The game token the game-keyed emitters wrap output in (Metamod `Games { <game_key> {..} }`,
|
|
/// Plugify `{ "<game_key>": {..} }`). Persisted here because `source2rosetta-gen` renders from the model
|
|
/// JSON alone, with no access to the deriver's `GameProfile`. Defaults to CS2's `csgo`.
|
|
#[serde(default = "default_game_key")]
|
|
pub game_key: String,
|
|
}
|
|
|
|
impl Default for Gamedata {
|
|
fn default() -> Self {
|
|
Self {
|
|
entries: BTreeMap::new(),
|
|
game_key: default_game_key(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Why the derivation could not produce a shipped locator — the closed domain the `unresolved` tier reports.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum FlagReason {
|
|
/// A catalogued signature that no longer resolves uniquely / wasn't recovered in the target.
|
|
SigDrifted,
|
|
/// A vtable offset whose recency-weighted vote fell below the confidence bar.
|
|
OffsetLowConf,
|
|
/// No chainable anchor / no reference history at all.
|
|
Unresolved,
|
|
/// The sig SHIPPED, but its ABI prototype shape drifted from the model consensus (review the prototype).
|
|
AbiDrift,
|
|
}
|
|
|
|
impl FlagReason {
|
|
/// The kebab id — the same string the `kebab-case` serialization emits, for callers that carry the
|
|
/// reason across to the monolith's `Unresolved.reason` (a `String`, kept stable for byte-reproducibility).
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
FlagReason::SigDrifted => "sig-drifted",
|
|
FlagReason::OffsetLowConf => "offset-low-conf",
|
|
FlagReason::Unresolved => "unresolved",
|
|
FlagReason::AbiDrift => "abi-drift",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A catalogue entry the derivation could NOT confidently produce. Emitted as a first-class sidecar
|
|
/// (never guessed into the gamedata — safety > recall) so it is both reviewable and machine-readable.
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct Flagged {
|
|
pub name: String,
|
|
pub reason: FlagReason,
|
|
/// The signal we do have: the carried value, vote confidence, "no reference", …
|
|
pub detail: String,
|
|
}
|
|
|
|
impl Flagged {
|
|
pub fn new(name: impl Into<String>, reason: FlagReason, detail: impl Into<String>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
reason,
|
|
detail: detail.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Gamedata {
|
|
pub fn set_signature(
|
|
&mut self,
|
|
name: impl Into<String>,
|
|
library: impl Into<String>,
|
|
linux: impl Into<String>,
|
|
) {
|
|
self.entries.entry(name.into()).or_default().signature = Some(Sig {
|
|
library: library.into(),
|
|
linux: linux.into(),
|
|
});
|
|
}
|
|
|
|
pub fn set_offset(&mut self, name: impl Into<String>, linux: i64) {
|
|
self.entries.entry(name.into()).or_default().offset = Some(linux);
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.entries.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.entries.is_empty()
|
|
}
|
|
}
|
|
|
|
// ===========================================================================================
|
|
// The monolith model — the shipped `gamedata-<game>.json`. Four confidence tiers with provenance
|
|
// + live-validation folded inline; `source2rosetta-gen` renders it into any framework format, and it is
|
|
// equally readable as-is by a consumer. A `MonoEntry` EMBEDS `Entry`, so the locator shape stays
|
|
// single-sourced on `render::locator_value` and never diverges. Lib-agnostic: an entry carries its
|
|
// `library` in the signature locator, so the monolith spans every derived library, not just libserver.
|
|
// ===========================================================================================
|
|
|
|
/// A monolith entry's confidence tier — its finer label within a section, serialized as kebab strings
|
|
/// (`core`, `self-named`, `dict-exact`, `contextual`, `corroborated`, `high`, `medium`, `low`).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum Tier {
|
|
Core,
|
|
SelfNamed,
|
|
/// Dictionary-corroborated by the FOLD (an exact hit in the harvested name catalogue).
|
|
DictExact,
|
|
Contextual,
|
|
/// Dictionary-corroborated by the EXPERIMENTAL band, and also the label the macOS ground-truth
|
|
/// transfer carries. Kept distinct from [`Tier::DictExact`] rather than merged: the two are produced
|
|
/// by different paths, and folding them together would additionally conflate ground-truth symbols
|
|
/// with dictionary guesses. Anything counting "corroborated" for display must count BOTH — see
|
|
/// [`Tier::is_dict_corroborated`].
|
|
Corroborated,
|
|
High,
|
|
Medium,
|
|
Low,
|
|
}
|
|
|
|
impl Tier {
|
|
/// Is this tier a dictionary/ground-truth corroboration, under either of its two labels?
|
|
pub fn is_dict_corroborated(self) -> bool {
|
|
matches!(self, Tier::DictExact | Tier::Corroborated)
|
|
}
|
|
}
|
|
|
|
/// Per-entry provenance, folded inline. Optional by tier: a `core` entry carries almost nothing, an
|
|
/// `experimental` guess carries the full grading.
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct Provenance {
|
|
pub tier: Tier,
|
|
/// Raw address in THIS build — a debugging/trace anchor (the monolith is per-build, so it's coherent).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub addr: Option<String>,
|
|
/// The confidence LABEL as the harvest records it — `"high"` / `"medium"` / `"low"` — a separate axis
|
|
/// from `tier` (a self-named entry can still be low-confidence). A string, not a number.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub confidence: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub self_named: Option<bool>,
|
|
/// Return is struct-by-value → unsafe to naive-call (the `RetClass::ByValue` flag).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub by_value: Option<bool>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub ret_class: Option<String>,
|
|
/// "catalogue" | "source2rosetta-nameext" | "contribution:<date>" | …
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub source: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub rtti_class: Option<String>,
|
|
/// high_confidence tier only — the naming rationale.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub rationale: Option<String>,
|
|
/// experimental tier only — how the dictionary corroborated the guess (`"exact"` / `"bare"` / `"none"`).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub corroboration: Option<String>,
|
|
/// experimental tier only — the same name was guessed at more than one address.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub collision: Option<bool>,
|
|
/// experimental tier only — protobuf/serializer/foreign plumbing (flagged, kept for completeness).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub dead_weight: Option<bool>,
|
|
/// core only — the target's ABI prototype-shape differs from the model's consensus (`"target [..] vs
|
|
/// history [..]"`). The signature still ships (a drifted arg-list is a loader-hook seam a byte-sig can't
|
|
/// see, not a wrong locator), but a consumer that ptrace-calls it should re-check the prototype.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub abi_drift: Option<String>,
|
|
}
|
|
|
|
impl Tier {
|
|
/// The tier's kebab id — the same string the `kebab-case` serialization emits, for a deriver that needs
|
|
/// it as a plain `&str` (e.g. a count-by-tier tally) without going through serde.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Tier::Core => "core",
|
|
Tier::SelfNamed => "self-named",
|
|
Tier::DictExact => "dict-exact",
|
|
Tier::Contextual => "contextual",
|
|
Tier::Corroborated => "corroborated",
|
|
Tier::High => "high",
|
|
Tier::Medium => "medium",
|
|
Tier::Low => "low",
|
|
}
|
|
}
|
|
|
|
/// Parse a tier from its kebab id (the inverse of the `kebab-case` serialization) — so a deriver holding
|
|
/// a tier as a computed string can lift it to the typed enum without a serde_json round-trip. `None` for
|
|
/// an unrecognised id (the caller decides whether that is a hard error).
|
|
pub fn from_id(s: &str) -> Option<Tier> {
|
|
Some(match s {
|
|
"core" => Tier::Core,
|
|
"self-named" => Tier::SelfNamed,
|
|
"dict-exact" => Tier::DictExact,
|
|
"contextual" => Tier::Contextual,
|
|
"corroborated" => Tier::Corroborated,
|
|
"high" => Tier::High,
|
|
"medium" => Tier::Medium,
|
|
"low" => Tier::Low,
|
|
_ => return None,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Provenance {
|
|
/// A provenance with only its tier set (all optional fields `None`) — the base for `core` entries and
|
|
/// the start point for functional-update construction (`Provenance { source: …, ..with_tier(t) }`).
|
|
pub fn with_tier(tier: Tier) -> Self {
|
|
Self {
|
|
tier,
|
|
addr: None,
|
|
confidence: None,
|
|
self_named: None,
|
|
by_value: None,
|
|
ret_class: None,
|
|
source: None,
|
|
rtti_class: None,
|
|
rationale: None,
|
|
corroboration: None,
|
|
collision: None,
|
|
dead_weight: None,
|
|
abi_drift: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One monolith function: a locator (the `signature`/`offset` model shape, flattened in from [`Entry`] so
|
|
/// the shape is single-sourced) plus its provenance and live-validation verdict.
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct MonoEntry {
|
|
#[serde(flatten)]
|
|
pub locator: Entry,
|
|
/// experimental offsets only: the vtable class the slot lives on (a reader's eyeball check).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub class: Option<String>,
|
|
pub provenance: Provenance,
|
|
/// Live-validation verdict: `Some(true)` passed, `Some(false)` dropped confident-bad, `None` unvalidated.
|
|
#[serde(default)]
|
|
pub validated: Option<bool>,
|
|
}
|
|
|
|
/// A catalogued function the derivation could not confidently produce — kept in-file (never a shipped
|
|
/// locator) so the monolith is the complete catalogue picture.
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct Unresolved {
|
|
pub reason: String, // "sig-drifted" | "offset-low-conf" | "unresolved" | …
|
|
pub detail: String,
|
|
}
|
|
|
|
/// Entry counts per section — a struct (not a map) so it serializes in this logical order, deterministically.
|
|
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
|
|
pub struct Counts {
|
|
pub core: usize,
|
|
pub high_confidence: usize,
|
|
pub experimental: usize,
|
|
pub unresolved: usize,
|
|
}
|
|
|
|
/// The monolith's intrinsic release identity. NO wall-clock field — volatile release metadata (`produced_at`,
|
|
/// `status`, urls, sha256, `based_on`) lives in the per-buildid manifest, so the monolith is fully
|
|
/// byte-reproducible.
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct MonoMeta {
|
|
pub game_key: String,
|
|
pub game: String, // display name
|
|
pub source_build: String,
|
|
/// `<game>-<buildid>-<patch>` — the buildid is embedded here; the per-buildid MANIFEST carries it as its
|
|
/// own field (volatile metadata is kept OUT of this byte-reproducible monolith).
|
|
pub version: String,
|
|
pub counts: Counts,
|
|
}
|
|
|
|
/// The full derived gamedata for one build — the shipped `gamedata-<game>.json`. Four confidence tiers, each
|
|
/// a key-sorted map. `source2rosetta-gen` renders it into any framework format; a consumer can equally read it
|
|
/// directly, gating `experimental` behind a runtime toggle off each entry's tier.
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct Monolith {
|
|
pub meta: MonoMeta,
|
|
pub core: BTreeMap<String, MonoEntry>,
|
|
pub high_confidence: BTreeMap<String, MonoEntry>,
|
|
pub experimental: BTreeMap<String, MonoEntry>,
|
|
pub unresolved: BTreeMap<String, Unresolved>,
|
|
}
|
|
|
|
/// Which of the monolith's three SHIPPABLE tiers a render includes — cumulative, most-confident first
|
|
/// (`unresolved` is never rendered; it has no locator). The `--tier` arg of `source2rosetta-gen`.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum TierSelect {
|
|
/// `core` only — the guaranteed, live-validated set.
|
|
Core,
|
|
/// core + high_confidence — adds the promoted name-extrapolations.
|
|
HighConfidence,
|
|
/// core + high_confidence + experimental — every locatable guess.
|
|
Experimental,
|
|
}
|
|
|
|
impl TierSelect {
|
|
/// Parse the `--tier` id (the monolith tier names, plus a couple of intuitive aliases).
|
|
pub fn from_id(s: &str) -> Option<TierSelect> {
|
|
match s {
|
|
"core" => Some(TierSelect::Core),
|
|
"high_confidence" | "high-confidence" | "stable" => Some(TierSelect::HighConfidence),
|
|
"experimental" | "full" => Some(TierSelect::Experimental),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every `--tier` id `from_id` accepts (canonical names) — for help text.
|
|
pub const TIER_IDS: &[&str] = &["core", "high_confidence", "experimental"];
|
|
|
|
impl Monolith {
|
|
/// Flatten the tiers up to `select` into one `name -> Entry` map — the input the flat framework emitters
|
|
/// (metamod/modsharp/swiftly/plugify) consume. Drops confident-bad entries (`validated == Some(false)`);
|
|
/// `unresolved` is never included (no locator); a more-confident tier wins a name collision.
|
|
pub fn select(&self, select: TierSelect) -> Gamedata {
|
|
let mut gd = Gamedata {
|
|
entries: BTreeMap::new(),
|
|
game_key: self.meta.game_key.clone(),
|
|
};
|
|
let mut add = |m: &BTreeMap<String, MonoEntry>| {
|
|
for (name, e) in m {
|
|
if e.validated == Some(false) {
|
|
continue;
|
|
}
|
|
gd.entries
|
|
.entry(name.clone())
|
|
.or_insert_with(|| e.locator.clone());
|
|
}
|
|
};
|
|
add(&self.core);
|
|
if select != TierSelect::Core {
|
|
add(&self.high_confidence);
|
|
}
|
|
if select == TierSelect::Experimental {
|
|
add(&self.experimental);
|
|
}
|
|
gd
|
|
}
|
|
}
|
|
|
|
/// One typed schema field (the field NAME is the map key). `offset` is static; `ty`/`kind`/`size` are
|
|
/// runtime-resolved (empty/zero when derived offline without a live process).
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct Field {
|
|
pub offset: i32,
|
|
#[serde(rename = "type", default, skip_serializing_if = "String::is_empty")]
|
|
pub ty: String,
|
|
#[serde(default)]
|
|
pub kind: FieldKind,
|
|
pub size: usize,
|
|
pub name_hash: u64,
|
|
}
|
|
|
|
/// How a schema field holds its value — a closed runtime domain (Source-2 `CSchemaType` category).
|
|
/// Serializes to lowercase tokens, the values `netvars-<game>.json` consumers expect.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum FieldKind {
|
|
/// Builtin / atomic / declared class / declared enum — held inline (the common case).
|
|
#[default]
|
|
Ref,
|
|
/// A pointer to the value.
|
|
Ptr,
|
|
/// A fixed-size inline array.
|
|
FixedArray,
|
|
}
|
|
|
|
/// The typed schema — the shipped `netvars-<game>.json`. Merges field offsets with runtime types:
|
|
/// class -> field -> [`Field`].
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct Schema {
|
|
pub meta: SchemaMeta,
|
|
pub classes: BTreeMap<String, BTreeMap<String, Field>>,
|
|
}
|
|
|
|
/// The typed schema's intrinsic identity (no wall-clock field, same rationale as [`MonoMeta`]).
|
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct SchemaMeta {
|
|
pub game_key: String,
|
|
pub source_build: String,
|
|
pub typed: usize,
|
|
pub untyped: usize,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod monolith_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn mono_entry_flattens_locator_and_kebabs_tier() {
|
|
let e = MonoEntry {
|
|
locator: Entry {
|
|
signature: None,
|
|
offset: Some(158),
|
|
},
|
|
class: None,
|
|
provenance: Provenance {
|
|
source: Some("catalogue".into()),
|
|
..Provenance::with_tier(Tier::Core)
|
|
},
|
|
validated: Some(true),
|
|
};
|
|
let v = serde_json::to_value(&e).unwrap();
|
|
assert_eq!(v["offset"], 158); // locator flattened to the top level
|
|
assert!(v.get("signature").is_none()); // None locator field omitted
|
|
assert_eq!(v["provenance"]["tier"], "core"); // kebab-case enum
|
|
assert_eq!(v["provenance"]["source"], "catalogue");
|
|
assert!(v["provenance"].get("confidence").is_none()); // None provenance field skipped
|
|
assert_eq!(v["validated"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn experimental_entry_keeps_class_and_serializes_null_validated() {
|
|
let e = MonoEntry {
|
|
locator: Entry {
|
|
signature: None,
|
|
offset: Some(40),
|
|
},
|
|
class: Some("CFoo".into()),
|
|
provenance: Provenance {
|
|
confidence: Some("low".into()),
|
|
self_named: Some(false),
|
|
collision: Some(true),
|
|
dead_weight: Some(false),
|
|
..Provenance::with_tier(Tier::Low)
|
|
},
|
|
validated: None,
|
|
};
|
|
let v = serde_json::to_value(&e).unwrap();
|
|
assert_eq!(v["class"], "CFoo");
|
|
assert_eq!(v["provenance"]["tier"], "low");
|
|
assert_eq!(v["provenance"]["collision"], true);
|
|
assert_eq!(v["validated"], serde_json::Value::Null); // present as null, not omitted
|
|
}
|
|
|
|
#[test]
|
|
fn monolith_round_trips() {
|
|
let mut m = Monolith {
|
|
meta: MonoMeta {
|
|
game_key: "csgo".into(),
|
|
game: "CS2".into(),
|
|
source_build: "2026-07-15_003539".into(),
|
|
version: "cs2-12345-0".into(),
|
|
counts: Counts {
|
|
core: 1,
|
|
high_confidence: 0,
|
|
experimental: 0,
|
|
unresolved: 1,
|
|
},
|
|
},
|
|
core: BTreeMap::new(),
|
|
high_confidence: BTreeMap::new(),
|
|
experimental: BTreeMap::new(),
|
|
unresolved: BTreeMap::new(),
|
|
};
|
|
m.core.insert(
|
|
"A::b".into(),
|
|
MonoEntry {
|
|
locator: Entry {
|
|
signature: Some(Sig {
|
|
library: "server".into(),
|
|
linux: "55 48 89 E5".into(),
|
|
}),
|
|
offset: None,
|
|
},
|
|
class: None,
|
|
provenance: Provenance {
|
|
source: Some("catalogue".into()),
|
|
..Provenance::with_tier(Tier::Core)
|
|
},
|
|
validated: Some(true),
|
|
},
|
|
);
|
|
m.unresolved.insert(
|
|
"C::d".into(),
|
|
Unresolved {
|
|
reason: "sig-drifted".into(),
|
|
detail: "no unique/recovered signature in target".into(),
|
|
},
|
|
);
|
|
let s = serde_json::to_string_pretty(&m).unwrap();
|
|
let back: Monolith = serde_json::from_str(&s).unwrap();
|
|
assert_eq!(back.core.len(), 1);
|
|
assert_eq!(
|
|
back.core["A::b"]
|
|
.locator
|
|
.signature
|
|
.as_ref()
|
|
.unwrap()
|
|
.library,
|
|
"server"
|
|
);
|
|
assert_eq!(back.unresolved["C::d"].reason, "sig-drifted");
|
|
assert_eq!(back.meta.counts.unresolved, 1);
|
|
}
|
|
}
|