Move Rider and Unity bridges to Rust

This commit is contained in:
Meta Vens 2026-05-06 07:55:02 +01:00
commit 8e656267d9
14 changed files with 1739 additions and 1250 deletions

View file

@ -0,0 +1,656 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use serde_json::json;
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
const RIDER_EXECUTABLE_NAMES: &[&str] = &["rider64.exe", "rider.exe", "rider.bat", "rider"];
fn main() -> Result<()> {
let mut args = env::args().skip(1);
let Some(command) = args.next() else {
usage()?;
return Ok(());
};
match run_command(&command, args.collect()) {
Ok((code, value)) => {
println!("{}", serde_json::to_string_pretty(&value)?);
std::process::exit(code);
}
Err(error) => {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": "error",
"error": error.to_string(),
}))?
);
std::process::exit(1);
}
}
}
fn run_command(command: &str, args: Vec<String>) -> Result<(i32, serde_json::Value)> {
match command {
"status" => {
let options = RiderOptions::parse(args)?;
Ok((0, status_rider(&options)?))
}
"context" => {
let options = RiderOptions::parse(args)?;
Ok((0, context_rider(&options)?))
}
"open-ref" => {
let options = RiderOptions::parse(args)?;
Ok((0, open_ref_rider(&options)?))
}
"guidance" => {
let options = RiderOptions::parse(args)?;
Ok((
0,
json!({ "guidance": bridge_guidance(&options.project_root)? }),
))
}
_ => usage(),
}
}
fn usage() -> Result<(i32, serde_json::Value)> {
Err(anyhow!(
"usage: epiphany-rider-bridge <status|context|open-ref|guidance> --project-root <path>"
))
}
#[derive(Debug)]
struct RiderOptions {
project_root: PathBuf,
artifact_root: PathBuf,
artifact_dir: Option<PathBuf>,
packet: Option<PathBuf>,
file: Option<PathBuf>,
line: Option<u32>,
column: Option<u32>,
selection_start: Option<u32>,
selection_end: Option<u32>,
symbol_name: Option<String>,
symbol_kind: Option<String>,
symbol_namespace: Option<String>,
launch: bool,
}
impl RiderOptions {
fn parse(args: Vec<String>) -> Result<Self> {
let root = repo_root()?;
let mut options = Self {
project_root: env::current_dir()?,
artifact_root: root.join(".epiphany-gui").join("rider"),
artifact_dir: None,
packet: None,
file: None,
line: None,
column: None,
selection_start: None,
selection_end: None,
symbol_name: None,
symbol_kind: None,
symbol_namespace: None,
launch: false,
};
let mut iter = args.into_iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--project-root" => options.project_root = next_path(&mut iter, &arg)?,
"--artifact-root" => options.artifact_root = next_path(&mut iter, &arg)?,
"--artifact-dir" => options.artifact_dir = Some(next_path(&mut iter, &arg)?),
"--packet" => options.packet = Some(next_path(&mut iter, &arg)?),
"--file" => options.file = Some(next_path(&mut iter, &arg)?),
"--line" => options.line = Some(next_value(&mut iter, &arg)?.parse()?),
"--column" => options.column = Some(next_value(&mut iter, &arg)?.parse()?),
"--selection-start" => {
options.selection_start = Some(next_value(&mut iter, &arg)?.parse()?)
}
"--selection-end" => {
options.selection_end = Some(next_value(&mut iter, &arg)?.parse()?)
}
"--symbol-name" => options.symbol_name = Some(next_value(&mut iter, &arg)?),
"--symbol-kind" => options.symbol_kind = Some(next_value(&mut iter, &arg)?),
"--symbol-namespace" => {
options.symbol_namespace = Some(next_value(&mut iter, &arg)?)
}
"--launch" => options.launch = true,
_ => return Err(anyhow!("unknown argument {arg:?}")),
}
}
Ok(options)
}
}
fn next_value(iter: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
iter.next()
.ok_or_else(|| anyhow!("{name} requires a value"))
}
fn next_path(iter: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
Ok(PathBuf::from(next_value(iter, name)?))
}
fn repo_root() -> Result<PathBuf> {
Ok(Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.ok_or_else(|| anyhow!("epiphany-core has no parent repo root"))?
.to_path_buf())
}
fn now_iso() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn artifact_dir(root: &Path, action: &str) -> Result<PathBuf> {
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
Ok(root
.canonicalize()
.unwrap_or_else(|_| root.to_path_buf())
.join(format!("rider-{action}-{nanos}-{}", std::process::id())))
}
fn write_json(path: &Path, value: &serde_json::Value) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))
.with_context(|| format!("failed to write {}", path.display()))
}
fn write_text(path: &Path, value: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, value).with_context(|| format!("failed to write {}", path.display()))
}
fn maybe_resolve(path: &Path) -> String {
clean_windows_path(
&path
.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.display()
.to_string(),
)
}
fn clean_windows_path(value: &str) -> String {
value.strip_prefix(r"\\?\").unwrap_or(value).to_string()
}
fn is_inside(path: &Path, root: &Path) -> bool {
let Ok(path) = path.canonicalize() else {
return false;
};
let Ok(root) = root.canonicalize() else {
return false;
};
path.starts_with(root)
}
fn relative_or_absolute(path: &Path, root: &Path) -> String {
match (path.canonicalize(), root.canonicalize()) {
(Ok(path), Ok(root)) => path
.strip_prefix(root)
.map(|value| value.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| path.display().to_string()),
_ => path.display().to_string(),
}
}
fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut result = Vec::new();
for path in paths {
let key = maybe_resolve(&path).to_lowercase();
if seen.insert(key) {
result.push(path);
}
}
result
}
fn rider_search_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Ok(value) = env::var("EPIPHANY_RIDER_ROOTS") {
roots.extend(env::split_paths(&value));
}
for (variable, fallback) in [
("ProgramFiles", r"C:\Program Files".to_string()),
("ProgramFiles(x86)", r"C:\Program Files (x86)".to_string()),
(
"LOCALAPPDATA",
dirs_home()
.join("AppData")
.join("Local")
.display()
.to_string(),
),
] {
let base = env::var(variable).unwrap_or(fallback);
roots.push(PathBuf::from(base).join("JetBrains"));
}
dedupe_paths(roots)
}
fn dirs_home() -> PathBuf {
env::var_os("USERPROFILE")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("."))
}
fn discover_rider_installations() -> Result<Vec<serde_json::Value>> {
let mut candidates = Vec::new();
if let Ok(value) = env::var("EPIPHANY_RIDER_PATH") {
candidates.push(PathBuf::from(value));
}
if let Some(path_var) = env::var_os("PATH") {
for dir in env::split_paths(&path_var) {
for name in RIDER_EXECUTABLE_NAMES {
candidates.push(dir.join(name));
}
}
}
for root in rider_search_roots() {
if !root.exists() {
continue;
}
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if !path.is_dir() || !name.contains("Rider") {
continue;
}
for exe in RIDER_EXECUTABLE_NAMES {
candidates.push(path.join("bin").join(exe));
}
}
}
let mut installations = Vec::new();
for path in dedupe_paths(candidates) {
if !path.exists() {
continue;
}
let version_hint = path
.components()
.rev()
.filter_map(|part| part.as_os_str().to_str())
.find(|part| part.contains("Rider"))
.map(ToOwned::to_owned);
installations.push(json!({
"path": maybe_resolve(&path),
"exists": true,
"versionHint": version_hint,
}));
}
Ok(installations)
}
fn discover_solution(project_root: &Path) -> Result<serde_json::Value> {
let root = project_root.canonicalize()?;
let mut top_level = Vec::new();
for entry in fs::read_dir(&root)? {
let path = entry?.path();
if path.extension().and_then(|value| value.to_str()) == Some("sln") {
top_level.push(path);
}
}
top_level.sort();
if !top_level.is_empty() {
return Ok(json!({
"status": "ready",
"path": top_level[0],
"candidates": top_level.iter().take(8).collect::<Vec<_>>(),
}));
}
let mut candidates = Vec::new();
collect_solutions(&root, &root, &mut candidates)?;
Ok(json!({
"status": if candidates.is_empty() { "missingSolution" } else { "ready" },
"path": candidates.first(),
"candidates": candidates.iter().take(8).collect::<Vec<_>>(),
}))
}
fn collect_solutions(root: &Path, current: &Path, candidates: &mut Vec<PathBuf>) -> Result<()> {
if candidates.len() >= 8 {
return Ok(());
}
for entry in fs::read_dir(current)? {
let path = entry?.path();
let rel = path.strip_prefix(root).unwrap_or(&path);
if rel.components().any(|component| {
let value = component.as_os_str().to_string_lossy();
value.starts_with('.')
|| matches!(
value.as_ref(),
"Library" | "Temp" | "obj" | "bin" | "node_modules"
)
}) {
continue;
}
if path.is_dir() {
collect_solutions(root, &path, candidates)?;
} else if path.extension().and_then(|value| value.to_str()) == Some("sln") {
candidates.push(path);
}
}
Ok(())
}
fn run_git(project_root: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(project_root)
.args(args)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn vcs_summary(project_root: &Path) -> serde_json::Value {
let branch = run_git(project_root, &["rev-parse", "--abbrev-ref", "HEAD"]);
let status = run_git(project_root, &["status", "--porcelain=v1"]);
let changed = run_git(project_root, &["diff", "--name-only"]);
let staged = run_git(project_root, &["diff", "--cached", "--name-only"]);
let mut changed_files: Vec<String> = changed
.as_deref()
.unwrap_or_default()
.lines()
.map(ToOwned::to_owned)
.collect();
if let Some(status) = &status {
for line in status.lines() {
let path = line.get(3..).unwrap_or_default().trim().to_string();
if !path.is_empty() && !changed_files.contains(&path) {
changed_files.push(path);
}
}
}
json!({
"status": if branch.is_some() { "ready" } else { "notGit" },
"branch": branch,
"dirty": status.as_deref().is_some_and(|value| !value.is_empty()),
"changedFiles": changed_files,
"stagedFiles": staged.as_deref().unwrap_or_default().lines().collect::<Vec<_>>(),
"changedRangesKnown": branch.is_some(),
})
}
fn render_status(summary: &serde_json::Value) -> String {
format!(
"# Rider Bridge Status\n\n- status: {}\n- workspace: `{}`\n- solution: `{}`\n- rider: `{}`\n- installations: {}\n- branch: {}\n- dirty: {}\n- note: {}\n\nRider is a source-context organ. This artifact is an operator-safe projection, not durable Epiphany truth.\n",
summary["status"].as_str().unwrap_or("unknown"),
summary["workspace"].as_str().unwrap_or("unknown"),
summary["solutionPath"].as_str().unwrap_or("none"),
summary["riderPath"].as_str().unwrap_or("missing"),
summary["installationCount"].as_u64().unwrap_or_default(),
summary["vcs"]["branch"].as_str().unwrap_or("unknown"),
summary["vcs"]["dirty"].as_bool().unwrap_or(false),
summary["note"].as_str().unwrap_or(""),
)
}
fn render_context(packet: &serde_json::Value, summary: &serde_json::Value) -> String {
format!(
"# Rider Context Packet\n\n- status: {}\n- project: `{}`\n- solution: `{}`\n- file: `{}`\n- selection: {}-{}\n- symbol: {}\n- artifact: `{}`\n\nModeling may use this packet as scratch/context. It is not accepted map state until reviewed.\n",
summary["status"].as_str().unwrap_or("unknown"),
packet["projectRoot"].as_str().unwrap_or("unknown"),
packet["solutionPath"].as_str().unwrap_or("none"),
packet["filePath"].as_str().unwrap_or("none"),
packet["selection"]["startLine"]
.as_i64()
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string()),
packet["selection"]["endLine"]
.as_i64()
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string()),
packet["symbol"]["name"].as_str().unwrap_or("none"),
summary["artifactPath"].as_str().unwrap_or("unknown"),
)
}
fn status_rider(options: &RiderOptions) -> Result<serde_json::Value> {
let project_root = options.project_root.canonicalize()?;
let directory = options
.artifact_dir
.clone()
.map(Ok)
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "inspect"))?;
let installations = discover_rider_installations()?;
let solution = discover_solution(&project_root)?;
let first_rider = installations.first();
let status = if first_rider.is_some() {
"ready"
} else {
"missingRider"
};
let note = if first_rider.is_some() {
"Rider executable is available for explicit source navigation and context capture."
} else {
"No Rider executable was found. Install Rider or set EPIPHANY_RIDER_PATH."
};
let summary = json!({
"kind": "riderBridgeStatus",
"status": status,
"workspace": project_root,
"solutionPath": solution["path"],
"solutionStatus": solution["status"],
"solutionCandidates": solution["candidates"],
"riderPath": first_rider.and_then(|value| value["path"].as_str()),
"installationCount": installations.len(),
"installations": installations,
"searchRoots": rider_search_roots(),
"vcs": vcs_summary(&project_root),
"capturedAt": now_iso(),
"artifactPath": directory,
"note": note,
});
write_json(&directory.join("rider-bridge-summary.json"), &summary)?;
write_text(
&directory.join("rider-bridge-status.md"),
&render_status(&summary),
)?;
Ok(summary)
}
fn packet_from_options(options: &RiderOptions, project_root: &Path) -> Result<serde_json::Value> {
if let Some(packet) = &options.packet {
let value: serde_json::Value = serde_json::from_str(&fs::read_to_string(packet)?)?;
if !value.is_object() {
return Err(anyhow!("context packet must be a JSON object"));
}
return Ok(value);
}
let solution = discover_solution(project_root)?;
let file_path = options
.file
.as_ref()
.map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()));
if let Some(file_path) = &file_path
&& !is_inside(file_path, project_root)
{
return Err(anyhow!(
"context file is outside project root: {}",
file_path.display()
));
}
Ok(json!({
"kind": "riderContext",
"capturedAt": now_iso(),
"projectRoot": project_root,
"solutionPath": solution["path"],
"filePath": file_path.as_ref().map(|path| relative_or_absolute(path, project_root)),
"caret": options.line.map(|line| json!({ "line": line, "column": options.column })),
"selection": if options.selection_start.is_some() || options.selection_end.is_some() {
json!({ "startLine": options.selection_start, "endLine": options.selection_end })
} else {
serde_json::Value::Null
},
"symbol": options.symbol_name.as_ref().map(|name| json!({
"name": name,
"kind": options.symbol_kind,
"namespace": options.symbol_namespace,
})),
"vcs": vcs_summary(project_root),
}))
}
fn context_rider(options: &RiderOptions) -> Result<serde_json::Value> {
let project_root = options.project_root.canonicalize()?;
let directory = options
.artifact_dir
.clone()
.map(Ok)
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "context"))?;
let packet = packet_from_options(options, &project_root)?;
let mut status_options = RiderOptions {
..options.clone_for_status(directory.clone())
};
status_options.artifact_dir = Some(directory.clone());
let mut summary = status_rider(&status_options)?;
summary["kind"] = json!("riderContextCapture");
summary["status"] = json!("captured");
summary["contextPath"] = json!(directory.join("rider-context.json"));
summary["filePath"] = packet["filePath"].clone();
summary["symbol"] = packet["symbol"].clone();
write_json(&directory.join("rider-context.json"), &packet)?;
write_json(&directory.join("rider-bridge-summary.json"), &summary)?;
write_text(
&directory.join("rider-context.md"),
&render_context(&packet, &summary),
)?;
Ok(summary)
}
impl RiderOptions {
fn clone_for_status(&self, directory: PathBuf) -> Self {
Self {
project_root: self.project_root.clone(),
artifact_root: self.artifact_root.clone(),
artifact_dir: Some(directory),
packet: None,
file: None,
line: None,
column: None,
selection_start: None,
selection_end: None,
symbol_name: None,
symbol_kind: None,
symbol_namespace: None,
launch: false,
}
}
}
fn open_ref_rider(options: &RiderOptions) -> Result<serde_json::Value> {
let project_root = options.project_root.canonicalize()?;
let directory = options
.artifact_dir
.clone()
.map(Ok)
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "open-ref"))?;
let status_options = options.clone_for_status(directory.clone());
let status = status_rider(&status_options)?;
let file_path = options
.file
.as_ref()
.ok_or_else(|| anyhow!("open-ref requires --file"))?
.canonicalize()?;
if !is_inside(&file_path, &project_root) {
return Err(anyhow!(
"code ref is outside project root: {}",
file_path.display()
));
}
let rider_path = status["riderPath"].as_str();
let mut command = Vec::<String>::new();
if let Some(path) = rider_path {
command.push(path.to_string());
command.push(file_path.display().to_string());
if let Some(line) = options.line {
command.push("--line".to_string());
command.push(line.to_string());
}
}
if let Some(path) = rider_path
&& options.launch
{
let mut launch = Command::new(path);
launch.arg(&file_path);
if let Some(line) = options.line {
launch.arg("--line").arg(line.to_string());
}
launch.current_dir(&project_root).spawn()?;
}
let summary = json!({
"kind": "riderOpenRef",
"status": if rider_path.is_some() { if options.launch { "launched" } else { "planned" } } else { "missingRider" },
"workspace": status["workspace"],
"solutionPath": status["solutionPath"],
"solutionStatus": status["solutionStatus"],
"solutionCandidates": status["solutionCandidates"],
"riderPath": status["riderPath"],
"installationCount": status["installationCount"],
"installations": status["installations"],
"searchRoots": status["searchRoots"],
"vcs": status["vcs"],
"capturedAt": status["capturedAt"],
"artifactPath": directory,
"note": status["note"],
"filePath": file_path,
"line": options.line,
"column": options.column,
"command": command,
"launched": options.launch && rider_path.is_some(),
});
write_json(&directory.join("rider-open-ref.json"), &summary)?;
write_json(&directory.join("rider-bridge-summary.json"), &summary)?;
write_text(
&directory.join("rider-bridge-status.md"),
&render_status(&summary),
)?;
Ok(summary)
}
fn bridge_guidance(project_root: &Path) -> Result<String> {
let root = repo_root()?;
let installations = discover_rider_installations()?;
let solution = discover_solution(project_root)?;
let status_command = format!(
"`epiphany-rider-bridge status --project-root {}`",
project_root.display()
);
if let Some(first) = installations.first() {
let solution_text = solution["path"].as_str().unwrap_or("no solution found yet");
Ok(format!(
"- Rider bridge: Rider is available at `{}` and solution context is `{}`. Use {} for an auditable source/IDE status receipt, and use `context --file <path> --selection-start <line> --selection-end <line> --symbol-name <name>` when the future plugin or operator sends a bounded source slice. Rider facts are scratch/context until modeling or verification accepts them.",
first["path"].as_str().unwrap_or("unknown"),
solution_text,
status_command,
))
} else {
Ok(format!(
"- Rider bridge: no Rider executable was found. Use {} to write an auditable missing-IDE artifact, or set EPIPHANY_RIDER_PATH. Do not pretend IDE diagnostics, changed ranges, or source navigation were captured until the bridge has a receipt. Native bridge binary is rooted at `{}`.",
status_command,
root.display(),
))
}
}

