diff --git a/decompiler/crates/kuna-cli/src/atomic_json.rs b/decompiler/crates/kuna-cli/src/atomic_json.rs new file mode 100644 index 00000000..ebfe18d8 --- /dev/null +++ b/decompiler/crates/kuna-cli/src/atomic_json.rs @@ -0,0 +1,166 @@ +//! Opt-in atomic documents share the ordinary record producer and JSON writer. +//! A graph that cannot fit must not discard otherwise readable reconstructed C. + +use crate::jsonfmt::{dumps_compact_bounded, Json}; + +// Ownership is moved out while trying bounded alternatives, never cloned. +struct RemovedFacet { + function: usize, + field: usize, + value: (String, Json), + added_error: bool, +} +fn take_facet(document: &mut Json, name: &str, limit: usize) -> Vec { + let mut removed = Vec::new(); + if let Json::Object(fields) = document { + if let Some(Json::Array(functions)) = fields + .iter_mut() + .find_map(|(key, value)| (key == "functions").then_some(value)) + { + for (function, record) in functions.iter_mut().enumerate() { + if let Json::Object(fields) = record { + if let Some(field) = fields.iter().position(|(key, _)| key == name) { + let value = fields.remove(field); + let error = format!("{name}_error"); + let added_error = !fields.iter().any(|(key, _)| key == &error); + if added_error { + fields.push((error, Json::Str(format!("atomic encoded document exceeds {limit} byte capacity; {name} omitted, code retained")))); + } + removed.push(RemovedFacet { + function, + field, + value, + added_error, + }); + } + } + } + } + } + removed +} +fn restore_facet(document: &mut Json, removed: Vec) { + if let Json::Object(fields) = document { + if let Some(Json::Array(functions)) = fields + .iter_mut() + .find_map(|(key, value)| (key == "functions").then_some(value)) + { + for facet in removed { + if let Json::Object(fields) = &mut functions[facet.function] { + if facet.added_error { + let error = format!("{}_error", facet.value.0); + fields.retain(|(key, _)| key != &error); + } + fields.insert(facet.field, facet.value); + } + } + } + } +} +fn bounded(document: &Json, budget: usize) -> Option { + let mut text = dumps_compact_bounded(document, budget)?; + text.push('\n'); + Some(text) +} +pub(crate) fn render(mut document: Json, limit: usize) -> Result { + let budget = limit + .checked_sub(1) + .ok_or("atomic JSON output budget is zero")?; + if let Some(text) = bounded(&document, budget) { + return Ok(text); + } + // Preserve existing graph availability if either facet alone would fit. + let calls = take_facet(&mut document, "atomic_calls", limit); + if !calls.is_empty() { + if let Some(text) = bounded(&document, budget) { + return Ok(text); + } + } + restore_facet(&mut document, calls); + let graph = take_facet(&mut document, "atomic_facts", limit); + if !graph.is_empty() { + if let Some(text) = bounded(&document, budget) { + return Ok(text); + } + } + take_facet(&mut document, "atomic_calls", limit); + bounded(&document, budget).ok_or_else(|| { + format!("atomic JSON exceeds {limit} byte capacity even without recovered facets") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encoded_overflow_preserves_code_and_replaces_only_the_graph() { + let original = Json::Object(vec![( + "functions".into(), + Json::Array(vec![Json::Object(vec![ + ("code".into(), Json::Str("retained C".into())), + ("error".into(), Json::Null), + ("atomic_facts".into(), Json::Str("😀".repeat(1000))), + ])]), + )]); + let full = render(original.clone(), 20000).unwrap(); + assert!(full.len() > 12000); // ASCII surrogate escaping is accounted for. + let reduced = render(original.clone(), 256).unwrap(); + let document: serde_json::Value = serde_json::from_str(&reduced).unwrap(); + let function = &document["functions"][0]; + assert_eq!(function["code"], "retained C"); + assert_eq!(function["error"], serde_json::Value::Null); + assert!(function.get("atomic_facts").is_none()); + assert!(function["atomic_facts_error"] + .as_str() + .unwrap() + .contains("256")); + assert!(reduced.len() <= 256); + assert!(render(original, 1).is_err()); + } +} + +#[cfg(test)] +mod independent_facet_tests { + use super::*; + fn document(graph: usize, calls: usize) -> Json { + Json::Object(vec![( + "functions".into(), + Json::Array(vec![Json::Object(vec![ + ("code".into(), Json::Str("retained C".into())), + ("ordinary".into(), Json::Number("42".into())), + ("atomic_facts".into(), Json::Str("x".repeat(graph))), + ("atomic_calls".into(), Json::Str("y".repeat(calls))), + ])]), + )]) + } + #[test] + fn each_facet_survives_the_others_encoded_overflow() { + for (graph, calls, keep) in [(2000, 1, "atomic_calls"), (1, 2000, "atomic_facts")] { + let rendered = render(document(graph, calls), 512).unwrap(); + assert!(rendered.len() <= 512); + let d: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + let f = &d["functions"][0]; + assert!(f.get(keep).is_some()); + assert_eq!(f["ordinary"], 42); + assert_eq!(f["code"], "retained C"); + } + } + #[test] + fn jointly_oversized_facets_preserve_the_existing_graph_when_it_fits() { + let rendered = render(document(1000, 1000), 1600).unwrap(); + let d: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert!(d["functions"][0].get("atomic_facts").is_some()); + assert!(d["functions"][0].get("atomic_calls").is_none()); + } + #[test] + fn base_record_survives_both_overflows_or_fails_explicitly() { + let rendered = render(document(2000, 2000), 512).unwrap(); + let d: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + let f = &d["functions"][0]; + assert!(f.get("atomic_facts").is_none() && f.get("atomic_calls").is_none()); + assert!(f["atomic_facts_error"].is_string() && f["atomic_calls_error"].is_string()); + assert_eq!(f["code"], "retained C"); + assert!(render(document(1, 1), 1).is_err()); + } +} diff --git a/decompiler/crates/kuna-cli/src/decompile.rs b/decompiler/crates/kuna-cli/src/decompile.rs index ad249b04..62074996 100644 --- a/decompiler/crates/kuna-cli/src/decompile.rs +++ b/decompiler/crates/kuna-cli/src/decompile.rs @@ -841,6 +841,8 @@ pub fn main(argv: &[String]) -> Result { let mut target: Option = None; let mut addr = false; let mut json = false; + let mut atomic_facts = false; + let mut atomic_output_bytes = None; let mut bfd_target: Option = None; let mut raw = false; let mut regions = false; @@ -871,6 +873,13 @@ pub fn main(argv: &[String]) -> Result { match a { "--addr" => addr = true, "--json" => json = true, + "--atomic-facts" => atomic_facts = true, + "--atomic-output-bytes" => { + let value = take_value(argv, &mut i, "--atomic-output-bytes")?; + atomic_output_bytes = Some(value.parse::() + .ok().filter(|bytes| *bytes > 0) + .ok_or("--atomic-output-bytes requires a positive byte count")?); + } "--raw" => raw = true, "--raw-image" => raw_image = true, "--base" => base = Some(parse_cli_address(&take_value(argv, &mut i, "--base")?)?), @@ -979,7 +988,13 @@ pub fn main(argv: &[String]) -> Result { return Err("--base requires --raw-image".into()); } + if atomic_output_bytes.is_some() && !atomic_facts { + return Err("--atomic-output-bytes requires --atomic-facts".into()); + } if json { + if atomic_facts && (!cfg!(feature = "embed-specs") || sleighpath.is_some()) { + return Err("--atomic-facts requires embedded specs and does not accept --sleighpath".into()); + } // Refused, not ignored: each of these is a `decomp_dbg` transcript the // in-process JSON path never produces, and silently dropping half a // request is the failure mode `--json` exists to end. @@ -997,6 +1012,8 @@ pub fn main(argv: &[String]) -> Result { binary: &binary, target: &target, by_address: addr, + atomic_facts, + atomic_output_bytes: atomic_output_bytes.unwrap_or(8 * 1024 * 1024), forwarded: &forwarded, mode: mode.as_deref(), bfd_target: bfd_target.as_deref(), @@ -1007,6 +1024,10 @@ pub fn main(argv: &[String]) -> Result { })); } + if atomic_facts { + return Err("--atomic-facts requires --json".into()); + } + // (kuna outlang, DIV-80) The auto policy -- follow the binary when the caller // named no language. See `decompile_all::detected_output_language`. if !saw_language && !options.iter().any(|(n, _)| n == "setlanguage") { @@ -1106,6 +1127,8 @@ struct JsonRequest<'a> { binary: &'a str, target: &'a str, by_address: bool, + atomic_facts: bool, + atomic_output_bytes: usize, /// The `--option` / `--language` tokens, in argv order. forwarded: &'a [String], mode: Option<&'a str>, @@ -1127,6 +1150,18 @@ struct JsonRequest<'a> { /// drift. fn run_json(req: &JsonRequest) -> i32 { let mut argv: Vec = vec![req.binary.to_string(), "--json".into()]; + if req.atomic_facts { + match crate::embedded_specs::tree() { + Some(Ok(root)) => { + argv.push("--sleighpath".into()); + argv.push(root.to_string_lossy().into_owned()); + } + _ => { + eprintln!("error: atomic facts require readable embedded specs"); + return 2; + } + } + } if req.by_address { argv.push("--addr".into()); argv.push(if looks_like_addr(req.target) { @@ -1168,7 +1203,7 @@ fn run_json(req: &JsonRequest) -> i32 { // it has to reconcile against a prose stderr line — the missing error channel // is half of why this flag exists. The verdict follows the document, matching // `run`'s stdout-then-stderr ordering. - let (text, failure) = match decompile_json(&args, req.target) { + let (text, failure) = match decompile_json(&args, req.target, req.atomic_facts.then_some(req.atomic_output_bytes)) { Ok(answered) => answered, Err(message) => ( decompile_all::render_result_json( @@ -1197,7 +1232,7 @@ fn run_json(req: &JsonRequest) -> i32 { /// was asked about exactly that function — so `--json` cannot turn the text /// surface's non-zero verdict into a success; the reason is in the document too, /// as the record's `error` and as the run-level one. -fn decompile_json(args: &AllArgs, target: &str) -> Result<(String, Option), String> { +fn decompile_json(args: &AllArgs, target: &str, atomic_limit: Option) -> Result<(String, Option), String> { let mut prog = decompile_all::load_program(args, DriverDefaults::Decompile)?; let targets = decompile_all::resolve_targets(&prog, args)?; let interior = crate::interior::selections(&prog, args, &targets); @@ -1217,7 +1252,7 @@ fn decompile_json(args: &AllArgs, target: &str) -> Result<(String, Option Result<(String, Option Result<(), String> { park_recovered_proto: false, single_target: false, want_tokens: false, + want_atomic_facts: false, want_pointer_arguments: args.jobs_pointerargs, }; let mut pending = entries.into_iter(); @@ -1264,7 +1265,7 @@ fn decompile_all(args: &Args, filters: &Filters) -> Result { let funcs = if callee_first { decompile_entries_callee_first(&mut prog, args, targets, explicit) } else { - decompile_entries(&mut prog, args, targets, /* pointer_arguments= */ true) + decompile_entries(&mut prog, args, targets, /* pointer_arguments= */ true, false) }; Ok(AllRun { funcs, discovered, assertions: prog.assertion_outcomes(), interior }) } @@ -1319,6 +1320,7 @@ pub(crate) fn decompile_entries( args: &Args, targets: Vec, pointer_arguments: bool, + want_atomic_facts: bool, ) -> Vec { arm_fn_budgets(prog, args); let opts = DecompileOptions { @@ -1326,6 +1328,7 @@ pub(crate) fn decompile_entries( want_provenance: args.json, single_target: targets.len() == 1, want_pointer_arguments: pointer_arguments, + want_atomic_facts, ..DecompileOptions::default() }; kuna_console::project::decompile_targets_with(prog, targets, &opts) @@ -2003,6 +2006,19 @@ pub(crate) fn render_result_json( render_selected_json(binary, funcs, options, error, None, assertions, interior) } +pub(crate) fn render_atomic_result_json( + binary: &str, + funcs: &[FuncResult], + options: &[(String, String)], + error: Option<&str>, + assertions: &[kuna_console::assertions::Outcome], + interior: &crate::interior::Interiors, + limit: usize, +) -> Result { + let language = last_option_value(options, "setlanguage").unwrap_or("c-language"); + crate::atomic_json::render(result_json(binary, funcs, language, error, None, assertions, interior), limit) +} + /// [`render_result_json`] plus the inventory `total` a NARROWED whole-binary run /// answers with. /// @@ -2152,6 +2168,10 @@ fn result_json( // Always present, `[]` unless the option is on. ("types".into(), types), ]; + if let Some(detail) = f.detail.as_ref() { + append_atomic_facet(&mut record, "atomic_facts", detail.atomic_facts.as_ref()); + append_atomic_facet(&mut record, "atomic_calls", detail.atomic_calls.as_ref()); + } if !f.stack_objects.is_empty() { record.push(("stack_objects".into(), Json::Array( f.stack_objects.iter().map(stack_object_json).collect(), @@ -3519,3 +3539,16 @@ mod triage_tests { } } + +// Named independent facets share serialization/failure semantics; a bad facet +// cannot erase the ordinary C record or another valid facet. +fn append_atomic_facet(record: &mut Vec<(String, Json)>, name: &str, facts: Option<&Result>) { + if let Some(facts) = facts { + let encoded = facts.as_ref().map_err(Clone::clone) + .and_then(|facts| serde_json::to_value(facts).map_err(|error| format!("atomic facet serialization failed: {error}"))); + match encoded { + Ok(value) => record.push((name.into(), Json::from(value))), + Err(reason) => record.push((format!("{name}_error"), Json::Str(reason))), + } + } +} diff --git a/decompiler/crates/kuna-cli/src/decompile_all/callee_first.rs b/decompiler/crates/kuna-cli/src/decompile_all/callee_first.rs index 3eb2a6e4..2b951ba2 100644 --- a/decompiler/crates/kuna-cli/src/decompile_all/callee_first.rs +++ b/decompiler/crates/kuna-cli/src/decompile_all/callee_first.rs @@ -28,6 +28,7 @@ pub(super) fn decompile_entries_callee_first( park_recovered_proto: false, single_target: targets.len() == 1, want_tokens: false, + want_atomic_facts: false, want_pointer_arguments: true, }; decompile_callee_first(prog, args, targets, explicit, base) diff --git a/decompiler/crates/kuna-cli/src/jsonfmt.rs b/decompiler/crates/kuna-cli/src/jsonfmt.rs index 198b85a6..71bd9675 100644 --- a/decompiler/crates/kuna-cli/src/jsonfmt.rs +++ b/decompiler/crates/kuna-cli/src/jsonfmt.rs @@ -28,6 +28,19 @@ pub enum Json { Object(Vec<(String, Json)>), } +impl From for Json { + fn from(value: serde_json::Value) -> Self { + match value { + serde_json::Value::Null => Json::Null, + serde_json::Value::Bool(value) => Json::Bool(value), + serde_json::Value::Number(value) => Json::Number(value.to_string()), + serde_json::Value::String(value) => Json::Str(value), + serde_json::Value::Array(values) => Json::Array(values.into_iter().map(Json::from).collect()), + serde_json::Value::Object(values) => Json::Object(values.into_iter().map(|(key, value)| (key, Json::from(value))).collect()), + } + } +} + /// Render `v` exactly as CPython's `json.dumps(v, indent=2)` would (ASCII-safe /// string escaping, `", "`/`": "` item/key separators collapsed to the indent /// form: items separated by `,\n`, keys by `": "`). @@ -50,6 +63,12 @@ pub fn dumps_compact(v: &Json) -> String { writer::render(v, Format::Compact) } +/// Enforce the caller's encoded-byte budget before growing the output buffer. +/// Uses the same writer and escaping as the other modes; no length estimator. +pub fn dumps_compact_bounded(v: &Json, limit: usize) -> Option { + writer::render_bounded(v, Format::Compact, limit) +} + /// Extract the JSON array/object span out of a `decomp_dbg` console transcript. /// /// Port of `kuna/catalog.py::_extract_json`: drop the fixed `[decomp]>` prompt diff --git a/decompiler/crates/kuna-cli/src/jsonfmt/writer.rs b/decompiler/crates/kuna-cli/src/jsonfmt/writer.rs index c4eca1db..06b9d024 100644 --- a/decompiler/crates/kuna-cli/src/jsonfmt/writer.rs +++ b/decompiler/crates/kuna-cli/src/jsonfmt/writer.rs @@ -1,7 +1,7 @@ //! JSON layout and ASCII-safe escaping shared by all CLI output modes. use super::Json; -use std::fmt::Write as _; +use std::fmt::{self, Write}; #[derive(Clone, Copy, PartialEq, Eq)] pub(super) enum Format { @@ -12,108 +12,142 @@ pub(super) enum Format { pub(super) fn render(value: &Json, format: Format) -> String { let mut out = String::new(); - write_value(&mut out, value, 0, format); + write_value(&mut out, value, 0, format).expect("String writer is infallible"); out } +struct BoundedString { + text: String, + limit: usize, +} + +impl Write for BoundedString { + fn write_str(&mut self, value: &str) -> fmt::Result { + let next = self.text.len().checked_add(value.len()).ok_or(fmt::Error)?; + if next > self.limit { + return Err(fmt::Error); + } + if next > self.text.capacity() { + let capacity = next + .max(self.text.capacity().saturating_mul(2)) + .min(self.limit); + self.text + .try_reserve_exact(capacity - self.text.len()) + .map_err(|_| fmt::Error)?; + } + self.text.push_str(value); + Ok(()) + } +} + +pub(super) fn render_bounded(value: &Json, format: Format, limit: usize) -> Option { + let mut out = BoundedString { + text: String::new(), + limit, + }; + write_value(&mut out, value, 0, format).ok()?; + Some(out.text) +} + impl Format { - fn line(self, out: &mut String, indent: usize) { + fn line(self, out: &mut impl Write, indent: usize) -> fmt::Result { if self != Self::Compact { - out.push('\n'); + out.write_char('\n')?; for _ in 0..indent { - out.push(' '); + out.write_char(' ')?; } } + Ok(()) } } -fn write_value(out: &mut String, value: &Json, indent: usize, format: Format) { +fn write_value(out: &mut impl Write, value: &Json, indent: usize, format: Format) -> fmt::Result { match value { - Json::Null => out.push_str("null"), - Json::Bool(true) => out.push_str("true"), - Json::Bool(false) => out.push_str("false"), - Json::Number(number) => out.push_str(number), + Json::Null => out.write_str("null"), + Json::Bool(true) => out.write_str("true"), + Json::Bool(false) => out.write_str("false"), + Json::Number(number) => out.write_str(number), Json::Str(text) => write_string(out, text), Json::Array(items) => { - out.push('['); + out.write_char('[')?; for (i, item) in items.iter().enumerate() { if i > 0 { - out.push(','); + out.write_char(',')?; } - format.line(out, indent + 2); - write_value(out, item, indent + 2, format); + format.line(out, indent + 2)?; + write_value(out, item, indent + 2, format)?; } if !items.is_empty() { - format.line(out, indent); + format.line(out, indent)?; } - out.push(']'); + out.write_char(']') } Json::Object(pairs) => { if format == Format::Sorted && pairs.len() > 1 { let mut ordered: Vec<_> = pairs.iter().collect(); ordered.sort_by(|a, b| a.0.cmp(&b.0)); - write_pairs(out, ordered.into_iter(), indent, format); + write_pairs(out, ordered.into_iter(), indent, format) } else { - write_pairs(out, pairs.iter(), indent, format); + write_pairs(out, pairs.iter(), indent, format) } } } } fn write_pairs<'a>( - out: &mut String, + out: &mut impl Write, pairs: impl ExactSizeIterator, indent: usize, format: Format, -) { +) -> fmt::Result { let nonempty = pairs.len() != 0; - out.push('{'); + out.write_char('{')?; for (i, (key, value)) in pairs.enumerate() { if i > 0 { - out.push(','); + out.write_char(',')?; } - format.line(out, indent + 2); - write_string(out, key); - out.push(':'); + format.line(out, indent + 2)?; + write_string(out, key)?; + out.write_char(':')?; if format != Format::Compact { - out.push(' '); + out.write_char(' ')?; } - write_value(out, value, indent + 2, format); + write_value(out, value, indent + 2, format)?; } if nonempty { - format.line(out, indent); + format.line(out, indent)?; } - out.push('}'); + out.write_char('}') } -fn write_string(out: &mut String, s: &str) { - out.push('"'); +fn write_string(out: &mut impl Write, s: &str) -> fmt::Result { + out.write_char('"')?; for ch in s.chars() { match ch { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - '\u{0008}' => out.push_str("\\b"), - '\u{000C}' => out.push_str("\\f"), + '"' => out.write_str("\\\""), + '\\' => out.write_str("\\\\"), + '\n' => out.write_str("\\n"), + '\r' => out.write_str("\\r"), + '\t' => out.write_str("\\t"), + '\u{0008}' => out.write_str("\\b"), + '\u{000C}' => out.write_str("\\f"), c if (c as u32) < 0x20 => { - let _ = write!(out, "\\u{:04x}", c as u32); + write!(out, "\\u{:04x}", c as u32) } - c if (c as u32) < 0x7F => out.push(c), + c if (c as u32) < 0x7F => out.write_char(c), c => { // ensure_ascii: emit \uXXXX (surrogate pair for astral planes). let cp = c as u32; if cp <= 0xFFFF { - let _ = write!(out, "\\u{:04x}", cp); + write!(out, "\\u{:04x}", cp) } else { let v = cp - 0x10000; let hi = 0xD800 + (v >> 10); let lo = 0xDC00 + (v & 0x3FF); - let _ = write!(out, "\\u{:04x}\\u{:04x}", hi, lo); + write!(out, "\\u{:04x}\\u{:04x}", hi, lo) } } - } + }?; } - out.push('"'); + out.write_char('"') } diff --git a/decompiler/crates/kuna-cli/src/jsonfmt/writer_tests.rs b/decompiler/crates/kuna-cli/src/jsonfmt/writer_tests.rs index 6710e12a..ef90468c 100644 --- a/decompiler/crates/kuna-cli/src/jsonfmt/writer_tests.rs +++ b/decompiler/crates/kuna-cli/src/jsonfmt/writer_tests.rs @@ -94,3 +94,21 @@ fn every_unicode_scalar_round_trips_through_ascii_output() { assert_eq!(serde_json::from_str::(&output).unwrap(), text); } } + +#[test] +fn bounded_writer_counts_actual_escaped_bytes_at_every_boundary() { + let value = Json::Object(vec![("escaped".into(), Json::Str("\0\"\\é😀".into()))]); + let complete = dumps_compact(&value); + assert!(complete.is_ascii()); + for limit in 0..complete.len() { + assert!(dumps_compact_bounded(&value, limit).is_none()); + } + assert_eq!( + dumps_compact_bounded(&value, complete.len()), + Some(complete.clone()) + ); + assert_eq!( + dumps_compact_bounded(&value, complete.len() + 1), + Some(complete) + ); +} diff --git a/decompiler/crates/kuna-cli/src/main.rs b/decompiler/crates/kuna-cli/src/main.rs index 2345743c..40fff9ec 100644 --- a/decompiler/crates/kuna-cli/src/main.rs +++ b/decompiler/crates/kuna-cli/src/main.rs @@ -1,6 +1,7 @@ //! Command dispatch for the kuna CLI. mod args; +mod atomic_json; mod callgraph; mod catalog; mod crypto; diff --git a/decompiler/crates/kuna-cli/src/project_stream.rs b/decompiler/crates/kuna-cli/src/project_stream.rs index e40b18c9..b8cae233 100644 --- a/decompiler/crates/kuna-cli/src/project_stream.rs +++ b/decompiler/crates/kuna-cli/src/project_stream.rs @@ -858,6 +858,7 @@ fn export( // every function of the export. single_target: false, want_tokens: false, + want_atomic_facts: false, want_pointer_arguments: false, }; diff --git a/decompiler/crates/kuna-console/src/inspect.rs b/decompiler/crates/kuna-console/src/inspect.rs index 74024b10..36fc51a1 100644 --- a/decompiler/crates/kuna-console/src/inspect.rs +++ b/decompiler/crates/kuna-console/src/inspect.rs @@ -24,6 +24,8 @@ pub const FUNCTION_ROW_CAP: usize = 4096; pub struct FuncDetail { pub tokens: Vec, pub tokens_error: Option, + pub atomic_facts: Option>, + pub atomic_calls: Option>, } impl FuncDetail { @@ -40,13 +42,13 @@ impl FuncDetail { ) -> Self { let mut tokens = kuna_srcmap::resolve(fd, var_refs, raw, untrimmed, variables); if let Err(error) = kuna_srcmap::verify(&tokens, code) { - return FuncDetail { tokens: Vec::new(), tokens_error: Some(error) }; + return FuncDetail { tokens: Vec::new(), tokens_error: Some(error), ..Default::default() }; } for t in &mut tokens { t.address = t.address.map(|a| prog.output_code_offset(a)); t.callee = t.callee.map(|a| prog.output_code_offset(a)); } - FuncDetail { tokens, tokens_error: None } + FuncDetail { tokens, tokens_error: None, ..Default::default() } } } diff --git a/decompiler/crates/kuna-console/src/project.rs b/decompiler/crates/kuna-console/src/project.rs index bc401db2..a3c5fa95 100644 --- a/decompiler/crates/kuna-console/src/project.rs +++ b/decompiler/crates/kuna-console/src/project.rs @@ -245,6 +245,8 @@ pub struct DecompileOptions { /// Capture the token source map ([`FuncResult::detail`]); implies the /// provenance render. pub want_tokens: bool, + /// Retain the read-only recovered IR from the same function instance. + pub want_atomic_facts: bool, /// Record [`FuncResult::pointerargs`] for a buffered driver. A batch that /// returns its results ([`decompile_targets_with`]) reconciles them itself; /// a pulled caller owns that step. @@ -278,6 +280,7 @@ pub fn decompile_targets( park_recovered_proto: false, single_target: targets.len() == 1, want_tokens: false, + want_atomic_facts: false, want_pointer_arguments: false, }; decompile_batch(prog, targets, &opts) @@ -321,6 +324,7 @@ pub fn export_options(single_target: bool) -> DecompileOptions { park_recovered_proto: false, single_target, want_tokens: false, + want_atomic_facts: false, want_pointer_arguments: true, } } @@ -509,6 +513,7 @@ pub fn decompile_pulled( header_carries_types, single_target, want_tokens, + want_atomic_facts, want_pointer_arguments, .. } = *opts; @@ -838,11 +843,16 @@ pub fn decompile_pulled( } } let var_refs = provenance.apply_to_variables_with_refs(&fd, &mut variables); - let detail = want_tokens.then(|| { + let mut detail = want_tokens.then(|| { Box::new(crate::inspect::FuncDetail::resolve( prog, &fd, &var_refs, &raw_tokens, &untrimmed, &code, &variables, )) }); + if want_atomic_facts { + let detail = detail.get_or_insert_with(Default::default); + detail.atomic_calls = Some(kuna_decomp::atomic_calls::collect_all(&fd)); + detail.atomic_facts = Some(kuna_decomp::atomic_facts::collect(prog.arch(), &fd)); + } for variable in &mut variables { for address in &mut variable.addresses { *address = prog.output_code_offset(*address); diff --git a/decompiler/crates/kuna-decomp/Cargo.toml b/decompiler/crates/kuna-decomp/Cargo.toml index 40b093b6..8f03b654 100644 --- a/decompiler/crates/kuna-decomp/Cargo.toml +++ b/decompiler/crates/kuna-decomp/Cargo.toml @@ -15,11 +15,11 @@ kuna-sleigh.workspace = true thiserror.workspace = true smallvec.workspace = true slotmap.workspace = true +serde = { version = "1", features = ["derive"] } [dev-dependencies] # Verifier-only: the datatest engine (item w10-structure-printbody). regex = { workspace = true } -serde = { version = "1", features = ["derive"] } toml = { version = "1.1", default-features = false, features = ["parse", "serde", "std"] } [build-dependencies] diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_calls.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_calls.rs new file mode 100644 index 00000000..91ce7f60 --- /dev/null +++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_calls.rs @@ -0,0 +1,247 @@ +//! Recovered call relations. Argument values and passing storage are separate. +use crate::{ + atomic_values::{self, operation_id, value_id, ValueFact}, + context::OpId, + fspec::{FuncCallSpecs, OFFSET_UNKNOWN}, + funcdata::Funcdata, +}; +use kuna_base::address::Address; +use kuna_num::opcodes::OpCode; +use serde::Serialize; +use std::collections::{BTreeSet, HashMap}; + +#[derive(Debug, Clone, Serialize)] +pub struct CallFacts { + pub schema_version: u32, + pub items: Vec, + pub values: Vec, + pub coverage: CallCoverage, +} +#[derive(Debug, Clone, Serialize)] +pub struct CallCoverage { + pub scope: &'static str, + pub blocks_examined: i32, + pub operations_examined: usize, + pub value_scope: &'static str, + pub machine_call_equivalence: &'static str, +} + +pub fn collect_all(fd: &Funcdata) -> Result { + const MAX_CALLS: usize = 12_000; + const MAX_VALUES: usize = 36_000; + const MAX_INPUTS: usize = 50_000; + if fd.bblocks_get_size() <= 0 { + return Err("recovered block membership is unavailable".into()); + } + if fd.num_calls() < 0 || fd.num_calls() as usize > MAX_CALLS { + return Err("atomic call-spec capacity exceeded".into()); + } + let mut specs = HashMap::new(); + for i in 0..fd.num_calls() { + let spec = fd.get_call_specs(i); + if specs.insert(spec.get_op(), spec).is_some() { + return Err("duplicate native call-spec operation".into()); + } + } + let mut items = Vec::new(); + let mut keys = BTreeSet::new(); + let mut inputs = 0usize; + let mut text_bytes = 0usize; + let mut operations_examined = 0usize; + for i in 0..fd.bblocks_get_size() { + let block = fd.bblocks_get_block(i); + let expected = fd.bb_op_len(block); + let mut examined = 0; + let mut cursor = fd.bb_op_head(block); + // Walk native membership directly; do not allocate a Vec of every op in + // a large block merely to select its calls. Length also detects cycles. + while let Some(key) = cursor { + if examined >= expected { + return Err("cyclic native block operation list".into()); + } + examined += 1; + operations_examined += 1; + let op = fd.obank().get(key).ok_or("stale block operation")?; + if op.get_parent() != Some(block) { + return Err("call scan block ownership mismatch".into()); + } + cursor = op.basic_neighbours().1; + if !matches!(op.code(), OpCode::CPUI_CALL | OpCode::CPUI_CALLIND) { + continue; + } + if items.len() >= MAX_CALLS { + return Err("atomic call capacity exceeded".into()); + } + inputs = inputs + .checked_add(op.num_input() as usize) + .ok_or("atomic call input count overflow")?; + if inputs > MAX_INPUTS { + return Err("atomic call input capacity exceeded".into()); + } + let spec = specs.get(&key).copied(); + if let Some(spec) = spec { + atomic_values::charge_text(&mut text_bytes, spec.get_name().len())?; + if spec.proto().has_model() { + atomic_values::charge_text( + &mut text_bytes, + spec.proto().get_model_name().len(), + )?; + } + } + for index in 0..op.num_input() { + if let Some(v) = op.get_in(index) { + keys.insert(v); + } + } + if let Some(v) = op.get_out() { + keys.insert(v); + } + if keys.len() > MAX_VALUES { + return Err("atomic call value capacity exceeded".into()); + } + items.push(collect(fd, key, spec)?); + } + if examined != expected { + return Err("native block operation count mismatch".into()); + } + } + let values = atomic_values::collect(fd, keys, &mut text_bytes)?; + Ok(CallFacts { + schema_version: 1, items, values, + coverage: CallCoverage { + scope: "all recovered block-member CALL/CALLIND operations", + blocks_examined: fd.bblocks_get_size(), operations_examined, + value_scope: "call inputs including target and outputs only; definitions may be outside this facet", + machine_call_equivalence: "unknown", + }, + }) +} + +#[derive(Debug, Clone, Serialize)] +pub struct Location { + pub space: String, + pub offset: String, +} +#[derive(Debug, Clone, Serialize)] +pub struct Storage { + #[serde(flatten)] + pub location: Location, + pub size_bytes: i32, +} +#[derive(Debug, Clone, Serialize)] +pub struct Argument { + pub input_index: i32, + pub value_id: Option, + pub storage: Option, + pub storage_authority: Option<&'static str>, +} +#[derive(Debug, Clone, Serialize)] +pub struct Prototype { + pub model: Option, + pub model_unknown: Option, + pub inputs_locked: bool, + pub variadic: bool, + pub parameter_shift: i32, +} +#[derive(Debug, Clone, Serialize)] +pub struct CallFact { + pub id: String, + pub origin_address: String, + pub block: i32, + pub opcode: String, + pub is_dead: bool, + pub target_value_id: Option, + pub spec_available: bool, + pub target: Option, + pub target_name: Option, + pub arguments: Vec, + pub output: Option, + pub prototype: Option, + pub stack_offset: Option, +} +fn location(address: &Address) -> Option { + address.get_space().map(|space| Location { + space: space.get_name().to_string(), + offset: format!("0x{:x}", address.get_offset()), + }) +} +fn storage(address: &Address, size_bytes: i32) -> Option { + if size_bytes <= 0 { + return None; + } + location(address).map(|location| Storage { + location, + size_bytes, + }) +} + +fn collect(fd: &Funcdata, key: OpId, spec: Option<&FuncCallSpecs>) -> Result { + let op = fd.obank().get(key).ok_or("stale call operation")?; + let arguments = (1..op.num_input()) + .map(|input_index| { + // The native owner stores finalized passing locations separately + // from SSA values. Admit that mapping only with matching arity. + // Placeholder/shifted inputs are not source parameter ordinals. + let binding = spec.and_then(|spec| { + if spec.get_paramshift() != 0 || spec.get_stack_placeholder_slot() == input_index { + return None; + } + if spec.final_input_storage().len() + 1 == op.num_input() as usize { + let (address, size) = &spec.final_input_storage()[(input_index - 1) as usize]; + return storage(address, *size).map(|s| (s, "final-input-storage")); + } + let proto = spec.proto(); + if proto.has_store() && proto.is_input_locked() { + return proto.get_param(input_index - 1).and_then(|param| { + storage(¶m.get_address(), param.get_size()) + .map(|s| (s, "locked-prototype")) + }); + } + None + }); + let (storage, storage_authority) = match binding { + Some((s, authority)) => (Some(s), Some(authority)), + None => (None, None), + }; + Argument { + input_index, + value_id: op.get_in(input_index).map(value_id), + storage, + storage_authority, + } + }) + .collect(); + Ok(CallFact { + id: operation_id(fd, key)?, + origin_address: format!("0x{:x}", op.get_seq_num().get_addr().get_offset()), + block: fd + .bblocks_ref() + .block(op.get_parent().ok_or("call has no recovered block")?) + .get_index(), + opcode: format!("{:?}", op.code()), + is_dead: op.is_dead(), + target_value_id: op.get_in(0).map(value_id), + spec_available: spec.is_some(), + target: spec.and_then(|s| location(s.get_entry_address())), + target_name: spec + .and_then(|s| (!s.get_name().is_empty()).then(|| s.get_name().to_string())), + arguments, + output: op.get_out().map(value_id), + prototype: spec.map(|s| { + let proto = s.proto(); + Prototype { + model: proto + .has_model() + .then(|| proto.get_model_name().to_string()), + model_unknown: proto.has_model().then(|| proto.is_model_unknown()), + inputs_locked: proto.is_input_locked(), + variadic: proto.is_dotdotdot(), + parameter_shift: s.get_paramshift(), + } + }), + stack_offset: spec.and_then(|s| { + (s.get_spacebase_offset() != OFFSET_UNKNOWN) + .then(|| format!("0x{:x}", s.get_spacebase_offset())) + }), + }) +} diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_facts.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_facts.rs new file mode 100644 index 00000000..3dfad1aa --- /dev/null +++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_facts.rs @@ -0,0 +1,231 @@ +//! Opt-in, read-only export of the recovered function IR for the REA experiment. +//! IDs are local to this captured model; addresses alone are not SSA identities. +use crate::{ + architecture::Architecture, + atomic_values::{self, operation_id, value_id, ValueFact}, + funcdata::Funcdata, +}; +use kuna_num::opcodes::OpCode; +use serde::Serialize; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Serialize)] +pub struct FunctionFacts { + pub schema_version: u32, + pub blocks: Vec, + pub operations: Vec, + pub values: Vec, + pub coverage: Coverage, +} +#[derive(Debug, Clone, Serialize)] +pub struct Coverage { + pub scope: &'static str, + pub machine_semantic_equivalence: &'static str, + pub indirect_branch_operations: Vec, + pub omitted_definition_ids: Vec, + pub issues: Vec, +} +#[derive(Debug, Clone, Serialize)] +pub struct BlockFact { + pub id: i32, + pub start: String, + pub operations: Vec, + pub predecessors: Vec, + pub successors: Vec, +} +#[derive(Debug, Clone, Serialize)] +pub struct EdgeFact { + pub id: String, + pub target: i32, + pub index: i32, + pub polarity: Option<&'static str>, + pub condition_value: Option, + pub condition_test: Option<&'static str>, +} +#[derive(Debug, Clone, Serialize)] +pub struct OperationFact { + pub id: String, + pub address: String, + pub block: i32, + pub opcode: String, + pub statement: String, + pub is_dead: bool, + pub inputs: Vec, + pub output: Option, +} +#[derive(Debug, Clone, Serialize)] +pub struct InputFact { + pub input_index: i32, + pub value_id: Option, + pub phi_predecessor: Option, + pub phi_predecessor_edge: Option, +} + +pub fn collect(arch: &Architecture, fd: &Funcdata) -> Result { + // Bounds cover allocations before JSON serialization, independently of the + // transport byte budget. Overflow is an unavailable facet, not clipped IR. + const MAX_OPERATIONS: usize = 12_000; + const MAX_VALUES: usize = 36_000; + const MAX_INPUTS: usize = 50_000; + const MAX_DISPLAY_BYTES: usize = 2 * 1024 * 1024; + if fd.bblocks_get_size() <= 0 { + return Err("recovered basic-block graph is unavailable".into()); + } + if fd.bblocks_get_size() > 12_000 { + return Err("atomic block capacity exceeded".into()); + } + let mut blocks = Vec::new(); + let mut operations = Vec::new(); + let mut value_keys = BTreeSet::new(); + let mut retained_ops = BTreeSet::new(); + let mut input_count = 0usize; + let mut edge_count = 0usize; + let mut display_bytes = 0usize; + let mut coverage = Coverage { + scope: "complete native recovered basic-block graph; block-member operations", + machine_semantic_equivalence: "unknown", + indirect_branch_operations: Vec::new(), + omitted_definition_ids: Vec::new(), + issues: Vec::new(), + }; + for block_index in 0..fd.bblocks_get_size() { + let block_key = fd.bblocks_get_block(block_index); + let block = fd.bblocks_ref().block(block_key); + let members = fd.bb_ops(block_key); + if members.len() > MAX_OPERATIONS - operations.len() { + return Err("atomic operation capacity exceeded".into()); + } + edge_count = edge_count + .checked_add(block.size_out() as usize) + .ok_or("atomic CFG edge count overflow")?; + if edge_count > MAX_INPUTS { + return Err("atomic CFG edge capacity exceeded".into()); + } + let tail = members.last().and_then(|id| fd.obank().get(*id)); + let conditional = + tail.filter(|op| op.code() == OpCode::CPUI_CBRANCH && block.size_out() == 2); + let condition_value = conditional.and_then(|op| op.get_in(1)).map(value_id); + let condition_test = conditional.map(|op| { + if op.is_boolean_flip() { + "eq-zero" + } else { + "ne-zero" + } + }); + let successors = (0..block.size_out()) + .map(|i| EdgeFact { + id: format!("{}:{i}", block.get_index()), + target: fd.bblocks_ref().block(block.get_out(i)).get_index(), + index: i, + polarity: conditional.map(|_| if i == 0 { "false" } else { "true" }), + condition_value: condition_value.clone(), + condition_test, + }) + .collect(); + blocks.push(BlockFact { + id: block.get_index(), + start: format!("0x{:x}", fd.bblocks_block_start(block_key).get_offset()), + operations: members + .iter() + .map(|id| operation_id(fd, *id)) + .collect::>()?, + predecessors: (0..block.size_in()) + .map(|i| fd.bblocks_ref().block(block.get_in(i)).get_index()) + .collect(), + successors, + }); + for key in members { + if operations.len() >= MAX_OPERATIONS { + return Err("atomic operation capacity exceeded".into()); + } + let op = fd.obank().get(key).ok_or("stale block operation")?; + let id = operation_id(fd, key)?; + if !retained_ops.insert(id.clone()) { + return Err("operation belongs to multiple recovered blocks".into()); + } + if op.get_parent() != Some(block_key) { + return Err("operation/block ownership mismatch".into()); + } + input_count = input_count + .checked_add(op.num_input() as usize) + .ok_or("atomic input count overflow")?; + if input_count > MAX_INPUTS { + return Err("atomic input-edge capacity exceeded".into()); + } + if op.code() == OpCode::CPUI_BRANCHIND { + coverage.indirect_branch_operations.push(id.clone()); + } + let phi = op.code() == OpCode::CPUI_MULTIEQUAL; + if phi && block.size_in() != op.num_input() { + coverage + .issues + .push(format!("{id}: phi/predecessor count mismatch")); + } + let inputs = (0..op.num_input()) + .map(|index| { + let value = op.get_in(index); + if let Some(v) = value { + value_keys.insert(v); + } + InputFact { + input_index: index, + value_id: value.map(value_id), + phi_predecessor: if phi && block.size_in() == op.num_input() { + Some(fd.bblocks_ref().block(block.get_in(index)).get_index()) + } else { + None + }, + phi_predecessor_edge: if phi && block.size_in() == op.num_input() { + Some(format!( + "{}:{}", + fd.bblocks_ref().block(block.get_in(index)).get_index(), + block.get_in_rev_index(index) + )) + } else { + None + }, + } + }) + .collect(); + if let Some(v) = op.get_out() { + value_keys.insert(v); + } + if value_keys.len() > MAX_VALUES { + return Err("atomic value capacity exceeded".into()); + } + let mut statement = String::new(); + crate::funcdata_printraw::render_op(&mut statement, arch, fd, key)?; + display_bytes = display_bytes + .checked_add(statement.len()) + .ok_or("atomic display count overflow")?; + if display_bytes > MAX_DISPLAY_BYTES { + return Err("atomic display capacity exceeded".into()); + } + operations.push(OperationFact { + id, + address: format!("0x{:x}", op.get_seq_num().get_addr().get_offset()), + block: block.get_index(), + opcode: format!("{:?}", op.code()), + statement, + is_dead: op.is_dead(), + inputs, + output: op.get_out().map(value_id), + }); + } + } + let values = atomic_values::collect(fd, value_keys, &mut display_bytes)?; + let missing: BTreeSet<_> = values + .iter() + .filter_map(|v| v.definition.as_ref()) + .filter(|id| !retained_ops.contains(*id)) + .cloned() + .collect(); + coverage.omitted_definition_ids = missing.into_iter().collect(); + Ok(FunctionFacts { + schema_version: 3, + blocks, + operations, + values, + coverage, + }) +} diff --git a/decompiler/crates/kuna-decomp/src/substrate/atomic_values.rs b/decompiler/crates/kuna-decomp/src/substrate/atomic_values.rs new file mode 100644 index 00000000..fc509847 --- /dev/null +++ b/decompiler/crates/kuna-decomp/src/substrate/atomic_values.rs @@ -0,0 +1,70 @@ +//! One read-only interpretation of native value and operation identities. +use crate::{ + context::{OpId, VarnodeId}, + funcdata::Funcdata, +}; +use serde::Serialize; +use slotmap::Key; +use std::collections::BTreeSet; + +pub const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024; +#[derive(Debug, Clone, Serialize)] +pub struct ValueFact { + pub id: String, + pub space: Option, + pub offset: String, + pub size_bytes: i32, + pub is_constant: bool, + pub is_input: bool, + pub is_annotation: bool, + pub definition: Option, + pub type_name: String, +} +pub fn value_id(id: VarnodeId) -> String { + format!("v{:x}", id.data().as_ffi()) +} +pub fn operation_id(fd: &Funcdata, id: OpId) -> Result { + let operation = fd.obank().get(id).ok_or("stale operation identity")?; + let seq = operation.get_seq_num(); + Ok(format!( + "0x{:x}:{:x}", + seq.get_addr().get_offset(), + seq.get_time() + )) +} +pub fn charge_text(used: &mut usize, bytes: usize) -> Result<(), String> { + *used = used + .checked_add(bytes) + .ok_or("atomic text count overflow")?; + if *used > MAX_TEXT_BYTES { + return Err("atomic text capacity exceeded".into()); + } + Ok(()) +} +pub fn collect( + fd: &Funcdata, + keys: BTreeSet, + text_bytes: &mut usize, +) -> Result, String> { + let mut values = Vec::with_capacity(keys.len()); + for key in keys { + let value = fd.vbank().get(key).ok_or("stale input/output value")?; + let type_name = value.get_type().get_name(); + charge_text(text_bytes, type_name.len())?; + values.push(ValueFact { + id: value_id(key), + space: value + .get_addr() + .get_space() + .map(|s| s.get_name().to_string()), + offset: format!("0x{:x}", value.get_offset()), + size_bytes: value.get_size(), + is_constant: value.is_constant(), + is_input: value.is_input(), + is_annotation: value.is_annotation(), + definition: value.get_def().map(|id| operation_id(fd, id)).transpose()?, + type_name: type_name.to_string(), + }); + } + Ok(values) +} diff --git a/decompiler/crates/kuna-decomp/src/substrate/funcdata_printraw.rs b/decompiler/crates/kuna-decomp/src/substrate/funcdata_printraw.rs index 39927ceb..844950a6 100644 --- a/decompiler/crates/kuna-decomp/src/substrate/funcdata_printraw.rs +++ b/decompiler/crates/kuna-decomp/src/substrate/funcdata_printraw.rs @@ -376,7 +376,7 @@ fn callother_operator_name( /// dispatch. The special op-codes (LOAD/STORE/branches/calls/RETURN/marker) /// match their `TypeOp*::printRaw` override; everything else falls to the /// generic Binary/Unary/Func shape keyed by the [`TypeOpClass`]. -fn render_op(s: &mut String, arch: &Architecture, fd: &Funcdata, op: OpId) -> Result<(), String> { +pub(crate) fn render_op(s: &mut String, arch: &Architecture, fd: &Funcdata, op: OpId) -> Result<(), String> { let opc = fd.obank().get(op).ok_or_else(|| "render_op: stale op".to_string())?.code(); let num_input = fd.obank().get(op).ok_or_else(|| "render_op: stale op".to_string())?.num_input(); let in_at = |i: int4| -> Option { fd.obank().get(op).and_then(|o| o.get_in(i)) }; diff --git a/decompiler/crates/kuna-decomp/src/substrate/mod.rs b/decompiler/crates/kuna-decomp/src/substrate/mod.rs index 00528cb5..96b10a6c 100644 --- a/decompiler/crates/kuna-decomp/src/substrate/mod.rs +++ b/decompiler/crates/kuna-decomp/src/substrate/mod.rs @@ -11,6 +11,9 @@ pub mod funcdata_block; pub mod funcdata_encode; pub mod funcdata_op; pub mod funcdata_printraw; +pub mod atomic_facts; +pub mod atomic_calls; +pub mod atomic_values; pub mod funcdata_varnode; pub mod dtype; pub mod context; diff --git a/docs/spec/00-overview.md b/docs/spec/00-overview.md index ce499c4f..976878d0 100644 --- a/docs/spec/00-overview.md +++ b/docs/spec/00-overview.md @@ -3431,3 +3431,50 @@ Reserving it creates no analysis varnode and changes no physical mapping or emitted C. Ghidra receives temporary storage for the logical local, rather than a persistable ordinary stack-variable location; ordinary symbol storage and register-plus-stack parameter JOINs keep their existing encoding. + +## Experimental read-only atomic facts + +The opt-in `decompile --json --atomic-facts` surface captures the same live +function after C rendering (`decompiler/crates/kuna-decomp/src/substrate/atomic_facts.rs`). +It does not change the analysis phases or C output. The default path does not +collect these records. This experiment requires embedded processor specs and +selects those assets explicitly; a caller-provided `--sleighpath` is rejected. + +The export retains ordered basic-block predecessors and successors, slotmap +value identities, operation sequence identities, operand indexes, outputs and +phi input-to-predecessor-edge bindings. IDs belong to this captured function +model; instruction addresses are origins, not SSA value identities. Native +recovered graph completeness does not establish runtime successor coverage, +memory alias dependencies or machine-semantic equivalence. Indirect-branch +operations and definitions outside block membership remain explicit. Capacity +failures make this facet unavailable with a reason and preserve the C record; +they do not emit a clipped graph labeled complete. + +`--atomic-output-bytes N` sets the encoded document budget (default 8 MiB). +Atomic JSON uses the ordinary record producer and the shared compact ASCII-safe +writer. That writer checks each encoded write before growing its bounded output +buffer, including control/surrogate escaping and the final newline. A document +that cannot fit drops only its graph facet, reports `atomic_facts_error`, and +retains C, locations and ordinary metadata. If that base record also cannot fit, +the command reports failure instead of truncating it or analyzing the target +again. This flag requires `--atomic-facts --json`; ordinary output is unchanged. + +Graph schema version 3 and an independent `atomic_calls` version-1 facet are +captured from the same live function after C rendering. The call facet scans +all native block members without allocating a copy of every block's operations; +it has its own call/input/value/text limits. Full-graph capacity failure leaves +calls and their boundary values available. Shared value extraction owns the +native value identities, flags, widths and definitions; boundary values do not +claim a complete function value/definition graph. + +`FuncCallSpecs` supplies recovered targets, names, prototype flags and passing +storage. Target input zero remains an SSA value (often an fspec annotation), +never a callee code address. CALLIND targets can remain unknown. Storage uses +matching-arity finalized locations or locked prototype storage; shifted inputs +and placeholders stay unknown. Input slots are not source parameter ordinals. + +The encoded writer attempts bounded facet alternatives without cloning their +contents. Oversized graph output preserves fitting calls; oversized calls +preserve a fitting graph. If neither fits, valid C and both failure reasons +remain. If C itself cannot fit, output fails explicitly without reanalysis. +Facet errors do not fabricate empty graph/call/value collections or change C.