Deep mode decompiles position-independent programs by name: addresses move between our image base and Ghidra's (#426)

In Deep mode, a position-independent program is decompiled by name: addresses move between our image base and the one Ghidra loaded it at (the bridge now reports it), with engine_entry and an engine_rebased limitation when they differ.
This commit is contained in:
bently-bot
2026-11-02 16:50:33 +00:00
committed by GitHub
parent 531f546dc0
commit 11166b3589
7 changed files with 280 additions and 26 deletions
+138 -14
View File
@@ -120,13 +120,29 @@ fn remember(ctx: &mut Ctx<'_>, provider: Result<Arc<dyn DeepDecompiler>, String>
provider.map_err(Some)
}
/// Decompile `entry` with the deep engine if available. `Err(why)` → the caller uses the built-in
/// lifter and passes on why Ghidra wasn't used (`None` in Clean room). `Ok(Err(..))` only for a
/// cancellation the caller should surface.
pub fn pseudo_code(ctx: &mut Ctx<'_>, entry: u64, params: serde_json::Value) -> Result<Result<ToolOutput, ToolError>, Option<String>> {
/// An address for the deep engine, and whose address space it is in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum At {
/// The built-in engine's, as every other tool reports it.
Ours(u64),
/// Already Ghidra's: the address in a name Ghidra made (`FUN_00101160`).
Engine(u64),
}
/// Decompile the function at `at` with the deep engine if available. `our_base` is the image
/// base the built-in engine reads the program at; Ghidra may have loaded it elsewhere (a
/// position-independent ELF goes to 0x100000), so addresses are moved between the two.
/// `Err(why)` → the caller uses the built-in lifter and passes on why Ghidra wasn't used (`None`
/// in Clean room). `Ok(Err(..))` only for a cancellation the caller should surface.
pub fn pseudo_code(ctx: &mut Ctx<'_>, at: At, our_base: u64, params: serde_json::Value) -> Result<Result<ToolOutput, ToolError>, Option<String>> {
let provider = provider_for_active(ctx)?;
let rebase = ghidra::Rebase::new(our_base, provider.image_base());
let entry = match at {
At::Ours(a) => rebase.to_engine(a),
At::Engine(a) => a,
};
match provider.decompile(entry) {
Ok(Some(df)) => Ok(build_evidence(ctx, provider.as_ref(), df, params)),
Ok(Some(df)) => Ok(build_evidence(ctx, provider.as_ref(), df, rebase, params)),
Ok(None) => Err(Some("Ghidra had no answer for this function.".into())),
Err(ghidra::GhidraError::Cancelled) => Ok(Err(ToolError::cancelled())),
Err(e) => {
@@ -150,7 +166,13 @@ pub fn pseudo_code(ctx: &mut Ctx<'_>, entry: u64, params: serde_json::Value) ->
}
}
fn build_evidence(ctx: &Ctx<'_>, provider: &dyn DeepDecompiler, df: DeepFunction, params: serde_json::Value) -> Result<ToolOutput, ToolError> {
fn build_evidence(
ctx: &Ctx<'_>,
provider: &dyn DeepDecompiler,
df: DeepFunction,
rebase: ghidra::Rebase,
params: serde_json::Value,
) -> Result<ToolOutput, ToolError> {
let subject = ctx.session.active()?.target.subject();
let env = Environment {
clean_room: false,
@@ -160,22 +182,38 @@ fn build_evidence(ctx: &Ctx<'_>, provider: &dyn DeepDecompiler, df: DeepFunction
language_id: provider.language_id(),
};
let line_count = df.pseudocode.as_deref().map(|p| p.lines().count()).unwrap_or(0);
// The entry as every other tool gives it; Ghidra's own only when it differs.
let entry = rebase.from_engine(df.entry);
let mut result = serde_json::json!({
"function": df.name,
"entry": format!("{entry:#x}"),
"signature": df.signature,
"pseudo_code": df.pseudocode,
"engine": provider.engine(),
});
if !rebase.is_identity() {
result["engine_entry"] = serde_json::json!(format!("{:#x}", df.entry));
}
let mut e = EvidenceBuilder::new("native_function_pseudocode", subject, env)
.params(params)
.result(serde_json::json!({
"function": df.name,
"entry": format!("{:#x}", df.entry),
"signature": df.signature,
"pseudo_code": df.pseudocode,
"engine": provider.engine(),
}))
.result(result)
.summary(format!("Pseudo-C for {} from the Deep engine ({line_count} lines).", df.name))
// Deep decompilation is derived by a mature external engine: stronger than the built-in
// inferred lifter, but still a reading aid rather than source.
.confidence(Confidence::Derived)
.target_content(true)
.limitation(Limitation::new("deep_engine", "the pseudo-C comes from the external Deep engine (Ghidra), not the original source"))
.location(Location::address(df.entry));
.location(Location::address(entry));
if !rebase.is_identity() {
e = e.limitation(Limitation::new(
"engine_rebased",
format!(
"Ghidra loaded this program at {:#x}, not where the built-in engine reads it ({:#x}); entry is the built-in address, engine_entry Ghidra's, and the addresses in Ghidra's names inside the pseudo-C (FUN_…, DAT_…) are Ghidra's",
rebase.theirs(),
rebase.ours(),
),
));
}
if df.created {
e = e.limitation(Limitation::new(
"made_on_demand",
@@ -184,3 +222,89 @@ fn build_evidence(ctx: &Ctx<'_>, provider: &dyn DeepDecompiler, df: DeepFunction
}
Ok(ToolOutput::evidence(e.build()?))
}
#[cfg(test)]
mod tests {
use super::super::native_common::test_support::*;
use super::*;
use craft_anything_testkit as tk;
use serde_json::json;
use std::sync::atomic::{AtomicU64, Ordering};
/// A deep engine that loaded the program at `base`, not where its headers say, as Ghidra does
/// with a position-independent ELF: it knows only its own addresses, and remembers the last
/// one it was asked for.
struct Moved {
base: u64,
asked: AtomicU64,
}
impl DeepDecompiler for Moved {
fn engine(&self) -> &'static str {
"ghidra"
}
fn engine_version(&self) -> Option<String> {
Some("test".into())
}
fn image_base(&self) -> Option<u64> {
Some(self.base)
}
fn decompile(&self, entry: u64) -> Result<Option<DeepFunction>, ghidra::GhidraError> {
self.asked.store(entry, Ordering::SeqCst);
if entry < self.base {
return Err(ghidra::GhidraError::Bridge { code: "not_found".into(), message: "the address is not in executable memory".into() });
}
let pseudocode = Some(format!("int FUN_{entry:08x}(void) {{ return 0; }}"));
Ok(Some(DeepFunction { entry, name: format!("FUN_{entry:08x}"), signature: None, pseudocode, created: false }))
}
fn list_functions(&self) -> Result<Vec<DeepFunction>, ghidra::GhidraError> {
Ok(Vec::new())
}
}
#[test]
fn a_function_named_in_our_addresses_reaches_an_engine_that_loaded_the_program_elsewhere() {
let (elf, l) = tk::elf64(tk::ElfArch::X86_64, true);
let e = engine_with("prog", &elf);
const SHIFT: u64 = 0x10_0000;
let moved = Arc::new(Moved { base: tk::ELF_BASE + SHIFT, asked: AtomicU64::new(0) });
{
let mut s = e.session();
s.settings.deep_mode = true;
let provider: Arc<dyn DeepDecompiler> = moved.clone();
s.active_mut().unwrap().ext.insert(Arc::new(DeepSlot { provider: Ok(provider) }));
}
// By name: the engine is asked for its own address, and the answer is in ours.
let r = call(&e, "native_function_pseudocode", json!({"function": "main"}));
assert_eq!(r["environment"]["engine"], "ghidra", "{r}");
assert_eq!(moved.asked.load(Ordering::SeqCst), l.code_va + SHIFT);
assert_eq!(r["result"]["entry"], format!("{:#x}", l.code_va));
assert_eq!(r["result"]["engine_entry"], format!("{:#x}", l.code_va + SHIFT));
let why = r["limitations"].as_array().unwrap().iter().find(|x| x["code"] == "engine_rebased").expect("says the addresses were moved");
assert!(why.to_string().contains(&format!("{:#x}", tk::ELF_BASE + SHIFT)), "{why}");
// A name Ghidra made carries Ghidra's address: passed on as it is. (A suffix with a hex
// letter: one of only digits is read as decimal until #318 is fixed.)
let ghidra_name = format!("FUN_{:08x}", l.code_va + SHIFT + 0x7a00);
let r = call(&e, "native_function_pseudocode", json!({"function": ghidra_name}));
assert_eq!(moved.asked.load(Ordering::SeqCst), l.code_va + SHIFT + 0x7a00);
assert_eq!(r["result"]["entry"], format!("{:#x}", l.code_va + 0x7a00));
}
#[test]
fn an_engine_at_our_base_gets_our_addresses_unchanged() {
let (elf, l) = tk::elf64(tk::ElfArch::X86_64, true);
let e = engine_with("prog", &elf);
let same = Arc::new(Moved { base: tk::ELF_BASE, asked: AtomicU64::new(0) });
{
let mut s = e.session();
s.settings.deep_mode = true;
let provider: Arc<dyn DeepDecompiler> = same.clone();
s.active_mut().unwrap().ext.insert(Arc::new(DeepSlot { provider: Ok(provider) }));
}
let r = call(&e, "native_function_pseudocode", json!({"function": "main"}));
assert_eq!(same.asked.load(Ordering::SeqCst), l.code_va);
assert_eq!(r["result"]["entry"], format!("{:#x}", l.code_va));
assert!(r["result"].get("engine_entry").is_none(), "{r}");
assert!(!r["limitations"].as_array().unwrap().iter().any(|x| x["code"] == "engine_rebased"));
}
}
+15 -4
View File
@@ -381,20 +381,24 @@ fn native_function_pseudocode(ctx: &mut Ctx<'_>, params: Value) -> Result<ToolOu
// built-in analysis stops short on a huge program (a 200 MB game has far more functions than
// it maps), so in Deep mode an address it can't place (0x…, FUN_…, sub_…) goes to Ghidra,
// which analysed the whole program and finds the function containing it.
let (entry, unknown) = {
// Ghidra may load the program somewhere else (a position-independent ELF goes to 0x100000),
// so the deep hook is told whose address this is: ours, or one from a name Ghidra made.
let (at, our_base, unknown) = {
let st = native(ctx)?;
let t = ctx.session.active()?;
let our_base = st.img.bin.image_base;
match resolve_function(t, &st.prog, &p.function) {
Ok(f) => (f.entry, None),
Ok(f) => (super::deep::At::Ours(f.entry), our_base, None),
Err(e) => match ghidra_address(&p.function).filter(|_| !ctx.session.clean_room()) {
Some(a) => (a, Some(e)),
Some(a) if is_ghidra_name(&p.function) => (super::deep::At::Engine(a), our_base, Some(e)),
Some(a) => (super::deep::At::Ours(a), our_base, Some(e)),
None => return Err(e),
},
}
};
// Deep mode (Ghidra) is preferred through this same tool id when it is on and available;
// otherwise, and whenever anything about it fails, we use the built-in clean-room lifter.
let why_not_deep = match super::deep::pseudo_code(ctx, entry, params_json(&p)) {
let why_not_deep = match super::deep::pseudo_code(ctx, at, our_base, params_json(&p)) {
Ok(result) => return result,
Err(why) => why,
};
@@ -427,6 +431,13 @@ fn native_function_pseudocode(ctx: &mut Ctx<'_>, params: Value) -> Result<ToolOu
}
/// An address Ghidra can look up: `0x…`, a bare hex address, or a generated name like `FUN_…`.
/// Whether `function` is a name Ghidra made from an address (`FUN_00101160`), so the address in it
/// is Ghidra's rather than ours.
fn is_ghidra_name(function: &str) -> bool {
let s = function.trim();
["FUN_", "SUB_", "sub_", "LAB_"].iter().any(|p| s.starts_with(p))
}
fn ghidra_address(function: &str) -> Option<u64> {
let s = function.trim();
if let Some(hex) = ["FUN_", "SUB_", "sub_", "LAB_"].iter().find_map(|p| s.strip_prefix(p)) {
@@ -132,7 +132,8 @@ public class CraftAnythingBridge extends GhidraScript {
return "{\"name\":\"CraftAnything bridge\",\"provider\":{\"id\":\"ghidra\",\"version\":\""
+ jsonEscape(ghidra.framework.Application.getApplicationVersion())
+ "\"},\"read_only\":true,\"analysed\":true,\"analysis_cut_short\":false,\"target\":{\"name\":\""
+ jsonEscape(currentProgram.getName()) + "\"}}";
+ jsonEscape(currentProgram.getName()) + "\",\"image_base\":\"0x"
+ Long.toHexString(currentProgram.getImageBase().getOffset()) + "\"}}";
}
if ("native_functions_list".equals(method)) {
StringBuilder sb = new StringBuilder("{\"functions\":[");
+63 -1
View File
@@ -67,12 +67,61 @@ pub trait DeepDecompiler: Send + Sync {
fn language_id(&self) -> Option<String> {
None
}
/// Decompile the function at `entry`. `Ok(None)` means the engine has no better answer.
/// Where the engine loaded the program (its image base), when it said. Ghidra puts a
/// position-independent ELF at 0x100000, not at the 0 its program headers give, so its
/// addresses differ from ours by that much; see [`Rebase`].
fn image_base(&self) -> Option<u64> {
None
}
/// Decompile the function at `entry` (an address in the engine's own space). `Ok(None)` means
/// the engine has no better answer.
fn decompile(&self, entry: u64) -> Result<Option<DeepFunction>, GhidraError>;
/// List the functions the engine found.
fn list_functions(&self) -> Result<Vec<DeepFunction>, GhidraError>;
}
/// Moves addresses between our built-in engine's view of a program and the Deep engine's, which
/// may have loaded it somewhere else: Ghidra puts a position-independent ELF (lowest segment at 0)
/// at 0x100000. Both sides keep the same offsets from their image base, so moving an address is
/// one subtraction and one addition. An address below the base it's moved from stays as it is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rebase {
ours: u64,
theirs: u64,
}
impl Rebase {
/// `ours`: our image base; `theirs`: the engine's, `None` when it didn't say (then no move).
pub fn new(ours: u64, theirs: Option<u64>) -> Rebase {
Rebase { ours, theirs: theirs.unwrap_or(ours) }
}
/// Our image base.
pub fn ours(&self) -> u64 {
self.ours
}
/// The engine's image base (ours when it didn't say).
pub fn theirs(&self) -> u64 {
self.theirs
}
/// Whether both sides use the same addresses.
pub fn is_identity(&self) -> bool {
self.ours == self.theirs
}
/// Our address as the engine knows it.
pub fn to_engine(&self, a: u64) -> u64 {
a.checked_sub(self.ours).and_then(|o| o.checked_add(self.theirs)).unwrap_or(a)
}
/// The engine's address as we know it.
pub fn from_engine(&self, a: u64) -> u64 {
a.checked_sub(self.theirs).and_then(|o| o.checked_add(self.ours)).unwrap_or(a)
}
}
/// Where Ghidra and its JDK live, and how long to wait.
#[derive(Debug, Clone)]
pub struct Config {
@@ -245,6 +294,8 @@ pub struct Session {
version: Option<String>,
/// The forced language id, when Deep mode was pinned to a processor.
language_id: Option<String>,
/// Where Ghidra loaded the program, from the bridge's handshake.
image_base: Option<u64>,
}
impl std::fmt::Debug for Session {
@@ -558,6 +609,7 @@ impl Session {
// First ping proves the token and that analysis finished.
let info = client.ping(cancel, deadline)?;
let version = info.get("provider").and_then(|p| p.get("version")).and_then(Value::as_str).map(String::from);
let image_base = image_base_of(&info);
if info.get("analysis_cut_short").and_then(Value::as_bool) == Some(true) {
return Err(GhidraError::Timeout("Ghidra reported that analysis timed out".into()));
}
@@ -569,6 +621,7 @@ impl Session {
runtime_root: scratch.keep(),
version,
language_id: cfg.language_id.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(String::from),
image_base,
})
}
@@ -595,6 +648,10 @@ impl DeepDecompiler for Session {
self.language_id.clone()
}
fn image_base(&self) -> Option<u64> {
self.image_base
}
fn decompile(&self, entry: u64) -> Result<Option<DeepFunction>, GhidraError> {
let never_cancel = || false;
let v = self.lock().request(
@@ -638,6 +695,11 @@ fn decompiled(entry: u64, v: &Value) -> Option<DeepFunction> {
})
}
/// Where the bridge's handshake says Ghidra loaded the program (`None` when it didn't say).
fn image_base_of(ping: &Value) -> Option<u64> {
ping.get("target").and_then(|t| t.get("image_base")).and_then(Value::as_str).and_then(parse_hex)
}
fn parse_hex(s: &str) -> Option<u64> {
let t = s.trim();
let h = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")).unwrap_or(t);
+54 -5
View File
@@ -95,7 +95,7 @@ fn serve(stream: TcpStream, expected: &str, saw_eof: &Arc<AtomicBool>) {
break; // close on a bad token
}
let result = match req.method.as_str() {
"ping" => "{\"name\":\"fake\",\"provider\":{\"id\":\"ghidra\",\"version\":\"12.1.4\"},\"analysed\":true,\"analysis_cut_short\":false}"
"ping" => "{\"name\":\"fake\",\"provider\":{\"id\":\"ghidra\",\"version\":\"12.1.4\"},\"analysed\":true,\"analysis_cut_short\":false,\"target\":{\"name\":\"t\",\"image_base\":\"0x100000\"}}"
.to_string(),
"native_functions_list" => "{\"functions\":[{\"name\":\"main\",\"address\":\"0x401000\"}]}".to_string(),
// As the real bridge: a known function, code its analysis never made a function (made
@@ -134,6 +134,8 @@ fn framing_ping_and_methods() {
let mut client = Client::connect(fake.addr, "secret-token", deadline).expect("connect");
let info = client.ping(&no_cancel(), deadline).expect("ping");
assert_eq!(info["provider"]["id"], "ghidra");
assert_eq!(image_base_of(&info), Some(0x10_0000), "the handshake says where Ghidra loaded the program");
assert_eq!(image_base_of(&serde_json::json!({"target": {"name": "t"}})), None, "an older bridge doesn't say");
let list = client.request("native_functions_list", serde_json::json!({}), &no_cancel(), deadline).expect("list");
assert_eq!(list["functions"][0]["name"], "main");
@@ -360,18 +362,23 @@ fn the_first_analysis_in_a_fresh_store_starts_instead_of_waiting_for_a_lock_it_c
/// read-only data: `start` jumps through the table it is handed, so no code names them. Returns
/// the program and the addresses of `triple`, the second function and the table.
fn pointer_table_elf() -> (Vec<u8>, u64, u64, u64) {
const BASE: u64 = 0x40_0000;
let (start, triple, negate, table) = (BASE + 0x100, BASE + 0x110, BASE + 0x120, BASE + 0x1000);
pointer_table_elf_at(0x40_0000, 2)
}
/// [`pointer_table_elf`] loaded at `base`, with ELF type `e_type` (2 executable, 3 position
/// independent).
fn pointer_table_elf_at(base: u64, e_type: u8) -> (Vec<u8>, u64, u64, u64) {
let (start, triple, negate, table) = (base + 0x100, base + 0x110, base + 0x120, base + 0x1000);
let mut b = vec![0u8; 0x1010];
let put = |b: &mut Vec<u8>, off: usize, bytes: &[u8]| b[off..off + bytes.len()].copy_from_slice(bytes);
// ELF header: ET_EXEC, x86-64, two program headers, no sections.
put(&mut b, 0, b"\x7fELF\x02\x01\x01");
put(&mut b, 16, &[2, 0, 62, 0, 1, 0, 0, 0]);
put(&mut b, 16, &[e_type, 0, 62, 0, 1, 0, 0, 0]);
put(&mut b, 24, &start.to_le_bytes());
put(&mut b, 32, &0x40u64.to_le_bytes());
put(&mut b, 52, &[64, 0, 56, 0, 2, 0, 64, 0]);
// PT_LOAD r-x (headers and code), PT_LOAD r-- (the table).
for (i, (flags, off, va, size)) in [(5u32, 0u64, BASE, 0x1000u64), (4, 0x1000, table, 0x10)].into_iter().enumerate() {
for (i, (flags, off, va, size)) in [(5u32, 0u64, base, 0x1000u64), (4, 0x1000, table, 0x10)].into_iter().enumerate() {
let o = 0x40 + i * 56;
put(&mut b, o, &1u32.to_le_bytes());
put(&mut b, o + 4, &flags.to_le_bytes());
@@ -421,6 +428,48 @@ fn live_ghidra_makes_and_decompiles_code_reached_only_through_a_pointer_table()
assert!(session.decompile(triple).unwrap().expect("an answer").created);
}
#[test]
fn addresses_move_between_our_image_base_and_the_engines() {
// Ghidra loads a position-independent ELF at 0x100000; we read it at 0.
let pie = Rebase::new(0, Some(0x10_0000));
assert!(!pie.is_identity());
assert_eq!((pie.to_engine(0x1160), pie.from_engine(0x10_1160)), (0x10_1160, 0x1160));
// Below the base it's moved from (not in the program): left as it is, never wrapped.
assert_eq!(pie.from_engine(0x40), 0x40);
let near_top = Rebase::new(0, Some(u64::MAX - 1));
assert_eq!(near_top.to_engine(0x10), 0x10, "no overflow");
// Same base, or an engine that didn't say: nothing moves.
for same in [Rebase::new(0x40_0000, Some(0x40_0000)), Rebase::new(0x40_0000, None)] {
assert!(same.is_identity());
assert_eq!((same.to_engine(0x40_1000), same.from_engine(0x40_1000)), (0x40_1000, 0x40_1000));
}
}
/// Real Ghidra (as above): a position-independent ELF is loaded away from 0, the handshake says
/// where, and our addresses reach the right code once moved there.
#[test]
fn live_ghidra_reports_where_it_loaded_a_position_independent_elf() {
let Some(store) = std::env::var_os("CRAFT_ANYTHING_TEST_GHIDRA").map(PathBuf::from) else { return };
let temp = tempfile::Builder::new().prefix("ghidra-live").tempdir().unwrap();
let cfg = Config {
analyze_headless: get::headless(None, &store).expect("Ghidra in the engine store"),
java_home: get::java_home(None, &store),
startup_timeout: Duration::from_secs(600),
store_dir: temp.path().to_path_buf(),
language_id: None,
compiler_spec: None,
memory_gb: Some(4),
};
let (elf, triple, _, _) = pointer_table_elf_at(0, 3);
let session = Session::start(&cfg, &elf, &craft_anything_evidence::sha256_hex(&elf), &"ef".repeat(32), &|| false, &|_, _| {}).expect("Ghidra starts");
let base = session.image_base().expect("the handshake says where the program is");
assert_ne!(base, 0, "Ghidra moves a program whose lowest segment is at 0");
let rebase = Rebase::new(0, Some(base));
let made = session.decompile(rebase.to_engine(triple)).unwrap().expect("an answer");
assert_eq!(rebase.from_engine(made.entry), triple);
assert!(made.pseudocode.as_deref().unwrap_or_default().contains("* 3"), "{made:?}");
}
#[test]
fn a_bridge_that_stops_early_leaves_its_log_beside_the_analysis() {
let d = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -23,7 +23,7 @@ workspace clippy, layers, fmt and the secret scan of all history were clean. Chi
| Rebuild workflow (Study → Build → Check → Refine) | `crates/ai/src/rebuild/` (kinds, checks and stacks are pluggable: docs/extending.md). Three kinds: website, app, game (a project whose stack is a game engine is always a game). The request decides how to build: a language, framework, engine or platform it names wins over the kind's default stack (Rust + egui for apps, Unity for games, the original's for websites), and the build check, the recorded stack and Play follow what was built (an Electron app opens in its own Electron). Websites: Study also captures up to 4 other pages of the site; menus are opened on a computer and on a phone; the check serves the build on loopback and compares every page at 1440, 768 and 390 px (likeness = mean of layout and perceptual detail, SSIM, averaged over pages, leaving out the parts of the original that change by themselves, found by photographing it three times; the note adds text, page structure, the colours, font, text sizes and spacing the build misses, and per width a page that scrolls sideways, heading sizes and sections whose padding, items per row or content column differ, the errors, failed requests and accessibility gaps the build adds, and the opened menus and pop-ups). A stack or library named in the request (Next.js, shadcn/ui, Tailwind CSS…, `stacks.toml`) is what the builder uses; with none named, a website's stack is the builder's choice (told to match what the original is built with) and the stack it used is recorded from app/package.json. The builder leads: it studies, asks the tools follow-up questions, records what it can't find out as unknowns (`core_unknown_add`; the run reports the open ones), and tests its own build mid-turn with `rebuild_check` (the same checks, for every kind); a check with anything it couldn't compare, or any claim that fails (a website page that scrolls sideways, logs new errors, fails to load a file, lacks a landmark, or a menu that doesn't open; for games each self-test step, for Deep-mode sheets each original number in the code, for console apps the same exit and output as the original's runs), isn't good enough; apps and games by whether they build. Up to 6 refinement rounds, or until a website reaches 90% likeness. A turn cut off by a time limit, crash or dropped connection resumes (up to 3 times); builder turns may run 24 h | Unit tests for prompts, stack choice, site detection, feed folding; live runs reached Build and decompiling (below) |
| Context-summary usage | Successful API context summaries contribute to the message totals consumed by build usage and receive a `model_turn` journal entry with `purpose: context_summary`. Cached-token pricing is retained; an unknown summary price makes the total unknown, and a failed call adds no invented usage | Scripted-model tests exercise the real `ApiConversation`, transcript resumption, exact input/output and cache-price totals, journal entries, empty summaries, local zero cost, unknown prices and failed summaries. No live provider or end-to-end rebuild was used for this correction |
| Games | Engine detection for 12 engines (Unreal, Unity, Godot, Source, GameMaker, RPG Maker, Ren'Py, MonoGame, LÖVE, CryEngine, Cocos, Cube) with versions. Unreal and Godot games are rebuilt in the same engine, others in Unity; installed engines are found, Godot installs itself, others get guided installs. Picking an inner folder (Engine, Binaries, Paks…) finds the game's folder. Just look inside a game shows what it's made of (`core_target_triage` on its folder: engine and version, how its code ships, the libraries its Unity assembly names give away, its own code, scenes and size) and opens it in the chat to ask about it. The builder keeps `app/MECHANICS.md` and its structured twin `app/mechanics.json` (checked after each build), in Deep mode only, may reuse the original's readable files for private use (never decrypting; generated pictures and 3D models replace its art when the person turns them on; Clean room copies none of its files), lists them in `CREDITS.md` (checked, with the private-use line in `README.md`); a game is ready only when its `--self-test` shows the player moving, acting and the HUD drawn, and the result can be started from the project page | Unit tests; live runs on Windows |
| Clean room and Deep mode | Clean room (the default) runs only the tools in `engine/src/clean_room.rs`: ones that touch no target or watch from outside (the website as a visitor sees it, pictures of a window, its controls, recorded runs, a phone's screen). Every other tool, a plugin's included, is refused in the app, CLI, MCP and agents, with a way to turn on Deep mode. A Clean room build studies an app or a game only from the person's pictures and words (and a picture of its window when runtime study is on); its builder's reads and commands go through an approval rule that keeps it out of the original's folder and site (with Claude Code, and with ACP agents for what they ask about; other agents aren't held to it, see roadmap R2.2). Deep mode (`deep_mode` in Settings, `--deep` for one CLI run) uses every tool: the builder sees them all, and a website's study also reads its JavaScript and watches its start page for ten seconds (capture/how-it-works.md). Ghidra adds its decompiler in Deep mode when it's installed: it is found, or installed after asking. Study starts one analysis of the main program, saved and shared with the builder's tool server through a lock file (up to 8 h, Java gets half the machine's memory or the amount set in Settings → Engines, 4 to 31 GB, four slow passes skipped). The builder must decompile before the build is checked; addresses the built-in analysis can't place go to Ghidra, which makes a function on demand (for this run only) where its analysis has none, e.g. code reached only through tables of pointers. Without Ghidra, Deep mode uses the built-in engines | Live runs with Ghidra 12.1.4 on Windows (a shared analysis, and a decompile in seconds on a large program). The guided install of Ghidra and its Java is offered on Linux and macOS too, but has only been tried on Windows. Clean room and Deep mode: engine, AI, MCP, CLI and app tests; no live Clean room build run yet |
| Clean room and Deep mode | Clean room (the default) runs only the tools in `engine/src/clean_room.rs`: ones that touch no target or watch from outside (the website as a visitor sees it, pictures of a window, its controls, recorded runs, a phone's screen). Every other tool, a plugin's included, is refused in the app, CLI, MCP and agents, with a way to turn on Deep mode. A Clean room build studies an app or a game only from the person's pictures and words (and a picture of its window when runtime study is on); its builder's reads and commands go through an approval rule that keeps it out of the original's folder and site (with Claude Code, and with ACP agents for what they ask about; other agents aren't held to it, see roadmap R2.2). Deep mode (`deep_mode` in Settings, `--deep` for one CLI run) uses every tool: the builder sees them all, and a website's study also reads its JavaScript and watches its start page for ten seconds (capture/how-it-works.md). Ghidra adds its decompiler in Deep mode when it's installed: it is found, or installed after asking. Study starts one analysis of the main program, saved and shared with the builder's tool server through a lock file (up to 8 h, Java gets half the machine's memory or the amount set in Settings → Engines, 4 to 31 GB, four slow passes skipped). The builder must decompile before the build is checked; addresses the built-in analysis can't place go to Ghidra, which makes a function on demand (for this run only) where its analysis has none, e.g. code reached only through tables of pointers. Without Ghidra, Deep mode uses the built-in engines | Live runs with Ghidra 12.1.4 on Windows (a shared analysis, and a decompile in seconds on a large program). Live runs with Ghidra 12.2 on Linux x86-64 (decompiles by name in position-independent programs and shared libraries, whose addresses Ghidra moves). The guided install of Ghidra and its Java is offered on Linux and macOS too, but has only been tried on Windows. Clean room and Deep mode: engine, AI, MCP, CLI and app tests; no live Clean room build run yet |
| Auto mode | Settings → Safety, on by default. Builder actions go ahead without asking, except a fixed set that is never done unasked (deleting outside the project, system changes, publishing…); questions get "decide yourself"; asset spending stops at the budget | Unit tests |
| Generated assets | Off until the person turns them on for a game (the switch on Make and on the project, `project_assets_allow`, `rebuild --generate-assets`, saved in craft.json; turning it on without a fal.ai key asks for one); the brief and the asset tools need both that and a key. `assets_image_generate` (Nano Banana Pro by default) and `assets_model_generate` (picture → Tripo P1 low-poly GLB with a face limit) through the person's key for each asset provider (fal.ai today); per-project budget, $5 by default (`[assets] budget_usd` in config.toml); models and prices from the `assets.toml` table (picture model selectable: Nano Banana Pro, FLUX dev or schnell; priced per picture or per megapixel); every call reports its cost | Unit tests; a live 3D generation with a real key (picture, then model) with FLUX dev; Nano Banana Pro not yet run with a real key |
| Claude Code builder | Runs the person's own `claude` in headless mode (Opus 5.5, high effort unless configured) with our MCP server, a permission-prompt tool and `core_person_ask`; preflights for our CLI (a build stops plainly if it's missing or won't start) and sign-in | Argument and stream-parser tests; live preflight; live builder turns; see [claude-code.md](claude-code.md) |
+7
View File
@@ -384,6 +384,13 @@ wrote its own pak reader in Python for a large archive. Tools an AI drives make
can (folders), take whole sets in one call, and every brief says to fix the call rather than
replace the tool. Read an AI run's transcript for tools it gave up on.
**Two engines, two address spaces.** Ghidra loads a position-independent ELF (lowest segment at
0) at 0x100000, while the built-in engine reads it where its headers say. Handing Ghidra our
address found nothing there, and Deep mode quietly fell back to the built-in decompiler for every
PIE program and shared library. Whenever an address crosses between engines, move it by their
image bases (`ghidra::Rebase`, from the base the bridge reports) and say in the evidence which
space each address is in.
## Agent tooling
**Shell quoting breaks generated edits.** Heredocs carrying Python with quotes, `'''` or `\n` were