View file

@ -0,0 +1,964 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use serde_json::json;
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const PROJECT_VERSION_RELATIVE: &str = "ProjectSettings/ProjectVersion.txt";
const UNITY_EDITOR_RELATIVE: &[&str] = &["Editor", "Unity.exe"];
const EDITOR_BRIDGE_RELATIVE: &str = "Assets/Editor/Epiphany/EpiphanyEditorBridge.cs";
const EDITOR_BRIDGE_EXECUTE_METHOD: &str = "GameCult.Epiphany.Unity.EpiphanyEditorBridge.RunProbe";
const FORBIDDEN_EXTRA_ARGS: &[&str] = &["-batchmode", "-quit", "-projectpath", "-logfile"];
fn main() -> Result<()> {
let mut args = env::args().skip(1);
let Some(command) = args.next() else {
return Err(anyhow!(
"usage: epiphany-unity-bridge <inspect|run|check-compilation|probe|run-tests|guidance>"
));
};
match run_command(&command, args.collect()) {
Ok((code, value)) => {
println!("{}", serde_json::to_string_pretty(&value)?);
std::process::exit(code);
}
Err(error) => {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": "error",
"error": error.to_string(),
}))?
);
std::process::exit(1);
}
}
}
fn run_command(command: &str, args: Vec<String>) -> Result<(i32, serde_json::Value)> {
let options = UnityOptions::parse(args)?;
let result = match command {
"inspect" => inspect_unity(&options)?,
"run" => run_unity(&options)?,
"check-compilation" => run_named_probe(&options, "check-compilation")?,
"probe" => {
let operation = options
.operation
.as_deref()
.ok_or_else(|| anyhow!("probe requires --operation"))?;
run_named_probe(&options, operation)?
}
"run-tests" => test_unity(&options)?,
"guidance" => json!({ "guidance": bridge_guidance(&options.project_path)? }),
_ => return Err(anyhow!("unsupported command: {command}")),
};
let blocked = result["runStatus"].as_str() == Some("blocked");
Ok((if blocked { 2 } else { 0 }, result))
}
#[derive(Debug)]
struct UnityOptions {
project_path: PathBuf,
artifact_root: PathBuf,
artifact_dir: Option<PathBuf>,
label: String,
timeout_seconds: u64,
dry_run: bool,
unity_args: Vec<String>,
operation: Option<String>,
scene: Option<String>,
asset: Option<String>,
guid: Option<String>,
max_objects: Option<u32>,
max_properties: Option<u32>,
max_dependencies: Option<u32>,
platform: String,
filter: Option<String>,
}
impl UnityOptions {
fn parse(args: Vec<String>) -> Result<Self> {
let root = repo_root()?;
let mut options = Self {
project_path: env::current_dir()?,
artifact_root: root.join(".epiphany-gui").join("runtime"),
artifact_dir: None,
label: "unity-bridge-run".to_string(),
timeout_seconds: 600,
dry_run: false,
unity_args: Vec::new(),
operation: None,
scene: None,
asset: None,
guid: None,
max_objects: None,
max_properties: None,
max_dependencies: None,
platform: "editmode".to_string(),
filter: None,
};
let mut iter = args.into_iter().peekable();
while let Some(arg) = iter.next() {
if arg == "--" {
options.unity_args.extend(iter);
break;
}
match arg.as_str() {
"--project-path" => options.project_path = next_path(&mut iter, &arg)?,
"--artifact-root" => options.artifact_root = next_path(&mut iter, &arg)?,
"--artifact-dir" => options.artifact_dir = Some(next_path(&mut iter, &arg)?),
"--label" => options.label = next_value(&mut iter, &arg)?,
"--timeout-seconds" => {
options.timeout_seconds = next_value(&mut iter, &arg)?.parse()?
}
"--dry-run" => options.dry_run = true,
"--operation" => options.operation = Some(next_value(&mut iter, &arg)?),
"--scene" => options.scene = Some(next_value(&mut iter, &arg)?),
"--asset" => options.asset = Some(next_value(&mut iter, &arg)?),
"--guid" => options.guid = Some(next_value(&mut iter, &arg)?),
"--max-objects" => {
options.max_objects = Some(next_value(&mut iter, &arg)?.parse()?)
}
"--max-properties" => {
options.max_properties = Some(next_value(&mut iter, &arg)?.parse()?)
}
"--max-dependencies" => {
options.max_dependencies = Some(next_value(&mut iter, &arg)?.parse()?)
}
"--platform" => options.platform = next_value(&mut iter, &arg)?,
"--filter" => options.filter = Some(next_value(&mut iter, &arg)?),
_ => options.unity_args.push(arg),
}
}
Ok(options)
}
}
fn next_value(iter: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
iter.next()
.ok_or_else(|| anyhow!("{name} requires a value"))
}
fn next_path(iter: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
Ok(PathBuf::from(next_value(iter, name)?))
}
fn repo_root() -> Result<PathBuf> {
Ok(Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.ok_or_else(|| anyhow!("epiphany-core has no parent repo root"))?
.to_path_buf())
}
fn now_iso() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn clean_windows_path(value: &str) -> String {
value.strip_prefix(r"\\?\").unwrap_or(value).to_string()
}
fn path_string(path: &Path) -> String {
clean_windows_path(&path.display().to_string())
}
fn maybe_resolve(path: &Path) -> String {
path_string(&path.canonicalize().unwrap_or_else(|_| path.to_path_buf()))
}
fn artifact_dir(root: &Path, action: &str) -> Result<PathBuf> {
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
Ok(root
.canonicalize()
.unwrap_or_else(|_| root.to_path_buf())
.join(format!("unity-{action}-{nanos}-{}", std::process::id())))
}
fn write_json(path: &Path, value: &serde_json::Value) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))
.with_context(|| format!("failed to write {}", path.display()))
}
fn write_text(path: &Path, value: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, value).with_context(|| format!("failed to write {}", path.display()))
}
fn read_unity_project_version(project_path: &Path) -> serde_json::Value {
let version_path = project_path.join(PROJECT_VERSION_RELATIVE);
let mut result = json!({
"path": path_string(&version_path),
"exists": version_path.exists(),
"editorVersion": null,
"editorVersionWithRevision": null,
});
if !version_path.exists() {
result["status"] = json!("missingProjectVersion");
result["note"] = json!("ProjectSettings/ProjectVersion.txt was not found.");
return result;
}
let Ok(raw) = fs::read_to_string(&version_path) else {
result["status"] = json!("missingProjectVersion");
result["note"] = json!("ProjectVersion.txt could not be read.");
return result;
};
for line in raw.lines() {
if let Some(value) = line.strip_prefix("m_EditorVersion:") {
result["editorVersion"] = json!(value.trim());
} else if let Some(value) = line.strip_prefix("m_EditorVersionWithRevision:") {
result["editorVersionWithRevision"] = json!(value.trim());
}
}
if result["editorVersion"]
.as_str()
.is_some_and(|value| !value.is_empty())
{
result["status"] = json!("ready");
} else {
result["status"] = json!("missingProjectVersion");
result["note"] = json!("ProjectVersion.txt did not contain m_EditorVersion.");
}
result
}
fn default_unity_editor_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Ok(value) = env::var("EPIPHANY_UNITY_EDITOR_ROOTS") {
roots.extend(env::split_paths(&value));
}
roots.push(
PathBuf::from(env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".to_string()))
.join("Unity")
.join("Hub")
.join("Editor"),
);
roots.push(
PathBuf::from(
env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".to_string()),
)
.join("Unity")
.join("Hub")
.join("Editor"),
);
dedupe_paths(roots)
}
fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut result = Vec::new();
for path in paths {
let key = maybe_resolve(&path).to_lowercase();
if seen.insert(key) {
result.push(path);
}
}
result
}
fn unity_editor_relative() -> PathBuf {
UNITY_EDITOR_RELATIVE.iter().collect()
}
fn editor_path_for_version(root: &Path, version: &str) -> PathBuf {
if root.file_name().and_then(|value| value.to_str()) == Some(version) {
root.join(unity_editor_relative())
} else {
root.join(version).join(unity_editor_relative())
}
}
fn installed_unity_editors(roots: &[PathBuf]) -> Result<Vec<serde_json::Value>> {
let mut editors = Vec::new();
let rel = unity_editor_relative();
for root in roots {
if !root.exists() {
continue;
}
let direct = root.join(&rel);
if direct.exists() {
editors.push(json!({
"version": root.file_name().and_then(|value| value.to_str()),
"root": path_string(root),
"editorPath": path_string(&direct),
}));
continue;
}
for entry in fs::read_dir(root)? {
let child = entry?.path();
if !child.is_dir() {
continue;
}
let editor = child.join(&rel);
if editor.exists() {
editors.push(json!({
"version": child.file_name().and_then(|value| value.to_str()),
"root": path_string(&child),
"editorPath": path_string(&editor),
}));
}
}
}
Ok(editors)
}
fn detect_editor_bridge(project_path: &Path) -> serde_json::Value {
let package_path = project_path.join(EDITOR_BRIDGE_RELATIVE);
json!({
"kind": "epiphanyUnityEditorBridge",
"path": path_string(&package_path),
"relativePath": EDITOR_BRIDGE_RELATIVE,
"exists": package_path.exists(),
"executeMethod": EDITOR_BRIDGE_EXECUTE_METHOD,
})
}
fn resolve_unity_editor(project_path: &Path) -> Result<serde_json::Value> {
let project_path = project_path
.canonicalize()
.unwrap_or_else(|_| project_path.to_path_buf());
let version = read_unity_project_version(&project_path);
let roots = default_unity_editor_roots();
let editors = installed_unity_editors(&roots)?;
let mut result = json!({
"kind": "unity",
"projectPath": path_string(&project_path),
"generatedAt": now_iso(),
"versionFile": version,
"searchRoots": roots.iter().map(|path| path_string(path)).collect::<Vec<_>>(),
"installedEditors": editors,
"projectVersion": null,
"projectVersionWithRevision": null,
"editorPath": null,
"status": "missingProjectVersion",
"note": "ProjectSettings/ProjectVersion.txt was not found or did not pin a Unity editor.",
"editorBridge": detect_editor_bridge(&project_path),
});
result["projectVersion"] = result["versionFile"]["editorVersion"].clone();
result["projectVersionWithRevision"] =
result["versionFile"]["editorVersionWithRevision"].clone();
let Some(project_version) = result["projectVersion"].as_str().map(ToOwned::to_owned) else {
return Ok(result);
};
if project_version.is_empty() {
return Ok(result);
}
let exact_candidates: Vec<PathBuf> = roots
.iter()
.map(|root| editor_path_for_version(root, &project_version))
.collect();
let exact = exact_candidates.iter().find(|path| path.exists());
result["candidatePaths"] = json!(
exact_candidates
.iter()
.map(|path| path_string(path))
.collect::<Vec<_>>()
);
if let Some(exact) = exact {
result["status"] = json!("ready");
result["editorPath"] = json!(path_string(exact));
result["note"] = json!(format!(
"Project pins Unity {project_version}; exact editor resolved at {}.",
path_string(exact)
));
} else {
let installed_versions = result["installedEditors"]
.as_array()
.into_iter()
.flatten()
.filter_map(|item| item["version"].as_str())
.collect::<Vec<_>>()
.join(", ");
result["status"] = json!("missingEditor");
result["note"] = json!(format!(
"Project pins Unity {project_version}, but no exact editor was found. Installed Hub versions: {}.",
if installed_versions.is_empty() {
"none detected"
} else {
&installed_versions
}
));
}
Ok(result)
}
fn render_inspection(summary: &serde_json::Value) -> String {
let installed_lines = summary["installedEditors"]
.as_array()
.filter(|items| !items.is_empty())
.map(|items| {
items
.iter()
.map(|item| {
format!(
"- {}: {}",
item["version"].as_str().unwrap_or("unknown"),
item["editorPath"].as_str().unwrap_or("unknown")
)
})
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec!["- none detected".to_string()]);
let candidate_lines = summary["candidatePaths"]
.as_array()
.filter(|items| !items.is_empty())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str())
.map(|item| format!("- {item}"))
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec!["- none".to_string()]);
format!(
"# Unity Runtime Bridge\n\nStatus: {}\nProject: {}\nProject version: {}\nEditor path: {}\n\nInstalled Hub editors:\n{}\n\nExact candidate paths:\n{}\n\nNote: {}\n",
summary["status"].as_str().unwrap_or("unknown"),
summary["projectPath"].as_str().unwrap_or("unknown"),
summary["projectVersion"].as_str().unwrap_or("none"),
summary["editorPath"].as_str().unwrap_or("none"),
installed_lines.join("\n"),
candidate_lines.join("\n"),
summary["note"].as_str().unwrap_or(""),
)
}
fn write_inspection_artifacts(directory: &Path, summary: &serde_json::Value) -> Result<()> {
write_json(&directory.join("unity-bridge-summary.json"), summary)?;
write_text(
&directory.join("unity-bridge-inspection.md"),
&render_inspection(summary),
)
}
fn validate_unity_args(values: &[String]) -> Result<()> {
let lowered: HashSet<String> = values.iter().map(|value| value.to_lowercase()).collect();
let forbidden = FORBIDDEN_EXTRA_ARGS
.iter()
.filter(|value| lowered.contains(**value))
.copied()
.collect::<Vec<_>>();
if !forbidden.is_empty() {
return Err(anyhow!(
"Unity bridge owns batchmode, quit, projectPath, and logFile; remove extra args: {}",
forbidden.join(", ")
));
}
Ok(())
}
fn build_unity_command(
summary: &serde_json::Value,
extra_args: &[String],
log_path: Option<&Path>,
) -> Result<Vec<String>> {
let editor_path = summary["editorPath"]
.as_str()
.ok_or_else(|| anyhow!("Unity editor path is unavailable."))?;
let project_path = summary["projectPath"]
.as_str()
.ok_or_else(|| anyhow!("Unity project path is unavailable."))?;
validate_unity_args(extra_args)?;
let mut command = vec![
editor_path.to_string(),
"-batchmode".to_string(),
"-quit".to_string(),
"-projectPath".to_string(),
project_path.to_string(),
];
if let Some(log_path) = log_path {
command.extend(["-logFile".to_string(), path_string(log_path)]);
}
command.extend(extra_args.iter().cloned());
Ok(command)
}
fn editor_bridge_ready(summary: &serde_json::Value) -> bool {
summary["editorBridge"]["exists"].as_bool() == Some(true)
}
fn named_probe_extra_args(
options: &UnityOptions,
operation: &str,
directory: &Path,
) -> Vec<String> {
let mut args = vec![
"-executeMethod".to_string(),
EDITOR_BRIDGE_EXECUTE_METHOD.to_string(),
"-epiphanyArtifactDir".to_string(),
path_string(directory),
"-epiphanyOperation".to_string(),
operation.to_string(),
];
for (value, flag) in [
(
options.scene.as_ref().map(ToString::to_string),
"-epiphanyScene",
),
(
options.asset.as_ref().map(ToString::to_string),
"-epiphanyAsset",
),
(
options.guid.as_ref().map(ToString::to_string),
"-epiphanyGuid",
),
(
options.max_objects.map(|value| value.to_string()),
"-epiphanyMaxObjects",
),
(
options.max_properties.map(|value| value.to_string()),
"-epiphanyMaxProperties",
),
(
options.max_dependencies.map(|value| value.to_string()),
"-epiphanyMaxDependencies",
),
] {
if let Some(value) = value {
args.extend([flag.to_string(), value]);
}
}
args
}
fn expected_artifacts(operation: &str) -> Vec<&'static str> {
match operation {
"inspect-project" => vec![
"project-facts.json",
"unity-probe-result.json",
"unity-probe-result.md",
],
"check-compilation" => vec![
"compilation.json",
"unity-probe-result.json",
"unity-probe-result.md",
],
"scene-facts" => vec![
"scene-facts.json",
"unity-probe-result.json",
"unity-probe-result.md",
],
"prefab-facts" => vec![
"prefab-facts.json",
"unity-probe-result.json",
"unity-probe-result.md",
],
"serialized-object" => vec![
"serialized-object-facts.json",
"unity-probe-result.json",
"unity-probe-result.md",
],
"reference-search" => vec![
"reference-search.json",
"unity-probe-result.json",
"unity-probe-result.md",
],
"run-tests" => vec!["test-results.xml", "unity.log"],
_ => Vec::new(),
}
}
fn probe_description(operation: &str) -> &'static str {
match operation {
"inspect-project" => "Capture Unity project/editor/build/render-pipeline facts.",
"check-compilation" => {
"Check whether the editor bridge can run after Unity script compilation."
}
"scene-facts" => {
"Capture scene hierarchy, components, prefab links, and serialized fields."
}
"prefab-facts" => {
"Capture prefab hierarchy, nested instances, overrides, and serialized fields."
}
"serialized-object" => "Capture SerializedObject properties for one asset.",
"reference-search" => "Search Unity asset dependencies for references to a GUID or asset.",
_ => "Run a named Unity editor bridge probe.",
}
}
fn write_command_artifact(
directory: &Path,
command: &[String],
dry_run: bool,
operation: &str,
expected_artifacts: Vec<&str>,
) -> Result<()> {
write_json(
&directory.join("unity-command.json"),
&json!({
"command": command,
"dryRun": dry_run,
"operation": operation,
"expectedArtifacts": expected_artifacts,
}),
)
}
fn run_unity(options: &UnityOptions) -> Result<serde_json::Value> {
let directory = options
.artifact_dir
.clone()
.map(Ok)
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "run"))?;
let project_path = options
.project_path
.canonicalize()
.unwrap_or_else(|_| options.project_path.clone());
let mut summary = resolve_unity_editor(&project_path)?;
summary["artifactPath"] = json!(path_string(&directory));
summary["operation"] = json!("run");
summary["label"] = json!(options.label);
summary["unityArgs"] = json!(options.unity_args);
if summary["status"].as_str() != Some("ready") {
summary["runStatus"] = json!("blocked");
summary["note"] = json!(format!(
"{} Runtime execution refused; install the exact pinned editor or use inspect artifacts as evidence of the missing runtime.",
summary["note"].as_str().unwrap_or("")
));
write_inspection_artifacts(&directory, &summary)?;
return Ok(summary);
}
let log_path = directory.join("unity.log");
let command = build_unity_command(&summary, &options.unity_args, Some(&log_path))?;
summary["command"] = json!(command);
summary["logPath"] = json!(path_string(&log_path));
if options.dry_run {
summary["runStatus"] = json!("planned");
summary["returncode"] = serde_json::Value::Null;
write_inspection_artifacts(&directory, &summary)?;
write_command_artifact(
&directory,
summary["command"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
.as_slice(),
true,
"run",
Vec::new(),
)?;
return Ok(summary);
}
execute_command(
&mut summary,
&directory,
&project_path,
options.timeout_seconds,
)?;
write_inspection_artifacts(&directory, &summary)?;
write_command_artifact(
&directory,
summary["command"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
.as_slice(),
false,
"run",
Vec::new(),
)?;
Ok(summary)
}
fn run_named_probe(options: &UnityOptions, operation: &str) -> Result<serde_json::Value> {
let allowed = [
"inspect-project",
"check-compilation",
"scene-facts",
"prefab-facts",
"serialized-object",
"reference-search",
];
if !allowed.contains(&operation) {
return Err(anyhow!("unsupported probe operation: {operation}"));
}
let directory =
options.artifact_dir.clone().map(Ok).unwrap_or_else(|| {
artifact_dir(&options.artifact_root, &operation.replace('-', "_"))
})?;
let project_path = options
.project_path
.canonicalize()
.unwrap_or_else(|_| options.project_path.clone());
let mut summary = resolve_unity_editor(&project_path)?;
summary["artifactPath"] = json!(path_string(&directory));
summary["operation"] = json!(operation);
summary["probe"] = json!({
"description": probe_description(operation),
"expectedArtifacts": expected_artifacts(operation),
});
if summary["status"].as_str() != Some("ready") {
summary["runStatus"] = json!("blocked");
summary["note"] = json!(format!(
"{} Runtime/editor probe refused; install the exact pinned editor or use inspect artifacts as evidence of the missing runtime.",
summary["note"].as_str().unwrap_or("")
));
write_inspection_artifacts(&directory, &summary)?;
return Ok(summary);
}
if !editor_bridge_ready(&summary) {
let bridge_path = summary["editorBridge"]["path"]
.as_str()
.map(ToOwned::to_owned)
.unwrap_or_else(|| path_string(&project_path.join(EDITOR_BRIDGE_RELATIVE)));
summary["status"] = json!("missingEditorBridgePackage");
summary["runStatus"] = json!("blocked");
summary["note"] = json!(format!(
"The Epiphany Unity editor package is missing. Add {bridge_path} before running editor-resident probes."
));
write_inspection_artifacts(&directory, &summary)?;
return Ok(summary);
}
let extra_args = named_probe_extra_args(options, operation, &directory);
let log_path = directory.join("unity.log");
let command = build_unity_command(&summary, &extra_args, Some(&log_path))?;
summary["command"] = json!(command);
summary["logPath"] = json!(path_string(&log_path));
if options.dry_run {
summary["runStatus"] = json!("planned");
summary["returncode"] = serde_json::Value::Null;
write_inspection_artifacts(&directory, &summary)?;
write_command_artifact(
&directory,
summary["command"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
.as_slice(),
true,
operation,
expected_artifacts(operation),
)?;
return Ok(summary);
}
execute_command(
&mut summary,
&directory,
&project_path,
options.timeout_seconds,
)?;
write_inspection_artifacts(&directory, &summary)?;
write_command_artifact(
&directory,
summary["command"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
.as_slice(),
false,
operation,
expected_artifacts(operation),
)?;
Ok(summary)
}
fn test_unity(options: &UnityOptions) -> Result<serde_json::Value> {
let directory = options
.artifact_dir
.clone()
.map(Ok)
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "run_tests"))?;
let project_path = options
.project_path
.canonicalize()
.unwrap_or_else(|_| options.project_path.clone());
let mut summary = resolve_unity_editor(&project_path)?;
summary["artifactPath"] = json!(path_string(&directory));
summary["operation"] = json!("run-tests");
summary["testPlatform"] = json!(options.platform);
summary["testFilter"] = json!(options.filter);
if summary["status"].as_str() != Some("ready") {
summary["runStatus"] = json!("blocked");
summary["note"] = json!(format!(
"{} Unity tests refused; install the exact pinned editor or use inspect artifacts as evidence of the missing runtime.",
summary["note"].as_str().unwrap_or("")
));
write_inspection_artifacts(&directory, &summary)?;
return Ok(summary);
}
let log_path = directory.join("unity.log");
let test_results = directory.join("test-results.xml");
let mut extra_args = vec![
"-runTests".to_string(),
"-testPlatform".to_string(),
options.platform.clone(),
"-testResults".to_string(),
path_string(&test_results),
];
if let Some(filter) = &options.filter {
extra_args.extend(["-testFilter".to_string(), filter.clone()]);
}
let command = build_unity_command(&summary, &extra_args, Some(&log_path))?;
summary["command"] = json!(command);
summary["logPath"] = json!(path_string(&log_path));
summary["testResultsPath"] = json!(path_string(&test_results));
if options.dry_run {
summary["runStatus"] = json!("planned");
summary["returncode"] = serde_json::Value::Null;
write_inspection_artifacts(&directory, &summary)?;
write_command_artifact(
&directory,
summary["command"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
.as_slice(),
true,
"run-tests",
expected_artifacts("run-tests"),
)?;
return Ok(summary);
}
execute_command(
&mut summary,
&directory,
&project_path,
options.timeout_seconds,
)?;
write_inspection_artifacts(&directory, &summary)?;
write_command_artifact(
&directory,
summary["command"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
.as_slice(),
false,
"run-tests",
expected_artifacts("run-tests"),
)?;
Ok(summary)
}
fn execute_command(
summary: &mut serde_json::Value,
directory: &Path,
project_path: &Path,
timeout_seconds: u64,
) -> Result<()> {
let command = summary["command"]
.as_array()
.ok_or_else(|| anyhow!("summary command is not an array"))?
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>();
let (program, args) = command
.split_first()
.ok_or_else(|| anyhow!("empty Unity command"))?;
let stdout_path = directory.join("unity-stdout.log");
let stderr_path = directory.join("unity-stderr.log");
fs::create_dir_all(directory)?;
let started = Instant::now();
let mut child = Command::new(program)
.args(args)
.current_dir(project_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
let timeout = Duration::from_secs(timeout_seconds);
loop {
if let Some(status) = child.try_wait()? {
let output = child.wait_with_output()?;
fs::write(&stdout_path, output.stdout)?;
fs::write(&stderr_path, output.stderr)?;
let code = status.code();
summary["runStatus"] = json!(if status.success() { "passed" } else { "failed" });
summary["returncode"] = json!(code);
summary["durationSeconds"] =
json!((started.elapsed().as_millis() as f64 / 1000.0).round());
summary["stdoutPath"] = json!(path_string(&stdout_path));
summary["stderrPath"] = json!(path_string(&stderr_path));
return Ok(());
}
if started.elapsed() > timeout {
let _ = child.kill();
let output = child.wait_with_output()?;
if !output.stdout.is_empty() {
fs::write(&stdout_path, output.stdout)?;
summary["stdoutPath"] = json!(path_string(&stdout_path));
}
if !output.stderr.is_empty() {
fs::write(&stderr_path, output.stderr)?;
summary["stderrPath"] = json!(path_string(&stderr_path));
}
summary["runStatus"] = json!("timedOut");
summary["returncode"] = serde_json::Value::Null;
summary["durationSeconds"] =
json!((started.elapsed().as_millis() as f64 / 1000.0).round());
summary["note"] = json!(format!(
"Unity command timed out after {timeout_seconds} seconds."
));
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn inspect_unity(options: &UnityOptions) -> Result<serde_json::Value> {
let directory = options
.artifact_dir
.clone()
.map(Ok)
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "inspect"))?;
let mut summary = resolve_unity_editor(&options.project_path)?;
summary["artifactPath"] = json!(path_string(&directory));
summary["operation"] = json!("inspect");
write_inspection_artifacts(&directory, &summary)?;
Ok(summary)
}
fn bridge_guidance(project_path: &Path) -> Result<String> {
let summary = resolve_unity_editor(project_path)?;
let inspect_command = format!(
"`epiphany-unity-bridge inspect --project-path {}`",
project_path.display()
);
if summary["status"].as_str() == Some("ready") {
if editor_bridge_ready(&summary) {
Ok(format!(
"- Unity bridge: project pins {} and the exact editor resolved to `{}`. If Unity execution is needed, use named bridge operations such as `epiphany-unity-bridge probe --project-path {} --operation scene-facts --scene <Assets/...unity>` or `epiphany-unity-bridge check-compilation --project-path {}`. The bridge owns -batchmode, -quit, -projectPath, -logFile, -executeMethod, and artifacts. Do not invoke `Unity`, `Unity.exe`, default installs, or PATH-resolved editors directly, and do not refactor Unity-owned scene/prefab state as raw text when the editor bridge can inspect it.",
summary["projectVersion"].as_str().unwrap_or("unknown"),
summary["editorPath"].as_str().unwrap_or("unknown"),
project_path.display(),
project_path.display(),
))
} else {
Ok(format!(
"- Unity bridge: project pins {} and the exact editor resolved to `{}`, but the resident editor package is missing. Add `{}` before claiming scene, prefab, or runtime facts. Do not invoke `Unity`, `Unity.exe`, default installs, or PATH-resolved editors directly.",
summary["projectVersion"].as_str().unwrap_or("unknown"),
summary["editorPath"].as_str().unwrap_or("unknown"),
EDITOR_BRIDGE_RELATIVE,
))
}
} else {
Ok(format!(
"- Unity bridge: {} Run {} to write an auditable runtime evidence artifact. Do not invoke `Unity`, `Unity.exe`, default installs, or PATH-resolved editors directly; if runtime parity is needed, stop with the bridge artifact as the evidence gap.",
summary["note"]
.as_str()
.unwrap_or("Unity bridge is blocked."),
inspect_command,
))
}
}

View file

@ -5,9 +5,9 @@ This is the first Rider-side bridge surface for Epiphany.
It is deliberately small:
- show an Epiphany tool window inside Rider
- call `tools/epiphany_rider_bridge.py status`
- call native `epiphany-rider-bridge status`
- send current editor file/selection context through
`tools/epiphany_rider_bridge.py context`
native `epiphany-rider-bridge context`
- write operator-safe artifacts under `.epiphany-gui/rider`
The plugin does not talk to worker transcripts, raw results, or agent internals.
@ -27,20 +27,21 @@ gradle buildPlugin
## Runtime Contract
The plugin shells out to the local bridge script instead of owning protocol
The plugin shells out to the local native bridge instead of owning protocol
logic:
```powershell
python E:\Projects\EpiphanyAgent\tools\epiphany_rider_bridge.py status --project-root <repo>
python E:\Projects\EpiphanyAgent\tools\epiphany_rider_bridge.py context --project-root <repo> --file <file> --selection-start <line> --selection-end <line>
epiphany-rider-bridge status --project-root <repo>
epiphany-rider-bridge context --project-root <repo> --file <file> --selection-start <line> --selection-end <line>
```
Set these environment variables if needed:
- `EPIPHANY_REPO_ROOT`
- `EPIPHANY_PYTHON`
- `EPIPHANY_RIDER_BRIDGE`
- `CARGO_TARGET_DIR`
- `EPIPHANY_RIDER_ARTIFACT_ROOT`
The first dogfood use can also skip the plugin and use the bridge script
The first dogfood use can also skip the plugin and use the native bridge
directly. The point is the receipt shape, not making Rider perform authority
the Epiphany coordinator owns.

View file

@ -33,10 +33,10 @@ class EpiphanyBridgeClient(private val project: Project) {
}
private fun runBridge(args: List<String>): String {
val script = File(epiphanyRoot, "tools/epiphany_rider_bridge.py")
val bridge = bridgePath()
val artifactRoot = System.getenv("EPIPHANY_RIDER_ARTIFACT_ROOT")
?: File(epiphanyRoot, ".epiphany-gui/rider").absolutePath
val command = mutableListOf(pythonPath(), script.absolutePath)
val command = mutableListOf(bridge.absolutePath)
command.addAll(args)
command.add("--artifact-root")
command.add(artifactRoot)
@ -50,16 +50,22 @@ class EpiphanyBridgeClient(private val project: Project) {
return output.ifBlank { "Epiphany bridge returned no output." }
}
private fun pythonPath(): String {
return System.getenv("EPIPHANY_PYTHON")
?: "C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe"
private fun bridgePath(): File {
System.getenv("EPIPHANY_RIDER_BRIDGE")?.let { return File(it).absoluteFile }
val targetDir = System.getenv("CARGO_TARGET_DIR")
?: "C:\\Users\\Meta\\.cargo-target-codex"
val candidate = File(targetDir, "debug/epiphany-rider-bridge.exe").absoluteFile
if (candidate.exists()) {
return candidate
}
return File(epiphanyRoot, "epiphany-core/target/debug/epiphany-rider-bridge.exe").absoluteFile
}
private fun findEpiphanyRoot(): File {
System.getenv("EPIPHANY_REPO_ROOT")?.let { return File(it).absoluteFile }
var cursor: File? = projectRoot
while (cursor != null) {
if (File(cursor, "tools/epiphany_rider_bridge.py").exists()) {
if (File(cursor, "epiphany-core/Cargo.toml").exists()) {
return cursor
}
cursor = cursor.parentFile

View file

@ -87,7 +87,7 @@ The landed machine now has:
- first auditable Phase 6 fixed-lane coordinator runner through `tools/epiphany_mvp_coordinator.py`, producing coordinator summary, JSONL steps, rendered snapshots, transcript, stderr, and final next-action artifacts while keeping semantic findings review-gated by default
- first auditable Phase 6 live-specialist runner through `tools/epiphany_mvp_live_specialist.py`, proving `roleLaunch -> agent_jobs worker -> report_agent_job_result -> roleResult` without manual backend completion
- first Phase 6 Aquarium operator shell extracted to sibling repo `E:\Projects\EpiphanyAquarium`, a Tauri v2 + React/WebGL client over the existing status bridge, dogfood artifacts, and GUI action artifacts, with its own distilled interface state/memory/doctrine plus durable checkpoint preparation, bounded status/coordinator artifact buttons, fixed modeling/verification/reorient launch and read-back buttons, and explicit review-gated reorient acceptance
- first Unity editor/runtime bridge through `tools/epiphany_unity_bridge.py`, `tools/epiphany_unity_bridge_smoke.py`, and the GUI Inspect Unity action, resolving exact project-pinned editors and writing runtime artifacts while refusing wrong or missing versions
- first Unity editor/runtime bridge through native `epiphany-unity-bridge`, `tools/epiphany_unity_bridge_smoke.py`, and the GUI Inspect Unity action, resolving exact project-pinned editors and writing runtime artifacts while refusing wrong or missing versions
- prompt-file ownership for major Epiphany prompt surfaces: rendered state intro/doctrine lives under `epiphany-core/src/prompts/`, while modeling/Body, research/Eyes, implementation/Hands, verification/Soul, reorientation/Life, coordinator/Self, and CRRC templates live in `vendor/codex/codex-rs/app-server/src/prompts/epiphany_specialists.toml`
- prompt-level anti-Greenspun guardrails: Epiphany keeps Codex's useful operator discipline and requires a bounded research/scout check before agents invent bespoke versions of known algorithms, parsers, schedulers, renderers, protocols, storage layers, security mechanisms, or workflow engines
- live Phase 6 reorientation app-server smoke coverage in `tools/epiphany_phase6_reorient_smoke.py`
@ -250,7 +250,7 @@ slice is now landed. The fixed lanes can catch modeling, verification, stale
job, no-diff, and reorientation failures, but the implementation lane cannot
prove engine assumptions by launching random local tools. A worker tried to
launch a legacy default Unity editor even though Aetheria pins Unity
`6000.1.10f1`. `tools/epiphany_unity_bridge.py` now reads the project pin,
`6000.1.10f1`. Native `epiphany-unity-bridge` now reads the project pin,
resolves only an exact Hub editor, refuses wrong or missing editors, owns the
batch/quit/projectPath command wrapper, and writes inspection/command/log
artifacts. On this machine Aetheria is correctly blocked: only Unity

View file

@ -222,13 +222,13 @@ Already landed:
- GUI graph dashboard using the adjacent `@epiphanygraph/epiphany-graph-viewer`
package over typed `graphs.architecture`, `graphs.dataflow`, and
`graphs.links`.
- `tools/epiphany_rider_bridge.py`, which writes operator-safe Rider
- native `epiphany-rider-bridge`, which writes operator-safe Rider
installation, solution, VCS, context, and open-ref artifacts.
- GUI **Inspect Rider** action and Rider artifact listing.
- `integrations/rider` frontend-only plugin scaffold for a tool window and
Send Context to Epiphany action. This scaffold still needs build verification
because Gradle is not installed on the current machine.
- `tools/epiphany_unity_bridge.py`, which:
- native `epiphany-unity-bridge`, which:
- reads `ProjectSettings/ProjectVersion.txt`
- resolves only the exact project-pinned Unity Hub editor
- refuses wrong or missing editor versions
@ -498,7 +498,7 @@ not need to wrestle it in the kitchen for breakfast.
Already landed:
```powershell
python tools/epiphany_unity_bridge.py inspect --project-path E:\Projects\Aetheria-Economy
epiphany-unity-bridge inspect --project-path E:\Projects\Aetheria-Economy
```
Outputs:
@ -590,33 +590,33 @@ thing" refactors. The Editor knows what the object is; use it.
### Phase U2: Bridge Command Contract
Extend `tools/epiphany_unity_bridge.py run` with named operations that call the
Extend native `epiphany-unity-bridge run` with named operations that call the
resident editor package instead of making freeform `-executeMethod` the normal
path.
Recommended CLI:
```powershell
python tools/epiphany_unity_bridge.py probe `
epiphany-unity-bridge probe `
--project-path E:\Projects\Aetheria-Economy `
--operation check-compilation
python tools/epiphany_unity_bridge.py probe `
epiphany-unity-bridge probe `
--project-path E:\Projects\Aetheria-Economy `
--operation scene-facts `
--scene Assets/Scenes/Main.unity
python tools/epiphany_unity_bridge.py probe `
epiphany-unity-bridge probe `
--project-path E:\Projects\Aetheria-Economy `
--operation prefab-facts `
--asset Assets/Prefabs/Nebula.prefab
python tools/epiphany_unity_bridge.py probe `
epiphany-unity-bridge probe `
--project-path E:\Projects\Aetheria-Economy `
--operation serialized-object `
--asset Assets/Materials/Nebula.mat
python tools/epiphany_unity_bridge.py test `
epiphany-unity-bridge run-tests `
--project-path E:\Projects\Aetheria-Economy `
--platform editmode `
--filter Aetheria.Rendering.Gravity*
@ -800,15 +800,15 @@ Start in tools/GUI, then promote to app-server only when stable.
Existing:
- `tools/epiphany_unity_bridge.py inspect`
- `epiphany-unity-bridge inspect`
- GUI `inspectUnity`
Add next:
- `tools/epiphany_unity_bridge.py probe`
- `tools/epiphany_unity_bridge.py test`
- `tools/epiphany_rider_bridge.py status`
- `tools/epiphany_rider_bridge.py context`
- `epiphany-unity-bridge probe`
- `epiphany-unity-bridge run-tests`
- `epiphany-rider-bridge status`
- `epiphany-rider-bridge context`
### App-Server Later
@ -1023,7 +1023,7 @@ Rider changed ranges or git diff touches frontier files
### Slice 3: Unity Bridge Operations
- Extend `epiphany_unity_bridge.py` from generic `run` to named operations:
- Extend native `epiphany-unity-bridge` from generic `run` to named operations:
- `inspect`
- `check-compilation`
- `run-tests`
@ -1062,7 +1062,7 @@ Rider changed ranges or git diff touches frontier files
### Slice 6: Rider Bridge CLI
- Add `tools/epiphany_rider_bridge.py` as a local protocol stub.
- Add native `epiphany-rider-bridge` as a local protocol stub.
- It should accept context packets from the future plugin and write artifacts.
- It should not require Rider plugin code yet.

View file

@ -55,12 +55,12 @@ remembers doctrine; the branch remembers the blade.
- A later dogfood attempt exposed another supervision scar: manually launching modeling, reorientation, and verification from the supervisor is not clean dogfood. The coordinator now treats `regatherManually` as fallback only after fixed lanes cannot advance, stops at `reviewModelingResult` for completed unaccepted modeling findings, and the CLI runner's explicit test-only `--auto-review` mode can accept modeling `statePatch` findings before launching verification. Production semantic findings remain review-gated.
- The latest dogfood pass fixed concrete harness wounds: stale completed backend jobs now project as terminal, accepted verifier non-pass findings no longer clear implementation, verification coverage accepts modeler source evidence ids as current-model coverage, implementation audits compare pre/post diff hashes instead of treating existing dirt as fresh work, the latest implementation audit status wins, blocked no-diff turns route back through CRRC, stale accepted reorient findings relaunch bounded reorientation when the checkpoint regathers, and accepted reorientation findings now bank resume-ready checkpoints.
- The latest audited implementation artifact is `.epiphany-dogfood/aetheria-supervised/gui-actions/continueImplementation-1777548050463576300-28568`: it correctly shows `workspaceChanged: false`, `dirtyWorkspace: true`, `trackedDiffChanged: false`, wrote a no-diff audit, advanced Epiphany state to revision 57, and routed coordinator/CRRC back to `launchReorientWorker` instead of pretending old dirt was progress.
- Aetheria dogfood exposed and then landed the first editor/runtime bridge. An implementation worker launched legacy `D:\Unity\Editor\Unity.exe -version` (Unity 5.5.0f3) even though Aetheria pins Unity `6000.1.10f1`; the stray process was killed. `tools/epiphany_unity_bridge.py` now resolves the project-pinned Unity editor, refuses wrong/missing versions, owns `-batchmode`, `-quit`, and `-projectPath`, and writes inspection/command/log artifacts. `tools/epiphany_gui_action.py` points implementation workers at that bridge, and the GUI has an Inspect Unity action.
- Aetheria dogfood exposed and then landed the first editor/runtime bridge. An implementation worker launched legacy `D:\Unity\Editor\Unity.exe -version` (Unity 5.5.0f3) even though Aetheria pins Unity `6000.1.10f1`; the stray process was killed. Native `epiphany-unity-bridge` now resolves the project-pinned Unity editor, refuses wrong/missing versions, owns `-batchmode`, `-quit`, and `-projectPath`, and writes inspection/command/log artifacts. `tools/epiphany_gui_action.py` points implementation workers at that bridge, and the GUI has an Inspect Unity action.
- The current Aetheria runtime truth is blocked but legible: the project pins Unity `6000.1.10f1`, this machine currently has Hub editor `6000.4.2f1`, and the bridge wrote `.epiphany-gui/runtime/unity-inspect-1777549218802064800-23832` proving the exact editor is missing. Treat that artifact as the evidence gap until the pinned editor exists. The `.epiphany-gui` directory name is still a backend artifact contract, even though the client repo is now Aquarium.
- The GUI parses `implementation-result.json` into artifact metadata, surfaces the latest implementation diff/no-diff outcome, and pauses immediate `Continue Implementation` repeats when the newest artifact is a no-diff implementation audit.
- The planning loop is now runtime-backed and operator-visible. Typed captures, backlog items, roadmap streams, Objective Drafts, and GitHub issue source refs live in `EpiphanyThreadState`, validate through revision-gated `thread/epiphany/update`, render into prompts when present, project read-only through `thread/epiphany/planning`, render in the GUI Planning panel, can be synthesized by the fixed Imagination/planning lane, and can be explicitly adopted through the artifacted `adoptObjectiveDraft` GUI action. Chat is deliberation, not an objective pipe; ideas and GitHub Issues remain planning state until explicit human adoption.
- The bridge/dashboard slice is now landed far enough to test locally. `tools/epiphany_rider_bridge.py` inspects Rider installation, solution, VCS, changed files, and captures file/selection/symbol context into `.epiphany-gui/rider` artifacts; Aquarium has Inspect Rider and renders Rider audit artifacts in Environment; Aquarium also embeds the adjacent EpiphanyGraph viewer over typed `graphs.architecture`, `graphs.dataflow`, and `graphs.links`.
- A first Rider plugin scaffold lives under `integrations/rider`: tool window, Refresh status, and Send Context to Epiphany action. It shells through `tools/epiphany_rider_bridge.py` and does not own state. `gradle` is not installed on this machine, so the scaffold is not build-verified yet.
- The bridge/dashboard slice is now landed far enough to test locally. Native `epiphany-rider-bridge` inspects Rider installation, solution, VCS, changed files, and captures file/selection/symbol context into `.epiphany-gui/rider` artifacts; Aquarium has Inspect Rider and renders Rider audit artifacts in Environment; Aquarium also embeds the adjacent EpiphanyGraph viewer over typed `graphs.architecture`, `graphs.dataflow`, and `graphs.links`.
- A first Rider plugin scaffold lives under `integrations/rider`: tool window, Refresh status, and Send Context to Epiphany action. It shells through native `epiphany-rider-bridge` and does not own state. `gradle` is not installed on this machine, so the scaffold is not build-verified yet.
- Next real product move: continue Aquarium UI work in `E:\Projects\EpiphanyAquarium`; keep EpiphanyAgent focused on backend contracts, typed state, coordinator policy, bridge tools, heartbeat scheduling, Face guardrails, and the ongoing purification from Python/JSON scaffolding into Rust/CultCache/CultNet organs. Then build-verify/package the Rider plugin scaffold when Gradle/wrapper support is available, install the Aetheria-pinned Unity `6000.1.10f1` editor, and run the next Aetheria dogfood pass through Aquarium/Rider/Unity bridge artifacts. GitHub Issues import is deferred until the backlog source is fresh enough to deserve Imagination's attention.
- The repo is an Epiphany fork of Codex, not a Codex preset.
- `vendor/codex` is tracked directly, not a submodule.
@ -138,8 +138,8 @@ The current spine, blessed but not yet finished:
- Ghostlight-derived heartbeat initiative scheduling through the native `epiphany-heartbeat-store` binary, with Self and Face included as first-class participants, idle rumination routed through normal `selfPatch` review/application, and the heartbeat state round-tripped through the typed CultCache MessagePack store in `state/agent-heartbeats.msgpack`
- Face as the public #aquarium-only surface for translating agent thought-weather into short chats or candidate drafts, without moderator authority
- Ghostlight-shaped role dossiers in `state/agents.msgpack`, plus `selfPatch` review projection through `roleResult`/`roleAccept` and accepted memory application through the native `epiphany-agent-memory-store` binary
- first Unity runtime bridge through `tools/epiphany_unity_bridge.py`, `tools/epiphany_unity_bridge_smoke.py`, GUI Inspect Unity, runtime artifact listing, and implementation prompt guardrails
- first Rider source-context bridge through `tools/epiphany_rider_bridge.py`, `tools/epiphany_rider_bridge_smoke.py`, GUI Inspect Rider, Rider artifact listing, implementation prompt guardrails, and a source scaffold under `integrations/rider`
- first Unity runtime bridge through native `epiphany-unity-bridge`, `tools/epiphany_unity_bridge_smoke.py`, GUI Inspect Unity, runtime artifact listing, and implementation prompt guardrails
- first Rider source-context bridge through native `epiphany-rider-bridge`, `tools/epiphany_rider_bridge_smoke.py`, GUI Inspect Rider, Rider artifact listing, implementation prompt guardrails, and a source scaffold under `integrations/rider`
- first GUI graph dashboard through the adjacent `@epiphanygraph/epiphany-graph-viewer` package over typed Epiphany graph state
- live scene app-server smoke through `tools/epiphany_phase6_scene_smoke.py`
- live jobs app-server smoke through `tools/epiphany_phase6_jobs_smoke.py`
@ -422,14 +422,14 @@ The next real move is still bridge construction, not another Aetheria
implementation dogfood run. The pinned Unity bridge is landed, the clean
Aetheria branch `codex/epiphany-unity-editor-bridge` now contains the resident
`Assets/Editor/Epiphany/EpiphanyEditorBridge.cs` package, and
`tools/epiphany_unity_bridge.py` can detect that package and plan named
native `epiphany-unity-bridge` can detect that package and plan named
editor-resident probes/tests through
`GameCult.Epiphany.Unity.EpiphanyEditorBridge.RunProbe`. The GUI Environment
panel now shows the latest Unity runtime audit, resident package presence,
execute method, installed/candidate editor paths, search roots, and artifact
bundle details. Rider bridge status now exists too: the GUI can inspect Rider
installation, solution, VCS branch/dirty state, changed files, installations,
and source-context artifacts through `tools/epiphany_rider_bridge.py`. Runtime
and source-context artifacts through native `epiphany-rider-bridge`. Runtime
execution still correctly blocks until Unity `6000.1.10f1` is installed. The
detailed next-environment plan now lives in
`notes/epiphany-rider-unity-integration-plan.md`: Rider is the

Binary file not shown.

View file

@ -148,7 +148,7 @@ accepted_design:
- Dogfood supervision is a separate operator role. If the supervising Codex session edits the target repo directly, that work can be useful seed material, but it is not clean Epiphany evidence and must be labeled as supervisor intervention or contamination.
- Dogfood supervision is also a projection boundary: the operator may inspect summaries, verdicts, missing coverage, state patches, job state, and artifact receipts, but not the specialist's raw stream of thought or full transcript unless a forensic debugging request explicitly breaks the seal.
- `tools/epiphany_agent_telemetry.py` derives operator-safe function/API telemetry from sealed app-server transcripts so the supervisor can see calls, statuses, paths, and counts without reading direct thoughts.
- Editor/runtime verification is now part of the MVP product boundary for engine repos: `tools/epiphany_unity_bridge.py` is the first controlled bridge, resolving the project-pinned Unity editor, refusing wrong or missing versions, running explicit batch/probe commands only through the exact editor, and writing inspection/command/log artifacts for evidence.
- Editor/runtime verification is now part of the MVP product boundary for engine repos: native `epiphany-unity-bridge` is the first controlled bridge, resolving the project-pinned Unity editor, refusing wrong or missing versions, running explicit batch/probe commands only through the exact editor, and writing inspection/command/log artifacts for evidence.
- Rider and Unity are environment organs, not sources of truth: Rider should expose IDE/source context, diagnostics, navigation, and changed ranges; Unity should expose pinned-editor runtime facts, compilation, tests, probes, assets, scenes, and logs; Epiphany remains the durable Self that coordinates, records, and reviews.
- The product architecture is three-pronged: Rider is the code view for human inspection of repo state, source tree, diffs, diagnostics, and code refs; Epiphany Aquarium is the agent dashboard for objectives, specialist lane state, logs/artifacts, persisted typed state, and graph/control-flow views; Unity is the runtime environment for pinned tests, probes, scene configuration, assets, shaders, and play/edit-mode evidence.
- Epiphany needs a workspace-scoped planning substrate separate from the active objective: conversation captures, inbox, normalized backlog, roadmap streams, objective drafts, and explicit objective adoption are distinct states. The active objective remains the bounded run contract; chat and imported issues are planning inputs until reviewed and promoted. `notes/epiphany-planning-substrate.md` defines the product model, including GitHub Issues import semantics.
@ -160,7 +160,7 @@ accepted_design:
- The first resident Unity editor package now lives in the Aetheria branch `codex/epiphany-unity-editor-bridge`: `Assets/Editor/Epiphany/EpiphanyEditorBridge.cs` exposes editor-resident inspect, compilation, scene, prefab, serialized-object, and reference-search probes with JSON/Markdown artifacts. Epiphany's Unity bridge detects that package and plans named probe/test commands through `GameCult.Epiphany.Unity.EpiphanyEditorBridge.RunProbe`, while runtime execution remains blocked until the exact project-pinned Unity editor is present.
- The Aquarium Environment surface reflects latest Unity and Rider bridge audits: Unity shows project-pinned editor status, resident package presence, execute method, installed/candidate editor paths, search roots, and artifact bundle details; Rider shows executable, solution, branch, dirty state, changed files, installations, and bridge artifacts.
- The adjacent EpiphanyGraph React viewer is embedded in the Aquarium graph dashboard through the local package dependency; it consumes `graphs.architecture`, `graphs.dataflow`, and `graphs.links` directly and exposes node/edge/code-ref inspection with machine-readable graph data behind the visual layout.
- `tools/epiphany_rider_bridge.py` is the first operator-safe Rider source-context bridge: it inspects Rider installation/solution/VCS status, captures current file/selection/symbol context packets, plans explicit Rider code-ref opens, writes `.epiphany-gui/rider` artifacts, and feeds implementation prompt guardrails without reading sealed worker streams or owning durable state.
- Native `epiphany-rider-bridge` is the first operator-safe Rider source-context bridge: it inspects Rider installation/solution/VCS status, captures current file/selection/symbol context packets, plans explicit Rider code-ref opens, writes `.epiphany-gui/rider` artifacts, and feeds implementation prompt guardrails without reading sealed worker streams or owning durable state.
- GitHub Issues should import into planning captures first, not directly into active objectives. The importer should preserve repository, issue number, ids, URL, title/body, labels, milestone, assignees, author, state, timestamps, comment metadata, PR marker, and optional Projects v2 metadata while keeping GitHub as a source adapter rather than Epiphany's internal backlog model.
- If the agent's persistent instructions, memory, workflow, or state shape are making it less coherent, slower to rehydrate, less honest, or more likely to build Jenga, the agent should ask the user to change it. A machine-spirit that knows its own corrosion must report it.
- Epiphany should make compaction less like death and more like sleep: bank the fire before the dark, so the next waking thing finds coals instead of ash and can meaningfully resume the pattern instead of merely continuing the task.
@ -185,12 +185,12 @@ landed_baseline:
- Phase 6 safe-boundary CRRC automation: turn-complete automation limited to coordinator-approved compact, successful pre-compaction handoff compact, and fixed reorient-worker launch actions.
- Phase 6 pre-compaction checkpoint intervention: token-count pressure hook that steers active turns once when `shouldPrepareCompaction` is reached so working context is banked before compaction, then latches a pending compact handoff for clean turn completion.
- Phase 6 Aquarium extraction: the Tauri v2 + React/WebGL operator console moved from embedded `apps/epiphany-gui` into sibling repo `E:\Projects\EpiphanyAquarium`, with its own distilled interface state, memory, doctrine, build, visual smoke, and Tauri check.
- Phase 6 Unity runtime bridge: `tools/epiphany_unity_bridge.py` plus GUI Inspect Unity action resolve pinned Unity editors, reject wrong/missing versions, and write runtime artifacts before engine assumptions are treated as testable.
- Phase 6 Unity runtime bridge: native `epiphany-unity-bridge` plus GUI Inspect Unity action resolve pinned Unity editors, reject wrong/missing versions, and write runtime artifacts before engine assumptions are treated as testable.
- Planning substrate design: `notes/epiphany-planning-substrate.md` formalizes conversation capture, inbox, backlog, roadmap streams, objective drafts, explicit adoption, the Imagination planning role, and GitHub Issues import.
- Phase 6 planning substrate runtime slice: typed planning state in `EpiphanyThreadState`, prompt rendering, revision-gated update validation, read-only `thread/epiphany/planning`, scene action reflection, generated schemas, and live planning smoke coverage.
- Phase 6 GUI planning operator slice: MVP status and Tauri/React GUI now render planning state, and `tools/epiphany_gui_action.py` exposes explicit selected Objective Draft adoption with artifacted state patch/update proof.
- Phase 6 Imagination role lane: `thread/epiphany/roles` now exposes Imagination/planning, `roleLaunch` can launch the fixed `planning-synthesis-worker`, `roleResult` projects its finding, `roleAccept` applies planning-only patches with review-gated Objective Drafts, and the GUI/status tools can launch, read, and accept that lane.
- Phase 6 Aquarium graph and Rider bridge slice: the Tauri/React/WebGL Aquarium embeds the adjacent EpiphanyGraph viewer over typed graph state, `tools/epiphany_rider_bridge.py` writes Rider/source-context artifacts, Aquarium Inspect Rider surfaces those artifacts, and `integrations/rider` scaffolds the first frontend-only Rider tool-window/context-action plugin.
- Phase 6 Aquarium graph and Rider bridge slice: the Tauri/React/WebGL Aquarium embeds the adjacent EpiphanyGraph viewer over typed graph state, native `epiphany-rider-bridge` writes Rider/source-context artifacts, Aquarium Inspect Rider surfaces those artifacts, and `integrations/rider` scaffolds the first frontend-only Rider tool-window/context-action plugin.
- Phase 6 role self-memory slice: Ghostlight-shaped role dossiers live in typed CultCache `state/agents.msgpack`, specialists may request bounded `selfPatch` lane-memory mutations, coordinator review projects acceptance/refusal reasons through `selfPersistence`, and GUI/coordinator accept paths apply accepted requests with the native `epiphany-agent-memory-store` binary.
- Phase 6 Face public-surface slice: Face's typed role dossier, Face prompt text, `state/face-discord.toml`, and the native `epiphany-face-discord` binary establish Face as the public #aquarium-only translator of Epiphany agent thought-weather, without VoidBot's moderation authority.
- Phase 6 heartbeat initiative slice: `state/agent-heartbeats.msgpack`, `epiphany-core::EpiphanyHeartbeatStateEntry`, the native `epiphany-heartbeat-store` binary, and heartbeat smoke coverage borrow Ghostlight initiative scheduling so Self, Face, Imagination, Eyes, Body, Hands, Soul, and Life compete for heartbeat slots; idle slots become bounded memory rumination instead of fake project work, and the live state surface now ticks/completes/applies rumination through typed CultCache MessagePack instead of JSON or Python wrapper state.

View file

@ -21,8 +21,6 @@ from epiphany_mvp_status import sanitize_for_operator
from epiphany_phase5_smoke import AppServerClient
from epiphany_phase5_smoke import ROOT
from epiphany_phase6_reorient_launch_smoke import BINDING_ID as REORIENT_BINDING_ID
from epiphany_rider_bridge import bridge_guidance as rider_bridge_guidance
from epiphany_unity_bridge import bridge_guidance as unity_bridge_guidance
DEFAULT_CODEX_HOME = ROOT / ".epiphany-gui" / "codex-home"
@ -475,11 +473,29 @@ def active_review_files_from_status(status: dict[str, Any]) -> list[str]:
def unity_guidance(cwd: Path) -> str:
return unity_bridge_guidance(cwd, ROOT)
try:
result = native_epiphany_bin(
"epiphany-unity-bridge",
"guidance",
"--project-path",
str(cwd),
)
except RuntimeError as error:
return f"- Unity bridge: native guidance failed: {error}"
return str(result.get("guidance") or "- Unity bridge: native guidance returned no text.")
def rider_guidance(cwd: Path) -> str:
return rider_bridge_guidance(cwd, ROOT)
try:
result = native_epiphany_bin(
"epiphany-rider-bridge",
"guidance",
"--project-root",
str(cwd),
)
except RuntimeError as error:
return f"- Rider bridge: native guidance failed: {error}"
return str(result.get("guidance") or "- Rider bridge: native guidance returned no text.")
def first_present(value: dict[str, Any], *paths: tuple[str, ...]) -> Any:

View file

@ -1,434 +0,0 @@
from __future__ import annotations
import argparse
from datetime import datetime
from datetime import timezone
import json
import os
from pathlib import Path
import shutil
import subprocess
import time
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_ARTIFACT_ROOT = ROOT / ".epiphany-gui" / "rider"
RIDER_EXECUTABLE_NAMES = ("rider64.exe", "rider.exe", "rider.bat", "rider")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def write_text(path: Path, value: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(value, encoding="utf-8")
def artifact_dir(root: Path, action: str) -> Path:
return root.resolve() / f"rider-{action}-{time.time_ns()}-{os.getpid()}"
def maybe_resolve(path: Path) -> str:
try:
return str(path.resolve())
except OSError:
return str(path)
def is_inside(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
return True
except ValueError:
return False
def relative_or_absolute(path: Path, root: Path) -> str:
try:
return str(path.resolve().relative_to(root.resolve())).replace("\\", "/")
except ValueError:
return str(path)
def dedupe_paths(paths: list[Path]) -> list[Path]:
seen: set[str] = set()
result: list[Path] = []
for path in paths:
key = str(path.resolve()) if path.exists() else str(path)
if key.lower() in seen:
continue
seen.add(key.lower())
result.append(path)
return result
def rider_search_roots() -> list[Path]:
roots: list[Path] = []
env_roots = os.environ.get("EPIPHANY_RIDER_ROOTS")
if env_roots:
roots.extend(Path(item) for item in env_roots.split(os.pathsep) if item)
for variable, fallback in (
("ProgramFiles", r"C:\Program Files"),
("ProgramFiles(x86)", r"C:\Program Files (x86)"),
("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")),
):
roots.append(Path(os.environ.get(variable, fallback)) / "JetBrains")
return dedupe_paths(roots)
def discover_rider_installations() -> list[dict[str, Any]]:
candidates: list[Path] = []
env_path = os.environ.get("EPIPHANY_RIDER_PATH")
if env_path:
candidates.append(Path(env_path))
for name in RIDER_EXECUTABLE_NAMES:
found = shutil.which(name)
if found:
candidates.append(Path(found))
for root in rider_search_roots():
if not root.exists():
continue
for directory in root.glob("JetBrains Rider*"):
for name in RIDER_EXECUTABLE_NAMES:
candidates.append(directory / "bin" / name)
installations: list[dict[str, Any]] = []
for path in dedupe_paths(candidates):
exists = path.exists()
if not exists:
continue
version_hint = None
for part in reversed(path.parts):
if "Rider" in part:
version_hint = part
break
installations.append(
{
"path": maybe_resolve(path),
"exists": exists,
"versionHint": version_hint,
}
)
return installations
def discover_solution(project_root: Path) -> dict[str, Any]:
root = project_root.resolve()
top_level = sorted(root.glob("*.sln"))
if top_level:
return {
"status": "ready",
"path": str(top_level[0]),
"candidates": [str(path) for path in top_level[:8]],
}
candidates: list[Path] = []
for path in root.rglob("*.sln"):
if any(
part.startswith(".") or part in {"Library", "Temp", "obj", "bin", "node_modules"}
for part in path.relative_to(root).parts
):
continue
candidates.append(path)
if len(candidates) >= 8:
break
return {
"status": "ready" if candidates else "missingSolution",
"path": str(candidates[0]) if candidates else None,
"candidates": [str(path) for path in candidates],
}
def run_git(project_root: Path, args: list[str]) -> str | None:
try:
completed = subprocess.run(
["git", "-C", str(project_root), *args],
capture_output=True,
check=False,
encoding="utf-8",
errors="replace",
timeout=10,
)
except (OSError, subprocess.TimeoutExpired):
return None
if completed.returncode != 0:
return None
return completed.stdout.strip()
def vcs_summary(project_root: Path) -> dict[str, Any]:
branch = run_git(project_root, ["rev-parse", "--abbrev-ref", "HEAD"])
status = run_git(project_root, ["status", "--porcelain=v1"])
changed_files = run_git(project_root, ["diff", "--name-only"])
staged_files = run_git(project_root, ["diff", "--cached", "--name-only"])
visible_changed = changed_files.splitlines() if changed_files else []
if status:
for line in status.splitlines():
path = line[3:].strip()
if path and path not in visible_changed:
visible_changed.append(path)
return {
"status": "ready" if branch is not None else "notGit",
"branch": branch,
"dirty": bool(status),
"changedFiles": visible_changed,
"stagedFiles": staged_files.splitlines() if staged_files else [],
"changedRangesKnown": branch is not None,
}
def render_status(summary: dict[str, Any]) -> str:
lines = [
"# Rider Bridge Status",
"",
f"- status: {summary.get('status')}",
f"- workspace: `{summary.get('workspace')}`",
f"- solution: `{summary.get('solutionPath') or 'none'}`",
f"- rider: `{summary.get('riderPath') or 'missing'}`",
f"- installations: {summary.get('installationCount')}",
f"- branch: {summary.get('vcs', {}).get('branch') or 'unknown'}",
f"- dirty: {summary.get('vcs', {}).get('dirty')}",
f"- note: {summary.get('note')}",
"",
"Rider is a source-context organ. This artifact is an operator-safe projection, not durable Epiphany truth.",
]
return "\n".join(lines) + "\n"
def render_context(packet: dict[str, Any], summary: dict[str, Any]) -> str:
symbol = packet.get("symbol") or {}
selection = packet.get("selection") or {}
lines = [
"# Rider Context Packet",
"",
f"- status: {summary.get('status')}",
f"- project: `{packet.get('projectRoot')}`",
f"- solution: `{packet.get('solutionPath') or 'none'}`",
f"- file: `{packet.get('filePath') or 'none'}`",
f"- selection: {selection.get('startLine') or 'none'}-{selection.get('endLine') or 'none'}",
f"- symbol: {symbol.get('name') or 'none'}",
f"- artifact: `{summary.get('artifactPath')}`",
"",
"Modeling may use this packet as scratch/context. It is not accepted map state until reviewed.",
]
return "\n".join(lines) + "\n"
def bridge_guidance(project_root: Path, repo_root: Path | None = None) -> str:
repo_root = repo_root or ROOT
script_path = repo_root / "tools" / "epiphany_rider_bridge.py"
installations = discover_rider_installations()
solution = discover_solution(project_root)
status_command = f"`python {script_path} status --project-root {project_root}`"
if installations:
solution_text = solution.get("path") or "no solution found yet"
return (
f"- Rider bridge: Rider is available at `{installations[0].get('path')}` and solution "
f"context is `{solution_text}`. Use {status_command} for an auditable source/IDE "
"status receipt, and use `context --file <path> --selection-start <line> --selection-end "
"<line> --symbol-name <name>` when the future plugin or operator sends a bounded source "
"slice. Rider facts are scratch/context until modeling or verification accepts them."
)
return (
f"- Rider bridge: no Rider executable was found. Use {status_command} to write an "
"auditable missing-IDE artifact, or set EPIPHANY_RIDER_PATH. Do not pretend IDE "
"diagnostics, changed ranges, or source navigation were captured until the bridge has a "
"receipt."
)
def status_rider(args: argparse.Namespace) -> dict[str, Any]:
project_root = args.project_root.resolve()
directory = args.artifact_dir.resolve() if args.artifact_dir else artifact_dir(args.artifact_root, "inspect")
installations = discover_rider_installations()
solution = discover_solution(project_root)
first_rider = installations[0] if installations else None
status = "ready" if first_rider else "missingRider"
note = (
"Rider executable is available for explicit source navigation and context capture."
if first_rider
else "No Rider executable was found. Install Rider or set EPIPHANY_RIDER_PATH."
)
summary: dict[str, Any] = {
"kind": "riderBridgeStatus",
"status": status,
"workspace": str(project_root),
"solutionPath": solution.get("path"),
"solutionStatus": solution.get("status"),
"solutionCandidates": solution.get("candidates", []),
"riderPath": first_rider.get("path") if first_rider else None,
"installationCount": len(installations),
"installations": installations,
"searchRoots": [str(path) for path in rider_search_roots()],
"vcs": vcs_summary(project_root),
"capturedAt": now_iso(),
"artifactPath": str(directory),
"note": note,
}
write_json(directory / "rider-bridge-summary.json", summary)
write_text(directory / "rider-bridge-status.md", render_status(summary))
return summary
def packet_from_args(args: argparse.Namespace, project_root: Path) -> dict[str, Any]:
if args.packet:
packet = json.loads(args.packet.read_text(encoding="utf-8"))
if not isinstance(packet, dict):
raise ValueError("context packet must be a JSON object")
return packet
solution = discover_solution(project_root)
file_path = args.file.resolve() if args.file else None
if file_path and not is_inside(file_path, project_root):
raise ValueError(f"context file is outside project root: {file_path}")
packet: dict[str, Any] = {
"kind": "riderContext",
"capturedAt": now_iso(),
"projectRoot": str(project_root),
"solutionPath": solution.get("path"),
"filePath": relative_or_absolute(file_path, project_root) if file_path else None,
"caret": {
"line": args.line,
"column": args.column,
}
if args.line
else None,
"selection": {
"startLine": args.selection_start,
"endLine": args.selection_end,
}
if args.selection_start or args.selection_end
else None,
"symbol": {
"name": args.symbol_name,
"kind": args.symbol_kind,
"namespace": args.symbol_namespace,
}
if args.symbol_name
else None,
"vcs": vcs_summary(project_root),
}
return packet
def context_rider(args: argparse.Namespace) -> dict[str, Any]:
project_root = args.project_root.resolve()
directory = args.artifact_dir.resolve() if args.artifact_dir else artifact_dir(args.artifact_root, "context")
packet = packet_from_args(args, project_root)
summary = status_rider(
argparse.Namespace(
project_root=project_root,
artifact_root=args.artifact_root,
artifact_dir=directory,
)
)
summary.update(
{
"kind": "riderContextCapture",
"status": "captured",
"contextPath": str(directory / "rider-context.json"),
"filePath": packet.get("filePath"),
"symbol": packet.get("symbol"),
}
)
write_json(directory / "rider-context.json", packet)
write_json(directory / "rider-bridge-summary.json", summary)
write_text(directory / "rider-context.md", render_context(packet, summary))
return summary
def open_ref_rider(args: argparse.Namespace) -> dict[str, Any]:
project_root = args.project_root.resolve()
directory = args.artifact_dir.resolve() if args.artifact_dir else artifact_dir(args.artifact_root, "open-ref")
status = status_rider(
argparse.Namespace(
project_root=project_root,
artifact_root=args.artifact_root,
artifact_dir=directory,
)
)
file_path = args.file.resolve()
if not is_inside(file_path, project_root):
raise ValueError(f"code ref is outside project root: {file_path}")
rider_path = status.get("riderPath")
command = [rider_path, str(file_path)] if rider_path else []
if rider_path and args.line:
command.extend(["--line", str(args.line)])
summary = {
**status,
"kind": "riderOpenRef",
"status": "planned" if rider_path else "missingRider",
"filePath": str(file_path),
"line": args.line,
"column": args.column,
"command": command,
"launched": False,
}
if rider_path and args.launch:
subprocess.Popen(command, cwd=project_root)
summary["status"] = "launched"
summary["launched"] = True
write_json(directory / "rider-open-ref.json", summary)
write_json(directory / "rider-bridge-summary.json", summary)
write_text(directory / "rider-bridge-status.md", render_status(summary))
return summary
def main() -> int:
parser = argparse.ArgumentParser(description="Operator-safe Rider source context bridge.")
subparsers = parser.add_subparsers(dest="command", required=True)
status_parser = subparsers.add_parser("status", help="Inspect Rider and source workspace status.")
status_parser.add_argument("--project-root", type=Path, default=Path.cwd())
status_parser.add_argument("--artifact-root", type=Path, default=DEFAULT_ARTIFACT_ROOT)
status_parser.add_argument("--artifact-dir", type=Path)
context_parser = subparsers.add_parser("context", help="Capture a Rider-style source context packet.")
context_parser.add_argument("--project-root", type=Path, default=Path.cwd())
context_parser.add_argument("--artifact-root", type=Path, default=DEFAULT_ARTIFACT_ROOT)
context_parser.add_argument("--artifact-dir", type=Path)
context_parser.add_argument("--packet", type=Path)
context_parser.add_argument("--file", type=Path)
context_parser.add_argument("--line", type=int)
context_parser.add_argument("--column", type=int)
context_parser.add_argument("--selection-start", type=int)
context_parser.add_argument("--selection-end", type=int)
context_parser.add_argument("--symbol-name")
context_parser.add_argument("--symbol-kind")
context_parser.add_argument("--symbol-namespace")
open_parser = subparsers.add_parser("open-ref", help="Plan or launch Rider at one code ref.")
open_parser.add_argument("--project-root", type=Path, default=Path.cwd())
open_parser.add_argument("--artifact-root", type=Path, default=DEFAULT_ARTIFACT_ROOT)
open_parser.add_argument("--artifact-dir", type=Path)
open_parser.add_argument("--file", type=Path, required=True)
open_parser.add_argument("--line", type=int)
open_parser.add_argument("--column", type=int)
open_parser.add_argument("--launch", action="store_true")
args = parser.parse_args()
try:
if args.command == "status":
result = status_rider(args)
elif args.command == "context":
result = context_rider(args)
elif args.command == "open-ref":
result = open_ref_rider(args)
else:
raise ValueError(f"Unsupported command: {args.command}")
except Exception as error:
print(json.dumps({"status": "error", "error": str(error)}, indent=2), flush=True)
return 1
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -5,7 +5,6 @@ import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Any
from epiphany_phase5_smoke import ROOT
@ -55,8 +54,7 @@ def create_fake_rider(root: Path) -> Path:
def run_bridge(workspace: Path, artifact_root: Path, env: dict[str, str], args: list[str]) -> dict[str, Any]:
completed = subprocess.run(
[
sys.executable,
str(ROOT / "tools" / "epiphany_rider_bridge.py"),
str(native_bridge_exe()),
args[0],
"--project-root",
str(workspace),
@ -152,5 +150,29 @@ def main() -> int:
return 0
def native_bridge_exe() -> Path:
exe = Path(os.environ.get("CARGO_TARGET_DIR", r"C:\Users\Meta\.cargo-target-codex")) / "debug" / "epiphany-rider-bridge.exe"
if exe.exists():
return exe
completed = subprocess.run(
[
"cargo",
"build",
"--manifest-path",
str(ROOT / "epiphany-core" / "Cargo.toml"),
"--bin",
"epiphany-rider-bridge",
],
cwd=ROOT,
capture_output=True,
encoding="utf-8",
errors="replace",
check=False,
)
require(completed.returncode == 0, f"failed to build native rider bridge: {completed.stderr}")
require(exe.exists(), f"native rider bridge executable was not built at {exe}")
return exe
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,764 +0,0 @@
from __future__ import annotations
import argparse
from datetime import datetime
from datetime import timezone
import json
import os
import subprocess
import time
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_ARTIFACT_ROOT = ROOT / ".epiphany-gui" / "runtime"
PROJECT_VERSION_RELATIVE = Path("ProjectSettings") / "ProjectVersion.txt"
UNITY_EDITOR_RELATIVE = Path("Editor") / "Unity.exe"
FORBIDDEN_EXTRA_ARGS = {"-batchmode", "-quit", "-projectpath", "-logfile"}
EDITOR_BRIDGE_RELATIVE = Path("Assets") / "Editor" / "Epiphany" / "EpiphanyEditorBridge.cs"
EDITOR_BRIDGE_EXECUTE_METHOD = "GameCult.Epiphany.Unity.EpiphanyEditorBridge.RunProbe"
NAMED_PROBE_OPERATIONS = {
"inspect-project": {
"description": "Capture Unity project/editor/build/render-pipeline facts.",
"expectedArtifacts": ["project-facts.json", "unity-probe-result.json", "unity-probe-result.md"],
},
"check-compilation": {
"description": "Check whether the editor bridge can run after Unity script compilation.",
"expectedArtifacts": ["compilation.json", "unity-probe-result.json", "unity-probe-result.md"],
},
"scene-facts": {
"description": "Capture scene hierarchy, components, prefab links, and serialized fields.",
"expectedArtifacts": ["scene-facts.json", "unity-probe-result.json", "unity-probe-result.md"],
},
"prefab-facts": {
"description": "Capture prefab hierarchy, nested instances, overrides, and serialized fields.",
"expectedArtifacts": ["prefab-facts.json", "unity-probe-result.json", "unity-probe-result.md"],
},
"serialized-object": {
"description": "Capture SerializedObject properties for one asset.",
"expectedArtifacts": [
"serialized-object-facts.json",
"unity-probe-result.json",
"unity-probe-result.md",
],
},
"reference-search": {
"description": "Search Unity asset dependencies for references to a GUID or asset.",
"expectedArtifacts": ["reference-search.json", "unity-probe-result.json", "unity-probe-result.md"],
},
}
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def write_text(path: Path, value: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(value, encoding="utf-8")
def read_unity_project_version(project_path: Path) -> dict[str, Any]:
version_path = project_path / PROJECT_VERSION_RELATIVE
result: dict[str, Any] = {
"path": str(version_path),
"exists": version_path.exists(),
"editorVersion": None,
"editorVersionWithRevision": None,
}
if not version_path.exists():
result["status"] = "missingProjectVersion"
result["note"] = "ProjectSettings/ProjectVersion.txt was not found."
return result
for line in version_path.read_text(encoding="utf-8", errors="replace").splitlines():
if line.startswith("m_EditorVersion:"):
result["editorVersion"] = line.split(":", 1)[1].strip() or None
elif line.startswith("m_EditorVersionWithRevision:"):
result["editorVersionWithRevision"] = line.split(":", 1)[1].strip() or None
if result["editorVersion"]:
result["status"] = "ready"
else:
result["status"] = "missingProjectVersion"
result["note"] = "ProjectVersion.txt did not contain m_EditorVersion."
return result
def default_unity_editor_roots() -> list[Path]:
roots: list[Path] = []
env_roots = os.environ.get("EPIPHANY_UNITY_EDITOR_ROOTS")
if env_roots:
roots.extend(Path(item) for item in env_roots.split(os.pathsep) if item)
roots.extend(
[
Path(os.environ.get("ProgramFiles", r"C:\Program Files"))
/ "Unity"
/ "Hub"
/ "Editor",
Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"))
/ "Unity"
/ "Hub"
/ "Editor",
]
)
seen: set[str] = set()
deduped: list[Path] = []
for root in roots:
key = str(root.resolve()) if root.exists() else str(root)
if key in seen:
continue
seen.add(key)
deduped.append(root)
return deduped
def editor_path_for_version(root: Path, version: str) -> Path:
if root.name == version:
return root / UNITY_EDITOR_RELATIVE
return root / version / UNITY_EDITOR_RELATIVE
def installed_unity_editors(roots: list[Path] | None = None) -> list[dict[str, Any]]:
editors: list[dict[str, Any]] = []
for root in roots or default_unity_editor_roots():
if not root.exists():
continue
if (root / UNITY_EDITOR_RELATIVE).exists():
editors.append(
{
"version": root.name,
"root": str(root),
"editorPath": str(root / UNITY_EDITOR_RELATIVE),
}
)
continue
for child in sorted(root.iterdir(), key=lambda path: path.name.lower()):
if not child.is_dir():
continue
editor_path = child / UNITY_EDITOR_RELATIVE
if editor_path.exists():
editors.append(
{
"version": child.name,
"root": str(child),
"editorPath": str(editor_path),
}
)
return editors
def resolve_unity_editor(project_path: Path) -> dict[str, Any]:
project_path = project_path.resolve()
version = read_unity_project_version(project_path)
roots = default_unity_editor_roots()
editors = installed_unity_editors(roots)
result: dict[str, Any] = {
"kind": "unity",
"projectPath": str(project_path),
"generatedAt": now_iso(),
"versionFile": version,
"searchRoots": [str(root) for root in roots],
"installedEditors": editors,
"projectVersion": version.get("editorVersion"),
"projectVersionWithRevision": version.get("editorVersionWithRevision"),
"editorPath": None,
"status": "missingProjectVersion",
"note": "ProjectSettings/ProjectVersion.txt was not found or did not pin a Unity editor.",
}
result["editorBridge"] = detect_editor_bridge(project_path)
project_version = version.get("editorVersion")
if not isinstance(project_version, str) or not project_version:
return result
exact_candidates = [editor_path_for_version(root, project_version) for root in roots]
exact = next((candidate for candidate in exact_candidates if candidate.exists()), None)
if exact is None:
installed_versions = ", ".join(editor["version"] for editor in editors) or "none detected"
result["status"] = "missingEditor"
result["candidatePaths"] = [str(candidate) for candidate in exact_candidates]
result["note"] = (
f"Project pins Unity {project_version}, but no exact editor was found. "
f"Installed Hub versions: {installed_versions}."
)
return result
result["status"] = "ready"
result["editorPath"] = str(exact)
result["candidatePaths"] = [str(candidate) for candidate in exact_candidates]
result["note"] = (
f"Project pins Unity {project_version}; exact editor resolved at {exact}."
)
return result
def detect_editor_bridge(project_path: Path) -> dict[str, Any]:
package_path = project_path.resolve() / EDITOR_BRIDGE_RELATIVE
return {
"kind": "epiphanyUnityEditorBridge",
"path": str(package_path),
"relativePath": str(EDITOR_BRIDGE_RELATIVE).replace("\\", "/"),
"exists": package_path.exists(),
"executeMethod": EDITOR_BRIDGE_EXECUTE_METHOD,
}
def render_inspection(summary: dict[str, Any]) -> str:
installed = summary.get("installedEditors")
if not isinstance(installed, list) or not installed:
installed_lines = ["- none detected"]
else:
installed_lines = [
f"- {item.get('version')}: {item.get('editorPath')}"
for item in installed
if isinstance(item, dict)
]
candidates = summary.get("candidatePaths")
if not isinstance(candidates, list) or not candidates:
candidate_lines = ["- none"]
else:
candidate_lines = [f"- {item}" for item in candidates]
return "\n".join(
[
"# Unity Runtime Bridge",
"",
f"Status: {summary.get('status')}",
f"Project: {summary.get('projectPath')}",
f"Project version: {summary.get('projectVersion') or 'none'}",
f"Editor path: {summary.get('editorPath') or 'none'}",
"",
"Installed Hub editors:",
*installed_lines,
"",
"Exact candidate paths:",
*candidate_lines,
"",
f"Note: {summary.get('note')}",
"",
]
)
def artifact_dir(root: Path, action: str) -> Path:
return root.resolve() / f"unity-{action}-{time.time_ns()}-{os.getpid()}"
def write_inspection_artifacts(directory: Path, summary: dict[str, Any]) -> None:
write_json(directory / "unity-bridge-summary.json", summary)
write_text(directory / "unity-bridge-inspection.md", render_inspection(summary))
def normalize_unity_args(values: list[str]) -> list[str]:
if values and values[0] == "--":
values = values[1:]
return values
def validate_unity_args(values: list[str]) -> None:
lowered = {value.lower() for value in values}
forbidden = sorted(lowered & FORBIDDEN_EXTRA_ARGS)
if forbidden:
raise ValueError(
"Unity bridge owns batchmode, quit, projectPath, and logFile; remove extra args: "
+ ", ".join(forbidden)
)
def build_unity_command(
summary: dict[str, Any],
extra_args: list[str],
*,
log_path: Path | None = None,
) -> list[str]:
editor_path = summary.get("editorPath")
if not isinstance(editor_path, str) or not editor_path:
raise ValueError("Unity editor path is unavailable.")
project_path = summary.get("projectPath")
if not isinstance(project_path, str) or not project_path:
raise ValueError("Unity project path is unavailable.")
validate_unity_args(extra_args)
command = [
editor_path,
"-batchmode",
"-quit",
"-projectPath",
project_path,
]
if log_path is not None:
command.extend(["-logFile", str(log_path)])
command.extend(extra_args)
return command
def editor_bridge_ready(summary: dict[str, Any]) -> bool:
bridge = summary.get("editorBridge")
return isinstance(bridge, dict) and bridge.get("exists") is True
def named_probe_extra_args(args: argparse.Namespace, operation: str, directory: Path) -> list[str]:
extra_args = [
"-executeMethod",
EDITOR_BRIDGE_EXECUTE_METHOD,
"-epiphanyArtifactDir",
str(directory),
"-epiphanyOperation",
operation,
]
optional_values = [
("scene", "-epiphanyScene"),
("asset", "-epiphanyAsset"),
("guid", "-epiphanyGuid"),
("max_objects", "-epiphanyMaxObjects"),
("max_properties", "-epiphanyMaxProperties"),
("max_dependencies", "-epiphanyMaxDependencies"),
]
for attribute, flag in optional_values:
value = getattr(args, attribute, None)
if value is None:
continue
extra_args.extend([flag, str(value)])
return extra_args
def write_command_artifact(
directory: Path,
*,
command: list[str],
dry_run: bool,
operation: str,
expected_artifacts: list[str] | None = None,
) -> None:
write_json(
directory / "unity-command.json",
{
"command": command,
"dryRun": dry_run,
"operation": operation,
"expectedArtifacts": expected_artifacts or [],
},
)
def run_unity(args: argparse.Namespace) -> dict[str, Any]:
directory = args.artifact_dir.resolve() if args.artifact_dir else artifact_dir(args.artifact_root, "run")
project_path = args.project_path.resolve()
summary = resolve_unity_editor(project_path)
summary["artifactPath"] = str(directory)
summary["operation"] = "run"
summary["label"] = args.label
extra_args = normalize_unity_args(list(args.unity_args or []))
summary["unityArgs"] = extra_args
if summary.get("status") != "ready":
summary["runStatus"] = "blocked"
summary["note"] = (
f"{summary.get('note')} Runtime execution refused; install the exact pinned editor "
"or use inspect artifacts as evidence of the missing runtime."
)
write_inspection_artifacts(directory, summary)
return summary
log_path = directory / "unity.log"
command = build_unity_command(summary, extra_args, log_path=log_path)
summary["command"] = command
summary["logPath"] = str(log_path)
if args.dry_run:
summary["runStatus"] = "planned"
summary["returncode"] = None
write_inspection_artifacts(directory, summary)
write_command_artifact(directory, command=command, dry_run=True, operation="run")
return summary
stdout_path = directory / "unity-stdout.log"
stderr_path = directory / "unity-stderr.log"
started = time.time()
try:
completed = subprocess.run(
command,
cwd=project_path,
capture_output=True,
check=False,
timeout=args.timeout_seconds,
)
duration_seconds = time.time() - started
stdout_path.parent.mkdir(parents=True, exist_ok=True)
stdout_path.write_bytes(completed.stdout)
stderr_path.write_bytes(completed.stderr)
summary.update(
{
"runStatus": "passed" if completed.returncode == 0 else "failed",
"returncode": completed.returncode,
"durationSeconds": round(duration_seconds, 3),
"stdoutPath": str(stdout_path),
"stderrPath": str(stderr_path),
}
)
except subprocess.TimeoutExpired as error:
duration_seconds = time.time() - started
stdout_path.parent.mkdir(parents=True, exist_ok=True)
if error.stdout:
stdout_path.write_bytes(error.stdout)
summary["stdoutPath"] = str(stdout_path)
if error.stderr:
stderr_path.write_bytes(error.stderr)
summary["stderrPath"] = str(stderr_path)
summary.update(
{
"runStatus": "timedOut",
"returncode": None,
"durationSeconds": round(duration_seconds, 3),
"note": f"Unity command timed out after {args.timeout_seconds} seconds.",
}
)
write_inspection_artifacts(directory, summary)
write_command_artifact(directory, command=command, dry_run=False, operation="run")
return summary
def run_named_probe(args: argparse.Namespace, operation: str) -> dict[str, Any]:
directory = (
args.artifact_dir.resolve()
if args.artifact_dir
else artifact_dir(args.artifact_root, operation.replace("-", "_"))
)
project_path = args.project_path.resolve()
summary = resolve_unity_editor(project_path)
summary["artifactPath"] = str(directory)
summary["operation"] = operation
summary["probe"] = NAMED_PROBE_OPERATIONS[operation]
if summary.get("status") != "ready":
summary["runStatus"] = "blocked"
summary["note"] = (
f"{summary.get('note')} Runtime/editor probe refused; install the exact pinned editor "
"or use inspect artifacts as evidence of the missing runtime."
)
write_inspection_artifacts(directory, summary)
return summary
if not editor_bridge_ready(summary):
bridge = summary.get("editorBridge")
bridge_path = bridge.get("path") if isinstance(bridge, dict) else str(project_path / EDITOR_BRIDGE_RELATIVE)
summary["status"] = "missingEditorBridgePackage"
summary["runStatus"] = "blocked"
summary["note"] = (
"The Epiphany Unity editor package is missing. Add "
f"{bridge_path} before running editor-resident probes."
)
write_inspection_artifacts(directory, summary)
return summary
extra_args = named_probe_extra_args(args, operation, directory)
log_path = directory / "unity.log"
command = build_unity_command(summary, extra_args, log_path=log_path)
summary["command"] = command
summary["logPath"] = str(log_path)
expected_artifacts = NAMED_PROBE_OPERATIONS[operation]["expectedArtifacts"]
if args.dry_run:
summary["runStatus"] = "planned"
summary["returncode"] = None
write_inspection_artifacts(directory, summary)
write_command_artifact(
directory,
command=command,
dry_run=True,
operation=operation,
expected_artifacts=expected_artifacts,
)
return summary
stdout_path = directory / "unity-stdout.log"
stderr_path = directory / "unity-stderr.log"
started = time.time()
try:
completed = subprocess.run(
command,
cwd=project_path,
capture_output=True,
check=False,
timeout=args.timeout_seconds,
)
duration_seconds = time.time() - started
stdout_path.parent.mkdir(parents=True, exist_ok=True)
stdout_path.write_bytes(completed.stdout)
stderr_path.write_bytes(completed.stderr)
summary.update(
{
"runStatus": "passed" if completed.returncode == 0 else "failed",
"returncode": completed.returncode,
"durationSeconds": round(duration_seconds, 3),
"stdoutPath": str(stdout_path),
"stderrPath": str(stderr_path),
}
)
except subprocess.TimeoutExpired as error:
duration_seconds = time.time() - started
stdout_path.parent.mkdir(parents=True, exist_ok=True)
if error.stdout:
stdout_path.write_bytes(error.stdout)
summary["stdoutPath"] = str(stdout_path)
if error.stderr:
stderr_path.write_bytes(error.stderr)
summary["stderrPath"] = str(stderr_path)
summary.update(
{
"runStatus": "timedOut",
"returncode": None,
"durationSeconds": round(duration_seconds, 3),
"note": f"Unity command timed out after {args.timeout_seconds} seconds.",
}
)
write_inspection_artifacts(directory, summary)
write_command_artifact(
directory,
command=command,
dry_run=False,
operation=operation,
expected_artifacts=expected_artifacts,
)
return summary
def check_compilation(args: argparse.Namespace) -> dict[str, Any]:
return run_named_probe(args, "check-compilation")
def probe_unity(args: argparse.Namespace) -> dict[str, Any]:
return run_named_probe(args, args.operation)
def test_unity(args: argparse.Namespace) -> dict[str, Any]:
directory = args.artifact_dir.resolve() if args.artifact_dir else artifact_dir(args.artifact_root, "run_tests")
project_path = args.project_path.resolve()
summary = resolve_unity_editor(project_path)
summary["artifactPath"] = str(directory)
summary["operation"] = "run-tests"
summary["testPlatform"] = args.platform
summary["testFilter"] = args.filter
if summary.get("status") != "ready":
summary["runStatus"] = "blocked"
summary["note"] = (
f"{summary.get('note')} Unity tests refused; install the exact pinned editor "
"or use inspect artifacts as evidence of the missing runtime."
)
write_inspection_artifacts(directory, summary)
return summary
log_path = directory / "unity.log"
test_results = directory / "test-results.xml"
extra_args = [
"-runTests",
"-testPlatform",
args.platform,
"-testResults",
str(test_results),
]
if args.filter:
extra_args.extend(["-testFilter", args.filter])
command = build_unity_command(summary, extra_args, log_path=log_path)
summary["command"] = command
summary["logPath"] = str(log_path)
summary["testResultsPath"] = str(test_results)
if args.dry_run:
summary["runStatus"] = "planned"
summary["returncode"] = None
write_inspection_artifacts(directory, summary)
write_command_artifact(
directory,
command=command,
dry_run=True,
operation="run-tests",
expected_artifacts=["test-results.xml", "unity.log"],
)
return summary
stdout_path = directory / "unity-stdout.log"
stderr_path = directory / "unity-stderr.log"
started = time.time()
try:
completed = subprocess.run(
command,
cwd=project_path,
capture_output=True,
check=False,
timeout=args.timeout_seconds,
)
duration_seconds = time.time() - started
stdout_path.parent.mkdir(parents=True, exist_ok=True)
stdout_path.write_bytes(completed.stdout)
stderr_path.write_bytes(completed.stderr)
summary.update(
{
"runStatus": "passed" if completed.returncode == 0 else "failed",
"returncode": completed.returncode,
"durationSeconds": round(duration_seconds, 3),
"stdoutPath": str(stdout_path),
"stderrPath": str(stderr_path),
}
)
except subprocess.TimeoutExpired as error:
duration_seconds = time.time() - started
stdout_path.parent.mkdir(parents=True, exist_ok=True)
if error.stdout:
stdout_path.write_bytes(error.stdout)
summary["stdoutPath"] = str(stdout_path)
if error.stderr:
stderr_path.write_bytes(error.stderr)
summary["stderrPath"] = str(stderr_path)
summary.update(
{
"runStatus": "timedOut",
"returncode": None,
"durationSeconds": round(duration_seconds, 3),
"note": f"Unity test command timed out after {args.timeout_seconds} seconds.",
}
)
write_inspection_artifacts(directory, summary)
write_command_artifact(
directory,
command=command,
dry_run=False,
operation="run-tests",
expected_artifacts=["test-results.xml", "unity.log"],
)
return summary
def inspect_unity(args: argparse.Namespace) -> dict[str, Any]:
directory = (
args.artifact_dir.resolve() if args.artifact_dir else artifact_dir(args.artifact_root, "inspect")
)
summary = resolve_unity_editor(args.project_path.resolve())
summary["artifactPath"] = str(directory)
summary["operation"] = "inspect"
write_inspection_artifacts(directory, summary)
return summary
def bridge_guidance(project_path: Path, repo_root: Path | None = None) -> str:
repo_root = repo_root or ROOT
summary = resolve_unity_editor(project_path)
script_path = repo_root / "tools" / "epiphany_unity_bridge.py"
inspect_command = (
f"`python {script_path} inspect --project-path {project_path}`"
)
if summary.get("status") == "ready":
if editor_bridge_ready(summary):
return (
f"- Unity bridge: project pins {summary.get('projectVersion')} and the exact editor "
f"resolved to `{summary.get('editorPath')}`. If Unity execution is needed, use named "
f"bridge operations such as `python {script_path} probe --project-path {project_path} "
"--operation scene-facts --scene <Assets/...unity>` or "
f"`python {script_path} check-compilation --project-path {project_path}`. The bridge "
"owns -batchmode, -quit, -projectPath, -logFile, -executeMethod, and artifacts. Do not "
"invoke `Unity`, `Unity.exe`, default installs, or PATH-resolved editors directly, and "
"do not refactor Unity-owned scene/prefab state as raw text when the editor bridge can "
"inspect it."
)
return (
f"- Unity bridge: project pins {summary.get('projectVersion')} and the exact editor "
f"resolved to `{summary.get('editorPath')}`, but the resident editor package is missing. "
f"Add `{EDITOR_BRIDGE_RELATIVE.as_posix()}` before claiming scene, prefab, or runtime "
"facts. Do not invoke `Unity`, `Unity.exe`, default installs, or PATH-resolved editors "
"directly."
)
return (
f"- Unity bridge: {summary.get('note')} Run {inspect_command} to write an auditable "
"runtime evidence artifact. Do not invoke `Unity`, `Unity.exe`, default installs, or "
"PATH-resolved editors directly; if runtime parity is needed, stop with the bridge "
"artifact as the evidence gap."
)
def main() -> int:
parser = argparse.ArgumentParser(description="Auditable pinned Unity editor bridge.")
subparsers = parser.add_subparsers(dest="command", required=True)
inspect_parser = subparsers.add_parser("inspect", help="Resolve the project-pinned editor.")
inspect_parser.add_argument("--project-path", type=Path, default=Path.cwd())
inspect_parser.add_argument("--artifact-root", type=Path, default=DEFAULT_ARTIFACT_ROOT)
inspect_parser.add_argument("--artifact-dir", type=Path)
run_parser = subparsers.add_parser("run", help="Run an explicit pinned-editor batch command.")
run_parser.add_argument("--project-path", type=Path, default=Path.cwd())
run_parser.add_argument("--artifact-root", type=Path, default=DEFAULT_ARTIFACT_ROOT)
run_parser.add_argument("--artifact-dir", type=Path)
run_parser.add_argument("--label", default="unity-bridge-run")
run_parser.add_argument("--timeout-seconds", type=int, default=600)
run_parser.add_argument("--dry-run", action="store_true")
run_parser.add_argument("unity_args", nargs=argparse.REMAINDER)
check_parser = subparsers.add_parser(
"check-compilation",
help="Run the resident editor bridge compilation probe.",
)
check_parser.add_argument("--project-path", type=Path, default=Path.cwd())
check_parser.add_argument("--artifact-root", type=Path, default=DEFAULT_ARTIFACT_ROOT)
check_parser.add_argument("--artifact-dir", type=Path)
check_parser.add_argument("--timeout-seconds", type=int, default=600)
check_parser.add_argument("--dry-run", action="store_true")
probe_parser = subparsers.add_parser(
"probe",
help="Run a named resident-editor probe through the pinned editor.",
)
probe_parser.add_argument("--project-path", type=Path, default=Path.cwd())
probe_parser.add_argument("--artifact-root", type=Path, default=DEFAULT_ARTIFACT_ROOT)
probe_parser.add_argument("--artifact-dir", type=Path)
probe_parser.add_argument("--timeout-seconds", type=int, default=600)
probe_parser.add_argument("--dry-run", action="store_true")
probe_parser.add_argument("--operation", choices=sorted(NAMED_PROBE_OPERATIONS), required=True)
probe_parser.add_argument("--scene")
probe_parser.add_argument("--asset")
probe_parser.add_argument("--guid")
probe_parser.add_argument("--max-objects", type=int)
probe_parser.add_argument("--max-properties", type=int)
probe_parser.add_argument("--max-dependencies", type=int)
test_parser = subparsers.add_parser(
"run-tests",
help="Run Unity editmode/playmode tests through the pinned editor.",
)
test_parser.add_argument("--project-path", type=Path, default=Path.cwd())
test_parser.add_argument("--artifact-root", type=Path, default=DEFAULT_ARTIFACT_ROOT)
test_parser.add_argument("--artifact-dir", type=Path)
test_parser.add_argument("--timeout-seconds", type=int, default=900)
test_parser.add_argument("--dry-run", action="store_true")
test_parser.add_argument("--platform", choices=["editmode", "playmode"], default="editmode")
test_parser.add_argument("--filter")
args = parser.parse_args()
try:
if args.command == "inspect":
result = inspect_unity(args)
elif args.command == "run":
result = run_unity(args)
elif args.command == "check-compilation":
result = check_compilation(args)
elif args.command == "probe":
result = probe_unity(args)
elif args.command == "run-tests":
result = test_unity(args)
else:
raise ValueError(f"Unsupported command: {args.command}")
except Exception as error:
print(json.dumps({"status": "error", "error": str(error)}, indent=2), flush=True)
return 1
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0 if result.get("runStatus") != "blocked" else 2
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -5,7 +5,6 @@ import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Any
from epiphany_phase5_smoke import ROOT
@ -64,8 +63,7 @@ def run_bridge(
env["EPIPHANY_UNITY_EDITOR_ROOTS"] = str(roots)
completed = subprocess.run(
[
sys.executable,
str(ROOT / "tools" / "epiphany_unity_bridge.py"),
str(native_bridge_exe()),
args[0],
"--project-path",
str(workspace),
@ -282,5 +280,29 @@ def main() -> int:
return 0
def native_bridge_exe() -> Path:
exe = Path(os.environ.get("CARGO_TARGET_DIR", r"C:\Users\Meta\.cargo-target-codex")) / "debug" / "epiphany-unity-bridge.exe"
if exe.exists():
return exe
completed = subprocess.run(
[
"cargo",
"build",
"--manifest-path",
str(ROOT / "epiphany-core" / "Cargo.toml"),
"--bin",
"epiphany-unity-bridge",
],
cwd=ROOT,
capture_output=True,
encoding="utf-8",
errors="replace",
check=False,
)
require(completed.returncode == 0, f"failed to build native unity bridge: {completed.stderr}")
require(exe.exists(), f"native unity bridge executable was not built at {exe}")
return exe
if __name__ == "__main__":
raise SystemExit(main())