From 54d0fb75ea6e8050d8ccbb3447dde2fb4e2df295 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:35:04 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20Godot=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E6=8F=92=E4=BB=B6=E4=B8=8E=E5=8F=97=E6=8E=A7?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存 接入 AGC 插件开关、Runner、Agent 工具与权限审计 完善执行回执确认、不确定状态阻断及卸载恢复 补齐 Windows 分发资源、定向测试与实机验收文档 --- .gitea/workflows/project-ci.yml | 17 + .../scripts/build-release.mjs | 1 + .../scripts/build-release.test.mjs | 2 +- .../scripts/cargo-features.mjs | 2 +- .../scripts/cargo-features.test.mjs | 2 +- .../src-tauri/Cargo.lock | 13 + .../src-tauri/Cargo.toml | 2 + apps/ai-game-creator-shell/src-tauri/build.rs | 45 + .../src-tauri/build_support/godot_bundle.rs | 215 ++ .../src-tauri/src/agent/direct_runtime/mod.rs | 18 + .../src-tauri/src/agent/direct_tool_bridge.rs | 58 +- .../src-tauri/src/agent/direct_tools_mcp.rs | 155 +- .../src/agent/runtime_actions/action_audit.rs | 2 +- .../agent/runtime_actions/action_execution.rs | 10 + .../agent/runtime_actions/parallel_ledger.rs | 1 + .../runtime_actions/tool_policy_snapshot.rs | 12 + .../src-tauri/src/agent/runtime_tools.rs | 4 +- .../src/agent/runtime_tools/editor_execute.rs | 135 + .../src/agent/runtime_tools/unity_editor.rs | 79 - .../src-tauri/src/agent_native_tools.rs | 74 +- .../src-tauri/src/builtin_plugins.rs | 76 +- .../src-tauri/src/editor_adapters.rs | 323 +- .../src/editor_adapters/execution.rs | 691 ++++ .../src-tauri/src/isolated_agent.rs | 2 + .../src-tauri/src/main.rs | 4 + .../src-tauri/src/plugin_host.rs | 451 ++- .../src-tauri/src/runner.rs | 4 +- .../src-tauri/src/runner/client.rs | 158 +- .../src-tauri/src/runner/dispatch.rs | 39 +- .../src-tauri/src/runner/endpoint.rs | 2 +- .../src-tauri/src/runner/server.rs | 1 + .../src-tauri/src/runner/tests.rs | 88 +- apps/ai-game-creator-shell/src/App.tsx | 4 +- .../tests/pluginHost.test.ts | 2 +- docs/README.md | 1 + .../shared-memory/decision-log.md | 4 + ...方案】AGC Godot编辑器插件接入-2026-09-20.md | 116 + ...案】AGC通用插件宿主与编辑器适配-2026-09-09.md | 3 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + .../Jenkinsfile.scheduled-revision-trigger | 2 +- package-lock.json | 18 + package.json | 9 +- .../src/contracts/gameCreationApp.test.ts | 7 +- .../shared/src/contracts/gameCreationApp.ts | 1 + plugins/README.md | 9 + .../native/gdextension/.gitignore | 2 + .../native/gdextension/build.ps1 | 84 + .../native/gdextension/src/bridge.gd | 436 +++ .../native/gdextension/src/native.c | 257 ++ .../gdextension/tests/native-smoke.test.mjs | 325 ++ .../native/gdextension/vendor/LICENSE.txt | 20 + .../vendor/gdextension_interface.h | 3185 +++++++++++++++++ .../native/gdextension/vendor/provenance.json | 10 + .../native/godot-editor-bridge/Cargo.lock | 294 ++ .../native/godot-editor-bridge/Cargo.toml | 19 + .../examples/install_location_smoke.rs | 461 +++ .../examples/live_smoke.rs | 85 + .../native/godot-editor-bridge/src/files.rs | 543 +++ .../native/godot-editor-bridge/src/lib.rs | 723 ++++ .../godot-editor-bridge/src/platform.rs | 437 +++ .../godot-editor-bridge/src/runtime_cache.rs | 330 ++ .../native/godot-editor-bridge/src/tests.rs | 1117 ++++++ .../godot-editor-bridge/src/transport.rs | 164 + plugins/agc-godot-editor/package.json | 13 + plugins/agc-godot-editor/plugin.json | 20 + plugins/agc-godot-editor/src/entry.mjs | 301 ++ plugins/agc-godot-editor/src/entry.test.mjs | 379 ++ plugins/agc-unity-editor/src/entry.mjs | 6 +- plugins/agc-unity-editor/src/entry.test.mjs | 7 + scripts/check-npm-workspaces.mjs | 3 + scripts/check-npm-workspaces.test.mjs | 2 + scripts/check-production-ops-guardrails.mjs | 2 +- scripts/lint-staged-rustfmt-workspaces.mjs | 5 + scripts/lint-staged-rustfmt.test.ts | 2 + scripts/project-ci-workflow.test.ts | 14 + .../crates/editor-adapter-api/src/lib.rs | 2 +- .../shared-contracts/src/game_creation_app.rs | 13 +- 77 files changed, 11545 insertions(+), 582 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/editor_execute.rs delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs create mode 100644 docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md create mode 100644 plugins/agc-godot-editor/native/gdextension/.gitignore create mode 100644 plugins/agc-godot-editor/native/gdextension/build.ps1 create mode 100644 plugins/agc-godot-editor/native/gdextension/src/bridge.gd create mode 100644 plugins/agc-godot-editor/native/gdextension/src/native.c create mode 100644 plugins/agc-godot-editor/native/gdextension/tests/native-smoke.test.mjs create mode 100644 plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt create mode 100644 plugins/agc-godot-editor/native/gdextension/vendor/gdextension_interface.h create mode 100644 plugins/agc-godot-editor/native/gdextension/vendor/provenance.json create mode 100644 plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.lock create mode 100644 plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml create mode 100644 plugins/agc-godot-editor/native/godot-editor-bridge/examples/install_location_smoke.rs create mode 100644 plugins/agc-godot-editor/native/godot-editor-bridge/examples/live_smoke.rs create mode 100644 plugins/agc-godot-editor/native/godot-editor-bridge/src/files.rs create mode 100644 plugins/agc-godot-editor/native/godot-editor-bridge/src/lib.rs create mode 100644 plugins/agc-godot-editor/native/godot-editor-bridge/src/platform.rs create mode 100644 plugins/agc-godot-editor/native/godot-editor-bridge/src/runtime_cache.rs create mode 100644 plugins/agc-godot-editor/native/godot-editor-bridge/src/tests.rs create mode 100644 plugins/agc-godot-editor/native/godot-editor-bridge/src/transport.rs create mode 100644 plugins/agc-godot-editor/package.json create mode 100644 plugins/agc-godot-editor/plugin.json create mode 100644 plugins/agc-godot-editor/src/entry.mjs create mode 100644 plugins/agc-godot-editor/src/entry.test.mjs diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index c38208380..fa1c68d1d 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -302,6 +302,23 @@ jobs: sleep $((attempt * 2)) done + - name: Prepare Godot plugin Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'Godot plugin Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + - name: Run AI game creator shell shared crate gates run: npm run check:native-shells:agc-rust-crates diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index ecee04d4c..b25620922 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -106,6 +106,7 @@ export const agcReleasePathPatterns = [ 'server-rs/crates/', 'plugins/agc-cocos-editor/', 'plugins/agc-unity-editor/', + 'plugins/agc-godot-editor/', 'apps/desktop-shell/src-tauri/icons/', 'package.json', 'package-lock.json', diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index cd0001741..9c9aed2cb 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -452,7 +452,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme spawn: (_binary, command) => { assert.ok( command.includes( - '--features=cocos-editor-execute,unity-editor-execute', + '--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute', ), ); assert.ok(command.includes('user-config.json')); diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.mjs index b9282a77f..515432f5b 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.mjs @@ -19,6 +19,6 @@ export function withDefaultCargoFeatures(argv, features) { export function defaultEditorFeatures(target) { return target === 'win32' || target.includes('windows') - ? ['cocos-editor-execute', 'unity-editor-execute'] + ? ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'] : []; } diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs index 97592ea11..3358524b9 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs @@ -9,7 +9,7 @@ test('Windows release includes the same editor feature as development', () => { buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'), [ 'build', - '--features=cocos-editor-execute,unity-editor-execute', + '--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute', '--target', 'x86_64-pc-windows-msvc', ], diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 57450d448..b70bb6666 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1755,6 +1755,7 @@ dependencies = [ "editor-adapter-api", "futures", "getrandom 0.3.4", + "godot-editor-bridge", "http", "image", "jsonschema", @@ -1981,6 +1982,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "godot-editor-bridge" +version = "0.1.0" +dependencies = [ + "editor-adapter-api", + "serde", + "serde_json", + "sha2", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "gtk" version = "0.18.2" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 4647fdfc9..a117f85a3 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -13,6 +13,7 @@ cocos-editor = ["cocos-editor-bridge/process-discovery"] cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"] cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"] unity-editor-execute = [] +godot-editor-execute = [] [build-dependencies] serde = { version = "1", features = ["derive"] } @@ -29,6 +30,7 @@ agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" } cocos-editor-bridge = { path = "../../../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false } editor-adapter-api = { path = "../../../server-rs/crates/editor-adapter-api" } unity-editor-bridge = { path = "../../../plugins/agc-unity-editor/native/unity-editor-bridge" } +godot-editor-bridge = { path = "../../../plugins/agc-godot-editor/native/godot-editor-bridge" } base64 = "0.22" axum = "0.8" chromiumoxide = "0.9.1" diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index c39386b4b..2cc8e48ec 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -2,6 +2,8 @@ mod codex_bundle; #[path = "build_support/frontend_dist_guard.rs"] mod frontend_dist_guard; +#[path = "build_support/godot_bundle.rs"] +mod godot_bundle; #[path = "build_support/runtime_prompt_bundle.rs"] mod runtime_prompt_bundle; @@ -197,6 +199,7 @@ fn main() { let manifest_path = manifest_dir.join("prompts/runtime/manifest.json"); stage_bundled_codex_cli(&manifest_dir); prepare_unity_editor_helper(&manifest_dir); + prepare_godot_editor_extension(&manifest_dir); stage_plugin_workspace(&manifest_dir); stage_cocos_editor_payload(&manifest_dir); let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) @@ -368,6 +371,39 @@ fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec Result { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("Godot 资源不可读 {}:{error}", path.display()))?; + #[cfg(windows)] + let linked = { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x400 != 0 + }; + #[cfg(not(windows))] + let linked = metadata.file_type().is_symlink(); + if linked { + return Err(format!("Godot 资源不能经过链接:{}", path.display())); + } + Ok(metadata) +} + +fn read_bundle_file(root: &Path, relative: &str) -> Result, String> { + plain_metadata(root)?; + let mut path = root.to_path_buf(); + for component in Path::new(relative).components() { + path.push(component); + plain_metadata(&path)?; + } + let metadata = plain_metadata(&path)?; + if !metadata.is_file() || metadata.len() == 0 { + return Err(format!("Godot 随包资源缺失或为空:{}", path.display())); + } + if relative.ends_with("metadata.json") && metadata.len() > 64 * 1024 { + return Err("Godot 构建元数据超过 64 KiB".to_string()); + } + fs::read(&path).map_err(|error| format!("读取 Godot 资源失败:{error}")) +} + +pub fn validate(root: &Path) -> Result)>, String> { + let files = BUNDLE_FILES + .iter() + .map(|relative| read_bundle_file(root, relative).map(|bytes| (*relative, bytes))) + .collect::, _>>()?; + let metadata: serde_json::Value = serde_json::from_slice(&files[1].1) + .map_err(|error| format!("Godot 构建元数据无效:{error}"))?; + for (field, expected) in [ + ("protocol", "agc.godot.editor.v1"), + ("platform", "windows"), + ("arch", "x86_64"), + ("entrySymbol", "agc_godot_editor_init"), + ("minimumGodotVersion", "4.7"), + ] { + if metadata[field].as_str() != Some(expected) { + return Err(format!("Godot 构建元数据 {field} 不匹配")); + } + } + if !metadata["buildId"].as_str().is_some_and(|value| { + value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + }) { + return Err("Godot 构建身份无效".to_string()); + } + let actual_sha256 = format!("{:x}", Sha256::digest(&files[0].1)); + if metadata["sha256"].as_str() != Some(actual_sha256.as_str()) { + return Err("Godot DLL 与构建元数据 SHA256 不匹配".to_string()); + } + Ok(files) +} + +pub fn stage(root: &Path, destination: &Path, target: &str, enabled: bool) -> Result<(), String> { + if target != "x86_64-pc-windows-msvc" || !enabled { + return Ok(()); + } + for (relative, bytes) in validate(root)? { + let path = destination.join(relative); + fs::create_dir_all(path.parent().expect("Godot resource parent")) + .map_err(|error| format!("创建 Godot 资源目录失败:{error}"))?; + fs::write(&path, bytes).map_err(|error| format!("写入 Godot 资源失败:{error}"))?; + } + Ok(()) +} + +pub fn source_files(root: &Path) -> Result, String> { + plain_metadata(root)?; + let mut sources = Vec::new(); + for entry in fs::read_dir(root).map_err(|error| format!("读取 Godot 源码失败:{error}"))? + { + let entry = entry.map_err(|error| format!("读取 Godot 源码目录项失败:{error}"))?; + if matches!(entry.file_name().to_str(), Some("bin" | ".build")) { + continue; + } + let metadata = plain_metadata(&entry.path())?; + if metadata.is_dir() { + sources.extend(source_files(&entry.path())?); + } else if metadata.is_file() { + sources.push(entry.path()); + } + } + sources.sort(); + Ok(sources) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(root: &Path) { + for relative in BUNDLE_FILES { + let path = root.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, b"fixture").unwrap(); + } + fs::write( + root.join(BUNDLE_FILES[1]), + serde_json::to_vec(&serde_json::json!({ + "protocol": "agc.godot.editor.v1", + "platform": "windows", + "arch": "x86_64", + "entrySymbol": "agc_godot_editor_init", + "minimumGodotVersion": "4.7", + "buildId": format!("sha256:{}", "a".repeat(64)), + "sha256": format!("{:x}", Sha256::digest(b"fixture")), + })) + .unwrap(), + ) + .unwrap(); + } + + #[test] + fn stage_only_verified_windows_runtime_and_not_build_inputs() { + let source = tempfile::tempdir().unwrap(); + let destination = tempfile::tempdir().unwrap(); + fixture(source.path()); + fs::write(source.path().join("bridge.gd"), "source").unwrap(); + fs::write(source.path().join("bin/win-x64/extra.dll"), "excluded").unwrap(); + stage( + source.path(), + destination.path(), + "x86_64-pc-windows-msvc", + true, + ) + .unwrap(); + for relative in BUNDLE_FILES { + assert_eq!( + fs::read(source.path().join(relative)).unwrap(), + fs::read(destination.path().join(relative)).unwrap() + ); + } + assert!(!destination.path().join("bridge.gd").exists()); + assert!(!destination.path().join("bin/win-x64/extra.dll").exists()); + } + + #[test] + fn unsupported_or_disabled_targets_need_no_native_artifacts() { + let destination = tempfile::tempdir().unwrap(); + for (target, enabled) in [ + ("aarch64-apple-darwin", true), + ("x86_64-apple-darwin", true), + ("x86_64-unknown-linux-gnu", true), + ("aarch64-pc-windows-msvc", true), + ("x86_64-pc-windows-msvc", false), + ] { + stage( + Path::new("missing-godot-native"), + destination.path(), + target, + enabled, + ) + .unwrap(); + assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0); + } + } + + #[test] + fn incomplete_or_tampered_bundle_fails_before_copying() { + let source = tempfile::tempdir().unwrap(); + let destination = tempfile::tempdir().unwrap(); + fixture(source.path()); + fs::write(source.path().join(BUNDLE_FILES[0]), b"tampered").unwrap(); + assert!(stage( + source.path(), + destination.path(), + "x86_64-pc-windows-msvc", + true + ) + .unwrap_err() + .contains("SHA256")); + assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0); + fixture(source.path()); + fs::remove_file(source.path().join("vendor/LICENSE.txt")).unwrap(); + assert!(validate(source.path()).is_err()); + } + + #[test] + fn source_watch_list_excludes_build_outputs() { + let source = tempfile::tempdir().unwrap(); + fixture(source.path()); + fs::create_dir(source.path().join(".build")).unwrap(); + fs::write(source.path().join(".build/bridge.obj"), "generated").unwrap(); + fs::write(source.path().join("bridge.gd"), "source").unwrap(); + let sources = source_files(source.path()).unwrap(); + assert_eq!(sources.len(), 3); + assert!(sources.contains(&source.path().join("bridge.gd"))); + assert!(!sources.iter().any(|path| path + .components() + .any(|component| component.as_os_str() == "bin" || component.as_os_str() == ".build"))); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 9e463a265..79256bcd0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -17,6 +17,7 @@ const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。Phaser 画布居中责任唯一:使用 Phaser Scale.FIT 与 autoCenter CENTER_BOTH 时,canvas 的直接父容器用普通 block 按需要的宽高确定尺寸,不得在同一个 canvas 父容器上叠加 grid/flex 的 place-items、justify-content、align-items 居中或 margin:auto、translate 居中;若选择用 CSS 居中,则必须把 Phaser autoCenter 设为 NO_CENTER。外围布局仍可用 flex/grid,但同一个 canvas 的定位责任只能有一处。预览偏移先查项目自身的 CSS 与 Phaser 配置,不得用修改 AGC iframe 偏移来掩盖。改完布局后必须在桌面与移动视口以及 resize 后实测 canvas 相对游戏父容器的中心误差不超过 1 CSS px、无溢出,并按项目 scripts 构建 dist 后复验。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite;二维游戏 Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;用户要做三维游戏时不受 Phaser 约束,由你自选三维技术栈(例如 Three.js / Babylon.js),不要用等轴伪 3D 冒充三维。两种情况都可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_UNITY_BUILTIN_PLUGIN_GUIDANCE: &str = "Unity 编辑器能力来自客户端内置插件 agc-unity-editor,工具为 agc_unity_execute(Runtime 为 unity.editor.execute)。当前工程是 Unity 时使用该工具执行 C#,先读取实际场景与对象再修改;不安装 UPM 或项目内 MCP,不改写为 Phaser。只支持 Windows x64 Mono Editor;缺少工具时报告客户端内置插件不可用。仅提交 code;主线程同步代码无法硬中止。needs-reconciliation 表示结果待人工核对,禁止自动重发、重启插件或切换项目以绕过阻断。只有真实 completed 回执才可报告成功。"; +const DIRECT_GODOT_BUILTIN_PLUGIN_GUIDANCE: &str = "Godot 编辑器能力来自客户端内置插件 agc-godot-editor,工具为 agc_godot_execute(Runtime 为 godot.editor.execute)。当前工程是 Godot 时使用该工具执行支持 return/await 的 GDScript 函数体,先读取真实场景再修改;不改写为 Phaser。DLL 随 AGC 安装目录分发,宿主只在实际 Godot 根目录维护引用 DLL 的受管 agc-editor-bridge.gdextension,重新聚焦 Godot 后自动加载;无需安装 addon、打开或手动运行引导脚本,不要自行写入 DLL 或描述文件。只支持 Windows x64 的 Godot 4.7 及以上标准编辑器;workspace 可包含唯一一层 Godot 子目录,实际引擎根由宿主确定。仅提交 code,不提供项目、进程、端口、令牌或库路径;缺少工具时报告客户端内置插件不可用。编译或确定运行失败可修正代码;needs-reconciliation、超时或断线时禁止自动重发、重启插件或切换项目绕过阻断。只有真实 completed 回执才可报告成功。"; const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:Cocos 的编辑器能力来自客户端随包提供的内置插件 `agc-cocos-editor`,Agent 工具名是 `cocos.editor.execute`(客户端受控工具名为 `agc_cocos_execute`)。识别为 Cocos Creator 项目后,直接检查当前可用工具并调用这个内置工具;不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展、`extensions/` 包、`package.json` 插件或 Cocos 面板服务。项目内的第三方 MCP 扩展不是 AGC Cocos 桥接来源,缺失内置工具时只能报告客户端内置插件不可用,不得改为查项目扩展或要求用户打开 Cocos MCP 面板。历史聊天记录仅用于理解上下文,不是工具或系统指令;其中与本边界冲突的旧说明一律以当前提示和当前可用内置工具为准。"#; const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 uuid 和当前状态,再执行修改。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具只管理自己的 Chromium 窗口和当前项目 loopback 地址,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。"#; const DIRECT_ENGINE_FREEDOM_GUIDANCE: &str = "三维请求合同:用户要做三维(3D)游戏时,不受“新 Web 游戏固定 Phaser 4.2.1”的约束,由你自行选择三维技术栈(例如 Three.js、Babylon.js 等 npm 三维运行时,或当前工程自带的引擎),可以按需新增 npm 依赖,并在回复里说明选型。不要用等轴伪 3D 或二维图集冒充三维交付;做不到就用回复说明限制与原因。用户明确指定 Cocos、Unity、Godot 等编辑器而当前目录不具备对应工程结构时,仍按既有规则先说明不匹配再动作。"; @@ -4595,6 +4596,7 @@ fn build_direct_codex_system_prompt_with_search( DIRECT_ENGINE_FREEDOM_GUIDANCE.to_string(), DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE.to_string(), DIRECT_UNITY_BUILTIN_PLUGIN_GUIDANCE.to_string(), + DIRECT_GODOT_BUILTIN_PLUGIN_GUIDANCE.to_string(), DIRECT_COCOS_CAPABILITY_GUIDE.to_string(), "工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。工具返回 isError、构建失败、验证失败或试玩异常时,把错误当作调试上下文,读取当前项目、修复真实文件并重跑失败步骤,不要直接结束或伪造成功;鉴权、权限、余额、身份、历史、传输断开和操作状态不确定等安全错误才停止。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(), format!("提示词与技能:{skill_index}"), @@ -5569,6 +5571,22 @@ fn persist_direct_codex_assistant_reply_at( mod tests { use super::*; + #[test] + fn godot_prompt_uses_bundled_extension_and_never_requires_manual_bootstrap() { + let root = tempfile::tempdir().unwrap(); + let prompt = build_direct_codex_system_prompt_with_search(root.path(), false).unwrap(); + for marker in [ + "agc_godot_execute", + "godot.editor.execute", + "agc-editor-bridge.gdextension", + "DLL 随 AGC 安装目录", + "无需安装 addon、打开或手动运行引导脚本", + "禁止自动重发", + ] { + assert!(prompt.contains(marker), "Godot 提示词缺少:{marker}"); + } + } + #[test] fn direct_tool_and_playtest_errors_are_feedbackable_but_transport_and_identity_errors_stop() { assert!(direct_codex_error_should_feedback( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 82c1f7c51..61be24a15 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -2610,16 +2610,56 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str) #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value { + bridge_editor_execute( + state, + arguments, + "unity.editor.execute", + "Unity", + "C# 代码", + crate::builtin_plugins::unity_editor_agent_tool_available_for_project, + crate::editor_adapters::execute_unity_editor_code, + ) + .await +} + +#[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] +async fn bridge_godot_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value { + bridge_editor_execute( + state, + arguments, + "godot.editor.execute", + "Godot", + "GDScript 函数体", + crate::builtin_plugins::godot_editor_agent_tool_available_for_project, + crate::editor_adapters::execute_godot_editor_code, + ) + .await +} + +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +async fn bridge_editor_execute( + state: &DirectToolBridgeState, + arguments: &Value, + tool: &'static str, + editor: &'static str, + language: &str, + available: fn(&Path) -> bool, + execute: fn(&Path, &str) -> Result, +) -> Value { let prepared = (|| { - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&state.root) { - return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string()); + if !available(&state.root) { + return Err(format!("当前项目不是 {editor} 项目或 {editor} 插件不可用")); } - enforce_project_permission_policy(&state.root, "unity.editor.execute")?; + enforce_project_permission_policy(&state.root, tool)?; bridge_reject_unknown_fields(arguments, &["code"])?; let code = arguments .get("code") .and_then(Value::as_str) - .ok_or_else(|| "code 必须是 C# 代码".to_string())?; + .ok_or_else(|| format!("code 必须是 {language}"))?; if code.trim().is_empty() || code.len() > 131072 || code.contains('\0') { return Err("code 不能为空、包含 NUL 或超过 128 KiB".to_string()); } @@ -2637,10 +2677,10 @@ async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) }; let root = state.root.clone(); let result = tokio::task::spawn_blocking(move || { - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&root) { - return Err("当前 Unity 插件不可用".to_string()); + if !available(&root) { + return Err(format!("当前 {editor} 插件不可用")); } - crate::editor_adapters::execute_unity_editor_code(&root, &code) + execute(&root, &code) }) .await; match result { @@ -2649,7 +2689,7 @@ async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) bridge_tool_result(redact_agent_runtime_error(&state.root, &response.to_string(), 32_000), Vec::new(), failed) } Ok(Err(error)) => bridge_tool_result(redact_agent_runtime_error(&state.root, &error, 480), Vec::new(), true), - Err(_) => bridge_tool_result(json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"error":"Unity 执行任务异常,请人工核对结果"}).to_string(), Vec::new(), true), + Err(_) => bridge_tool_result(json!({"ok":false,"status":"needs-reconciliation","dispatched":true,"retryAllowed":false,"error":format!("{editor} 执行任务异常,请人工核对结果")}).to_string(), Vec::new(), true), } } @@ -2855,6 +2895,8 @@ async fn handle_direct_tool_bridge( "agc_cocos_execute" => bridge_cocos_execute(&state, &request.arguments).await, #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] "agc_unity_execute" => bridge_unity_execute(&state, &request.arguments).await, + #[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] + "agc_godot_execute" => bridge_godot_execute(&state, &request.arguments).await, #[cfg(all(windows, feature = "cocos-editor-execute"))] operation if cocos_editor_bridge::is_cocos_operation(operation) => { bridge_cocos_call(&state, &request.arguments, Some(operation)).await diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index e4436fff5..dd05660f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -76,12 +76,18 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option async fn direct_tools_mcp_specs() -> Value { let mut cocos_editor_available = false; let mut unity_editor_available = false; + let mut godot_editor_available = false; if cfg!(all(windows, feature = "cocos-editor-execute")) || cfg!(all( windows, target_arch = "x86_64", feature = "unity-editor-execute" )) + || cfg!(all( + windows, + target_arch = "x86_64", + feature = "godot-editor-execute" + )) { // 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具。 if let Ok(result) = tokio::time::timeout( @@ -111,6 +117,14 @@ async fn direct_tools_mcp_specs() -> Value { .iter() .any(|tool| tool == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME) }); + godot_editor_available = availability + .as_ref() + .and_then(|v| v["tools"].as_array()) + .is_some_and(|tools| { + tools + .iter() + .any(|tool| tool == crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME) + }); } } } @@ -118,6 +132,7 @@ async fn direct_tools_mcp_specs() -> Value { controlled_web_search_enabled(), cocos_editor_available, unity_editor_available, + godot_editor_available, ) } @@ -149,13 +164,14 @@ fn resource_tool_prompt_schema_max_chars() -> usize { #[cfg(test)] fn direct_tools_mcp_specs_for(controlled_web_search: bool, cocos_editor_available: bool) -> Value { - direct_tools_mcp_specs_for_plugins(controlled_web_search, cocos_editor_available, false) + direct_tools_mcp_specs_for_plugins(controlled_web_search, cocos_editor_available, false, false) } fn direct_tools_mcp_specs_for_plugins( controlled_web_search: bool, _cocos_editor_available: bool, _unity_editor_available: bool, + _godot_editor_available: bool, ) -> Value { let tools = vec![ json!({ @@ -604,6 +620,14 @@ fn direct_tools_mcp_specs_for_plugins( "inputSchema": {"type":"object", "properties":{"code":{"type":"string", "minLength":1, "maxLength":131072}}, "required":["code"], "additionalProperties":false} })); } + #[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] + if _godot_editor_available { + tools.push(json!({ + "name": "agc_godot_execute", + "description": "在当前项目已打开的 Windows x64 Godot 4.7+ 标准编辑器执行支持 return/await 的 GDScript 函数体。宿主管理安装目录 DLL 和受管描述文件,聚焦自动加载,无需手跑脚本。仅提交 code;结果不确定时禁止自动重发。", + "inputSchema": {"type":"object", "properties":{"code":{"type":"string", "minLength":1, "maxLength":131072}}, "required":["code"], "additionalProperties":false} + })); + } if controlled_web_search { tools.push(json!({ "name": "agc_web_search", @@ -693,11 +717,25 @@ async fn call_agc_cocos_execute(arguments: &Value) -> Value { #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] async fn call_agc_unity_execute(arguments: &Value) -> Value { + call_agc_editor_execute("agc_unity_execute", "C# 代码", arguments).await +} + +#[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] +async fn call_agc_godot_execute(arguments: &Value) -> Value { + call_agc_editor_execute("agc_godot_execute", "GDScript 函数体", arguments).await +} + +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +async fn call_agc_editor_execute(tool: &str, language: &str, arguments: &Value) -> Value { let validated = validate_tool_object_fields(arguments, &["code"]).and_then(|()| { let code = arguments .get("code") .and_then(Value::as_str) - .ok_or_else(|| "code 必须是 C# 代码".to_string())?; + .ok_or_else(|| format!("code 必须是 {language}"))?; if code.trim().is_empty() || code.len() > 131072 || code.contains('\0') { return Err("code 不能为空、包含 NUL 或超过 128 KiB".to_string()); } @@ -706,7 +744,7 @@ async fn call_agc_unity_execute(arguments: &Value) -> Value { if let Err(error) = validated { return mcp_tool_result(error, Vec::new(), true); } - call_client_tool_bridge("agc_unity_execute", arguments).await + call_client_tool_bridge(tool, arguments).await } fn mcp_success(id: Value, result: Value) -> Value { @@ -1813,6 +1851,8 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< "agc_cocos_execute" => call_agc_cocos_execute(&arguments).await, #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] "agc_unity_execute" => call_agc_unity_execute(&arguments).await, + #[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] + "agc_godot_execute" => call_agc_godot_execute(&arguments).await, #[cfg(all(windows, feature = "cocos-editor-execute"))] operation if cocos_editor_bridge::is_cocos_operation(operation) => { call_client_tool_bridge(operation, &arguments).await @@ -2012,6 +2052,115 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { mod tests { use super::*; + #[test] + fn godot_tool_schema_is_code_only_and_follows_host_availability() { + for available in [false, true] { + let specs = direct_tools_mcp_specs_for_plugins(false, false, false, available); + let tool = specs["tools"] + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == "agc_godot_execute"); + assert_eq!( + tool.is_some(), + available + && cfg!(all( + windows, + target_arch = "x86_64", + feature = "godot-editor-execute" + )) + ); + if let Some(tool) = tool { + assert_eq!(tool["inputSchema"]["additionalProperties"], false); + assert_eq!(tool["inputSchema"]["required"], json!(["code"])); + assert_eq!( + tool["inputSchema"]["properties"].as_object().unwrap().len(), + 1 + ); + } + } + } + + #[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] + #[tokio::test] + async fn godot_mcp_rejects_target_override_and_invalid_code_before_bridge() { + for arguments in [ + json!({"code":"return 42", "projectPath":"C:/other"}), + json!({"code":"return 42", "processId":123}), + json!({"code":"return 42", "dllPath":"C:/other.dll"}), + json!({"code":""}), + json!({"code":"a\u{0}b"}), + json!({"code":"中".repeat(44_000)}), + ] { + let response = call_agc_godot_execute(&arguments).await; + assert_eq!(response["isError"], true); + let text = response["content"][0]["text"].as_str().unwrap(); + assert!(!text.contains("bridge"), "输入校验不应访问 bridge:{text}"); + } + } + + #[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] + #[tokio::test] + async fn godot_tools_follow_bound_host_project_and_plugin_switch() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let project = crate::tests::canonical_test_tempdir("godot-mcp-project-"); + std::fs::create_dir(project.path().join("game")).unwrap(); + std::fs::write( + project.path().join("game/project.godot"), + "config_version=5\n", + ) + .unwrap(); + std::fs::create_dir(project.path().join(".agent")).unwrap(); + std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); + let bridge = + super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) + .await + .unwrap(); + for enabled in [false, true, false, true] { + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID, + enabled, + ) + .unwrap(); + let specs = EXTERNAL_MCP_BRIDGE_URL + .scope(bridge.url().to_string(), direct_tools_mcp_specs()) + .await; + assert_eq!( + specs["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "agc_godot_execute"), + enabled + ); + if !enabled { + let response = EXTERNAL_MCP_BRIDGE_URL + .scope( + bridge.url().to_string(), + call_agc_godot_execute(&json!({"code":"return 42"})), + ) + .await; + assert_eq!(response["isError"], true); + assert!(response.to_string().contains("不可用")); + } + } + std::fs::remove_file(project.path().join("game/project.godot")).unwrap(); + let specs = EXTERNAL_MCP_BRIDGE_URL + .scope(bridge.url().to_string(), direct_tools_mcp_specs()) + .await; + assert!(!specs.to_string().contains("agc_godot_execute")); + drop(bridge); + let specs = EXTERNAL_MCP_BRIDGE_URL + .scope( + "http://127.0.0.1:1/unavailable".to_string(), + direct_tools_mcp_specs(), + ) + .await; + assert!(!specs.to_string().contains("agc_godot_execute")); + } + #[test] fn remove_background_arguments_enforce_mode_color_contract() { for fields in [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index e1d8ccd01..09f5db4bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -1620,7 +1620,7 @@ pub(crate) fn agent_runtime_tool_action_input_summary( .unwrap_or(160) ), "command.run_limited" => format!("commandId={}", text(&["commandId", "command_id", "id"])), - "cocos.editor.execute" | "unity.editor.execute" => format!( + "cocos.editor.execute" | "unity.editor.execute" | "godot.editor.execute" => format!( "codeChars={} · codeSha256={:x}", chars(&["code"]), Sha256::digest( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index a685a2d3f..e8beb4103 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -393,6 +393,16 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ true, || observe_agent_runtime_unity_editor_execute(root, action, pending_action), ), + "godot.editor.execute" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + true, + || observe_agent_runtime_godot_editor_execute(root, action, pending_action), + ), "preview.validate" => { observe_agent_runtime_preview_validate( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs index 40924de58..a1a2684be 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs @@ -98,6 +98,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id( #[cfg(feature = "cocos-editor-execute")] "cocos.editor.execute" => Some("cocos.editor.execute"), "unity.editor.execute" => Some("unity.editor.execute"), + "godot.editor.execute" => Some("godot.editor.execute"), "preview.start" => Some("preview.start"), "preview.validate" => Some("preview.validate"), "image.inspect" => Some("image.inspect"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index c851f0d70..ecfc5c6b1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -78,6 +78,9 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { if crate::builtin_plugins::unity_editor_agent_tool_available() { tools.push(crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME); } + if crate::builtin_plugins::godot_editor_agent_tool_available() { + tools.push(crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME); + } tools } @@ -167,6 +170,11 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( { continue; } + if tool == crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME + && !crate::builtin_plugins::godot_editor_agent_tool_available_for_project(root) + { + continue; + } if isolated && ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS.contains(&tool) { denied_tools.push(tool.to_string()); continue; @@ -213,6 +221,10 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( *tool != crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME || crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) }) + .filter(|tool| { + *tool != crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME + || crate::builtin_plugins::godot_editor_agent_tool_available_for_project(root) + }) .map(str::to_string) .collect(), auto_tools, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 4850d6fcf..659c9acb2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -6,12 +6,12 @@ mod command_ops; mod context; mod delegation; mod delivery; +mod editor_execute; mod file_ops; mod goal_contract; mod helpers; mod isolated_joins; mod media; -mod unity_editor; pub(in crate::agent) use media::design_foundation_ui_page_output_path_is_valid; mod policy; mod preview; @@ -27,6 +27,7 @@ pub(in crate::agent) use command_ops::*; pub(in crate::agent) use context::*; pub(in crate::agent) use delegation::*; pub(in crate::agent) use delivery::*; +pub(in crate::agent) use editor_execute::*; pub(in crate::agent) use file_ops::*; pub(in crate::agent) use goal_contract::*; pub(in crate::agent) use helpers::*; @@ -39,7 +40,6 @@ pub(in crate::agent) use project_ops::*; pub(in crate::agent) use run_status::*; pub(in crate::agent) use task_ops::*; pub(in crate::agent) use ui_workflow::*; -pub(in crate::agent) use unity_editor::*; #[cfg(test)] pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/editor_execute.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/editor_execute.rs new file mode 100644 index 000000000..e815100b6 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/editor_execute.rs @@ -0,0 +1,135 @@ +use super::*; +use serde_json::Value; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct EditorExecuteInput { + code: String, +} + +pub(in crate::agent) fn observe_agent_runtime_unity_editor_execute( + root: &Path, + action: &AgentRuntimeToolAction, + pending_action: Option<&AgentRuntimePendingToolAction>, +) -> AgentRuntimeToolObservation { + observe_agent_runtime_editor_execute( + root, + action, + pending_action, + "unity.editor.execute", + "Unity", + crate::builtin_plugins::unity_editor_agent_tool_available_for_project, + crate::editor_adapters::execute_unity_editor_code, + ) +} + +pub(in crate::agent) fn observe_agent_runtime_godot_editor_execute( + root: &Path, + action: &AgentRuntimeToolAction, + pending_action: Option<&AgentRuntimePendingToolAction>, +) -> AgentRuntimeToolObservation { + observe_agent_runtime_editor_execute( + root, + action, + pending_action, + "godot.editor.execute", + "Godot", + crate::builtin_plugins::godot_editor_agent_tool_available_for_project, + crate::editor_adapters::execute_godot_editor_code, + ) +} + +fn observe_agent_runtime_editor_execute( + root: &Path, + action: &AgentRuntimeToolAction, + pending_action: Option<&AgentRuntimePendingToolAction>, + tool: &str, + editor: &str, + available: fn(&Path) -> bool, + execute: fn(&Path, &str) -> Result, +) -> AgentRuntimeToolObservation { + let execution = (|| { + let input: EditorExecuteInput = serde_json::from_value(action.input.clone()) + .map_err(|error| format!("{tool} 输入无效:{error}"))?; + if pending_action.is_none() { + return Err(format!("{tool} 必须绑定 durable pending action")); + } + if !available(root) { + return Err(format!("当前项目不是 {editor} 项目或 {editor} 插件不可用")); + } + execute(root, &input.code) + })(); + match execution { + Ok(response) => { + let status = match response["status"].as_str() { + Some("completed") if response["ok"] == true => "ok", + Some("needs-reconciliation") => { + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } + _ => "failed", + }; + AgentRuntimeToolObservation { + tool: tool.to_string(), + status: status.to_string(), + summary: match status { + "ok" => format!("{editor} 编辑器已返回执行成功回执"), + "needs-reconciliation" => format!("{editor} 执行结果待人工核对,禁止自动重发"), + _ => format!("{editor} 编辑器执行失败"), + }, + detail: Some(redact_agent_runtime_project_paths( + root, + &response.to_string(), + 32_000, + )), + } + } + Err(error) => AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 480), + detail: None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unity_execute_requires_pending_action_and_rejects_project_override() { + for input in [ + serde_json::json!({"code":"return 2;"}), + serde_json::json!({"code":"return 2;", "projectPath":"C:/other"}), + ] { + let action = AgentRuntimeToolAction { + tool: "unity.editor.execute".to_string(), + reason: None, + input, + }; + let observation = + observe_agent_runtime_unity_editor_execute(Path::new("C:/unity"), &action, None); + assert_eq!(observation.status, "failed"); + } + } + + #[test] + fn godot_execute_requires_pending_action_and_rejects_target_overrides() { + for input in [ + serde_json::json!({"code":"return 42"}), + serde_json::json!({"code":"return 42", "projectPath":"C:/other"}), + serde_json::json!({"code":"return 42", "processId":123}), + serde_json::json!({"code":"return 42", "dllPath":"C:/other.dll"}), + ] { + let action = AgentRuntimeToolAction { + tool: "godot.editor.execute".to_string(), + reason: None, + input, + }; + let observation = + observe_agent_runtime_godot_editor_execute(Path::new("C:/godot"), &action, None); + assert_eq!(observation.tool, "godot.editor.execute"); + assert_eq!(observation.status, "failed"); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs deleted file mode 100644 index d0cd9fe55..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs +++ /dev/null @@ -1,79 +0,0 @@ -use super::*; - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct UnityEditorExecuteInput { - code: String, -} - -pub(in crate::agent) fn observe_agent_runtime_unity_editor_execute( - root: &Path, - action: &AgentRuntimeToolAction, - pending_action: Option<&AgentRuntimePendingToolAction>, -) -> AgentRuntimeToolObservation { - let execution = (|| { - let input: UnityEditorExecuteInput = serde_json::from_value(action.input.clone()) - .map_err(|error| format!("unity.editor.execute 输入无效:{error}"))?; - if pending_action.is_none() { - return Err("unity.editor.execute 必须绑定 durable pending action".to_string()); - } - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) { - return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string()); - } - crate::editor_adapters::execute_unity_editor_code(root, &input.code) - })(); - match execution { - Ok(response) => { - let status = match response["status"].as_str() { - Some("completed") if response["ok"] == true => "ok", - Some("needs-reconciliation") => { - AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION - } - _ => "failed", - }; - AgentRuntimeToolObservation { - tool: "unity.editor.execute".to_string(), - status: status.to_string(), - summary: match status { - "ok" => "Unity 编辑器已返回执行成功回执", - "needs-reconciliation" => "Unity 执行结果待人工核对,禁止自动重发", - _ => "Unity 编辑器执行失败", - } - .to_string(), - detail: Some(redact_agent_runtime_project_paths( - root, - &response.to_string(), - 32_000, - )), - } - } - Err(error) => AgentRuntimeToolObservation { - tool: "unity.editor.execute".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 480), - detail: None, - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn unity_execute_requires_pending_action_and_rejects_project_override() { - for input in [ - serde_json::json!({"code":"return 2;"}), - serde_json::json!({"code":"return 2;", "projectPath":"C:/other"}), - ] { - let action = AgentRuntimeToolAction { - tool: "unity.editor.execute".to_string(), - reason: None, - input, - }; - let observation = - observe_agent_runtime_unity_editor_execute(Path::new("C:/unity"), &action, None); - assert_eq!(observation.status, "failed"); - } - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 5375f5055..9d25349d5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -253,12 +253,13 @@ fn build_agent_runtime_native_capability_registry( fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegistry, String> { - // 两个独立开关产生四份目录,使用同一快照选缓存并构建。 - static REGISTRIES: [OnceLock, String>>; 4] = - [const { OnceLock::new() }; 4]; + // 三个独立开关产生八份目录,使用同一快照选缓存并构建。 + static REGISTRIES: [OnceLock, String>>; 8] = + [const { OnceLock::new() }; 8]; let tools = agent_runtime_native_executable_tools(); let index = usize::from(tools.contains(&crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME)) - | (usize::from(tools.contains(&crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME)) << 1); + | (usize::from(tools.contains(&crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME)) << 1) + | (usize::from(tools.contains(&crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME)) << 2); let cache = ®ISTRIES[index]; cache .get_or_init(|| build_agent_runtime_native_capability_registry(tools)) @@ -315,6 +316,10 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_project( let name = native_runtime_function_name_for_tool("unity.editor.execute"); tools.retain(|tool| tool.name != name); } + if !crate::builtin_plugins::godot_editor_agent_tool_available_for_project(root) { + let name = native_runtime_function_name_for_tool("godot.editor.execute"); + tools.retain(|tool| tool.name != name); + } Ok(tools) } @@ -1052,6 +1057,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。" } "unity.editor.execute" => "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。", + "godot.editor.execute" => "在当前 Godot 项目已打开的 Windows x64 标准编辑器中执行支持 return/await 的 GDScript 函数体。DLL 原件保留在安装目录,宿主在私有缓存准备每实例加载副本,受管描述文件引用该副本,重新聚焦后自动加载;仅提交 code,结果待核对时禁止自动重发。", "blackboard.write" => "向项目级共享黑板追加稳定结论。", "agent.message" => "向一个目标 Agent 写入定向上下文消息。", "agent.delegate" => { @@ -1249,7 +1255,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { } }), "command.exec" | "command.start" => command_start_input_schema(), - "cocos.editor.execute" | "unity.editor.execute" => json!({ + "cocos.editor.execute" | "unity.editor.execute" | "godot.editor.execute" => json!({ "type": "object", "required": ["code"], "additionalProperties": false, @@ -1863,6 +1869,64 @@ mod tests { } } + #[test] + fn godot_native_registry_follows_toggle_without_reusing_other_editor_cache() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join("game")).unwrap(); + std::fs::write( + project.path().join("game/project.godot"), + "config_version=5\n", + ) + .unwrap(); + let other_project = tempfile::tempdir().unwrap(); + for enabled in [false, true, false, true] { + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID, + enabled, + ) + .unwrap(); + let expected = enabled + && cfg!(all( + windows, + target_arch = "x86_64", + feature = "godot-editor-execute" + )); + assert_eq!( + native_runtime_function_name("godot.editor.execute").is_some(), + expected + ); + let name = native_runtime_function_name_for_tool("godot.editor.execute"); + assert_eq!( + build_agent_runtime_native_function_tools_for_project( + project.path(), + "__all_agents__" + ) + .unwrap() + .iter() + .any(|tool| tool.name == name), + expected + ); + assert!(!build_agent_runtime_native_function_tools_for_project( + other_project.path(), + "__all_agents__" + ) + .unwrap() + .iter() + .any(|tool| tool.name == name)); + } + } + + #[test] + fn godot_native_schema_cannot_override_execution_identity() { + let schema = runtime_tool_input_schema("godot.editor.execute"); + assert_eq!(schema["additionalProperties"], false); + assert_eq!(schema["required"], json!(["code"])); + assert_eq!(schema["properties"].as_object().unwrap().len(), 1); + } + #[test] fn strict_native_function_schemas_match_openai_subset() { let functions = diff --git a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs index a3a002a60..e0c024087 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs @@ -19,6 +19,8 @@ pub(crate) const AGC_COCOS_EDITOR_PLUGIN_ID: &str = "agc-cocos-editor"; pub(crate) const AGC_COCOS_EDITOR_TOOL_NAME: &str = "cocos.editor.execute"; pub(crate) const AGC_UNITY_EDITOR_PLUGIN_ID: &str = "agc-unity-editor"; pub(crate) const AGC_UNITY_EDITOR_TOOL_NAME: &str = "unity.editor.execute"; +pub(crate) const AGC_GODOT_EDITOR_PLUGIN_ID: &str = "agc-godot-editor"; +pub(crate) const AGC_GODOT_EDITOR_TOOL_NAME: &str = "godot.editor.execute"; const STATE_FILE_NAME: &str = "builtin-plugins.json"; const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1"; @@ -27,6 +29,7 @@ const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1"; pub(crate) enum BuiltinPlugin { CocosEditor, UnityEditor, + GodotEditor, } impl BuiltinPlugin { @@ -34,26 +37,30 @@ impl BuiltinPlugin { match self { Self::CocosEditor => AGC_COCOS_EDITOR_PLUGIN_ID, Self::UnityEditor => AGC_UNITY_EDITOR_PLUGIN_ID, + Self::GodotEditor => AGC_GODOT_EDITOR_PLUGIN_ID, } } /// 未持久化任何开关时的默认状态。 fn default_enabled(self) -> bool { match self { - Self::CocosEditor | Self::UnityEditor => true, + Self::CocosEditor | Self::UnityEditor | Self::GodotEditor => true, } } /// 该插件是否向 Agent 暴露 Runtime 工具。 fn exposes_agent_tools(self) -> bool { match self { - Self::CocosEditor | Self::UnityEditor => true, + Self::CocosEditor | Self::UnityEditor | Self::GodotEditor => true, } } } -pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] = - &[BuiltinPlugin::CocosEditor, BuiltinPlugin::UnityEditor]; +pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] = &[ + BuiltinPlugin::CocosEditor, + BuiltinPlugin::UnityEditor, + BuiltinPlugin::GodotEditor, +]; pub(crate) fn builtin_plugin(id: &str) -> Option { BUILTIN_PLUGINS @@ -251,6 +258,13 @@ pub(crate) fn agent_tool_available(plugin: BuiltinPlugin) -> bool { feature = "unity-editor-execute" )) && unity_editor_bridge::is_supported_platform() } + BuiltinPlugin::GodotEditor => { + cfg!(all( + windows, + target_arch = "x86_64", + feature = "godot-editor-execute" + )) && godot_editor_bridge::is_supported_platform() + } } && is_enabled(plugin.id()) } @@ -284,6 +298,9 @@ pub(crate) fn available_agent_tools() -> Vec<&'static str> { if unity_editor_agent_tool_available() { available.push(AGC_UNITY_EDITOR_TOOL_NAME); } + if godot_editor_agent_tool_available() { + available.push(AGC_GODOT_EDITOR_TOOL_NAME); + } available } @@ -295,6 +312,8 @@ pub(crate) fn available_agent_tools_for_project(root: &Path) -> Vec<&'static str .filter(|tool| { if *tool == AGC_UNITY_EDITOR_TOOL_NAME { unity_editor_agent_tool_available_for_project(root) + } else if *tool == AGC_GODOT_EDITOR_TOOL_NAME { + godot_editor_agent_tool_available_for_project(root) } else { cocos_editor_agent_tool_available_for_project(root) } @@ -314,6 +333,18 @@ pub(crate) fn unity_editor_agent_tool_available_for_project(root: &Path) -> bool .is_some() } +pub(crate) fn godot_editor_agent_tool_available() -> bool { + agent_tool_available(BuiltinPlugin::GodotEditor) +} + +pub(crate) fn godot_editor_agent_tool_available_for_project(root: &Path) -> bool { + godot_editor_agent_tool_available() + && crate::project::discover_local_godot_project_root(root) + .ok() + .flatten() + .is_some() +} + #[cfg(test)] pub(crate) use tests::test_lock; @@ -329,6 +360,43 @@ mod tests { .unwrap_or_else(|error| error.into_inner()) } + #[test] + fn godot_tools_follow_real_subproject_and_independent_toggle() { + let _guard = test_lock(); + let config = tempdir().unwrap(); + initialize(config.path()).unwrap(); + let project = tempdir().unwrap(); + fs::create_dir(project.path().join("game")).unwrap(); + fs::write( + project.path().join("game/project.godot"), + "config_version=5\n", + ) + .unwrap(); + let supported = cfg!(all( + windows, + target_arch = "x86_64", + feature = "godot-editor-execute" + )); + assert_eq!( + available_agent_tools_for_project(project.path()).contains(&AGC_GODOT_EDITOR_TOOL_NAME), + supported + ); + set_enabled(AGC_GODOT_EDITOR_PLUGIN_ID, false).unwrap(); + assert!(!available_agent_tools_for_project(project.path()) + .contains(&AGC_GODOT_EDITOR_TOOL_NAME)); + assert!(is_enabled(AGC_UNITY_EDITOR_PLUGIN_ID)); + set_enabled(AGC_GODOT_EDITOR_PLUGIN_ID, true).unwrap(); + fs::create_dir(project.path().join("other")).unwrap(); + fs::write( + project.path().join("other/project.godot"), + "config_version=5\n", + ) + .unwrap(); + assert!(!godot_editor_agent_tool_available_for_project( + project.path() + )); + } + #[test] fn unity_tool_visibility_requires_project_platform_and_independent_toggle() { let _guard = test_lock(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs index b77ec5a7a..33f6b0098 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs @@ -14,284 +14,24 @@ use crate::plugin_host::PluginHost; use editor_adapter_api::{EditorAdapter, EditorConnectionInfo}; use serde_json::{json, Value}; use std::path::Path; -use std::sync::{Mutex, OnceLock}; -struct UnityPendingDelivery { - id: String, - outcome_known: bool, -} +mod execution; +pub(crate) use execution::*; -fn unity_pending_delivery() -> &'static Mutex> { - static PENDING: OnceLock>> = OnceLock::new(); - PENDING.get_or_init(|| Mutex::new(None)) -} +/// GUI 只转发已有 Runner RPC;每个引擎的连接和回执均归同一个 owner。 +struct RunnerManagedEditorAdapter(ManagedEditor); -pub(crate) fn unity_execution_fence_path(config_dir: &Path) -> std::path::PathBuf { - config_dir.join("unity-editor-execution.pending") -} - -pub(crate) fn unity_uncertain_fence_path(config_dir: &Path) -> std::path::PathBuf { - config_dir.join("unity-editor-execution.uncertain") -} - -pub(crate) fn mark_unity_execution_uncertain_at(config_dir: &Path) -> Result<(), String> { - use std::io::Write; - let path = unity_uncertain_fence_path(config_dir); - match std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - { - Ok(mut file) => file - .write_all(b"needs-reconciliation") - .and_then(|_| file.sync_all()) - .map_err(|_| "无法持久记录 Unity 执行不确定状态".to_string()), - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), - Err(_) => Err("无法持久记录 Unity 执行不确定状态".to_string()), - } -} - -pub(crate) fn mark_unity_execution_uncertain() -> Result<(), String> { - let config = crate::game_creator_runtime_config_dir_lock() - .lock() - .map_err(|_| "Unity 配置锁损坏")? - .clone() - .ok_or("Unity 执行宿主尚未初始化")?; - mark_unity_execution_uncertain_at(&config) -} - -fn current_unity_execution_fence() -> Result { - crate::game_creator_runtime_config_dir_lock() - .lock() - .map_err(|_| "Unity 配置锁损坏")? - .as_deref() - .map(unity_execution_fence_path) - .ok_or_else(|| "Unity 执行宿主尚未初始化".to_string()) -} - -fn remove_unity_execution_fence(path: &Path) -> Result<(), String> { - match std::fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(_) => Err("无法清理 Unity 执行确认记录,继续阻断执行".to_string()), - } -} - -/// 调用方必须同时独占 GUI 参与锁及 Runner 实例锁,保证这是全部宿主退出后的首次打开。 -pub(crate) fn reset_unity_execution_fence_for_fresh_gui(config_dir: &Path) -> Result<(), String> { - remove_unity_execution_fence(&unity_execution_fence_path(config_dir))?; - remove_unity_execution_fence(&unity_uncertain_fence_path(config_dir)) -} - -pub(crate) fn unity_execute_receipt_is_valid(value: &Value) -> bool { - if value["retryAllowed"] != false { - return false; - } - let valid_error = value["error"]["code"] - .as_str() - .is_some_and(|code| !code.trim().is_empty()) - && value["error"]["message"] - .as_str() - .is_some_and(|message| !message.trim().is_empty()); - match value["status"].as_str() { - Some("completed") => { - value["ok"] == true && value["dispatched"] == true && value.get("result").is_some() - } - Some("failed") => value["ok"] == false && value["dispatched"].is_boolean() && valid_error, - Some("needs-reconciliation") => { - value["ok"] == false && value["dispatched"] == true && valid_error - } - _ => false, - } -} - -fn unity_reconciliation(message: &str) -> Value { - json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"dispatched":true,"error":{"code":"needs-reconciliation","message":message}}) -} - -pub(crate) fn unity_not_dispatched(message: &str) -> Value { - json!({"ok":false,"status":"failed","retryAllowed":false,"dispatched":false,"error":{"code":"not-dispatched","message":message}}) -} - -/// 仅在长寿命 Runner 中触达 native service,GUI / Runtime / DirectProject 共用此入口。 -pub(crate) fn unity_editor_rpc(method: &str, params: Value) -> Result { - let method = method.strip_prefix("editor.").unwrap_or(method); - if crate::runner::external_agent_runner_is_server_process() { - unity_editor_rpc_owned(method, params, None) - } else { - crate::runner::call_external_unity_editor(method, params) - } -} - -pub(crate) fn execute_unity_editor_code(root: &Path, code: &str) -> Result { - unity_editor_rpc( - "execute", - json!({"projectPath":root.to_string_lossy(),"code":code,"timeoutMs":60000}), - ) -} - -/// GUI 读不到执行回执时不会发送 ack;该门闩不能被插件、连接或项目生命周期清除。 -pub(crate) fn unity_editor_rpc_owned( - method: &str, - params: Value, - delivery_id: Option<&str>, -) -> Result { - let method = method.strip_prefix("editor.").unwrap_or(method); - if !matches!( - method, - "detect" | "connect" | "status" | "execute" | "disconnect" - ) { - return Err("Unity RPC 方法不受支持".to_string()); - } - if method == "disconnect" { - unity_editor_bridge::disconnect_unity_editor(); - return Ok( - json!({"adapter":"unity-editor","connected":false,"pid":null,"projectPath":params.get("projectPath"),"version":null}), - ); - } - if method == "connect" { - unity_editor_bridge::disconnect_unity_editor(); - } - let project = params - .get("projectPath") - .and_then(Value::as_str) - .ok_or("缺少 projectPath")?; - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(Path::new(project)) { - return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string()); - } - let mut delivery = if method == "execute" { - let mut pending = match unity_pending_delivery().try_lock() { - Ok(pending) => pending, - Err(std::sync::TryLockError::WouldBlock) => { - return Ok(unity_not_dispatched( - "Unity 编辑器已有请求正在执行,请等待回执", - )) - } - Err(std::sync::TryLockError::Poisoned(_)) => { - return Ok(unity_reconciliation("Unity 执行状态异常,请人工核对")) - } - }; - let fence = current_unity_execution_fence()?; - let uncertain_fence = fence.with_extension("uncertain"); - if uncertain_fence.exists() { - return Ok(unity_reconciliation( - "Unity 执行回执未确认,请核对后退出全部宿主再重新打开", - )); - } - if pending - .as_ref() - .is_some_and(|pending| pending.outcome_known) - { - return Ok(unity_not_dispatched( - "Unity 上一条执行正在等待客户端确认回执", - )); - } - if pending.is_some() || fence.exists() { - return Ok(unity_reconciliation( - "先前 Unity 执行回执尚未确认,退出全部 AGC 和 Runner 后重新打开才可恢复", - )); - } - let id = delivery_id - .map(str::to_string) - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - use std::io::Write; - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&fence) - .map_err(|_| "无法独占保存 Unity 执行确认记录,未发送请求")?; - file.write_all(id.as_bytes()) - .and_then(|_| file.sync_all()) - .map_err(|_| "无法持久保存 Unity 执行确认记录,未发送请求")?; - *pending = Some(UnityPendingDelivery { - id, - outcome_known: false, - }); - Some(pending) - } else { - None - }; - let result = unity_editor_bridge::UnityEditorAdapter::new(Vec::new()).rpc(method, params); - if method == "execute" { - // native 的 Err 均为发送前失败;发送后的未知状态由结构化 result 携带并锁存。 - let mut result = result.unwrap_or_else(|error| json!({"ok":false,"status":"failed","retryAllowed":false,"dispatched":false,"error":{"code":"not-dispatched","message":error}})); - if !unity_execute_receipt_is_valid(&result) { - result = unity_reconciliation("Unity 原生执行回执格式损坏,禁止自动重发"); - } - let known = result["status"] != "needs-reconciliation"; - // 与本次 pending 写入同一临界区决定确认,避免返回后再次抢锁造成误判。 - if delivery_id.is_some() { - result["ackRequired"] = json!(known); - } - if let Some(pending) = delivery.as_mut() { - if let Some(pending) = pending.as_mut() { - pending.outcome_known = known; - } - if delivery_id.is_none() && known { - if current_unity_execution_fence() - .and_then(|path| remove_unity_execution_fence(&path)) - .is_err() - { - return Ok(unity_reconciliation( - "Unity 执行已返回,但确认记录无法提交,请人工核对", - )); - } - **pending = None; - } - } - return Ok(result); - } - result -} - -pub(crate) fn acknowledge_unity_editor_delivery(request_id: &str) -> Result<(), String> { - let mut pending = unity_pending_delivery() - .try_lock() - .map_err(|_| "Unity 执行尚未结束")?; - if !pending - .as_ref() - .is_some_and(|pending| pending.id == request_id && pending.outcome_known) - { - return Err("Unity 回执确认身份不匹配或执行结果仍不确定".to_string()); - } - remove_unity_execution_fence(¤t_unity_execution_fence()?)?; - *pending = None; - Ok(()) -} - -#[cfg(test)] -pub(crate) fn unity_delivery_requires_ack(request_id: &str) -> bool { - unity_pending_delivery() - .try_lock() - .ok() - .is_some_and(|pending| { - pending - .as_ref() - .is_some_and(|pending| pending.id == request_id && pending.outcome_known) - }) -} - -pub(crate) fn disconnect_unity_editor_connection() { - if crate::runner::external_agent_runner_is_server_process() { - unity_editor_bridge::disconnect_unity_editor(); - } else { - let _ = crate::runner::disconnect_external_unity_editor(); - } -} - -/// GUI 只代理已有 Runner RPC,不创建第二份 helper 或不确定门闩。 -struct RunnerUnityEditorAdapter; - -impl EditorAdapter for RunnerUnityEditorAdapter { +impl EditorAdapter for RunnerManagedEditorAdapter { fn id(&self) -> &'static str { - "unity-editor" + self.0.adapter() } fn detect(&self, project_path: &Path) -> Result { - serde_json::from_value(unity_editor_rpc( + serde_json::from_value(managed_editor_rpc( + self.0, "detect", json!({"projectPath":project_path.to_string_lossy()}), )?) - .map_err(|_| "Unity 探测回执格式无效".to_string()) + .map_err(|_| "编辑器探测回执格式无效".to_string()) } fn connect( &mut self, @@ -299,20 +39,26 @@ impl EditorAdapter for RunnerUnityEditorAdapter { project_path: &Path, _version: &str, ) -> Result { - serde_json::from_value(unity_editor_rpc( + serde_json::from_value(managed_editor_rpc( + self.0, "connect", json!({"processId":pid,"projectPath":project_path.to_string_lossy()}), )?) - .map_err(|_| "Unity 连接回执格式无效".to_string()) + .map_err(|_| "编辑器连接回执格式无效".to_string()) } fn disconnect(&mut self) { - disconnect_unity_editor_connection(); + let _ = disconnect_managed_editor_connection(self.0); } fn translate_rpc(&self, method: &str, params: Value) -> Result { - unity_editor_bridge::UnityEditorAdapter::new(Vec::new()).translate_rpc(method, params) + match self.0 { + ManagedEditor::Unity => unity_editor_bridge::UnityEditorAdapter::new(Vec::new()) + .translate_rpc(method, params), + ManagedEditor::Godot => godot_editor_bridge::GodotEditorAdapter::new(Vec::new()) + .translate_rpc(method, params), + } } fn rpc(&self, method: &str, params: Value) -> Result { - unity_editor_rpc(method, params) + managed_editor_rpc(self.0, method, params) } } @@ -344,6 +90,29 @@ pub(crate) fn configure_unity_helper_for_runtime() -> Result<(), String> { unity_editor_bridge::configure_helper_candidates(candidates) } +pub(crate) const GODOT_BRIDGE_PAYLOAD_RELATIVE: &str = + "plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll"; + +/// 安装包与开发构建使用同一插件资源布局,不将 DLL 复制进 Godot 工程。 +pub(crate) fn configure_godot_payload_for_runtime(config_dir: &Path) -> Result<(), String> { + godot_editor_bridge::configure_runtime_cache_dir(config_dir.join("godot-editor-runtime"))?; + let mut candidates = Vec::new(); + if let Ok(executable) = std::env::current_exe() { + if let Some(directory) = executable.parent() { + candidates.push(directory.join(GODOT_BRIDGE_PAYLOAD_RELATIVE)); + } + } + #[cfg(debug_assertions)] + candidates.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .ok_or("插件工作区目录不可用")? + .join(GODOT_BRIDGE_PAYLOAD_RELATIVE), + ); + godot_editor_bridge::configure_payload_candidates(candidates) +} + pub(crate) fn register_linked_editor_adapters( app: &tauri::AppHandle, host: &PluginHost, @@ -360,7 +129,11 @@ pub(crate) fn register_linked_editor_adapters( } #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] { - host.register_editor_adapter(Box::new(RunnerUnityEditorAdapter))?; + host.register_editor_adapter(Box::new(RunnerManagedEditorAdapter(ManagedEditor::Unity)))?; + } + #[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] + { + host.register_editor_adapter(Box::new(RunnerManagedEditorAdapter(ManagedEditor::Godot)))?; } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs new file mode 100644 index 000000000..cb1472fb4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs @@ -0,0 +1,691 @@ +//! Runner 管理的编辑器执行回执;各编辑器共享协议,分别保存连接及不确定状态。 + +use serde_json::{json, Value}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use editor_adapter_api::EditorAdapter; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ManagedEditor { + Unity, + Godot, +} + +impl ManagedEditor { + pub(crate) fn name(self) -> &'static str { + match self { + Self::Unity => "Unity", + Self::Godot => "Godot", + } + } + pub(crate) fn adapter(self) -> &'static str { + match self { + Self::Unity => "unity-editor", + Self::Godot => "godot-editor", + } + } + pub(crate) fn rpc_method(self) -> &'static str { + match self { + Self::Unity => "unity.editor.rpc", + Self::Godot => "godot.editor.rpc", + } + } + pub(crate) fn ack_method(self) -> &'static str { + match self { + Self::Unity => "unity.editor.ack", + Self::Godot => "godot.editor.ack", + } + } + pub(crate) fn mark_method(self) -> &'static str { + match self { + Self::Unity => "unity.editor.mark_uncertain", + Self::Godot => "godot.editor.mark_uncertain", + } + } + pub(crate) fn from_rpc_method(method: &str) -> Option { + match method { + "unity.editor.rpc" | "unity.editor.ack" | "unity.editor.mark_uncertain" => { + Some(Self::Unity) + } + "godot.editor.rpc" | "godot.editor.ack" | "godot.editor.mark_uncertain" => { + Some(Self::Godot) + } + _ => None, + } + } + pub(crate) fn for_plugin(id: &str) -> Option { + match id { + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID => Some(Self::Unity), + crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID => Some(Self::Godot), + _ => None, + } + } + fn available(self, root: &Path) -> bool { + match self { + Self::Unity => { + crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) + } + Self::Godot => { + crate::builtin_plugins::godot_editor_agent_tool_available_for_project(root) + } + } + } + fn native_rpc(self, method: &str, params: Value) -> Result { + match self { + Self::Unity => { + unity_editor_bridge::UnityEditorAdapter::new(Vec::new()).rpc(method, params) + } + Self::Godot => { + godot_editor_bridge::GodotEditorAdapter::new(Vec::new()).rpc(method, params) + } + } + } + fn disconnect_native(self, project: Option<&Path>) -> Result<(), String> { + match self { + Self::Unity => { + unity_editor_bridge::disconnect_unity_editor(); + Ok(()) + } + Self::Godot => project + .ok_or_else(|| "Godot 清理必须绑定原受控项目".to_string()) + .and_then(godot_editor_bridge::disconnect_godot_editor_for_project), + } + } +} + +pub(super) struct PendingDelivery { + id: String, + outcome_known: bool, +} + +fn pending_delivery(editor: ManagedEditor) -> &'static Mutex> { + static UNITY: OnceLock>> = OnceLock::new(); + static GODOT: OnceLock>> = OnceLock::new(); + match editor { + ManagedEditor::Unity => &UNITY, + ManagedEditor::Godot => &GODOT, + } + .get_or_init(|| Mutex::new(None)) +} + +pub(crate) fn editor_execution_fence_path(editor: ManagedEditor, config: &Path) -> PathBuf { + config.join(format!("{}-execution.pending", editor.adapter())) +} + +pub(crate) fn editor_uncertain_fence_path(editor: ManagedEditor, config: &Path) -> PathBuf { + config.join(format!("{}-execution.uncertain", editor.adapter())) +} + +pub(crate) fn mark_editor_execution_uncertain_at( + editor: ManagedEditor, + config: &Path, +) -> Result<(), String> { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(editor_uncertain_fence_path(editor, config)) + { + Ok(mut file) => file + .write_all(b"needs-reconciliation") + .and_then(|_| file.sync_all()) + .map_err(|_| format!("无法持久记录 {} 执行不确定状态", editor.name())), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(_) => Err(format!("无法持久记录 {} 执行不确定状态", editor.name())), + } +} + +fn current_config() -> Result { + crate::game_creator_runtime_config_dir_lock() + .lock() + .map_err(|_| "编辑器执行配置锁损坏")? + .clone() + .ok_or_else(|| "编辑器执行宿主尚未初始化".into()) +} + +const GODOT_PROJECTS_FILE: &str = "godot-editor-authorized-projects.json"; + +/// 只在宿主私有配置中记录曾授权安装桥的工作区,Runner 重启不丢失清理归属。 +pub(crate) fn godot_authorized_projects_at(config: &Path) -> Result, String> { + let path = config.join(GODOT_PROJECTS_FILE); + let metadata = match std::fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(_) => return Err("Godot 项目归属记录不可读".into()), + }; + #[cfg(windows)] + let linked = { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x400 != 0 + }; + #[cfg(not(windows))] + let linked = metadata.file_type().is_symlink(); + if linked || !metadata.is_file() || metadata.len() > 64 * 1024 { + return Err("Godot 项目归属记录类型或大小无效".into()); + } + let value: Value = + serde_json::from_slice(&std::fs::read(path).map_err(|_| "Godot 项目归属记录不可读")?) + .map_err(|_| "Godot 项目归属记录损坏")?; + if value["schemaVersion"] != "agc.godot.authorized-projects.v1" { + return Err("Godot 项目归属记录版本无效".into()); + } + value["projects"] + .as_array() + .ok_or("Godot 项目归属记录缺少项目")? + .iter() + .map(|value| { + value + .as_str() + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .ok_or_else(|| "Godot 项目归属路径无效".into()) + }) + .collect() +} + +fn update_godot_authorized_project_at( + config: &Path, + project: &Path, + authorized: bool, +) -> Result<(), String> { + static WRITE_LOCK: Mutex<()> = Mutex::new(()); + let _guard = WRITE_LOCK.lock().map_err(|_| "Godot 项目归属锁损坏")?; + let mut projects = godot_authorized_projects_at(config)?; + if authorized { + let project = project + .canonicalize() + .map_err(|_| "Godot 受控工作区不可读")?; + if !projects.contains(&project) { + projects.push(project); + } + } else { + let canonical = project + .canonicalize() + .unwrap_or_else(|_| project.to_path_buf()); + projects.retain(|value| value != project && value != &canonical); + } + let bytes = serde_json::to_vec( + &json!({"schemaVersion":"agc.godot.authorized-projects.v1", "projects":projects}), + ) + .map_err(|_| "Godot 项目归属记录编码失败")?; + if bytes.len() > 64 * 1024 { + return Err("Godot 待清理项目归属超过限制".into()); + } + let mut temporary = + tempfile::NamedTempFile::new_in(config).map_err(|_| "无法创建 Godot 项目归属记录")?; + temporary + .write_all(&bytes) + .and_then(|_| temporary.as_file().sync_all()) + .map_err(|_| "无法持久保存 Godot 项目归属")?; + temporary + .persist(config.join(GODOT_PROJECTS_FILE)) + .map_err(|_| "无法提交 Godot 项目归属记录")?; + Ok(()) +} + +pub(crate) fn godot_cleanup_projects_at( + config: &Path, + explicit: Option<&Path>, +) -> Result, String> { + let projects = godot_authorized_projects_at(config)?; + let Some(project) = explicit else { + return Ok(projects); + }; + let canonical = project + .canonicalize() + .unwrap_or_else(|_| project.to_path_buf()); + if projects.contains(&canonical) || projects.contains(&project.to_path_buf()) { + return Ok(vec![canonical]); + } + Ok(if godot_project_cleanup_required(project)? { + vec![canonical] + } else { + Vec::new() + }) +} + +pub(crate) fn godot_project_cleanup_required(project: &Path) -> Result { + let Some(relative) = crate::project::discover_local_godot_project_root(project)? else { + return Ok(false); + }; + let root = project.join(relative); + if root.join("agc-editor-bridge.gdextension").exists() { + return Ok(true); + } + match std::fs::read_dir(root.join(".godot/agc")) { + Ok(mut entries) => Ok(entries.next().is_some()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err("无法确认 Godot 桥缓存是否已清理".into()), + } +} + +pub(crate) fn mark_editor_execution_uncertain(editor: ManagedEditor) -> Result<(), String> { + mark_editor_execution_uncertain_at(editor, ¤t_config()?) +} + +fn remove_fence(path: &Path) -> Result<(), String> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("无法清理编辑器执行确认记录,继续阻断执行".into()), + } +} + +/// 仅在同时独占 GUI 参与锁与 Runner 实例锁后调用。 +pub(crate) fn reset_editor_execution_fences_for_fresh_gui(config: &Path) -> Result<(), String> { + for editor in [ManagedEditor::Unity, ManagedEditor::Godot] { + remove_fence(&editor_execution_fence_path(editor, config))?; + remove_fence(&editor_uncertain_fence_path(editor, config))?; + } + Ok(()) +} + +pub(crate) fn editor_execute_receipt_is_valid(value: &Value) -> bool { + if value["retryAllowed"] != false { + return false; + } + let valid_error = value["error"]["code"] + .as_str() + .is_some_and(|v| !v.trim().is_empty()) + && value["error"]["message"] + .as_str() + .is_some_and(|v| !v.trim().is_empty()); + match value["status"].as_str() { + Some("completed") => { + value["ok"] == true + && value["dispatched"] == true + && value.get("result").is_some() + && value.get("error").is_none() + } + Some("failed") => { + value["ok"] == false + && value["dispatched"].is_boolean() + && valid_error + && value.get("result").is_none() + } + Some("needs-reconciliation") => { + value["ok"] == false + && value["dispatched"] == true + && valid_error + && value.get("result").is_none() + } + _ => false, + } +} + +pub(crate) fn editor_reconciliation(message: &str) -> Value { + json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"dispatched":true, + "error":{"code":"needs-reconciliation","message":message}}) +} + +pub(crate) fn editor_not_dispatched(message: &str) -> Value { + json!({"ok":false,"status":"failed","retryAllowed":false,"dispatched":false, + "error":{"code":"not-dispatched","message":message}}) +} + +pub(crate) fn managed_editor_rpc( + editor: ManagedEditor, + method: &str, + params: Value, +) -> Result { + let method = method.strip_prefix("editor.").unwrap_or(method); + if crate::runner::external_agent_runner_is_server_process() { + managed_editor_rpc_owned(editor, method, params, None) + } else { + crate::runner::call_external_managed_editor(editor, method, params) + } +} + +pub(crate) fn execute_managed_editor_code( + editor: ManagedEditor, + root: &Path, + code: &str, +) -> Result { + managed_editor_rpc( + editor, + "execute", + json!({"projectPath":root.to_string_lossy(),"code":code,"timeoutMs":60000}), + ) +} + +pub(crate) fn managed_editor_rpc_owned( + editor: ManagedEditor, + method: &str, + params: Value, + delivery_id: Option<&str>, +) -> Result { + let method = method.strip_prefix("editor.").unwrap_or(method); + if !matches!( + method, + "detect" | "connect" | "status" | "execute" | "disconnect" + ) { + return Err(format!("{} RPC 方法不受支持", editor.name())); + } + // 即使 Runner 自动重启,持久 fence 仍禁止重新准备/升级或卸载未知执行中的桥。 + if editor == ManagedEditor::Godot && matches!(method, "connect" | "disconnect") { + let pending = pending_delivery(editor) + .try_lock() + .map_err(|_| "Godot 仍在执行,请等待回执")?; + let config = current_config()?; + if pending.is_some() + || editor_execution_fence_path(editor, &config).exists() + || editor_uncertain_fence_path(editor, &config).exists() + { + return Err("Godot 执行回执尚未确认,暂不重新安装或卸载编辑器桥".into()); + } + } + if method == "disconnect" { + if editor == ManagedEditor::Godot { + let config = current_config()?; + let explicit = params + .get("projectPath") + .and_then(Value::as_str) + .map(Path::new); + for project in godot_cleanup_projects_at(&config, explicit)? { + editor.disconnect_native(Some(&project))?; + update_godot_authorized_project_at(&config, &project, false)?; + } + } else { + editor.disconnect_native(None)?; + } + return Ok( + json!({"adapter":editor.adapter(),"connected":false,"pid":null,"projectPath":params.get("projectPath"),"version":null}), + ); + } + if method == "connect" && editor == ManagedEditor::Unity { + editor.disconnect_native(None)?; + } + let project = params + .get("projectPath") + .and_then(Value::as_str) + .ok_or("缺少 projectPath")?; + if !editor.available(Path::new(project)) { + return Err(format!("当前项目不是 {} 项目或插件不可用", editor.name())); + } + if editor == ManagedEditor::Godot && matches!(method, "connect" | "execute") { + update_godot_authorized_project_at(¤t_config()?, Path::new(project), true)?; + } + let mut delivery = if method == "execute" { + let mut pending = match pending_delivery(editor).try_lock() { + Ok(pending) => pending, + Err(std::sync::TryLockError::WouldBlock) => { + return Ok(editor_not_dispatched("编辑器已有请求正在执行,请等待回执")) + } + Err(std::sync::TryLockError::Poisoned(_)) => { + return Ok(editor_reconciliation("编辑器执行状态异常,请人工核对")) + } + }; + let config = current_config()?; + let fence = editor_execution_fence_path(editor, &config); + if editor_uncertain_fence_path(editor, &config).exists() { + return Ok(editor_reconciliation( + "编辑器执行回执未确认,请核对后退出全部宿主再重新打开", + )); + } + if pending.as_ref().is_some_and(|p| p.outcome_known) { + return Ok(editor_not_dispatched("上一条执行正在等待客户端确认回执")); + } + if pending.is_some() || fence.exists() { + return Ok(editor_reconciliation( + "先前编辑器执行回执尚未确认,禁止自动重发", + )); + } + let id = delivery_id + .map(str::to_string) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&fence) + .map_err(|_| "无法独占保存编辑器执行确认记录,未发送请求")?; + file.write_all(id.as_bytes()) + .and_then(|_| file.sync_all()) + .map_err(|_| "无法持久保存编辑器执行确认记录,未发送请求")?; + *pending = Some(PendingDelivery { + id, + outcome_known: false, + }); + Some(pending) + } else { + None + }; + let result = editor.native_rpc(method, params); + if method != "execute" { + return result; + } + // 原生服务的 Err 仅表示派发前失败;派发后的未知状态必须是结构化结果。 + let mut result = result.unwrap_or_else(|error| editor_not_dispatched(&error)); + if !editor_execute_receipt_is_valid(&result) { + result = editor_reconciliation("原生执行回执格式损坏,禁止自动重发"); + } + let known = result["status"] != "needs-reconciliation"; + if delivery_id.is_some() { + result["ackRequired"] = json!(known); + } + if let Some(pending) = delivery.as_mut() { + if let Some(pending) = pending.as_mut() { + pending.outcome_known = known; + } + if delivery_id.is_none() && known { + if current_config() + .and_then(|config| remove_fence(&editor_execution_fence_path(editor, &config))) + .is_err() + { + return Ok(editor_reconciliation( + "编辑器执行已返回,但确认记录无法提交,请人工核对", + )); + } + **pending = None; + } + } + Ok(result) +} + +pub(crate) fn acknowledge_editor_delivery( + editor: ManagedEditor, + request_id: &str, +) -> Result<(), String> { + let mut pending = pending_delivery(editor) + .try_lock() + .map_err(|_| "编辑器执行尚未结束")?; + if !pending + .as_ref() + .is_some_and(|p| p.id == request_id && p.outcome_known) + { + return Err("编辑器回执确认身份不匹配或执行结果仍不确定".into()); + } + remove_fence(&editor_execution_fence_path(editor, ¤t_config()?))?; + *pending = None; + Ok(()) +} + +pub(crate) fn disconnect_managed_editor_connection(editor: ManagedEditor) -> Result<(), String> { + disconnect_managed_editor_project(editor, None) +} + +pub(crate) fn disconnect_managed_editor_project( + editor: ManagedEditor, + project: Option<&Path>, +) -> Result<(), String> { + if crate::runner::external_agent_runner_is_server_process() { + managed_editor_rpc_owned(editor, "disconnect", json!({"projectPath":project}), None) + .map(|_| ()) + } else { + crate::runner::disconnect_external_managed_editor_project(editor, project) + } +} + +// 现役 Unity 入口共享同一实现,保留其调用方及持久文件名。 +pub(crate) fn unity_execution_fence_path(config: &Path) -> PathBuf { + editor_execution_fence_path(ManagedEditor::Unity, config) +} +pub(crate) fn unity_uncertain_fence_path(config: &Path) -> PathBuf { + editor_uncertain_fence_path(ManagedEditor::Unity, config) +} +pub(crate) fn mark_unity_execution_uncertain_at(config: &Path) -> Result<(), String> { + mark_editor_execution_uncertain_at(ManagedEditor::Unity, config) +} +pub(crate) fn unity_execute_receipt_is_valid(value: &Value) -> bool { + editor_execute_receipt_is_valid(value) +} +pub(crate) fn unity_editor_rpc_owned( + method: &str, + params: Value, + delivery_id: Option<&str>, +) -> Result { + managed_editor_rpc_owned(ManagedEditor::Unity, method, params, delivery_id) +} +pub(crate) fn acknowledge_unity_editor_delivery(id: &str) -> Result<(), String> { + acknowledge_editor_delivery(ManagedEditor::Unity, id) +} +pub(crate) fn execute_unity_editor_code(root: &Path, code: &str) -> Result { + execute_managed_editor_code(ManagedEditor::Unity, root, code) +} +pub(crate) fn execute_godot_editor_code(root: &Path, code: &str) -> Result { + execute_managed_editor_code(ManagedEditor::Godot, root, code) +} +pub(crate) fn disconnect_unity_editor_connection() { + let _ = disconnect_managed_editor_connection(ManagedEditor::Unity); +} + +#[cfg(test)] +pub(super) fn unity_pending_delivery() -> &'static Mutex> { + pending_delivery(ManagedEditor::Unity) +} +#[cfg(test)] +pub(crate) fn unity_delivery_requires_ack(id: &str) -> bool { + pending_delivery(ManagedEditor::Unity) + .try_lock() + .ok() + .is_some_and(|p| p.as_ref().is_some_and(|p| p.id == id && p.outcome_known)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn editor_receipts_require_complete_outcomes_and_accept_explicit_null() { + assert!(editor_execute_receipt_is_valid( + &json!({"ok":true,"status":"completed","dispatched":true,"retryAllowed":false,"result":null}) + )); + assert!(!editor_execute_receipt_is_valid( + &json!({"ok":true,"status":"completed","dispatched":true,"retryAllowed":false}) + )); + assert!(!editor_execute_receipt_is_valid( + &json!({"ok":false,"status":"needs-reconciliation","dispatched":false,"retryAllowed":false,"error":{"code":"timeout","message":"lost"}}) + )); + for status in ["completed", "failed", "needs-reconciliation"] { + assert!(!editor_execute_receipt_is_valid( + &json!({"ok":status=="completed","status":status,"dispatched":true,"retryAllowed":false,"result":null,"error":{"code":"conflict","message":"both"}}) + )); + } + } + + #[test] + fn godot_cleanup_ownership_survives_ack_and_fresh_host_fence_reset() { + let config = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + update_godot_authorized_project_at(config.path(), project.path(), true).unwrap(); + let root = project.path().canonicalize().unwrap(); + assert_eq!( + godot_cleanup_projects_at(config.path(), None).unwrap(), + vec![root.clone()] + ); + reset_editor_execution_fences_for_fresh_gui(config.path()).unwrap(); + assert_eq!( + godot_authorized_projects_at(config.path()).unwrap(), + vec![root] + ); + update_godot_authorized_project_at(config.path(), project.path(), false).unwrap(); + assert!(godot_authorized_projects_at(config.path()) + .unwrap() + .is_empty()); + } + + #[test] + fn independent_editors_cannot_acknowledge_or_clear_each_others_delivery() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + let previous = crate::game_creator_runtime_config_dir_lock() + .lock() + .unwrap() + .replace(config.path().to_path_buf()); + let godot = ManagedEditor::Godot; + let unity = ManagedEditor::Unity; + std::fs::write( + editor_execution_fence_path(godot, config.path()), + "godot-id", + ) + .unwrap(); + std::fs::write( + editor_execution_fence_path(unity, config.path()), + "unity-id", + ) + .unwrap(); + *pending_delivery(godot).lock().unwrap() = Some(PendingDelivery { + id: "godot-id".into(), + outcome_known: true, + }); + *pending_delivery(unity).lock().unwrap() = Some(PendingDelivery { + id: "unity-id".into(), + outcome_known: true, + }); + assert!(acknowledge_editor_delivery(godot, "unity-id").is_err()); + assert!(editor_execution_fence_path(godot, config.path()).exists()); + acknowledge_editor_delivery(godot, "godot-id").unwrap(); + assert!(editor_execution_fence_path(unity, config.path()).exists()); + acknowledge_editor_delivery(unity, "unity-id").unwrap(); + *crate::game_creator_runtime_config_dir_lock() + .lock() + .unwrap() = previous; + } + + #[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] + #[test] + fn godot_dispatch_rejects_disabled_project_and_requires_matching_ack_for_preflight_error() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("project.godot"), "config_version=5\n").unwrap(); + let previous = crate::game_creator_runtime_config_dir_lock() + .lock() + .unwrap() + .replace(config.path().to_path_buf()); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let params = json!({"projectPath":project.path(),"code":""}); + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID, + false, + ) + .unwrap(); + assert!(managed_editor_rpc_owned( + ManagedEditor::Godot, + "execute", + params.clone(), + Some("disabled") + ) + .is_err()); + assert!(!editor_execution_fence_path(ManagedEditor::Godot, config.path()).exists()); + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID, + true, + ) + .unwrap(); + let reply = managed_editor_rpc_owned( + ManagedEditor::Godot, + "execute", + params, + Some("invalid-code"), + ) + .unwrap(); + assert_eq!(reply["dispatched"], false); + assert_eq!(reply["ackRequired"], true); + assert!(acknowledge_editor_delivery(ManagedEditor::Godot, "other").is_err()); + acknowledge_editor_delivery(ManagedEditor::Godot, "invalid-code").unwrap(); + *crate::game_creator_runtime_config_dir_lock() + .lock() + .unwrap() = previous; + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs index d523dc20a..5c7337494 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs @@ -46,6 +46,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[ "command.stdin", "cocos.editor.execute", "unity.editor.execute", + "godot.editor.execute", "preview.start", "agent.delegate", "agent.spawn_isolated", @@ -68,6 +69,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS: &[&str] = &[ "command.stdin", "cocos.editor.execute", "unity.editor.execute", + "godot.editor.execute", "preview.start", "agent.delegate", "agent.spawn_isolated", diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 5b806ed6e..a7cb9b8b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1,5 +1,9 @@ #![cfg_attr(all(not(dev), target_os = "windows"), windows_subsystem = "windows")] +#[cfg(test)] +#[path = "../build_support/godot_bundle.rs"] +mod godot_bundle; + use std::collections::BTreeMap; use std::fs; use std::fs::{File, OpenOptions}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 71117b0df..1166d26bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -10,6 +10,7 @@ use std::fs; use std::io::{BufRead, BufReader, Write}; use std::path::{Component, Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::sync::Arc; use std::sync::Mutex; @@ -159,10 +160,18 @@ struct RunningPlugin { pending: PendingRpc, registrations: Arc>, next_request_id: u64, + editor_context: Option, + active: Arc, } impl Drop for RunningPlugin { fn drop(&mut self) { + self.active.store(false, Ordering::SeqCst); + if let Some(context) = &self.editor_context { + if let Ok(mut project) = context.try_lock() { + *project = None; + } + } #[cfg(unix)] unsafe { libc::kill(-(self.child.id() as i32), libc::SIGKILL); @@ -655,6 +664,8 @@ fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result Result, St } fn write_rpc(stdin: &mut ChildStdin, value: &Value) -> Result<(), String> { - let payload = - serde_json::to_string(value).map_err(|error| format!("序列化插件 RPC 失败:{error}"))?; - if payload.len() > MAX_RPC_BYTES { - return Err("插件 RPC 请求过大".to_string()); - } - writeln!(stdin, "{payload}").map_err(|error| format!("写入插件 RPC 失败:{error}"))?; + let payload = serialize_rpc(value)?; + stdin + .write_all(&payload) + .map_err(|error| format!("写入插件 RPC 失败:{error}"))?; stdin .flush() .map_err(|error| format!("刷新插件 RPC 失败:{error}")) } +fn serialize_rpc(value: &Value) -> Result, String> { + let mut payload = + serde_json::to_vec(value).map_err(|error| format!("序列化插件 RPC 失败:{error}"))?; + if payload.len() > MAX_RPC_BYTES { + return Err("插件 RPC 请求过大".to_string()); + } + payload.push(b'\n'); + Ok(payload) +} + +fn write_prepared_rpc( + writer: &mut impl Write, + payload: &[u8], + phase: &AtomicU8, +) -> Result<(), String> { + // 0=未写,1=可能已写,2=截止前取消;取消后后台线程不得补发。 + phase + .compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst) + .map_err(|_| "插件 RPC 已在写入前取消".to_string())?; + writer + .write_all(payload) + .and_then(|_| writer.flush()) + .map_err(|error| format!("写入插件 RPC 失败:{error}")) +} + +fn cancel_rpc_before_write(phase: &AtomicU8) -> bool { + phase + .compare_exchange(0, 2, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() +} + +fn finalize_managed_plugin_result( + phase: &AtomicU8, + result: Result, + mark_uncertain: impl FnOnce() -> Result<(), String>, +) -> Result { + if phase.load(Ordering::SeqCst) == 1 + && !result.as_ref().is_ok_and(|value| { + crate::editor_adapters::editor_execute_receipt_is_valid(value) + && value["status"] != "needs-reconciliation" + }) + { + let message = if mark_uncertain().is_ok() { + "插件执行回执丢失或无效,请人工核对,禁止重放" + } else { + "插件执行结果待核对,持久阻断记录未能确认,请停止执行并人工核对" + }; + Ok(crate::editor_adapters::editor_reconciliation(message)) + } else { + result + } +} + fn write_rpc_shared(stdin: &Arc>, value: &Value) -> Result<(), String> { let mut stdin = stdin .lock() @@ -778,13 +840,14 @@ fn require_plugin_adapter(id: &str, editors: &EditorRegistry) -> Result<(), Stri let adapter = match id { crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID => "cocos-editor", crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID => "unity-editor", + crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID => "godot-editor", _ => return Ok(()), }; if !has_editor_adapter(editors, adapter)? { - let name = if adapter == "cocos-editor" { - "Cocos" - } else { - "Unity" + let name = match adapter { + "cocos-editor" => "Cocos", + "unity-editor" => "Unity", + _ => "Godot", }; return Err(format!("当前客户端不支持 {name} 编辑器桥接")); } @@ -805,6 +868,12 @@ fn plugin_matches_project(id: &str, project: Option<&Path>) -> bool { .flatten() .is_some() }), + crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID => project.is_some_and(|path| { + crate::project::discover_local_godot_project_root(path) + .ok() + .flatten() + .is_some() + }), _ => true, } } @@ -1174,14 +1243,33 @@ impl PluginHost { if !crate::builtin_plugins::is_builtin(id) { return Err("只有内置插件可以使用可用开关;导入扩展请使用扩展启用状态".to_string()); } + let mut cleanup = Ok(()); if !enabled { let _ = self.stop(id); - if id == crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID { - crate::editor_adapters::disconnect_unity_editor_connection(); + if let Some(editor) = crate::editor_adapters::ManagedEditor::for_plugin(id) { + let state = self + .state + .lock() + .map_err(|_| "插件宿主锁已损坏".to_string())?; + let project = state + .active_project + .lock() + .map_err(|_| "项目上下文锁已损坏".to_string())? + .clone(); + drop(state); + cleanup = crate::editor_adapters::disconnect_managed_editor_connection(editor) + .and_then(|_| { + crate::editor_adapters::disconnect_managed_editor_project( + editor, + project.as_deref(), + ) + }); } } crate::builtin_plugins::set_enabled(id, enabled)?; - self.refresh() + let summaries = self.refresh()?; + cleanup.map_err(|error| format!("插件已禁用,编辑器资源暂未清理:{error}"))?; + Ok(summaries) } pub(crate) fn read_panel( @@ -1235,7 +1323,7 @@ impl PluginHost { } pub(crate) fn call(&self, id: &str, method: String, params: Value) -> Result { - let (root, request_id, response_receiver, pending, writer, response_timeout) = { + let (root, request_id, response_receiver, pending, writer, response_timeout, payload) = { let mut state = self .state .lock() @@ -1263,6 +1351,9 @@ impl PluginHost { .checked_add(1) .ok_or_else(|| "插件 RPC id 已耗尽".to_string())?; let (sender, receiver) = mpsc::channel(); + let payload = serialize_rpc( + &json!({"jsonrpc":"2.0", "id":request_id, "method":method, "params":params}), + )?; { let mut pending = running .pending @@ -1280,22 +1371,31 @@ impl PluginHost { Arc::clone(&running.pending), Arc::clone(&running.stdin), response_timeout, + payload, ) }; let deadline = Instant::now() + response_timeout; - let unity_execute = id == crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID - && method == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME; + let managed_execute = + crate::editor_adapters::ManagedEditor::for_plugin(id).filter(|editor| match editor { + crate::editor_adapters::ManagedEditor::Unity => { + method == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME + } + crate::editor_adapters::ManagedEditor::Godot => { + method == crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME + } + }); let (write_sender, write_receiver) = mpsc::channel(); + let write_phase = Arc::new(AtomicU8::new(0)); + let phase = Arc::clone(&write_phase); thread::spawn(move || { - let _ = write_sender.send(write_rpc_shared( - &writer, - &json!({"jsonrpc":"2.0", "id":request_id, "method":method, "params":params}), - )); + let result = writer + .lock() + .map_err(|_| "插件 stdin 锁已损坏".to_string()) + .and_then(|mut writer| write_prepared_rpc(&mut *writer, &payload, &phase)); + let _ = write_sender.send(result); }); - let mut command_sent = false; - let result = match write_receiver.recv_timeout(RPC_TIMEOUT) { + let mut result = match write_receiver.recv_timeout(RPC_TIMEOUT) { Ok(Ok(())) => { - command_sent = true; match response_receiver .recv_timeout(deadline.saturating_duration_since(Instant::now())) { @@ -1306,6 +1406,7 @@ impl PluginHost { } Ok(Err(error)) => Err(error), Err(_) => { + cancel_rpc_before_write(&write_phase); self.terminate_rpc_instance(id, &pending); Err("插件 RPC 写入超时".to_string()) } @@ -1313,15 +1414,11 @@ impl PluginHost { if let Ok(mut pending) = pending.lock() { pending.remove(&request_id); } - if unity_execute - && command_sent - && !result.as_ref().is_ok_and(|value| { - crate::editor_adapters::unity_execute_receipt_is_valid(value) - && value["status"] != "needs-reconciliation" - }) - { - // Unity 已知结果到 JS / 调用者的最后一跳丢失同样不可通过重载插件重试。 - let _ = crate::runner::mark_external_unity_editor_uncertain(); + if let Some(editor) = managed_execute { + // 最后一跳丢失同样不能通过插件重载解除执行阻断。 + result = finalize_managed_plugin_result(&write_phase, result, || { + crate::runner::mark_external_editor_uncertain(editor) + }); } audit( &root, @@ -1364,10 +1461,26 @@ impl PluginHost { let writer = Arc::clone(&running.stdin); let pending = Arc::clone(&running.pending); let registrations = Arc::clone(&running.registrations); + let active = Arc::clone(&running.active); + let active_project = if record.manifest.adapter.is_some() { + let context = Arc::new(Mutex::new( + active_project + .lock() + .ok() + .and_then(|project| project.clone()), + )); + running.editor_context = Some(Arc::clone(&context)); + context + } else { + active_project + }; let manifest = record.manifest.clone(); let root = root.to_path_buf(); thread::spawn(move || { while let Ok(line) = lines.recv() { + if !active.load(Ordering::SeqCst) { + break; + } let Ok(envelope) = serde_json::from_str::(&line) else { continue; }; @@ -1672,6 +1785,23 @@ impl PluginHost { } pub(crate) fn set_active_project(&self, project_path: Option) -> Result<(), String> { + self.set_active_project_with_cleanup(project_path, |previous| { + crate::editor_adapters::disconnect_unity_editor_connection(); + if previous.is_some() { + crate::editor_adapters::disconnect_managed_editor_project( + crate::editor_adapters::ManagedEditor::Godot, + previous, + )?; + } + Ok(()) + }) + } + + fn set_active_project_with_cleanup( + &self, + project_path: Option, + cleanup: impl FnOnce(Option<&Path>) -> Result<(), String>, + ) -> Result<(), String> { let mut state = self .state .lock() @@ -1692,7 +1822,19 @@ impl PluginHost { .map_err(|_| "项目上下文锁已损坏".to_string())? .clone(); if previous != project { - crate::editor_adapters::disconnect_unity_editor_connection(); + // 先撤销旧编辑器进程的独立上下文,再更新可见项目;清理失败不能恢复旧授权。 + for record in state + .plugins + .values_mut() + .filter(|record| record.manifest.adapter.is_some()) + { + if let Some(running) = record.running.take() { + drop(running); + } + if record.manifest.enabled { + record.status = "stopped".to_string(); + } + } } *state .active_project @@ -1738,6 +1880,11 @@ impl PluginHost { } } } + drop(state); + if previous != project { + cleanup(previous.as_deref()) + .map_err(|error| format!("当前项目已切换,旧编辑器桥仍待清理:{error}"))?; + } Ok(()) } @@ -1809,6 +1956,22 @@ impl PluginHost { .editors .try_lock() .map_err(|_| "编辑器注册表锁已损坏".to_string())?; + if adapter == "godot-editor" { + if !editors.contains_key(&adapter) { + return Err(format!("未知编辑器适配器:{adapter}")); + } + let project = state + .active_project + .lock() + .map_err(|_| "项目上下文锁已损坏".to_string())? + .clone(); + drop(editors); + drop(state); + return crate::editor_adapters::disconnect_managed_editor_project( + crate::editor_adapters::ManagedEditor::Godot, + project.as_deref(), + ); + } editors .get_mut(&adapter) .ok_or_else(|| format!("未知编辑器适配器:{adapter}"))? @@ -1930,6 +2093,87 @@ pub(crate) async fn set_agc_plugin_project_path( #[cfg(test)] mod tests { use super::*; + + #[test] + fn writer_receipt_loss_after_json_write_is_persistently_uncertain() { + let config = tempfile::tempdir().unwrap(); + let phase = AtomicU8::new(0); + let payload = serialize_rpc(&json!({"jsonrpc":"2.0","id":1,"method":"godot.editor.execute","params":{"code":"return 42"}})).unwrap(); + let mut sink = Vec::new(); + write_prepared_rpc(&mut sink, &payload, &phase).unwrap(); + // JSON 已完整到达对端,但 writer 的最后一跳完成通知丢失。 + assert_eq!(sink, payload); + assert!(!cancel_rpc_before_write(&phase)); + let result = + finalize_managed_plugin_result(&phase, Err("writer 回执丢失".into()), || { + crate::editor_adapters::mark_editor_execution_uncertain_at( + crate::editor_adapters::ManagedEditor::Godot, + config.path(), + ) + }) + .unwrap(); + assert_eq!(result["status"], "needs-reconciliation"); + assert!(crate::editor_adapters::editor_uncertain_fence_path( + crate::editor_adapters::ManagedEditor::Godot, + config.path() + ) + .exists()); + } + + #[test] + fn writer_cancelled_before_start_never_dispatches_later() { + let phase = AtomicU8::new(0); + assert!(cancel_rpc_before_write(&phase)); + let mut sink = Vec::new(); + assert!(write_prepared_rpc(&mut sink, b"{}\n", &phase).is_err()); + assert!(sink.is_empty()); + assert!( + finalize_managed_plugin_result(&phase, Err("未发送".into()), || panic!( + "不应标记已派发" + )) + .is_err() + ); + assert!(serialize_rpc(&json!({"code":"x".repeat(MAX_RPC_BYTES)})).is_err()); + } + + #[test] + fn project_switch_cleanup_failure_keeps_new_project_and_revokes_old_editor_process() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + let old = tempfile::tempdir().unwrap(); + let new = tempfile::tempdir().unwrap(); + fs::write(old.path().join("project.godot"), "config_version=5\n").unwrap(); + fs::write(new.path().join("project.godot"), "config_version=5\n").unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let host = PluginHost::default(); + host.initialize(config.path()).unwrap(); + host.register_editor_adapter(Box::new(StubManagedAdapter("godot-editor"))) + .unwrap(); + host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins")) + .unwrap(); + host.set_active_project_with_cleanup(Some(old.path().to_string_lossy().into()), |_| Ok(())) + .unwrap(); + host.start("agc-godot-editor").unwrap(); + let context = host.state.lock().unwrap().plugins["agc-godot-editor"] + .running + .as_ref() + .unwrap() + .editor_context + .clone() + .unwrap(); + let result = host + .set_active_project_with_cleanup(Some(new.path().to_string_lossy().into()), |_| { + Err("旧桥清理失败".into()) + }); + assert!(result.unwrap_err().contains("已切换")); + let state = host.state.lock().unwrap(); + assert_eq!( + *state.active_project.lock().unwrap(), + Some(new.path().canonicalize().unwrap()) + ); + assert!(state.plugins["agc-godot-editor"].running.is_none()); + assert!(context.lock().unwrap().is_none()); + } use tempfile::tempdir; fn manifest() -> PluginManifest { @@ -2189,14 +2433,14 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p struct StubCocosAdapter; - struct StubUnityAdapter; + struct StubManagedAdapter(&'static str); - impl EditorAdapter for StubUnityAdapter { + impl EditorAdapter for StubManagedAdapter { fn id(&self) -> &'static str { - "unity-editor" + self.0 } fn detect(&self, _project_path: &Path) -> Result { - Ok(EditorConnectionInfo::disconnected("unity-editor")) + Ok(EditorConnectionInfo::disconnected(self.0)) } fn connect( &mut self, @@ -2218,71 +2462,82 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p } #[test] - fn workspace_unity_plugin_round_trips_and_stops_when_leaving_project() { - let _guard = crate::builtin_plugins::test_lock(); - let config = tempdir().unwrap(); - let project = tempdir().unwrap(); - for directory in ["Assets", "Packages", "ProjectSettings"] { - fs::create_dir(project.path().join(directory)).unwrap(); - } - fs::write( - project.path().join("ProjectSettings/ProjectVersion.txt"), - "m_EditorVersion: 6000.0.1f1", - ) - .unwrap(); - crate::builtin_plugins::initialize(config.path()).unwrap(); - let host = PluginHost::default(); - host.initialize(config.path()).unwrap(); - host.register_editor_adapter(Box::new(StubUnityAdapter)) - .unwrap(); - host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins")) - .unwrap(); - assert!(!host - .list() - .unwrap() - .iter() - .any(|plugin| plugin.id == "agc-unity-editor")); - host.set_active_project(Some(project.path().to_string_lossy().into_owned())) - .unwrap(); - host.start("agc-unity-editor").unwrap(); - let deadline = Instant::now() + Duration::from_secs(10); - loop { - if host.list().unwrap().iter().any(|plugin| { - plugin.id == "agc-unity-editor" - && plugin.commands.len() == 1 - && plugin.capabilities.len() == 1 - }) { - break; + fn workspace_editor_plugins_round_trip_and_stop_when_leaving_project() { + for (plugin_id, adapter_id, execute_tool) in [ + ("agc-unity-editor", "unity-editor", "unity.editor.execute"), + ("agc-godot-editor", "godot-editor", "godot.editor.execute"), + ] { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempdir().unwrap(); + let project = tempdir().unwrap(); + if adapter_id == "unity-editor" { + for directory in ["Assets", "Packages", "ProjectSettings"] { + fs::create_dir(project.path().join(directory)).unwrap(); + } + fs::write( + project.path().join("ProjectSettings/ProjectVersion.txt"), + "m_EditorVersion: 6000.0.1f1", + ) + .unwrap(); + } else { + fs::write(project.path().join("project.godot"), "config_version=5\n").unwrap(); } - assert!(Instant::now() < deadline); - thread::sleep(Duration::from_millis(20)); - } - let response = host - .call( - "agc-unity-editor", - "unity.editor.execute".to_string(), - json!({"code":"return 2;"}), + crate::builtin_plugins::initialize(config.path()).unwrap(); + let host = PluginHost::default(); + host.initialize(config.path()).unwrap(); + host.register_editor_adapter(Box::new(StubManagedAdapter(adapter_id))) + .unwrap(); + host.set_plugin_workspace( + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"), ) .unwrap(); - assert_eq!(response["status"], "completed"); - assert_eq!( - response["result"]["projectPath"], - project - .path() - .canonicalize() + assert!(!host + .list() .unwrap() - .to_string_lossy() - .as_ref() - ); - host.set_active_project(None).unwrap(); - assert!(!host - .list() - .unwrap() - .iter() - .any(|plugin| plugin.id == "agc-unity-editor")); - assert!(host.state.lock().unwrap().plugins["agc-unity-editor"] - .running - .is_none()); + .iter() + .any(|plugin| plugin.id == plugin_id)); + host.set_active_project(Some(project.path().to_string_lossy().into_owned())) + .unwrap(); + host.start(plugin_id).unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if host.list().unwrap().iter().any(|plugin| { + plugin.id == plugin_id + && plugin.commands.len() == 1 + && plugin.capabilities.len() == 1 + }) { + break; + } + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(20)); + } + let response = host + .call( + plugin_id, + execute_tool.to_string(), + json!({"code":"return 2"}), + ) + .unwrap(); + assert_eq!(response["status"], "completed"); + assert_eq!( + response["result"]["projectPath"], + project + .path() + .canonicalize() + .unwrap() + .to_string_lossy() + .as_ref() + ); + host.set_active_project(None).unwrap(); + assert!(!host + .list() + .unwrap() + .iter() + .any(|plugin| plugin.id == plugin_id)); + assert!(host.state.lock().unwrap().plugins[plugin_id] + .running + .is_none()); + } } impl EditorAdapter for StubCocosAdapter { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index c3a15f007..d20a5f1f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -24,8 +24,8 @@ pub(crate) use client::{ wake_external_agent_runner_pending_for_run, }; pub(crate) use client::{ - call_external_unity_editor, disconnect_external_unity_editor, - mark_external_unity_editor_uncertain, + call_external_managed_editor, disconnect_external_managed_editor, + disconnect_external_managed_editor_project, mark_external_editor_uncertain, }; #[cfg(windows)] pub(crate) use endpoint::validate_windows_regular_file_handle; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index f0e924543..72ee2dc83 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -509,7 +509,7 @@ fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts( pub(super) fn external_agent_runner_client_read_timeout(method: &str) -> Duration { match method { "runtime.compact" => EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT, - "unity.editor.rpc" => Duration::from_secs(80), + "unity.editor.rpc" | "godot.editor.rpc" => Duration::from_secs(80), _ => EXTERNAL_AGENT_RUNNER_IO_TIMEOUT, } } @@ -1680,21 +1680,33 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( } } -pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Result { +pub(crate) fn call_external_managed_editor( + editor: crate::editor_adapters::ManagedEditor, + method: &str, + mut params: Value, +) -> Result { let deadline = Instant::now() + Duration::from_secs(80); - static EXECUTION_UNCERTAIN: std::sync::atomic::AtomicBool = + static UNITY_UNCERTAIN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + static GODOT_UNCERTAIN: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + let execution_uncertain = match editor { + crate::editor_adapters::ManagedEditor::Unity => &UNITY_UNCERTAIN, + crate::editor_adapters::ManagedEditor::Godot => &GODOT_UNCERTAIN, + }; let config_dir = external_agent_runner_config_dir().ok_or("外部 Agent Runner 尚未配置")?; - let uncertain_result = || serde_json::json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"dispatched":true,"error":{"code":"runner-receipt-unconfirmed","message":"Unity 执行回执未确认,核对后退出全部 AGC 和 Runner 再重新打开"}}); - if method == "execute" && EXECUTION_UNCERTAIN.load(std::sync::atomic::Ordering::SeqCst) { + let uncertain_result = || serde_json::json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"dispatched":true,"error":{"code":"runner-receipt-unconfirmed","message":"编辑器 执行回执未确认,核对后退出全部 AGC 和 Runner 再重新打开"}}); + if method == "execute" && execution_uncertain.load(std::sync::atomic::Ordering::SeqCst) { return Ok(uncertain_result()); } if method == "execute" - && crate::editor_adapters::unity_uncertain_fence_path(&config_dir).exists() + && crate::editor_adapters::editor_uncertain_fence_path(editor, &config_dir).exists() { return Ok(uncertain_result()); } - let endpoint = if crate::editor_adapters::unity_execution_fence_path(&config_dir).exists() { + let endpoint = if crate::editor_adapters::editor_execution_fence_path(editor, &config_dir) + .exists() + { // 在途 fence 可能只是正常并发;由活着的 owner 区分 busy 与 unknown。 // 此分支绝不自动重启 Runner,以免丢失未确认执行的进程内状态。 match read_external_agent_runner_endpoint(&external_agent_runner_endpoint_path(&config_dir)) @@ -1707,7 +1719,7 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res let _configure = match external_agent_runner_configure_lock().try_lock() { Ok(guard) => guard, Err(_) if method == "execute" => { - return Ok(crate::editor_adapters::unity_not_dispatched( + return Ok(crate::editor_adapters::editor_not_dispatched( "Runner 正在配置,请等待当前操作完成", )) } @@ -1718,11 +1730,11 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res let remaining = deadline.saturating_duration_since(Instant::now()); if remaining < Duration::from_secs(16) { return if method == "execute" { - Ok(crate::editor_adapters::unity_not_dispatched( - "Unity 调用启动预算已耗尽,未派发执行", + Ok(crate::editor_adapters::editor_not_dispatched( + "编辑器 调用启动预算已耗尽,未派发执行", )) } else { - Err("Unity 调用启动预算已耗尽".to_string()) + Err("编辑器 调用启动预算已耗尽".to_string()) }; } if let Some(params) = params.as_object_mut() { @@ -1732,7 +1744,7 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res .is_some_and(|timeout| (1..=60_000).contains(&timeout)) }) { return if method == "execute" { - Ok(crate::editor_adapters::unity_not_dispatched( + Ok(crate::editor_adapters::editor_not_dispatched( "timeoutMs 必须在 1..=60000", )) } else { @@ -1746,7 +1758,13 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res let bounded = requested.min(remaining.as_millis().saturating_sub(15_000) as u64); params.insert("timeoutMs".to_string(), serde_json::json!(bounded)); } - let request_id = random_identifier(b"agc-unity-editor-request")?; + let request_id = random_identifier(b"agc-editor-request")?; + // 所有可能失败的随机身份生成必须在真实执行派发前完成。 + let acknowledgement_id = random_identifier(b"agc-editor-ack")?; + let persist_uncertain = || { + execution_uncertain.store(true, std::sync::atomic::Ordering::SeqCst); + let _ = crate::editor_adapters::mark_editor_execution_uncertain_at(editor, &config_dir); + }; let request_params = ExternalAgentRunnerRequestParams { editor_rpc: Some( serde_json::json!({"method":method,"params":params,"deadlineMs":unix_millis()+remaining.as_millis() as u64}), @@ -1758,7 +1776,7 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res &endpoint, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, request_id.clone(), - "unity.editor.rpc", + editor.rpc_method(), request_params, Duration::from_secs(2), remaining.saturating_sub(Duration::from_secs(7)), @@ -1767,17 +1785,16 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res match response { Ok(mut value) => { if method == "execute" { - if !crate::editor_adapters::unity_execute_receipt_is_valid(&value) { - EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + if !crate::editor_adapters::editor_execute_receipt_is_valid(&value) { + persist_uncertain(); return Ok(uncertain_result()); } if value["status"] == "needs-reconciliation" { - EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + persist_uncertain(); return Ok(value); } let Some(ack_required) = value.get("ackRequired").and_then(Value::as_bool) else { - EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); - let _ = crate::editor_adapters::mark_unity_execution_uncertain_at(&config_dir); + persist_uncertain(); return Ok(uncertain_result()); }; if let Some(object) = value.as_object_mut() { @@ -1787,15 +1804,15 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res return Ok(value); } if Instant::now() + Duration::from_secs(3) >= deadline { - EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + persist_uncertain(); return Ok(uncertain_result()); } let acknowledgement = send_external_agent_runner_request_with_protocol_and_id_and_timeouts( &endpoint, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, - random_identifier(b"agc-unity-ack")?, - "unity.editor.ack", + acknowledgement_id, + editor.ack_method(), ExternalAgentRunnerRequestParams { editor_rpc: Some(serde_json::json!({"requestId":request_id})), ..Default::default() @@ -1805,25 +1822,47 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res Duration::from_millis(500), ); if !acknowledgement.is_ok_and(|response| response["acknowledged"] == true) { - EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); - let _ = crate::editor_adapters::mark_unity_execution_uncertain_at(&config_dir); + persist_uncertain(); return Ok(uncertain_result()); } } Ok(value) } Err(_) if method == "execute" => { - EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst); + persist_uncertain(); Ok(uncertain_result()) } Err(error) => Err(error), } } -pub(crate) fn disconnect_external_unity_editor() -> Result<(), String> { +pub(crate) fn disconnect_external_managed_editor( + editor: crate::editor_adapters::ManagedEditor, +) -> Result<(), String> { + disconnect_external_managed_editor_project(editor, None) +} + +pub(crate) fn disconnect_external_managed_editor_project( + editor: crate::editor_adapters::ManagedEditor, + project: Option<&Path>, +) -> Result<(), String> { let Some(config_dir) = external_agent_runner_config_dir() else { - return Ok(()); + return if editor == crate::editor_adapters::ManagedEditor::Godot + && project + .map(crate::editor_adapters::godot_project_cleanup_required) + .transpose()? + .unwrap_or(false) + { + Err("Godot 清理宿主尚未初始化,无法确认旧桥已卸载".into()) + } else { + Ok(()) + }; }; + if editor == crate::editor_adapters::ManagedEditor::Godot { + return disconnect_external_godot_projects(&config_dir, project, |params| { + call_external_managed_editor(editor, "disconnect", params) + }); + } let path = external_agent_runner_endpoint_path(&config_dir); if !path.exists() { return Ok(()); @@ -1831,7 +1870,7 @@ pub(crate) fn disconnect_external_unity_editor() -> Result<(), String> { let endpoint = read_external_agent_runner_endpoint(&path)?; send_external_agent_runner_request( &endpoint, - "unity.editor.rpc", + editor.rpc_method(), ExternalAgentRunnerRequestParams { editor_rpc: Some(serde_json::json!({"method":"disconnect","params":{}})), ..Default::default() @@ -1840,17 +1879,72 @@ pub(crate) fn disconnect_external_unity_editor() -> Result<(), String> { .map(|_| ()) } -pub(crate) fn mark_external_unity_editor_uncertain() -> Result<(), String> { +fn disconnect_external_godot_projects( + config: &Path, + explicit: Option<&Path>, + mut cleanup: impl FnMut(Value) -> Result, +) -> Result<(), String> { + // endpoint 丢失不等于编辑器桥消失;用持久授权根启动原 owner 的清理流程。 + for project in crate::editor_adapters::godot_cleanup_projects_at(config, explicit)? { + let result = cleanup(serde_json::json!({"projectPath":project}))?; + if result["adapter"] != "godot-editor" + || result["connected"] != false + || result.get("error").is_some() + || result.get("accepted").is_some() + || result["status"] == "needs-reconciliation" + { + return Err("Godot 原生桥尚未确认卸载".into()); + } + } + Ok(()) +} + +#[cfg(test)] +mod managed_cleanup_tests { + use super::*; + + #[test] + fn godot_cleanup_recovers_authorized_projects_without_runner_endpoint() { + let config = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let expected = project.path().canonicalize().unwrap(); + fs::write( + config.path().join("godot-editor-authorized-projects.json"), + serde_json::json!({ + "schemaVersion":"agc.godot.authorized-projects.v1", "projects":[expected] + }) + .to_string(), + ) + .unwrap(); + assert!(!external_agent_runner_endpoint_path(config.path()).exists()); + let mut called = false; + let result = disconnect_external_godot_projects(config.path(), None, |params| { + called = true; + assert_eq!(params["projectPath"], serde_json::json!(expected)); + Ok(serde_json::json!({"accepted":true,"status":"shutting-down"})) + }); + assert!(called); + assert!(result.is_err()); + assert_eq!( + crate::editor_adapters::godot_authorized_projects_at(config.path()).unwrap(), + vec![expected] + ); + } +} + +pub(crate) fn mark_external_editor_uncertain( + editor: crate::editor_adapters::ManagedEditor, +) -> Result<(), String> { let config_dir = external_agent_runner_config_dir().ok_or("外部 Agent Runner 尚未配置")?; // 先保存 GUI 与 Runner 共享的单向 fence;网络丢失也不能解锁。 - crate::editor_adapters::mark_unity_execution_uncertain_at(&config_dir)?; + crate::editor_adapters::mark_editor_execution_uncertain_at(editor, &config_dir)?; let endpoint = read_external_agent_runner_endpoint(&external_agent_runner_endpoint_path(&config_dir))?; send_external_agent_runner_request_with_protocol_and_id_and_timeouts( &endpoint, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, - random_identifier(b"agc-unity-mark-uncertain")?, - "unity.editor.mark_uncertain", + random_identifier(b"agc-editor-mark-uncertain")?, + editor.mark_method(), ExternalAgentRunnerRequestParams::default(), Duration::from_millis(500), Duration::from_secs(1), diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 62659b134..0d71971be 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -236,7 +236,9 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current( } fn external_agent_runner_method_requires_current_gui_owner_claim(method: &str) -> bool { - method.starts_with("runtime.") || method.starts_with("unity.editor.") + method.starts_with("runtime.") + || method.starts_with("unity.editor.") + || method.starts_with("godot.editor.") } pub(super) fn external_agent_runner_request_session_id( @@ -1031,8 +1033,10 @@ pub(super) fn handle_external_agent_runner_request( } match request.method.as_str() { - // 编辑器使用自身的有界并发门闩;不能持有 Runtime 全局写请求缓存锁等待 Unity。 - "unity.editor.rpc" => { + // 编辑器使用自身的有界并发门闩;不能持有 Runtime 全局写请求缓存锁等待 编辑器。 + "unity.editor.rpc" | "godot.editor.rpc" => { + let editor = crate::editor_adapters::ManagedEditor::from_rpc_method(&request.method) + .expect("matched editor RPC"); #[derive(Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct EditorCall { @@ -1048,14 +1052,15 @@ pub(super) fn handle_external_agent_runner_request( let call: EditorCall = serde_json::from_value( request.params.editor_rpc.clone().ok_or("缺少 editorRpc")?, ) - .map_err(|_| "Unity RPC 参数无效".to_string())?; + .map_err(|_| "编辑器 RPC 参数无效".to_string())?; if call .deadline_ms .is_some_and(|deadline| deadline <= unix_millis()) { - return Err("Unity RPC 派发期限已过,未发送执行".to_string()); + return Err("编辑器 RPC 派发期限已过,未发送执行".to_string()); } - crate::editor_adapters::unity_editor_rpc_owned( + crate::editor_adapters::managed_editor_rpc_owned( + editor, &call.method, call.params, Some(&request.request_id), @@ -1084,25 +1089,27 @@ pub(super) fn handle_external_agent_runner_request( .and_then(|value| value["method"].as_str()) == Some("execute") => { - let mut value = crate::editor_adapters::unity_not_dispatched(&error); + let mut value = crate::editor_adapters::editor_not_dispatched(&error); value["ackRequired"] = json!(false); ExternalAgentRunnerResponse::success(&request.request_id, value) } Err(error) => ExternalAgentRunnerResponse::failure( &request.request_id, - "unity-editor-failed", + "editor-rpc-failed", error, ), } } - "unity.editor.ack" => { + "unity.editor.ack" | "godot.editor.ack" => { + let editor = crate::editor_adapters::ManagedEditor::from_rpc_method(&request.method) + .expect("matched editor ACK"); let result = request .params .editor_rpc .as_ref() .and_then(|value| value["requestId"].as_str()) - .ok_or_else(|| "缺少 Unity 回执身份".to_string()) - .and_then(crate::editor_adapters::acknowledge_unity_editor_delivery); + .ok_or_else(|| "缺少 编辑器 回执身份".to_string()) + .and_then(|id| crate::editor_adapters::acknowledge_editor_delivery(editor, id)); match result { Ok(()) => ExternalAgentRunnerResponse::success( &request.request_id, @@ -1110,20 +1117,22 @@ pub(super) fn handle_external_agent_runner_request( ), Err(error) => ExternalAgentRunnerResponse::failure( &request.request_id, - "unity-ack-failed", + "editor-ack-failed", error, ), } } - "unity.editor.mark_uncertain" => { - match crate::editor_adapters::mark_unity_execution_uncertain() { + "unity.editor.mark_uncertain" | "godot.editor.mark_uncertain" => { + let editor = crate::editor_adapters::ManagedEditor::from_rpc_method(&request.method) + .expect("matched editor uncertain RPC"); + match crate::editor_adapters::mark_editor_execution_uncertain(editor) { Ok(()) => ExternalAgentRunnerResponse::success( &request.request_id, json!({"status":"needs-reconciliation"}), ), Err(error) => ExternalAgentRunnerResponse::failure( &request.request_id, - "unity-mark-failed", + "editor-mark-failed", error, ), } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index b57eddf78..994f21a18 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -1032,7 +1032,7 @@ pub(crate) fn acquire_external_agent_runner_gui_participant_lock( "Agent Runner 单实例锁", )?; if runner.is_some() { - crate::editor_adapters::reset_unity_execution_fence_for_fresh_gui(config_dir)?; + crate::editor_adapters::reset_editor_execution_fences_for_fresh_gui(config_dir)?; } drop(participant); runner diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs index 701699b04..67c01849a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs @@ -214,6 +214,7 @@ pub(crate) fn run_external_agent_runner_server( EXTERNAL_AGENT_RUNNER_SERVER_PROCESS.store(true, Ordering::Release); crate::set_game_creator_runtime_config_dir(config_dir.clone()); crate::editor_adapters::configure_unity_helper_for_runtime()?; + crate::editor_adapters::configure_godot_payload_for_runtime(&config_dir)?; set_external_agent_runner_config_dir(config_dir.clone()); let boot_id = random_identifier(b"genarrative-agent-runner-boot-id")?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 5d35f0c71..1173bc2e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -21,47 +21,53 @@ use crate::{ static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0); #[test] -fn unity_pending_execution_survives_new_window_and_runner_restart_until_full_gui_restart() { - let directory = unique_test_directory(); - let config = private_runner_test_config_dir(&directory); - let first = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); - let fence = crate::editor_adapters::unity_execution_fence_path(&config); - fs::write(&fence, "unknown-request").unwrap(); - crate::editor_adapters::mark_unity_execution_uncertain_at(&config).unwrap(); - let uncertain_fence = crate::editor_adapters::unity_uncertain_fence_path(&config); - let second = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); - assert!( - fence.exists(), - "new window must not clear pending execution" - ); - drop(second); - let runner = acquire_external_agent_runner_instance_lock( - &external_agent_runner_lock_path(&config), - "unity-test-boot", - ) - .unwrap(); - drop(first); - let while_runner_alive = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); - assert!(fence.exists(), "running owner prevents recovery"); - drop(while_runner_alive); - drop(runner); - let restarted_runner = acquire_external_agent_runner_instance_lock( - &external_agent_runner_lock_path(&config), - "unity-test-boot-2", - ) - .unwrap(); - assert!( - fence.exists(), - "automatic Runner restart must not clear pending execution" - ); - assert!(uncertain_fence.exists()); - drop(restarted_runner); - let _fresh = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); - assert!( - !fence.exists(), - "all GUI and Runner exited: a fresh GUI can recover" - ); - assert!(!uncertain_fence.exists()); +fn editor_pending_execution_survives_new_window_and_runner_restart_until_full_gui_restart() { + for editor in [ + crate::editor_adapters::ManagedEditor::Unity, + crate::editor_adapters::ManagedEditor::Godot, + ] { + let directory = unique_test_directory(); + let config = private_runner_test_config_dir(&directory); + let first = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); + let fence = crate::editor_adapters::editor_execution_fence_path(editor, &config); + fs::write(&fence, "unknown-request").unwrap(); + crate::editor_adapters::mark_editor_execution_uncertain_at(editor, &config).unwrap(); + let uncertain_fence = crate::editor_adapters::editor_uncertain_fence_path(editor, &config); + let second = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); + assert!( + fence.exists(), + "new window must not clear pending execution" + ); + drop(second); + let runner = acquire_external_agent_runner_instance_lock( + &external_agent_runner_lock_path(&config), + "editor-test-boot", + ) + .unwrap(); + drop(first); + let while_runner_alive = + acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); + assert!(fence.exists(), "running owner prevents recovery"); + drop(while_runner_alive); + drop(runner); + let restarted_runner = acquire_external_agent_runner_instance_lock( + &external_agent_runner_lock_path(&config), + "editor-test-boot-2", + ) + .unwrap(); + assert!( + fence.exists(), + "automatic Runner restart must not clear pending execution" + ); + assert!(uncertain_fence.exists()); + drop(restarted_runner); + let _fresh = acquire_external_agent_runner_gui_participant_lock(&config).unwrap(); + assert!( + !fence.exists(), + "all GUI and Runner exited: a fresh GUI can recover" + ); + assert!(!uncertain_fence.exists()); + } } struct TestDirectoryGuard(PathBuf); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 4b29e7fb3..2ffab1383 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -622,7 +622,9 @@ export function App({ ? { id: 'agc-cocos-editor', title: 'Cocos Creator' } : workspaceProjectKind === 'unity' ? { id: 'agc-unity-editor', title: 'Unity' } - : null; + : workspaceProjectKind === 'godot' + ? { id: 'agc-godot-editor', title: 'Godot' } + : null; void setAgcPluginProjectPath(nextProjectPath) .then(async () => { if (active && editorPlugin && nextProjectPath) { diff --git a/apps/ai-game-creator-shell/tests/pluginHost.test.ts b/apps/ai-game-creator-shell/tests/pluginHost.test.ts index c01ef820e..91527e560 100644 --- a/apps/ai-game-creator-shell/tests/pluginHost.test.ts +++ b/apps/ai-game-creator-shell/tests/pluginHost.test.ts @@ -44,7 +44,7 @@ describe('插件自动启动使用后端能力投影', () => { expect(invoke).toHaveBeenCalledWith('list_agc_plugins'); }); - it.each(['agc-cocos-editor', 'agc-unity-editor'])( + it.each(['agc-cocos-editor', 'agc-unity-editor', 'agc-godot-editor'])( '支持的编辑器插件按原入口启动:%s', async (pluginId) => { const invoke = vi.fn(async (command: string) => diff --git a/docs/README.md b/docs/README.md index 1c688c29a..9ba20f6ba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,6 +41,7 @@ - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 - [AGC Unity 编辑器插件接入](./technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md):DotCraft Attach 来源、Windows Mono 接入、项目身份、执行回执和分发边界。 +- [AGC Godot 编辑器插件接入](<./technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>):GDExtension 聚焦加载、安装资源、受管描述文件、UID 归属、GDScript 回执与 Runner 边界。 - [AGC Cocos Creator 编辑器桥接模块](<./technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md>):独立 crate、feature 开关、目标校验与 Windows 注入边界。 - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、固定 dev 服务、OSS 清单与官网最新客户端下载。 - [AGC 模板库与模板建项](./technical/【技术方案】AGC模板库与模板建项-2026-09-17.md):`templates/` 前缀的模板库契约、下载安装与「用模板建项目」链路。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 61fb4144e..e6dd1189f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,9 @@ # 决策记录 +## 2026-09-20 Godot 编辑器执行接入 + +Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和不确定执行回执合同,编辑器实现留在 `plugins/agc-godot-editor`。用户选择 DLL 原件随 AGC 安装资源分发,并确认按编辑器实例在 AGC 私有缓存准备临时加载副本,以满足 Godot Windows 加载器的同目录 `~DLL` 写入要求;项目内不复制 DLL,只用受管 `.gdextension` 引导。Godot 自动 UID 伴生文件必须记录归属并在确认卸载后按内容匹配清理。工作区根不迁移到 Godot 子目录,原始项目配置与场景只通过明确编辑操作修改。完整合同及验证范围见 [Godot 编辑器插件接入](<../../technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>)。 + > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 ## 2026-09-17 GameCreationApp 资源 kind 只保留一份词汇表:严格解析 + `app_log!` 留痕 diff --git a/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md b/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md new file mode 100644 index 000000000..31022fd09 --- /dev/null +++ b/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md @@ -0,0 +1,116 @@ +# AGC Godot 编辑器插件接入 + +> 文档状态:`current` +> 规范关系:承接 AGC 通用插件宿主与编辑器适配主规范 + +更新时间:`2026-09-20` + +## 目标与边界 + +将 Godot 编辑器操控接入现有 AGC PluginHost、EditorAdapter、Runner、内置插件开关、权限审计和 Agent 工具链。Windows x64 的 Godot 4.7 及以上标准编辑器是首个实现目标,实机验收使用 4.7.2;其他平台和 .NET 编辑器不得从该结果推断支持。 + +初版工程路径支持 Windows 本地盘符目录;UNC/网络共享路径在准备描述文件前明确拒绝。链接/reparse point 继续按同一文件边界失败关闭。 + +DLL 原件随 AGC 安装包放在插件资源目录中;Godot Windows 加载器会在被加载文件旁生成 `~DLL`,因此宿主在 AGC 私有配置目录的运行缓存中按编辑器实例和构建身份准备临时加载副本。AGC 向项目新增一个可扫描的受管 `.gdextension` 描述文件,通过绝对路径引用该实例的加载副本;Godot 可自动生成其同名 `.uid` 伴生文件。DLL 不复制进工程。重新聚焦 Godot 后,由官方文件扫描完成首次加载;不需要用户打开或运行脚本,不创建 EditorPlugin addon,不修改 project.godot 或业务场景文件。编译工具链只属于开发与打包环境,不要求终端用户安装编译器。 + +本次包含连接、状态、GDScript 执行和真实回执、断开及资源清理,并验证通过代码读取和修改独立测试场景。专用截图/输入/场景工具目录、云服务、额外 MCP 服务、公开后端 API 和发布上传不在本次范围;通用执行可以调用 EditorInterface,不能把文件生成冒充编辑器内执行。 + +## 入口与归属 + +- 插件 id 为 `agc-godot-editor`,适配器为 `godot-editor`;命令 `godot.editor.execute`、连接能力 `godot.editor.connection`,DirectProject 工具为 `agc_godot_execute`。复用已有扩展列表和启用开关,不建立平行插件管理页面。 +- 项目发现沿用现有 Godot 工作区合同:工作区根保持用户选定目录;实际 Godot 根由普通 project.godot 在根或唯一一层子目录中确定。准备描述文件和读取 Godot 缓存只作用于实际 Godot 根,通用文件工具/Runtime 的工作区根不改变。 +- 平台、内置开关、项目及目标身份必须在执行入口重新检查。插件只能处理宿主传入的当前受控项目,模型不能覆盖项目路径、DLL 路径、端口、令牌或目标实例。 +- 只连接已打开且唯一匹配真实工程路径的 Godot Editor;校验 PID、进程启动身份、Godot 版本、握手中的工程路径与会话代次。多个候选、非编辑器、路径不符或已退出的进程均拒绝,不启动或关闭用户编辑器。 +- GUI、DirectProject 和 Agent Runtime 的原生操作统一由长寿命 Runner 持有。项目切换使连接失效,迟到回执不能改变新项目状态。 + +## 分发与描述文件 + +- 安装资源布局为 `plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll`,邻接元数据记录协议、构建身份和 DLL SHA256。开发模式允许宿主提供仓库插件目录中的同结构产物;RPC 不接受自定义 DLL 候选。 +- 缓存根仅由宿主提供,为其私有配置目录下的 `godot-editor-runtime`;按 `PID + startedFileTime + buildId` 隔离,所有路径分量受控且拒绝链接/reparse point。复制前验证安装原件及元数据,缓存已有文件必须匹配来源、归属及 SHA,不能加载被替换的同名文件。受管描述同时保留原件与加载副本身份,重启恢复不得把工程给出的任意 DLL 路径当作受信任来源。 +- 实际模块核验同时覆盖加载副本及 Godot 在同目录生成的精确 `~agc_godot_editor.dll`,要求规范化路径和 DLL 字节身份一致。确认原生卸载后,只清理本实例、本构建、内容未变的缓存文件;不能跨编辑器删除或复用影子副本。安装位置更新和实例 PID 重用都必须重新验证。 +- 原生扩展只加载自身受信任包内的实现;GDScript 桥源码编译进 DLL,目标工程不能替换引导脚本。保留 Godot 官方 ABI 来源及 MIT 许可;生成的 DLL、缓存和机器路径不提交。 +- 描述文件固定为实际 Godot 根下的 `agc-editor-bridge.gdextension`,带 AGC 所有权标记和构建身份。首次使用可创建,内容一致时不重写;安装路径或构建身份变化时只更新本插件拥有的文件。已有同名非受管文件、链接/reparse point 或未知内容必须拒绝覆盖。 +- `.agent`、点号目录和 `.gdignore` 路径不会被 Godot 自动扫描,因此描述文件不能放在那里。会话发现文件仅放在 `.godot/agc/` 缓存内,不进入 manifest、对话或日志;校验缓存各级目录没有链接跳转。 +- 描述文件及引擎自动生成的 `.uid` 按同一归属管理:创建描述文件前已有孤立同名 UID 时拒绝接管;扫描生成后在 `.godot/agc/` 记录描述内容及 UID 内容指纹。重连、升级和清理核对此记录,仅删除原内容未变的本插件伴生文件;记录丢失、用户改动或未知同名文件时保留并报告,不把“格式合法”当作删除授权。 +- connect/execute 可以准备受管描述文件,并尝试把经过验证的目标编辑器窗口置前触发扫描;若系统不允许聚焦或握手未就绪,返回明确的未派发错误,提示重新聚焦后连接。连接重试不重放业务代码。 +- 升级、断开和禁用时先通过有效旧连接停用内存桥/卸载原生扩展,再清理与本会话匹配的受管描述文件和会话缓存。用户改动过的文件不删除;无可信握手时不能把删除文件当作已卸载。编辑器已退出时允许清理已确认归属的本地痕迹。 +- 安装目录可能只读;插件不得向 DLL 所在目录写令牌、状态或日志。 +- 安装位置/构建身份升级必须先确认旧扩展已卸载,再原子更新受管描述文件。旧 DLL 仍被目标进程加载、shutdown 结果不明或旧会话无法核实时,保留痕迹并返回可诊断错误,不用替换引用冒充完成升级。 +- 正式描述文件禁用自动 DLL 热重载;首次聚焦发现加载不受此设置影响。版本/路径更新走上述受控卸载和新加载,防止引擎自动热重载越过正在执行或待核对的任务。 + +## 执行协议与结果 + +引擎端协议固定为 `agc.godot.editor.v1`。本机回环 TCP 的 JSONL 每个请求包含 `protocol`、正整数 `id`、`generation`、`token`、`method` 和 `params`;方法为 `status`、`execute`、`shutdown`。会话缓存字段为 `protocol`、`buildId`、`pid`、`startedFileTime`(字符串)、`generation`、`projectPath`、`version`、`port`、`token`。文件名为 `editor-bridge-.json`,单文件最多 64 KiB。每次加载产生随机代次和令牌,端口只监听 127.0.0.1。 + +每条响应回显 `protocol/id/generation/pid/projectPath/buildId`,并包含 `result`;执行的 result 沿用 AGC 结构:`ok`、`status`、`dispatched`、`retryAllowed:false`、成功 `result` 或失败 `error:{code,message}`,可附有界日志。状态为 `completed`、`failed`、`needs-reconciliation`。连接状态沿用 `EditorConnectionInfo`,可增加代次、就绪与版本诊断字段。 + +`execute.params` 固定为 `{code:string,timeoutMs:integer}`;`timeoutMs` 为 1..60000 的剩余预算。`status.params` 和 `shutdown.params` 均为空对象。status 的 result 为 `{connected:true,pid,projectPath,version,generation,buildId,executing:boolean}`。shutdown 在没有在途执行时返回 `{accepted:true,status:"shutting-down"}`,仅表示停机请求已受理;桥在发送该回执后移除自己的会话缓存、关闭监听并卸载扩展。宿主必须继续校验同一目标进程的原生模块已卸载、原代次会话消失,才允许删除/替换描述文件或投影为断开完成。拒绝停机返回 `{accepted:false,error:{code,message}}`,不能把 accepted 当作卸载完成回执。 + +- GDScript 是可含 return/await 的函数体,非空、不含 NUL,最多 128 KiB;请求和回执最多 2 MiB,日志有界。执行运行于编辑器主线程,临时桥的 owner=null,不加入用户场景。 +- 只有真实执行完成且没有捕获到脚本运行错误才返回 completed。编译失败和确定运行失败返回可修复诊断,不以 nil 返回值伪装成功;空值返回本身仍是合法结果。执行日志不暴露令牌或宿主凭据。 +- 使用同一总期限覆盖发现、准备、连接、发送和读取;并发写执行立即拒绝,不积压截止后可能被派发的代码。原生服务不自动重发 execute。 +- 发送前的参数/路径/权限/平台/未连接错误为 `failed, dispatched:false`;发送后的超时、断线、损坏或身份不符回执为 `needs-reconciliation, dispatched:true, retryAllowed:false`。 +- 主线程无限循环不能承诺硬中止。async 未返回或运行状态不明时必须保留不确定阻断,不能因重连、插件启停、切换工程或 Runner 自动重启消除。 +- await 全程保持单执行占用;未完成或结果不确定期间,shutdown/禁用/切换只能禁止新增执行,不能卸载正在使用的桥或清除 fence。待状态已知且无在途执行后再完成资源清理;不确定时返回明确待核对状态。 +- 复用现有执行回执确认机制:Runner 派发前持久化请求身份;调用者确认完整匹配的终态回执后才能清理 pending。丢失最后一跳回执保持阻断,只有核对后退出全部 AGC/Runner 并重新打开才能恢复。 +- fence 的持久范围是同一完整宿主会话:自动重启 Runner、新窗口、JS 插件重载均不能清除。只有用户已核对编辑器状态、全部旧 AGC/Runner 退出,且新 GUI 同时独占现有 GUI 参与锁与 Runner 实例锁时,才能沿用现有恢复入口开始新的宿主会话;该规则与 Unity 一致,不建立另一套自动 reconcile。 + +## 契约与兼容 + +不修改服务端 API、DTO 结构、SpacetimeDB schema 或游戏持久业务数据。已有项目命令目录增加 `godot.editor.execute`,默认权限为 confirm,Rust 与 TypeScript 镜像保持一致;实际执行继续服从当前运行档及项目权限策略。通用 EditorAdapter 文档允许编辑器适配器按自身已授权合同维护受管引导文件;Cocos/Unity 的不写工程连接行为保持原约定。内置开关和审计继续使用已有存储。现有项目没有描述文件时按首次连接创建,不引入历史兼容路径。 + +## 验收 + +| 条款 | 必须取得的证据 | +| --- | --- | +| 包与来源 | 原生 DLL 构建、官方 ABI/许可、源码内无机器路径;staging 校验 DLL 及元数据只进入 Windows x64 资源 | +| 受管文件 | 首次生成、幂等、安装路径更新、非受管文件冲突、路径穿越/链接拒绝、正常卸载及异常保留 | +| 目标身份 | 根/一层 Godot 项目,PID/启动身份/工程/代次/构建身份校验;错误目标拒绝 | +| 宿主接入 | manifest、启停、开关、权限、Runner RPC、DirectProject/Runtime 工具和项目切换定向测试 | +| 执行 | 42、场景读取与独立场景修改/撤销、nil、编译错、运行错、async、有界日志和拒绝并发 | +| 不确定结果 | 发送前后失败分类、超时/断线/损坏回执、ACK 归属、插件及 Runner 重启不解除阻断 | +| 实机 | 已打开的独立 Godot 4.7.2 工程中无需脚本 UI 操作的首次加载、执行、卸载、重新连接;DLL 保持安装资源布局且原有工程文件哈希不变 | +| 仓库门禁 | 相关 Rust/JS/前端定向测试、类型检查、文档索引、编码和 git diff --check;真实编辑器、打包资源与安装包 UI 的结果分别说明 | + +## 已确定的产品选择 + +用户明确选择 DLL 原件留在 AGC 安装目录,并确认每个编辑器的临时加载副本放 AGC 缓存,工程内不复制 DLL。正式实现已按下列证据重新验收,前置 PoC 不作为交付依据。 + +## 本地验收结果(2026-09-20) + +Windows x64、Godot 4.7.2 标准编辑器的本地实现验收通过。证据保存在 gitignored 的 `.app/diagnostics/`,不随源码提交;复验入口保留在插件源码和现有测试中。下列测试集合存在交叉,不相加为总用例数。 + +| 验收面 | 已取得的证据 | +| --- | --- | +| 原生服务与文件边界 | `godot-cache-tests-final.log`:30 项通过;覆盖受管文件及 UID、目标身份、缓存来源/哈希、安装更新、实例隔离、链接拒绝、异常保留和不确定状态 | +| 真实引擎原生执行 | `godot-native-final-tests.log`:12 项通过、0 跳过;真实 headless Godot 验证同步/async、编译/运行错、超时占用、有界输出、代次拒绝及卸载重连 | +| 插件与宿主 | `godot-plugin-js-final.log`:Cocos/Unity/Godot JS 共 31 项通过;`godot-shell-final-tests.log`:Godot 过滤 29 项通过;`godot-host-final-tests.log`:PluginHost 16 项通过,含切项目撤销旧上下文、派发前取消及派发后回执丢失 | +| 共享权限与前端 | `godot-web-final-tests.log`:28 项通过;Rust 命令权限契约通过,AGC typecheck 通过;Godot 命令继续使用默认 confirm | +| 现有宿主回归 | EditorAdapter、Unity、Runner 重启 fence 和缺失 endpoint 清理的定向测试通过;构建脚本、workspace、CI 路由和 rustfmt hook 定向测试通过 | +| 原生 GUI | `godot-plugin-verified-20260920/evidence/native-gui-smoke.log`:同一编辑器内返回 42/null、读取场景、增加节点后撤销、await、编译/运行错误、卸载、重连及再次卸载成功 | +| 多实例与只读安装 | 同目录 `parallel-live-modules.json`、`parallel-verified-summary.json`:两个编辑器同时使用不同缓存路径的官方 `~DLL`,均返回 42、确认卸载,安装原件只读且未变 | +| 安装位置更新 | 同目录 `install-location-smoke.log`:旧连接卸载、新安装来源生成新代次和引用、返回 42、清理两代缓存;两个安装来源及原有工程文件均未变 | +| 真实 Runner | 同目录 `runner-smoke-psapi.log`:standalone Runner 经正式执行/ACK 路径取得真实 Godot 回执,拒绝错误 ACK,接受正确 ACK,完成场景修改/撤销及断开;`runner-restart-before.log`、`runner-restart-after.log`:新 Runner 无需重新连接即可恢复原归属并清理旧桥 | +| 资源与收尾 | 完整 Windows feature debug 构建通过;当前 `src-tauri/resources/plugins/agc-godot-editor` 仅含 manifest、入口、DLL、元数据、许可和来源六个文件,其 DLL 与实机测试一致;同目录 `final-cleanup.json` 确认工程及安装来源哈希未变、自建 GUI 正常退出 | + +正式 Runner smoke 使用安装资源布局下的 debug 可执行文件,不等同于 NSIS 安装包 UI 验证。本次未运行安装包 UI smoke、真实 Provider 生成或远端 CI,未制作发布包或上传发布;其他 Godot 版本、.NET 编辑器与其他平台仍须各自验收。AGC 全量聚合门禁不在上述定向结果中。 + +### 复验入口 + +从仓库根运行,原生构建要求 Windows x64 C 编译器。先把 `AGC_GODOT_TEST_EXECUTABLE` 设置为待验证的标准 Godot 编辑器绝对路径;未设置时 headless 测试会跳过,不能视为实机通过。 + +```powershell +powershell -NoProfile -File plugins/agc-godot-editor/native/gdextension/build.ps1 +node --test plugins/agc-godot-editor/native/gdextension/tests/native-smoke.test.mjs +cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml +npm run agc:plugins:test +cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features cocos-editor-execute,unity-editor-execute,godot-editor-execute godot +cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features cocos-editor-execute,unity-editor-execute,godot-editor-execute plugin_host +npx vitest run apps/ai-game-creator-shell/tests/pluginHost.test.ts packages/shared/src/contracts/gameCreationApp.test.ts --threads=false +npm run typecheck --workspace @genarrative/ai-game-creator-shell +npm run check:doc-index +npm run check:encoding +git diff --check +``` + +真实 GUI 使用 `native/godot-editor-bridge/examples/live_smoke.rs`;安装位置变更使用同目录的 `install_location_smoke.rs`。二者要求显式传入自有可丢弃工程、已打开编辑器 PID、可信安装 DLL、工程外私有缓存及 `--allow-fixture-mutations`,具体参数见源码用法。多实例验证分别传入两个工程和 PID,通过 `live_smoke` 的 `--hold-ms` 让加载时间重叠,同时核验原生模块路径。Runner 复验须经正式长度前缀 RPC、完整 ACK 和私有配置恢复路径,不能用原生示例替代 Runner 证据。 diff --git a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md index 50e5b1c23..0f5cd9bd8 100644 --- a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md +++ b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md @@ -20,6 +20,7 @@ packages/agc-plugin-sdk/src/index.ts server-rs/crates/editor-adapter-api/src/lib.rs plugins/agc-cocos-editor/ (第一个编辑器插件包) plugins/agc-unity-editor/ (Unity Mono 编辑器插件包) +plugins/agc-godot-editor/ (Godot GDExtension 编辑器插件包) ``` 现有 DirectProject 的 Skill/MCP 导入仍保留。它们是 Codex 扩展注入链路,不等同于本宿主管理的可运行 AGC Plugin。 @@ -123,6 +124,8 @@ Unity 插件复用此扩展点,GUI 适配器通过已有 Runner RPC 转发到 Windows x64 的 Attach helper 来源、构建工具链和执行回执合同见 [Unity 插件接入](./【技术方案】AGC Unity编辑器插件接入-2026-09-18.md)。 +Godot 使用同一 Runner 执行与回执确认层,按引擎分别保存 pending/uncertain 状态,不能相互确认或清除。`godot-editor` 的受控连接允许按用户已选方案维护项目内 `.gdextension` 引用及 Godot 自动生成的 UID;DLL 随安装资源分发,探测仍只读,原始项目配置和场景不改。具体文件归属、GDScript 错误/async、升级卸载与分发合同见 [Godot 插件接入](<./【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>)。 + ## Tauri 命令 `list_agc_extensions` 返回统一的 Plugin/Skill/MCP catalog;`list_agc_plugins`、`refresh_agc_plugins`、`start_agc_plugin`、`stop_agc_plugin`、`reload_agc_plugin`、`call_agc_plugin` 和 `read_agc_plugin_panel` 提供 Runtime Plugin 管理入口;`set_agc_plugin_project_path` 设置当前项目的受控上下文。编辑器适配器通过宿主 registry 和 Plugin RPC 使用,不增加编辑器专属 Tauri 命令。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 7eac15f9d..8f08efafe 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,9 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-09-20 Godot 编辑器插件 + +已有 Godot 工程通过内置 `agc-godot-editor` 接入 `godot.editor.execute` / `agc_godot_execute`,复用通用 PluginHost、EditorAdapter、Runner、可用开关和权限审计。DLL 随 AGC 安装资源分发,项目内受管描述文件触发官方 GDExtension 聚焦加载;工作区与实际 Godot 根继续遵守双根合同。GDScript 的真实完成、编译/运行错误、await 和不确定回执按 [Godot 编辑器插件接入](<./【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>) 验收,不能用原型或模拟回执替代正式实机结果。 + ## 2026-09-17 GameCreationApp 资源 kind:唯一词汇表、严格解析与 `app_log!` 留痕 本节覆盖 2026-09-15 节里关于「canonical 字符串列表 / legacy 别名表 / `tracing` 留痕 / ts-rs 生成路径」的表述;枚举成员集合、「不迁移、不静默转换」的总体口径不变。 diff --git a/jenkins/Jenkinsfile.scheduled-revision-trigger b/jenkins/Jenkinsfile.scheduled-revision-trigger index 1d2464322..c2a8c0af5 100644 --- a/jenkins/Jenkinsfile.scheduled-revision-trigger +++ b/jenkins/Jenkinsfile.scheduled-revision-trigger @@ -99,7 +99,7 @@ pipeline { while IFS= read -r changed_path; do [[ -z "${changed_path}" ]] && continue case "${changed_path}" in - apps/ai-game-creator-shell/*|packages/*|server-rs/crates/*|plugins/agc-cocos-editor/*|plugins/agc-unity-editor/*|apps/desktop-shell/src-tauri/icons/*|package.json|package-lock.json) + apps/ai-game-creator-shell/*|packages/*|server-rs/crates/*|plugins/agc-cocos-editor/*|plugins/agc-unity-editor/*|plugins/agc-godot-editor/*|apps/desktop-shell/src-tauri/icons/*|package.json|package-lock.json) agc_scope=changed ;; esac diff --git a/package-lock.json b/package-lock.json index 1df71b8e4..58939c2c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "packages/shared", "plugins/agc-cocos-editor", "plugins/agc-unity-editor", + "plugins/agc-godot-editor", "tools/spine-json-export-validator" ], "dependencies": { @@ -5162,6 +5163,10 @@ "resolved": "plugins/agc-cocos-editor", "link": true }, + "node_modules/@genarrative/agc-plugin-godot-editor": { + "resolved": "plugins/agc-godot-editor", + "link": true + }, "node_modules/@genarrative/agc-plugin-sdk": { "resolved": "packages/agc-plugin-sdk", "link": true @@ -23137,6 +23142,13 @@ "@genarrative/agc-plugin-sdk": "0.1.0" } }, + "plugins/agc-godot-editor": { + "name": "@genarrative/agc-plugin-godot-editor", + "version": "0.1.0", + "dependencies": { + "@genarrative/agc-plugin-sdk": "0.1.0" + } + }, "tools/spine-json-export-validator": { "name": "@genarrative/spine-json-export-validator", "version": "0.1.0", @@ -26526,6 +26538,12 @@ "@genarrative/agc-plugin-sdk": "0.1.0" } }, + "@genarrative/agc-plugin-godot-editor": { + "version": "file:plugins/agc-godot-editor", + "requires": { + "@genarrative/agc-plugin-sdk": "0.1.0" + } + }, "@genarrative/agc-plugin-sdk": { "version": "file:packages/agc-plugin-sdk" }, diff --git a/package.json b/package.json index f3faa3992..7436a415f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "packages/shared", "plugins/agc-cocos-editor", "plugins/agc-unity-editor", + "plugins/agc-godot-editor", "tools/spine-json-export-validator" ], "scripts": { @@ -68,7 +69,7 @@ "check:git-hooks": "node --test scripts/git-hooks.test.mjs", "check:npm-workspaces": "node --test scripts/check-npm-workspaces.test.mjs && node scripts/check-npm-workspaces.mjs", "check:repository-ci": "bash scripts/check-repository-ci.sh", - "check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml -- --check", + "check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml -- --check", "check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", @@ -119,7 +120,7 @@ "typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit", "lint": "npm run check:encoding && npm run check:doc-index && npm run check:npm-workspaces && npm run check:git-hooks && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck", "lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .", - "format:rust": "cargo fmt --all --manifest-path server-rs/Cargo.toml && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml", + "format:rust": "cargo fmt --all --manifest-path server-rs/Cargo.toml && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo fmt --all --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml", "format": "prettier --write . && npm run format:rust", "format:check": "prettier --check . && npm run check:rustfmt", "format:staged": "lint-staged", @@ -165,8 +166,8 @@ "agc:build": "npm --prefix apps/ai-game-creator-shell run build --", "agc:skill-pack:check": "npm --prefix apps/ai-game-creator-shell run skill-pack:check", "agc:skill-pack:sync": "npm --prefix apps/ai-game-creator-shell run skill-pack:sync", - "agc:plugins:test": "node --test plugins/agc-cocos-editor/src/entry.test.mjs plugins/agc-unity-editor/src/entry.test.mjs", - "agc:plugins:native-test": "cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml", + "agc:plugins:test": "node --test plugins/agc-cocos-editor/src/entry.test.mjs plugins/agc-unity-editor/src/entry.test.mjs plugins/agc-godot-editor/src/entry.test.mjs", + "agc:plugins:native-test": "cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml", "agc:plugins:check": "npm run agc:plugins:test && npm run agc:plugins:native-test", "agc:check": "npm run ai-game-creator-shell:check", "agc:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck", diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index f4ba6c847..44053ca4e 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -51,7 +51,7 @@ describe('AI 游戏创作 App 共享契约', () => { it('keeps command permissions explicit', () => { const commandIds = GAME_CREATION_APP_COMMANDS.map((command) => command.id); - expect(GAME_CREATION_APP_COMMANDS).toHaveLength(66); + expect(GAME_CREATION_APP_COMMANDS).toHaveLength(67); expect(commandIds).toContain('project.bootstrap'); expect(commandIds).toContain('project.git_inspect'); expect(commandIds).toContain('project.git_commit'); @@ -63,6 +63,11 @@ describe('AI 游戏创作 App 共享契约', () => { expect(commandIds).toContain('command.stdin'); expect(commandIds).toContain('command.terminate'); expect(commandIds).toContain('cocos.editor.execute'); + expect( + GAME_CREATION_APP_COMMANDS.find( + (command) => command.id === 'godot.editor.execute', + )?.permission, + ).toBe('confirm'); expect(commandIds).toContain('mcp.call'); expect(commandIds.indexOf('command.exec')).toBe( commandIds.indexOf('command.run_limited') + 1, diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 0b3271271..17103aa6f 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -73,6 +73,7 @@ export const GAME_CREATION_APP_COMMANDS = [ { id: 'command.stdin', permission: 'confirm' }, { id: 'command.terminate', permission: 'confirm' }, { id: 'cocos.editor.execute', permission: 'confirm' }, + { id: 'godot.editor.execute', permission: 'confirm' }, { id: 'canvas.project_open', permission: 'confirm' }, { id: 'canvas.project_sync', permission: 'confirm' }, { id: 'canvas.asset_import', permission: 'confirm' }, diff --git a/plugins/README.md b/plugins/README.md index f694e3586..2517e62e9 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -4,6 +4,9 @@ ```text plugins/ +├─ agc-godot-editor/ Godot GDExtension 编辑器桥接(Windows x64) +│ ├─ src/ AGC 插件协议入口 +│ └─ native/ GDExtension 载荷、实例缓存与 EditorAdapter ├─ agc-unity-editor/ Unity Mono 编辑器桥接(Windows x64) │ ├─ src/ AGC 插件协议入口 │ ├─ native/ 通用 EditorAdapter 与 helper 生命周期 @@ -78,3 +81,9 @@ feature 会构建自包含 Attach helper,并只将运行文件与许可放入 .NET 10 SDK 与 Visual Studio C++ x64 工具链,最终用户不需另装这两项。 源码来源、执行归属和验收边界见 [Unity 插件接入](../docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md)。 + +Godot 插件通过 `godot-editor-execute` feature 构建并校验 GDExtension 载荷,只分发 +运行入口、DLL、元数据及许可。构建机需要 Windows x64 C 工具链;DLL 原件留在安装资源, +每个编辑器的临时加载副本放在 AGC 私有缓存。连接时维护工程内受管 `.gdextension` +引用及其 UID,通过 Godot 聚焦扫描首次加载。文件归属、真实执行和卸载规则见 +[Godot 插件接入](<../docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>)。 diff --git a/plugins/agc-godot-editor/native/gdextension/.gitignore b/plugins/agc-godot-editor/native/gdextension/.gitignore new file mode 100644 index 000000000..0082e3d3d --- /dev/null +++ b/plugins/agc-godot-editor/native/gdextension/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/.build/ diff --git a/plugins/agc-godot-editor/native/gdextension/build.ps1 b/plugins/agc-godot-editor/native/gdextension/build.ps1 new file mode 100644 index 000000000..a85facb22 --- /dev/null +++ b/plugins/agc-godot-editor/native/gdextension/build.ps1 @@ -0,0 +1,84 @@ +param([string]$Compiler = $env:AGC_GODOT_C_COMPILER) +$ErrorActionPreference = 'Stop' +$root = $PSScriptRoot +if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Godot editor native payload requires Windows x64.' } +if (-not [Environment]::Is64BitProcess) { throw 'A 64-bit build host is required.' } +if (-not $Compiler) { + foreach ($candidate in @('gcc.exe', 'clang.exe', 'cl.exe')) { + $found = Get-Command $candidate -ErrorAction SilentlyContinue + if ($found) { $Compiler = $found.Source; break } + } +} +if (-not $Compiler) { throw 'No C compiler found. Install a Windows x64 C toolchain or pass -Compiler.' } +$Compiler = (Get-Command $Compiler -ErrorAction Stop).Source +$build = Join-Path $root '.build' +$output = Join-Path $root 'bin/win-x64' +New-Item -ItemType Directory -Path $build,$output -Force | Out-Null +$utf8 = [Text.UTF8Encoding]::new($false) +$inputs = @('src/native.c','src/bridge.gd','vendor/gdextension_interface.h','vendor/provenance.json','build.ps1') +$fingerprint = 'agc.godot.editor.v1/windows/x86_64/c11/O2' + "`n" +$fingerprint += 'compiler:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $Compiler).Hash.ToLowerInvariant() + "`n" +foreach ($inputPath in $inputs) { $fingerprint += $inputPath + ':' + (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root $inputPath)).Hash.ToLowerInvariant() + "`n" } +$hasher = [Security.Cryptography.SHA256]::Create() +try { $buildId = 'sha256:' + ([BitConverter]::ToString($hasher.ComputeHash($utf8.GetBytes($fingerprint))).Replace('-','').ToLowerInvariant()) } finally { $hasher.Dispose() } +$existingDll = Join-Path $output 'agc_godot_editor.dll' +$existingMetadata = Join-Path $output 'metadata.json' +if ((Test-Path -LiteralPath $existingDll) -and (Test-Path -LiteralPath $existingMetadata)) { + try { + $existing = Get-Content -LiteralPath $existingMetadata -Raw | ConvertFrom-Json + $existingHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $existingDll).Hash.ToLowerInvariant() + if ($existing.protocol -eq 'agc.godot.editor.v1' -and $existing.buildId -eq $buildId -and $existing.sha256 -eq $existingHash -and $existing.entrySymbol -eq 'agc_godot_editor_init' -and $existing.platform -eq 'windows' -and $existing.arch -eq 'x86_64' -and $existing.minimumGodotVersion -eq '4.7') { + Write-Output "Native payload is current: $buildId" + return + } + } catch { Write-Verbose 'Existing metadata could not be verified; rebuilding.' } +} +$script = [IO.File]::ReadAllBytes((Join-Path $root 'src/bridge.gd')) +$embedded = [Text.StringBuilder]::new() +[void]$embedded.AppendLine('/* Generated from src/bridge.gd; never reads a project-side script. */') +[void]$embedded.AppendLine('#define AGC_BUILD_ID "' + $buildId + '"') +[void]$embedded.AppendLine('static const unsigned char AGC_EMBEDDED_BRIDGE[] = {') +for ($index = 0; $index -lt $script.Length; $index += 32) { + $last = [Math]::Min($index + 31, $script.Length - 1) + [void]$embedded.AppendLine(($script[$index..$last] -join ',') + ',') +} +[void]$embedded.AppendLine('0};') +[IO.File]::WriteAllText((Join-Path $build 'embedded_bridge.h'), $embedded.ToString(), $utf8) +$previousTemp = $env:TEMP +$previousTmp = $env:TMP +$previousLocation = Get-Location +try { + $env:TEMP = $build + $env:TMP = $build + Set-Location -LiteralPath $build + $source = Join-Path $root 'src/native.c' + $vendor = Join-Path $root 'vendor' + $temporaryDll = Join-Path $build 'agc_godot_editor.dll' + $compilerName = [IO.Path]::GetFileName($Compiler).ToLowerInvariant() + if ($compilerName -eq 'cl.exe') { + & $Compiler /nologo /std:c11 /O2 /W4 /WX /LD /D_CRT_SECURE_NO_WARNINGS "/I$vendor" "/I$build" $source "/Fe:$temporaryDll" /link /Brepro + } else { + $flags = @('-std=c11','-O2','-Wall','-Wextra','-Werror','-shared') + if ($compilerName -eq 'gcc.exe') { $flags += @('-static-libgcc','-Wl,--no-insert-timestamp') } + & $Compiler @flags -I $vendor -I $build $source -o $temporaryDll + } + if ($LASTEXITCODE -ne 0) { throw "Native compiler exited with $LASTEXITCODE" } + $dll = Join-Path $output 'agc_godot_editor.dll' + Copy-Item -LiteralPath $temporaryDll -Destination $dll -Force + $metadata = [ordered]@{ + protocol = 'agc.godot.editor.v1' + buildId = $buildId + sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $dll).Hash.ToLowerInvariant() + platform = 'windows' + arch = 'x86_64' + entrySymbol = 'agc_godot_editor_init' + minimumGodotVersion = '4.7' + } + [IO.File]::WriteAllText((Join-Path $output 'metadata.json'), ($metadata | ConvertTo-Json) + "`n", $utf8) + Write-Output "Built $dll" + Write-Output "Build identity: $buildId" +} finally { + Set-Location -LiteralPath $previousLocation + $env:TEMP = $previousTemp + $env:TMP = $previousTmp +} diff --git a/plugins/agc-godot-editor/native/gdextension/src/bridge.gd b/plugins/agc-godot-editor/native/gdextension/src/bridge.gd new file mode 100644 index 000000000..a48644488 --- /dev/null +++ b/plugins/agc-godot-editor/native/gdextension/src/bridge.gd @@ -0,0 +1,436 @@ +@tool +extends Node + +const PROTOCOL := "agc.godot.editor.v1" +const DESCRIPTOR := "res://agc-editor-bridge.gdextension" +const MAX_MESSAGE := 2 * 1024 * 1024 +const MAX_CODE := 128 * 1024 +const MAX_PEERS := 8 +const NODE_NAME := "_AGC_GODOT_EDITOR_BRIDGE" +const OWNER_META := "_agc_godot_editor_protocol" + +class Capture extends Logger: + var mutex := Mutex.new() + var entries: Array = [] + var used := 0 + var had_error := false + var truncated := false + var secret := "" + + func add(level: String, text: String, failure: bool) -> void: + mutex.lock() + had_error = had_error or failure + var cleaned := text.replace(secret, "[redacted]") if not secret.is_empty() else text + cleaned = cleaned.left(4096) + var size := cleaned.to_utf8_buffer().size() + if entries.size() < 128 and used + size <= 65536: + entries.append({"level": level, "message": cleaned}) + used += size + else: + truncated = true + mutex.unlock() + + func snapshot() -> Dictionary: + mutex.lock() + var result := {"logs": entries.duplicate(true), "failed": had_error, "truncated": truncated} + mutex.unlock() + return result + + func _log_message(message: String, error: bool) -> void: + add("error" if error else "info", message, error) + + func _log_error(function: String, file: String, line: int, code: String, rationale: String, _notify: bool, error_type: int, _backtraces: Array[ScriptBacktrace]) -> void: + var failure := error_type != Logger.ERROR_TYPE_WARNING + add("error" if failure else "warning", "%s (%s:%d %s)" % [rationale if not rationale.is_empty() else code, file, line, function], failure) + +var server := TCPServer.new() +var peers: Array = [] +var session: Dictionary = {} +var session_path := "" +var bridge_ready := false +var busy := false +var shutting_down := false +var native_removed := false +var shutdown_scheduled := false +var execution: Dictionary = {} +var evaluator: RefCounted +var evaluation_script: GDScript +var capture: Capture + +# Called once by the native entry point, deferred beyond extension initialization. +func bootstrap(build_id: String, started_file_time: String, cache_path: String) -> void: + if not Engine.is_editor_hint(): + queue_free() + return + var root := EditorInterface.get_base_control().get_tree().root + var previous := root.get_node_or_null(NODE_NAME) + if previous != null: + if previous.get_meta(OWNER_META, "") != PROTOCOL or not previous.has_method("_retire_for_handoff"): + _bootstrap_failed("godot_bridge_name_conflict", "桥节点名称已被其它对象占用,未接管。") + return + if previous.get("busy") == true: + _bootstrap_failed("godot_bridge_busy", "旧桥仍在执行,禁止覆盖或卸载。") + return + if not previous.call("_retire_for_handoff"): + _bootstrap_failed("godot_bridge_generation_conflict", "旧桥尚未停机,未替换其会话。") + return + name = NODE_NAME + set_meta(OWNER_META, PROTOCOL) + process_mode = Node.PROCESS_MODE_ALWAYS + root.add_child(self, false, Node.INTERNAL_MODE_BACK) + owner = null + var generation_bytes := Crypto.new().generate_random_bytes(32) + var token_bytes := Crypto.new().generate_random_bytes(32) + if generation_bytes.size() != 32 or token_bytes.size() != 32: + _bootstrap_failed("godot_bridge_entropy_failed", "无法创建安全会话身份。") + return + if server.listen(0, "127.0.0.1") != OK: + _bootstrap_failed("godot_bridge_listen_failed", "无法监听本机回环端口。") + return + session_path = cache_path + var engine_version := Engine.get_version_info() + session = {"protocol": PROTOCOL, "buildId": build_id, + "pid": OS.get_process_id(), "startedFileTime": started_file_time, + "generation": generation_bytes.hex_encode(), + "projectPath": ProjectSettings.globalize_path("res://").trim_suffix("/"), + "version": "%d.%d.%d" % [engine_version.major, engine_version.minor, engine_version.patch], + "port": server.get_local_port(), "token": token_bytes.hex_encode()} + if not _write_session(): + server.stop() + _bootstrap_failed("godot_bridge_cache_failed", "无法安全写入会话缓存。") + return + bridge_ready = true + set_process(true) + +func _bootstrap_failed(code: String, message: String) -> void: + push_error("AGC Godot: %s: %s" % [code, message]) + queue_free() + +# Only an already stopped, non-executing generation may surrender its fixed name. +func _retire_for_handoff() -> bool: + if busy or not (is_queued_for_deletion() or native_removed or (shutting_down and not server.is_listening())): + return false + _detach_retired_node() + return true + +func _detach_retired_node() -> void: + if busy: + return + bridge_ready = false + shutting_down = true + set_process(false) + _remove_session() + server.stop() + for connection in peers.duplicate(): + _drop_peer(connection) + var parent := get_parent() + if parent != null: + # queue_free is end-of-frame; detach now so a same-flush bootstrap can claim the name. + name = NODE_NAME + "_retired_" + str(get_instance_id()) + parent.remove_child(self) + if not is_queued_for_deletion(): + queue_free() + +func _is_link(path: String) -> bool: + var directory := DirAccess.open(path.get_base_dir()) + return directory == null or directory.is_link(path.get_file()) + +func _write_session() -> bool: + # The native side checks every Windows path component for reparse points. + var directory := session_path.get_base_dir() + if _is_link(directory) or _is_link(directory.get_base_dir()) or _is_link(session_path): + return false + var temporary := session_path + "." + str(session.generation) + ".tmp" + if FileAccess.file_exists(temporary) or _is_link(temporary): + return false + var file := FileAccess.open(temporary, FileAccess.WRITE) + if file == null: + return false + file.store_string(JSON.stringify(session)) + file.flush() + var error := file.get_error() + file.close() + if error != OK or DirAccess.rename_absolute(temporary, session_path) != OK: + DirAccess.remove_absolute(temporary) + return false + return true + +func _remove_session() -> void: + if session_path.is_empty() or session.is_empty(): + return + var directory := session_path.get_base_dir() + if _is_link(directory) or _is_link(directory.get_base_dir()) or _is_link(session_path): + return + var file := FileAccess.open(session_path, FileAccess.READ) + if file == null or file.get_length() > 65536: + return + var stored: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if stored is Dictionary and stored.get("protocol") == PROTOCOL and stored.get("generation") == session.get("generation") and stored.get("pid") == OS.get_process_id(): + DirAccess.remove_absolute(session_path) + +func _process(_delta: float) -> void: + if not bridge_ready: + return + if busy and not execution.get("replied", false) and Time.get_ticks_msec() >= int(execution.deadline): + _reply_execution(_failure("godot_execution_timeout", "执行超过期限,状态待核对;不会自动重试。", true, "needs-reconciliation")) + if server.is_listening(): + while server.is_connection_available(): + var incoming := server.take_connection() + if shutting_down or peers.size() >= MAX_PEERS: + incoming.disconnect_from_host() + else: + peers.append({"socket": incoming, "rx": PackedByteArray(), "tx": PackedByteArray(), "last": Time.get_ticks_msec(), "shutdown": false}) + for connection in peers.duplicate(): + _poll_peer(connection) + +func _drop_peer(connection: Dictionary) -> void: + connection.socket.disconnect_from_host() + peers.erase(connection) + +func _poll_peer(connection: Dictionary) -> void: + var socket: StreamPeerTCP = connection.socket + socket.poll() + if socket.get_status() != StreamPeerTCP.STATUS_CONNECTED: + _drop_peer(connection) + return + var available := socket.get_available_bytes() + if available > 0: + if connection.rx.size() + available > MAX_MESSAGE: + _drop_peer(connection) + return + var packet: Array = socket.get_data(available) + if packet[0] != OK: + _drop_peer(connection) + return + connection.rx.append_array(packet[1]) + connection.last = Time.get_ticks_msec() + if connection.tx.is_empty() and not connection.shutdown: + var newline: int = connection.rx.find(10) + if newline >= 0: + var line: String = connection.rx.slice(0, newline).get_string_from_utf8() + connection.rx = connection.rx.slice(newline + 1) + _dispatch(connection, line) + if not connection.tx.is_empty(): + var sent: Array = socket.put_partial_data(connection.tx) + if sent[0] != OK: + _drop_peer(connection) + return + connection.tx = connection.tx.slice(int(sent[1])) + if connection.tx.is_empty() and connection.shutdown and not shutdown_scheduled: + shutdown_scheduled = true + _finish_shutdown.call_deferred() + if not busy and Time.get_ticks_msec() - int(connection.last) > 65000: + _drop_peer(connection) + +func _dispatch(connection: Dictionary, text: String) -> void: + # Godot strings replace U+0000; reject it before JSON parsing can erase that evidence. + var cursor := 0 + while cursor < text.length(): + if text.unicode_at(cursor) == 0: + _drop_peer(connection) + return + if text.unicode_at(cursor) == 92: + if text.substr(cursor, 6).to_lower() == "\\u0000": + _drop_peer(connection) + return + cursor += 1 + cursor += 1 + var request: Variant = JSON.parse_string(text) + if not request is Dictionary or request.get("protocol") != PROTOCOL or request.get("generation") != session.generation or request.get("token") != session.token: + _drop_peer(connection) + return + var id: Variant = request.get("id") + if not (id is float or id is int) or id < 1 or id != floor(id) or id > 9007199254740991: + _drop_peer(connection) + return + var params: Variant = request.get("params") + if not params is Dictionary: + _send(connection, int(id), _failure("godot_invalid_params", "params 必须是对象。", false)) + return + match request.get("method", ""): + "status": + if not params.is_empty(): + _send(connection, int(id), _failure("godot_invalid_params", "status.params 必须为空。", false)) + return + _send(connection, int(id), {"connected": true, "pid": session.pid, + "projectPath": session.projectPath, "version": session.version, + "generation": session.generation, "buildId": session.buildId, "executing": busy}) + "shutdown": + if not params.is_empty() or busy: + _send(connection, int(id), {"accepted": false, "error": {"code": "godot_execution_in_progress" if busy else "godot_invalid_params", "message": "执行尚未结束,不能卸载。" if busy else "shutdown.params 必须为空。"}}) + return + shutting_down = true + connection.shutdown = true + _send(connection, int(id), {"accepted": true, "status": "shutting-down"}) + "execute": + var code: Variant = params.get("code") + var timeout: Variant = params.get("timeoutMs") + if params.size() != 2 or not code is String or code.strip_edges().is_empty() or code.to_utf8_buffer().has(0) or code.to_utf8_buffer().size() > MAX_CODE or not (timeout is int or timeout is float) or timeout != floor(timeout) or timeout < 1 or timeout > 60000: + _send(connection, int(id), _failure("godot_invalid_params", "code 或 timeoutMs 不符合执行协议。", false)) + return + if busy or shutting_down or native_removed: + _send(connection, int(id), _failure("godot_execution_in_progress", "已有执行或正在关闭,不接受新的执行。", false)) + return + busy = true + execution = {"connection": connection, "id": int(id), "deadline": Time.get_ticks_msec() + int(timeout), "replied": false} + _execute.call_deferred(code) + _: + _send(connection, int(id), _failure("godot_unknown_method", "未知方法。", false)) + +func _failure(code: String, message: String, dispatched: bool, status := "failed") -> Dictionary: + return {"ok": false, "status": status, "dispatched": dispatched, "retryAllowed": false, "error": {"code": code, "message": message}} + +func _execute(code: String) -> void: + if Time.get_ticks_msec() >= int(execution.deadline): + _reply_execution(_failure("godot_execution_expired", "执行前期限已耗尽。", false)) + _complete_execution() + return + capture = Capture.new() + capture.secret = session.token + OS.add_logger(capture) + evaluation_script = GDScript.new() + var source := "@tool\nextends RefCounted\nfunc run():\n" + for line in code.split("\n"): + source += "\t" + line + "\n" + evaluation_script.source_code = source + var compile_error := evaluation_script.reload() + if compile_error != OK: + _reply_execution(_with_logs(_failure("godot_compile_error", "GDScript 编译失败。", true))) + _complete_execution() + return + evaluator = evaluation_script.new() + if evaluator == null: + _reply_execution(_with_logs(_failure("godot_script_creation_failed", "无法创建执行实例。", true))) + _complete_execution() + return + # Await also accepts immediate values; keep all strong references and busy until completion. + var value: Variant = await evaluator.call("run") + var captured := capture.snapshot() + if captured.failed: + _reply_execution(_with_logs(_failure("godot_runtime_error", "GDScript 运行失败。", true))) + else: + var budget := {"bytes": 0, "nodes": 0, "failed": false} + var safe_value: Variant = _json_value(value, 0, [], budget) + if budget.failed: + _reply_execution(_with_logs(_failure("godot_result_not_serializable", "执行结果无法在有界 JSON 回执内表示。", true))) + else: + _reply_execution(_with_logs({"ok": true, "status": "completed", "dispatched": true, "retryAllowed": false, "result": safe_value})) + _complete_execution() + +func _with_logs(result: Dictionary) -> Dictionary: + if capture != null: + var data := capture.snapshot() + result.logs = data.logs + result.logsTruncated = data.truncated + return result + +func _json_value(value: Variant, depth: int, ancestors: Array, budget: Dictionary) -> Variant: + budget.nodes += 1 + if depth > 24 or budget.nodes > 50000 or budget.bytes > MAX_MESSAGE - 131072: + budget.failed = true + return null + match typeof(value): + TYPE_NIL, TYPE_BOOL, TYPE_INT: + budget.bytes += 24 + return value + TYPE_FLOAT: + if not is_finite(value): + budget.failed = true + budget.bytes += 32 + return value + TYPE_STRING, TYPE_STRING_NAME: + var text := str(value) + if text.to_utf8_buffer().size() > MAX_MESSAGE - 131072: + budget.failed = true + return null + budget.bytes += JSON.stringify(text).to_utf8_buffer().size() + if budget.bytes > MAX_MESSAGE - 131072: + budget.failed = true + return text + TYPE_ARRAY, TYPE_DICTIONARY: + for ancestor in ancestors: + if is_same(value, ancestor): + budget.failed = true + return null + var next := ancestors.duplicate() + next.append(value) + if value is Array: + var result: Array = [] + for item in value: + result.append(_json_value(item, depth + 1, next, budget)) + if budget.failed: + break + return result + var result: Dictionary = {} + for key in value: + if not (key is String or key is StringName): + budget.failed = true + break + var safe_key: Variant = _json_value(str(key), depth + 1, next, budget) + result[safe_key] = _json_value(value[key], depth + 1, next, budget) + if budget.failed: + break + return result + _: + budget.failed = true + return null + +func _reply_execution(result: Dictionary) -> void: + if execution.get("replied", true): + return + execution.replied = true + var connection: Dictionary = execution.connection + if peers.has(connection): + _send(connection, int(execution.id), result) + +func _complete_execution() -> void: + if capture != null: + OS.remove_logger(capture) + capture = null + evaluator = null + evaluation_script = null + busy = false + execution = {} + if native_removed: + _detach_retired_node() + +func _send(connection: Dictionary, id: int, result: Dictionary) -> void: + var envelope := {"protocol": PROTOCOL, "id": id, "generation": session.generation, + "pid": session.pid, "projectPath": session.projectPath, "buildId": session.buildId, "result": result} + var bytes := (JSON.stringify(envelope) + "\n").to_utf8_buffer() + if bytes.size() > MAX_MESSAGE: + envelope.result = _failure("godot_result_too_large", "执行回执超过 2 MiB。", true) + bytes = (JSON.stringify(envelope) + "\n").to_utf8_buffer() + if connection.tx.size() + bytes.size() > MAX_MESSAGE: + _drop_peer(connection) + return + connection.tx.append_array(bytes) + +func _finish_shutdown() -> void: + if busy: + return + _remove_session() + server.stop() + for connection in peers.duplicate(): + _drop_peer(connection) + GDExtensionManager.unload_extension(DESCRIPTOR) + _detach_retired_node() + +func native_deinitialize() -> void: + native_removed = true + shutting_down = true + _remove_session() + server.stop() + for connection in peers.duplicate(): + _drop_peer(connection) + if not busy: + _detach_retired_node() + +func _exit_tree() -> void: + _remove_session() + server.stop() + for connection in peers.duplicate(): + _drop_peer(connection) + if capture != null: + OS.remove_logger(capture) diff --git a/plugins/agc-godot-editor/native/gdextension/src/native.c b/plugins/agc-godot-editor/native/gdextension/src/native.c new file mode 100644 index 000000000..54e9ae5d7 --- /dev/null +++ b/plugins/agc-godot-editor/native/gdextension/src/native.c @@ -0,0 +1,257 @@ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include +#include +#include "gdextension_interface.h" +#include "embedded_bridge.h" + +/* Windows x64 ABI storage is deliberately oversized and naturally aligned. + * Objects are constructed/destructed solely through the official interface. */ +typedef union { max_align_t alignment; unsigned char bytes[128]; } Storage; +static GDExtensionInterfacePrintWarning api_warning; +static GDExtensionInterfaceVariantCall api_call; +static GDExtensionInterfaceVariantDestroy api_destroy; +static GDExtensionInterfaceVariantGetType api_type; +static GDExtensionInterfaceGlobalGetSingleton api_singleton; +static GDExtensionInterfaceStringNameNewWithLatin1Chars api_name; +static GDExtensionInterfaceStringNewWithUtf8Chars api_string; +static GDExtensionInterfaceStringToUtf8Chars api_utf8; +static GDExtensionVariantFromTypeConstructorFunc from_object, from_string, from_name; +static GDExtensionTypeFromVariantConstructorFunc to_int, to_string; +static GDExtensionPtrDestructor destroy_name, destroy_string; +static Storage retained_script, retained_node; +static int script_live, node_live, started; + +static void report_failure(const char *operation, int code) { + char message[256]; + snprintf(message, sizeof(message), "AGC Godot editor bridge: %s failed (%d).", operation, code); + if (api_warning) api_warning(message, "agc_godot_editor", "native.c", 0, 0); +} + +static void name_variant(Storage *out, const char *text) { + Storage name; + api_name(&name, text, 0); + from_name(out, &name); + destroy_name(&name); +} + +static void string_variant(Storage *out, const char *text) { + Storage string; + api_string(&string, text); + from_string(out, &string); + destroy_string(&string); +} + +static int invoke(Storage *receiver, const char *method, + const GDExtensionConstVariantPtr *arguments, int count, Storage *out) { + Storage name; + GDExtensionCallError error = { GDEXTENSION_CALL_OK, 0, 0 }; + api_name(&name, method, 0); + api_call(receiver, &name, arguments, count, out, &error); + destroy_name(&name); + if (error.error != GDEXTENSION_CALL_OK) { + report_failure(method, (int)error.error); + return 0; + } + return 1; +} + +static int singleton_variant(Storage *out, const char *text) { + Storage name; + api_name(&name, text, 0); + GDExtensionObjectPtr object = api_singleton(&name); + destroy_name(&name); + if (!object) return 0; + from_object(out, &object); + return 1; +} + +static char *variant_utf8(Storage *value) { + if (api_type(value) != GDEXTENSION_VARIANT_TYPE_STRING) return NULL; + Storage string; + to_string(&string, value); + GDExtensionInt length = api_utf8(&string, NULL, 0); + char *text = NULL; + if (length >= 0 && length < 131072) { + text = (char *)malloc((size_t)length + 1); + if (text) { + api_utf8(&string, text, length); + text[length] = '\0'; + } + } + destroy_string(&string); + return text; +} + +static int plain_directory(const wchar_t *path) { + DWORD attrs = GetFileAttributesW(path); + return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY) && + !(attrs & FILE_ATTRIBUTE_REPARSE_POINT); +} + +static int ensure_cache_directory(wchar_t *path, size_t capacity, const wchar_t *part) { + size_t length = wcslen(path), addition = wcslen(part); + if (length + addition + 2 >= capacity) return 0; + if (length && path[length - 1] != L'\\') path[length++] = L'\\'; + memcpy(path + length, part, (addition + 1) * sizeof(wchar_t)); + if (!CreateDirectoryW(path, NULL) && GetLastError() != ERROR_ALREADY_EXISTS) return 0; + return plain_directory(path); +} + +static char *prepare_cache_path(void) { + Storage settings, argument, result; + if (!singleton_variant(&settings, "ProjectSettings")) return NULL; + string_variant(&argument, "res://"); + const GDExtensionConstVariantPtr args[] = { &argument }; + int ok = invoke(&settings, "globalize_path", args, 1, &result); + char *root_utf8 = ok ? variant_utf8(&result) : NULL; + api_destroy(&result); + api_destroy(&argument); + api_destroy(&settings); + if (!root_utf8) return NULL; + wchar_t path[32768]; + int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, root_utf8, -1, path, 32768); + free(root_utf8); + if (length < 4 || path[1] != L':') return NULL; + for (int index = 0; index < length; ++index) if (path[index] == L'/') path[index] = L'\\'; + /* Reject links/junctions in every existing directory, including project ancestors. */ + for (int index = 3; index < length; ++index) { + if (path[index] != L'\\' && path[index] != L'\0') continue; + wchar_t saved = path[index]; + path[index] = L'\0'; + int plain = plain_directory(path); + path[index] = saved; + if (!plain) return NULL; + } + if (!ensure_cache_directory(path, 32768, L".godot") || + !ensure_cache_directory(path, 32768, L"agc")) return NULL; + wchar_t suffix[96]; + swprintf(suffix, 96, L"\\editor-bridge-%lu.json", (unsigned long)GetCurrentProcessId()); + if (wcslen(path) + wcslen(suffix) + 1 >= 32768) return NULL; + wcscat(path, suffix); + DWORD attrs = GetFileAttributesW(path); + if (attrs != INVALID_FILE_ATTRIBUTES && (attrs & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY))) return NULL; + int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, path, -1, NULL, 0, NULL, NULL); + if (size <= 0) return NULL; + char *cache = (char *)malloc((size_t)size); + if (cache) WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, path, -1, cache, size, NULL, NULL); + return cache; +} + +static void release_references(void) { + if (node_live) { api_destroy(&retained_node); node_live = 0; } + if (script_live) { api_destroy(&retained_script); script_live = 0; } +} + +static int schedule_bridge(void) { + FILETIME creation, exit_time, kernel, user; + if (!GetProcessTimes(GetCurrentProcess(), &creation, &exit_time, &kernel, &user)) return 0; + ULARGE_INTEGER timestamp; + timestamp.LowPart = creation.dwLowDateTime; + timestamp.HighPart = creation.dwHighDateTime; + char started_file_time[32]; + snprintf(started_file_time, sizeof(started_file_time), "%llu", (unsigned long long)timestamp.QuadPart); + char *cache_path = prepare_cache_path(); + if (!cache_path) { report_failure("session_cache_path", 0); return 0; } + Storage classdb, class_arg, result, source; + if (!singleton_variant(&classdb, "ClassDB")) { free(cache_path); return 0; } + name_variant(&class_arg, "GDScript"); + const GDExtensionConstVariantPtr class_args[] = { &class_arg }; + int ok = invoke(&classdb, "instantiate", class_args, 1, &retained_script); + script_live = 1; + api_destroy(&class_arg); + api_destroy(&classdb); + if (!ok || api_type(&retained_script) != GDEXTENSION_VARIANT_TYPE_OBJECT) { free(cache_path); return 0; } + string_variant(&source, (const char *)AGC_EMBEDDED_BRIDGE); + const GDExtensionConstVariantPtr source_args[] = { &source }; + ok = invoke(&retained_script, "set_source_code", source_args, 1, &result); + api_destroy(&result); + api_destroy(&source); + if (!ok) { free(cache_path); return 0; } + ok = invoke(&retained_script, "reload", NULL, 0, &result); + int64_t reload_error = -1; + if (ok && api_type(&result) == GDEXTENSION_VARIANT_TYPE_INT) to_int(&reload_error, &result); + api_destroy(&result); + if (!ok || reload_error != 0) { free(cache_path); report_failure("bridge_compile", (int)reload_error); return 0; } + ok = invoke(&retained_script, "new", NULL, 0, &retained_node); + node_live = 1; + if (!ok || api_type(&retained_node) != GDEXTENSION_VARIANT_TYPE_OBJECT) { free(cache_path); return 0; } + Storage method, build, process_identity, cache; + name_variant(&method, "bootstrap"); + string_variant(&build, AGC_BUILD_ID); + string_variant(&process_identity, started_file_time); + string_variant(&cache, cache_path); + free(cache_path); + const GDExtensionConstVariantPtr deferred[] = { &method, &build, &process_identity, &cache }; + ok = invoke(&retained_node, "call_deferred", deferred, 4, &result); + api_destroy(&result); + api_destroy(&method); + api_destroy(&build); + api_destroy(&process_identity); + api_destroy(&cache); + return ok; +} + +static void initialize_bridge(void *userdata, GDExtensionInitializationLevel level) { + (void)userdata; + if (level != GDEXTENSION_INITIALIZATION_EDITOR || started) return; + started = 1; + if (!schedule_bridge()) release_references(); +} + +static void deinitialize_bridge(void *userdata, GDExtensionInitializationLevel level) { + (void)userdata; + if (level != GDEXTENSION_INITIALIZATION_EDITOR) return; + if (node_live && api_type(&retained_node) == GDEXTENSION_VARIANT_TYPE_OBJECT) { + Storage returned; + invoke(&retained_node, "native_deinitialize", NULL, 0, &returned); + api_destroy(&returned); + } + release_references(); +} + +__declspec(dllexport) GDExtensionBool agc_godot_editor_init( + GDExtensionInterfaceGetProcAddress get_proc_address, + GDExtensionClassLibraryPtr library, + GDExtensionInitialization *initialization) { + (void)library; + if (!get_proc_address || !initialization) return 0; +#define LOAD(variable, type, symbol) do { \ + GDExtensionInterfaceFunctionPtr raw_function = get_proc_address(symbol); \ + _Static_assert(sizeof(type) == sizeof(raw_function), "Windows function pointer ABI mismatch"); \ + memcpy(&(variable), &raw_function, sizeof(variable)); \ + if (!variable) return 0; \ +} while (0) + LOAD(api_warning, GDExtensionInterfacePrintWarning, "print_warning"); + LOAD(api_call, GDExtensionInterfaceVariantCall, "variant_call"); + LOAD(api_destroy, GDExtensionInterfaceVariantDestroy, "variant_destroy"); + LOAD(api_type, GDExtensionInterfaceVariantGetType, "variant_get_type"); + LOAD(api_singleton, GDExtensionInterfaceGlobalGetSingleton, "global_get_singleton"); + LOAD(api_name, GDExtensionInterfaceStringNameNewWithLatin1Chars, "string_name_new_with_latin1_chars"); + LOAD(api_string, GDExtensionInterfaceStringNewWithUtf8Chars, "string_new_with_utf8_chars"); + LOAD(api_utf8, GDExtensionInterfaceStringToUtf8Chars, "string_to_utf8_chars"); + GDExtensionInterfaceGetVariantFromTypeConstructor get_from; + GDExtensionInterfaceGetVariantToTypeConstructor get_to; + GDExtensionInterfaceVariantGetPtrDestructor get_destructor; + LOAD(get_from, GDExtensionInterfaceGetVariantFromTypeConstructor, "get_variant_from_type_constructor"); + LOAD(get_to, GDExtensionInterfaceGetVariantToTypeConstructor, "get_variant_to_type_constructor"); + LOAD(get_destructor, GDExtensionInterfaceVariantGetPtrDestructor, "variant_get_ptr_destructor"); +#undef LOAD + from_object = get_from(GDEXTENSION_VARIANT_TYPE_OBJECT); + from_string = get_from(GDEXTENSION_VARIANT_TYPE_STRING); + from_name = get_from(GDEXTENSION_VARIANT_TYPE_STRING_NAME); + to_int = get_to(GDEXTENSION_VARIANT_TYPE_INT); + to_string = get_to(GDEXTENSION_VARIANT_TYPE_STRING); + destroy_name = get_destructor(GDEXTENSION_VARIANT_TYPE_STRING_NAME); + destroy_string = get_destructor(GDEXTENSION_VARIANT_TYPE_STRING); + if (!from_object || !from_string || !from_name || !to_int || !to_string || !destroy_name || !destroy_string) return 0; + initialization->minimum_initialization_level = GDEXTENSION_INITIALIZATION_EDITOR; + initialization->userdata = NULL; + initialization->initialize = initialize_bridge; + initialization->deinitialize = deinitialize_bridge; + return 1; +} diff --git a/plugins/agc-godot-editor/native/gdextension/tests/native-smoke.test.mjs b/plugins/agc-godot-editor/native/gdextension/tests/native-smoke.test.mjs new file mode 100644 index 000000000..acc2bd577 --- /dev/null +++ b/plugins/agc-godot-editor/native/gdextension/tests/native-smoke.test.mjs @@ -0,0 +1,325 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const executable = process.env.AGC_GODOT_TEST_EXECUTABLE; +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +async function until(predicate, duration = 15000) { + const end = Date.now() + duration; + while (Date.now() < end) { + const value = predicate(); + if (value) return value; + await pause(25); + } + throw Error('Native Godot condition timed out'); +} + +function call(session, method, params = {}, overrides = {}) { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ + host: '127.0.0.1', + port: session.port, + }); + let data = ''; + socket.setTimeout(10000, () => + socket.destroy(Error('Native response timed out')), + ); + socket.once('error', reject); + socket.once('connect', () => + socket.write( + JSON.stringify({ + protocol: session.protocol, + id: 1, + generation: session.generation, + token: session.token, + method, + params, + ...overrides, + }) + '\n', + ), + ); + socket.on('data', (chunk) => { + data += chunk; + const newline = data.indexOf('\n'); + if (newline < 0) return; + try { + const response = JSON.parse(data.slice(0, newline)); + assert.equal(response.protocol, session.protocol); + assert.equal(response.generation, session.generation); + assert.equal(response.pid, session.pid); + assert.equal(response.buildId, session.buildId); + assert.equal( + path.resolve(response.projectPath).toLowerCase(), + path.resolve(session.projectPath).toLowerCase(), + ); + resolve(response.result); + } catch (error) { + reject(error); + } + socket.end(); + }); + socket.once('end', () => { + if (!data.includes('\n')) reject(Error('No receipt')); + }); + }); +} + +test( + 'real headless Godot native bridge execution and lifecycle', + { skip: !executable, timeout: 90000 }, + async (t) => { + assert.equal(process.platform, 'win32'); + const fixture = path.join(root, '.build', `smoke-${Date.now()}`); + fs.mkdirSync(fixture, { recursive: true }); + const projectText = + 'config_version=5\n[application]\nconfig/name="AGC Native Bridge Smoke"\n[rendering]\nrenderer/rendering_method="gl_compatibility"\n'; + fs.writeFileSync(path.join(fixture, 'project.godot'), projectText); + const dll = path.join(root, 'bin/win-x64/agc_godot_editor.dll'); + const metadata = JSON.parse( + fs.readFileSync(path.join(root, 'bin/win-x64/metadata.json'), 'utf8'), + ); + fs.writeFileSync( + path.join(fixture, 'agc-editor-bridge.gdextension'), + `[configuration]\nentry_symbol="agc_godot_editor_init"\ncompatibility_minimum="4.7"\nreloadable=false\n[libraries]\nwindows.editor.x86_64="${dll.replaceAll('\\', '/')}"\n`, + ); + const child = spawn( + executable, + [ + '--headless', + '--editor', + '--path', + fixture, + '--log-file', + path.join(fixture, 'editor.log'), + ], + { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let output = ''; + child.stdout.on('data', (chunk) => { + output += chunk; + }); + child.stderr.on('data', (chunk) => { + output += chunk; + }); + const exit = once(child, 'exit'); + let session; + try { + const sessionPath = path.join( + fixture, + '.godot/agc', + `editor-bridge-${child.pid}.json`, + ); + session = await until(() => { + if (child.exitCode !== null) throw Error(`Godot exited: ${output}`); + try { + return JSON.parse(fs.readFileSync(sessionPath, 'utf8')); + } catch { + return false; + } + }).catch((error) => { + throw Error(`${error.message}\n${output}`); + }); + assert.equal(session.pid, child.pid); + assert.equal(session.buildId, metadata.buildId); + assert.match(session.startedFileTime, /^[0-9]+$/); + assert.match(session.token, /^[0-9a-f]{64}$/); + assert.match(session.generation, /^[0-9a-f]{64}$/); + const execute = (code, timeoutMs = 5000) => + call(session, 'execute', { code, timeoutMs }); + const armReload = async (unloadFirst, delay = 0.2) => { + const helper = `@tool\nextends Node\nfunc _ready():\n\tget_tree().create_timer(${delay}).timeout.connect(_swap)\nfunc _swap():\n${unloadFirst ? '\tGDExtensionManager.unload_extension("res://agc-editor-bridge.gdextension")\n' : ''}\tvar result = GDExtensionManager.load_extension("res://agc-editor-bridge.gdextension")\n\tif result != GDExtensionManager.LOAD_STATUS_OK:\n\t\tpush_error("AGC test reload failed")\n\tqueue_free()\n`; + const reply = await execute( + `var script := GDScript.new()\nscript.source_code = ${JSON.stringify(helper)}\nassert(script.reload() == OK)\nvar helper: Node = script.new()\nEditorInterface.get_base_control().get_tree().root.add_child(helper)\nreturn true`, + ); + assert.equal(reply.ok, true); + }; + const nextSession = async (previous) => { + session = await until(() => { + try { + const found = JSON.parse(fs.readFileSync(sessionPath, 'utf8')); + return found.generation !== previous ? found : false; + } catch { + return false; + } + }, 5000); + assert.equal(session.pid, child.pid); + assert.equal(session.buildId, metadata.buildId); + assert.equal((await execute('return 42')).result, 42); + }; + await t.test( + 'finite code, null, and JSON values have trusted receipts', + async () => { + const status = await call(session, 'status'); + assert.equal(status.connected, true); + assert.equal(status.executing, false); + const result = await execute('return 6 * 7'); + assert.equal(result.status, 'completed'); + assert.equal(result.result, 42); + const nil = await execute('return null'); + assert.equal(nil.ok, true); + assert.equal(nil.result, null); + const identity = await execute( + 'return {"pid": OS.get_process_id(), "editor": Engine.is_editor_hint()}', + ); + assert.deepEqual(identity.result, { pid: child.pid, editor: true }); + }, + ); + await t.test( + 'compile and runtime errors cannot become null success', + async () => { + const compilation = await execute('var broken ='); + assert.equal(compilation.ok, false); + assert.equal(compilation.error.code, 'godot_compile_error'); + const runtime = await execute( + 'var values: Array = []\nreturn values[8]', + ); + assert.equal(runtime.ok, false); + assert.equal(runtime.error.code, 'godot_runtime_error'); + assert.ok(runtime.logs.some((entry) => entry.level === 'error')); + assert.equal((await execute('return 42')).result, 42); + }, + ); + await t.test( + 'await holds occupancy and refuses shutdown until completion', + async () => { + const pending = execute( + 'await (Engine.get_main_loop() as SceneTree).create_timer(0.25).timeout\nreturn 42', + ); + await pause(60); + assert.equal((await call(session, 'status')).executing, true); + const concurrent = await execute('return 99'); + assert.equal(concurrent.dispatched, false); + assert.equal(concurrent.error.code, 'godot_execution_in_progress'); + assert.equal((await call(session, 'shutdown')).accepted, false); + assert.equal((await pending).result, 42); + assert.equal((await call(session, 'status')).executing, false); + }, + ); + await t.test( + 'async timeout does not release an in-flight execution', + async () => { + const timeout = await execute( + 'await (Engine.get_main_loop() as SceneTree).create_timer(0.3).timeout\nreturn 42', + 80, + ); + assert.equal(timeout.status, 'needs-reconciliation'); + assert.equal(timeout.dispatched, true); + assert.equal((await call(session, 'status')).executing, true); + assert.equal((await call(session, 'shutdown')).accepted, false); + await pause(350); + assert.equal((await call(session, 'status')).executing, false); + }, + ); + await t.test('captured logs, code, and results are bounded', async () => { + const logged = await execute( + 'for i in range(150):\n\tprint("entry " + str(i))\nreturn 42', + ); + assert.equal(logged.result, 42); + assert.ok(logged.logs.length <= 128); + assert.equal(logged.logsTruncated, true); + const code = await execute('#'.repeat(128 * 1024 + 1)); + assert.equal(code.dispatched, false); + const result = await execute('return "x".repeat(2 * 1024 * 1024)'); + assert.equal(result.ok, false); + assert.equal(result.error.code, 'godot_result_not_serializable'); + }); + await t.test('stale identity fails closed', async () => { + await assert.rejects( + call( + session, + 'execute', + { code: 'return 99', timeoutMs: 1000 }, + { generation: 'stale' }, + ), + /No receipt/, + ); + await assert.rejects( + call(session, 'status', {}, { token: 'wrong' }), + /No receipt/, + ); + assert.equal((await execute('return 42')).result, 42); + }); + await t.test( + 'errors after await are still definite failures', + async () => { + const result = await execute( + 'var values: Array = []\nawait (Engine.get_main_loop() as SceneTree).create_timer(0.05).timeout\nreturn values[9]', + 1200, + ); + assert.equal(result.ok, false); + assert.equal(result.error.code, 'godot_runtime_error'); + assert.equal((await call(session, 'status')).executing, false); + }, + ); + await t.test( + 'bootstrap cannot replace a busy generation and reports the conflict', + async () => { + const generation = session.generation; + const conflict = await execute( + 'var bridge = EditorInterface.get_base_control().get_tree().root.get_node("_AGC_GODOT_EDITOR_BRIDGE")\nvar duplicate = bridge.get_script().new()\nduplicate.bootstrap(bridge.session.buildId, bridge.session.startedFileTime, bridge.session_path)\nreturn true', + ); + assert.equal(conflict.ok, false); + assert.ok( + conflict.logs.some((entry) => + entry.message.includes('godot_bridge_busy'), + ), + ); + assert.equal( + JSON.parse(fs.readFileSync(sessionPath, 'utf8')).generation, + generation, + ); + assert.equal((await execute('return 42')).result, 42); + }, + ); + await t.test( + 'unload and reload in one deferred flush transfers the fixed node name', + async () => { + const previous = session.generation; + await armReload(true); + await nextSession(previous); + assert.equal((await call(session, 'status')).executing, false); + }, + ); + await t.test( + 'shutdown then a fresh controlled load reconnects without restarting the editor', + async () => { + const previous = session.generation; + await armReload(false, 0.4); + assert.equal((await call(session, 'shutdown')).accepted, true); + await until(() => !fs.existsSync(sessionPath), 5000); + await nextSession(previous); + }, + ); + await t.test( + 'shutdown receipt precedes listener and own cache removal', + async () => { + assert.deepEqual(await call(session, 'shutdown'), { + accepted: true, + status: 'shutting-down', + }); + await until(() => !fs.existsSync(sessionPath), 5000); + await assert.rejects(call(session, 'status')); + assert.equal( + fs.readFileSync(path.join(fixture, 'project.godot'), 'utf8'), + projectText, + ); + assert.ok( + !output.includes(session.token), + 'Session token must not enter engine logs', + ); + }, + ); + } finally { + // This child was created by this test; no shared/user editor is touched. + child.kill(); + await Promise.race([exit, pause(5000)]); + fs.writeFileSync(path.join(fixture, 'test-output.log'), output); + } + }, +); diff --git a/plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt b/plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt new file mode 100644 index 000000000..0e3ba08d6 --- /dev/null +++ b/plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). +Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/agc-godot-editor/native/gdextension/vendor/gdextension_interface.h b/plugins/agc-godot-editor/native/gdextension/vendor/gdextension_interface.h new file mode 100644 index 000000000..8c34a4474 --- /dev/null +++ b/plugins/agc-godot-editor/native/gdextension/vendor/gdextension_interface.h @@ -0,0 +1,3185 @@ +/* Generated from official Godot 4.7.2-stable interface JSON. */ +/* Source commit: ed1daf0bf001b61586d9930840f2f1394092c079. */ +/* Copyright Godot contributors; see GODOT-LICENSE.txt. */ +#ifndef AGC_GODOT_GDEXTENSION_INTERFACE_H +#define AGC_GODOT_GDEXTENSION_INTERFACE_H +#ifndef __cplusplus +#include +#include + +typedef uint32_t char32_t; +typedef uint16_t char16_t; +#else +#include +#include + +extern "C" { +#endif + +typedef enum { + GDEXTENSION_VARIANT_TYPE_NIL = 0, + GDEXTENSION_VARIANT_TYPE_BOOL = 1, + GDEXTENSION_VARIANT_TYPE_INT = 2, + GDEXTENSION_VARIANT_TYPE_FLOAT = 3, + GDEXTENSION_VARIANT_TYPE_STRING = 4, + GDEXTENSION_VARIANT_TYPE_VECTOR2 = 5, + GDEXTENSION_VARIANT_TYPE_VECTOR2I = 6, + GDEXTENSION_VARIANT_TYPE_RECT2 = 7, + GDEXTENSION_VARIANT_TYPE_RECT2I = 8, + GDEXTENSION_VARIANT_TYPE_VECTOR3 = 9, + GDEXTENSION_VARIANT_TYPE_VECTOR3I = 10, + GDEXTENSION_VARIANT_TYPE_TRANSFORM2D = 11, + GDEXTENSION_VARIANT_TYPE_VECTOR4 = 12, + GDEXTENSION_VARIANT_TYPE_VECTOR4I = 13, + GDEXTENSION_VARIANT_TYPE_PLANE = 14, + GDEXTENSION_VARIANT_TYPE_QUATERNION = 15, + GDEXTENSION_VARIANT_TYPE_AABB = 16, + GDEXTENSION_VARIANT_TYPE_BASIS = 17, + GDEXTENSION_VARIANT_TYPE_TRANSFORM3D = 18, + GDEXTENSION_VARIANT_TYPE_PROJECTION = 19, + GDEXTENSION_VARIANT_TYPE_COLOR = 20, + GDEXTENSION_VARIANT_TYPE_STRING_NAME = 21, + GDEXTENSION_VARIANT_TYPE_NODE_PATH = 22, + GDEXTENSION_VARIANT_TYPE_RID = 23, + GDEXTENSION_VARIANT_TYPE_OBJECT = 24, + GDEXTENSION_VARIANT_TYPE_CALLABLE = 25, + GDEXTENSION_VARIANT_TYPE_SIGNAL = 26, + GDEXTENSION_VARIANT_TYPE_DICTIONARY = 27, + GDEXTENSION_VARIANT_TYPE_ARRAY = 28, + GDEXTENSION_VARIANT_TYPE_PACKED_BYTE_ARRAY = 29, + GDEXTENSION_VARIANT_TYPE_PACKED_INT32_ARRAY = 30, + GDEXTENSION_VARIANT_TYPE_PACKED_INT64_ARRAY = 31, + GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT32_ARRAY = 32, + GDEXTENSION_VARIANT_TYPE_PACKED_FLOAT64_ARRAY = 33, + GDEXTENSION_VARIANT_TYPE_PACKED_STRING_ARRAY = 34, + GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR2_ARRAY = 35, + GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR3_ARRAY = 36, + GDEXTENSION_VARIANT_TYPE_PACKED_COLOR_ARRAY = 37, + GDEXTENSION_VARIANT_TYPE_PACKED_VECTOR4_ARRAY = 38, + GDEXTENSION_VARIANT_TYPE_VARIANT_MAX = 39, +} GDExtensionVariantType; + +typedef enum { + GDEXTENSION_VARIANT_OP_EQUAL = 0, + GDEXTENSION_VARIANT_OP_NOT_EQUAL = 1, + GDEXTENSION_VARIANT_OP_LESS = 2, + GDEXTENSION_VARIANT_OP_LESS_EQUAL = 3, + GDEXTENSION_VARIANT_OP_GREATER = 4, + GDEXTENSION_VARIANT_OP_GREATER_EQUAL = 5, + GDEXTENSION_VARIANT_OP_ADD = 6, + GDEXTENSION_VARIANT_OP_SUBTRACT = 7, + GDEXTENSION_VARIANT_OP_MULTIPLY = 8, + GDEXTENSION_VARIANT_OP_DIVIDE = 9, + GDEXTENSION_VARIANT_OP_NEGATE = 10, + GDEXTENSION_VARIANT_OP_POSITIVE = 11, + GDEXTENSION_VARIANT_OP_MODULE = 12, + GDEXTENSION_VARIANT_OP_POWER = 13, + GDEXTENSION_VARIANT_OP_SHIFT_LEFT = 14, + GDEXTENSION_VARIANT_OP_SHIFT_RIGHT = 15, + GDEXTENSION_VARIANT_OP_BIT_AND = 16, + GDEXTENSION_VARIANT_OP_BIT_OR = 17, + GDEXTENSION_VARIANT_OP_BIT_XOR = 18, + GDEXTENSION_VARIANT_OP_BIT_NEGATE = 19, + GDEXTENSION_VARIANT_OP_AND = 20, + GDEXTENSION_VARIANT_OP_OR = 21, + GDEXTENSION_VARIANT_OP_XOR = 22, + GDEXTENSION_VARIANT_OP_NOT = 23, + GDEXTENSION_VARIANT_OP_IN = 24, + GDEXTENSION_VARIANT_OP_MAX = 25, +} GDExtensionVariantOperator; + +/* In this API there are multiple functions which expect the caller to pass a pointer + * on return value as parameter. + * In order to make it clear if the caller should initialize the return value or not + * we have two flavor of types: + * - `GDExtensionXXXPtr` for pointer on an initialized value + * - `GDExtensionUninitializedXXXPtr` for pointer on uninitialized value + * + * Notes: + * - Not respecting those requirements can seems harmless, but will lead to unexpected + * segfault or memory leak (for instance with a specific compiler/OS, or when two + * native extensions start doing ptrcall on each other). + * - Initialization must be done with the function pointer returned by `variant_get_ptr_constructor`, + * zero-initializing the variable should not be considered a valid initialization method here ! + * - Some types have no destructor (see `extension_api.json`'s `has_destructor` field), for + * them it is always safe to skip the constructor for the return value if you are in a hurry ;-) + */ +typedef void *GDExtensionVariantPtr; +typedef const void *GDExtensionConstVariantPtr; +typedef void *GDExtensionUninitializedVariantPtr; +typedef void *GDExtensionStringNamePtr; +typedef const void *GDExtensionConstStringNamePtr; +typedef void *GDExtensionUninitializedStringNamePtr; +typedef void *GDExtensionStringPtr; +typedef const void *GDExtensionConstStringPtr; +typedef void *GDExtensionUninitializedStringPtr; +typedef void *GDExtensionObjectPtr; +typedef const void *GDExtensionConstObjectPtr; +typedef void *GDExtensionUninitializedObjectPtr; +typedef void *GDExtensionTypePtr; +typedef const void *GDExtensionConstTypePtr; +typedef void *GDExtensionUninitializedTypePtr; +typedef const void *GDExtensionMethodBindPtr; +typedef int64_t GDExtensionInt; +typedef uint8_t GDExtensionBool; +typedef uint64_t GDObjectInstanceID; +typedef void *GDExtensionRefPtr; +typedef const void *GDExtensionConstRefPtr; +typedef enum { + GDEXTENSION_CALL_OK = 0, + GDEXTENSION_CALL_ERROR_INVALID_METHOD = 1, + /* Expected a different variant type. */ + GDEXTENSION_CALL_ERROR_INVALID_ARGUMENT = 2, + /* Expected lower number of arguments. */ + GDEXTENSION_CALL_ERROR_TOO_MANY_ARGUMENTS = 3, + /* Expected higher number of arguments. */ + GDEXTENSION_CALL_ERROR_TOO_FEW_ARGUMENTS = 4, + GDEXTENSION_CALL_ERROR_INSTANCE_IS_NULL = 5, + /* Used for const call. */ + GDEXTENSION_CALL_ERROR_METHOD_NOT_CONST = 6, +} GDExtensionCallErrorType; + +typedef struct { + GDExtensionCallErrorType error; + int32_t argument; + int32_t expected; +} GDExtensionCallError; + +typedef void (*GDExtensionVariantFromTypeConstructorFunc)(GDExtensionUninitializedVariantPtr, GDExtensionTypePtr); +typedef void (*GDExtensionTypeFromVariantConstructorFunc)(GDExtensionUninitializedTypePtr, GDExtensionVariantPtr); +typedef void *(*GDExtensionVariantGetInternalPtrFunc)(GDExtensionVariantPtr); +typedef void (*GDExtensionPtrOperatorEvaluator)(GDExtensionConstTypePtr p_left, GDExtensionConstTypePtr p_right, GDExtensionTypePtr r_result); +typedef void (*GDExtensionPtrBuiltInMethod)(GDExtensionTypePtr p_base, const GDExtensionConstTypePtr *p_args, GDExtensionTypePtr r_return, int32_t p_argument_count); +typedef void (*GDExtensionPtrConstructor)(GDExtensionUninitializedTypePtr p_base, const GDExtensionConstTypePtr *p_args); +typedef void (*GDExtensionPtrDestructor)(GDExtensionTypePtr p_base); +typedef void (*GDExtensionPtrSetter)(GDExtensionTypePtr p_base, GDExtensionConstTypePtr p_value); +typedef void (*GDExtensionPtrGetter)(GDExtensionConstTypePtr p_base, GDExtensionTypePtr r_value); +typedef void (*GDExtensionPtrIndexedSetter)(GDExtensionTypePtr p_base, GDExtensionInt p_index, GDExtensionConstTypePtr p_value); +typedef void (*GDExtensionPtrIndexedGetter)(GDExtensionConstTypePtr p_base, GDExtensionInt p_index, GDExtensionTypePtr r_value); +typedef void (*GDExtensionPtrKeyedSetter)(GDExtensionTypePtr p_base, GDExtensionConstTypePtr p_key, GDExtensionConstTypePtr p_value); +typedef void (*GDExtensionPtrKeyedGetter)(GDExtensionConstTypePtr p_base, GDExtensionConstTypePtr p_key, GDExtensionTypePtr r_value); +typedef uint32_t (*GDExtensionPtrKeyedChecker)(GDExtensionConstVariantPtr p_base, GDExtensionConstVariantPtr p_key); +typedef void (*GDExtensionPtrUtilityFunction)(GDExtensionTypePtr r_return, const GDExtensionConstTypePtr *p_args, int32_t p_argument_count); +typedef GDExtensionObjectPtr (*GDExtensionClassConstructor)(); +typedef void *(*GDExtensionInstanceBindingCreateCallback)(void *p_token, void *p_instance); +typedef void (*GDExtensionInstanceBindingFreeCallback)(void *p_token, void *p_instance, void *p_binding); +typedef GDExtensionBool (*GDExtensionInstanceBindingReferenceCallback)(void *p_token, void *p_binding, GDExtensionBool p_reference); +typedef struct { + GDExtensionInstanceBindingCreateCallback create_callback; + GDExtensionInstanceBindingFreeCallback free_callback; + GDExtensionInstanceBindingReferenceCallback reference_callback; +} GDExtensionInstanceBindingCallbacks; + +typedef void *GDExtensionClassInstancePtr; +typedef GDExtensionBool (*GDExtensionClassSet)(GDExtensionClassInstancePtr p_instance, GDExtensionConstStringNamePtr p_name, GDExtensionConstVariantPtr p_value); +typedef GDExtensionBool (*GDExtensionClassGet)(GDExtensionClassInstancePtr p_instance, GDExtensionConstStringNamePtr p_name, GDExtensionVariantPtr r_ret); +typedef uint64_t (*GDExtensionClassGetRID)(GDExtensionClassInstancePtr p_instance); +typedef struct { + GDExtensionVariantType type; + GDExtensionStringNamePtr name; + GDExtensionStringNamePtr class_name; + /* Bitfield of `PropertyHint` (defined in `extension_api.json`). */ + uint32_t hint; + GDExtensionStringPtr hint_string; + /* Bitfield of `PropertyUsageFlags` (defined in `extension_api.json`). */ + uint32_t usage; +} GDExtensionPropertyInfo; + +typedef struct { + GDExtensionStringNamePtr name; + GDExtensionPropertyInfo return_value; + /* Bitfield of `GDExtensionClassMethodFlags`. */ + uint32_t flags; + int32_t id; + /* Arguments: `default_arguments` is an array of size `argument_count`. */ + uint32_t argument_count; + GDExtensionPropertyInfo *arguments; + /* Default arguments: `default_arguments` is an array of size `default_argument_count`. */ + uint32_t default_argument_count; + GDExtensionVariantPtr *default_arguments; +} GDExtensionMethodInfo; + +typedef const GDExtensionPropertyInfo *(*GDExtensionClassGetPropertyList)(GDExtensionClassInstancePtr p_instance, uint32_t *r_count); +typedef void (*GDExtensionClassFreePropertyList)(GDExtensionClassInstancePtr p_instance, const GDExtensionPropertyInfo *p_list); /* Deprecated in Godot 4.3. Use `GDExtensionClassFreePropertyList2` instead. */ +typedef void (*GDExtensionClassFreePropertyList2)(GDExtensionClassInstancePtr p_instance, const GDExtensionPropertyInfo *p_list, uint32_t p_count); +typedef GDExtensionBool (*GDExtensionClassPropertyCanRevert)(GDExtensionClassInstancePtr p_instance, GDExtensionConstStringNamePtr p_name); +typedef GDExtensionBool (*GDExtensionClassPropertyGetRevert)(GDExtensionClassInstancePtr p_instance, GDExtensionConstStringNamePtr p_name, GDExtensionVariantPtr r_ret); +typedef GDExtensionBool (*GDExtensionClassValidateProperty)(GDExtensionClassInstancePtr p_instance, GDExtensionPropertyInfo *p_property); +typedef void (*GDExtensionClassNotification)(GDExtensionClassInstancePtr p_instance, int32_t p_what); /* Deprecated in Godot 4.2. Use `GDExtensionClassNotification2` instead. */ +typedef void (*GDExtensionClassNotification2)(GDExtensionClassInstancePtr p_instance, int32_t p_what, GDExtensionBool p_reversed); +typedef void (*GDExtensionClassToString)(GDExtensionClassInstancePtr p_instance, GDExtensionBool *r_is_valid, GDExtensionStringPtr p_out); +typedef void (*GDExtensionClassReference)(GDExtensionClassInstancePtr p_instance); +typedef void (*GDExtensionClassUnreference)(GDExtensionClassInstancePtr p_instance); +typedef void (*GDExtensionClassCallVirtual)(GDExtensionClassInstancePtr p_instance, const GDExtensionConstTypePtr *p_args, GDExtensionTypePtr r_ret); +/* Called to construct an instance of the class. + * For classes descending from RefCounted, the reference count should be zero. + */ +typedef GDExtensionObjectPtr (*GDExtensionClassCreateInstance)(void *p_class_userdata); /* Deprecated in Godot 4.4. Use `GDExtensionClassCreateInstance3` instead. */ +/* Called to construct an instance of the class. + * For classes descending from RefCounted, the reference count should be zero. + */ +typedef GDExtensionObjectPtr (*GDExtensionClassCreateInstance2)(void *p_class_userdata, GDExtensionBool p_notify_postinitialize); /* Deprecated in Godot 4.7. Use `GDExtensionClassCreateInstance3` instead. */ +/* Called to construct an instance of the class. + * For classes descending from RefCounted, the reference count should already be incremented by 1. + */ +typedef GDExtensionObjectPtr (*GDExtensionClassCreateInstance3)(void *p_class_userdata, GDExtensionBool p_notify_postinitialize); +typedef void (*GDExtensionClassFreeInstance)(void *p_class_userdata, GDExtensionClassInstancePtr p_instance); +typedef GDExtensionClassInstancePtr (*GDExtensionClassRecreateInstance)(void *p_class_userdata, GDExtensionObjectPtr p_object); +typedef GDExtensionClassCallVirtual (*GDExtensionClassGetVirtual)(void *p_class_userdata, GDExtensionConstStringNamePtr p_name); /* Deprecated in Godot 4.4. Use `GDExtensionClassGetVirtual2` instead. */ +typedef GDExtensionClassCallVirtual (*GDExtensionClassGetVirtual2)(void *p_class_userdata, GDExtensionConstStringNamePtr p_name, uint32_t p_hash); +typedef void *(*GDExtensionClassGetVirtualCallData)(void *p_class_userdata, GDExtensionConstStringNamePtr p_name); /* Deprecated in Godot 4.4. Use `GDExtensionClassGetVirtualCallData2` instead. */ +typedef void *(*GDExtensionClassGetVirtualCallData2)(void *p_class_userdata, GDExtensionConstStringNamePtr p_name, uint32_t p_hash); +typedef void (*GDExtensionClassCallVirtualWithData)(GDExtensionClassInstancePtr p_instance, GDExtensionConstStringNamePtr p_name, void *p_virtual_call_userdata, const GDExtensionConstTypePtr *p_args, GDExtensionTypePtr r_ret); +typedef struct { + GDExtensionBool is_virtual; + GDExtensionBool is_abstract; + GDExtensionClassSet set_func; + GDExtensionClassGet get_func; + GDExtensionClassGetPropertyList get_property_list_func; + GDExtensionClassFreePropertyList free_property_list_func; + GDExtensionClassPropertyCanRevert property_can_revert_func; + GDExtensionClassPropertyGetRevert property_get_revert_func; + GDExtensionClassNotification notification_func; + GDExtensionClassToString to_string_func; + GDExtensionClassReference reference_func; + GDExtensionClassUnreference unreference_func; + /* Class constructor. Required unless the class is virtual or abstract. */ + GDExtensionClassCreateInstance create_instance_func; + /* Destructor; mandatory. */ + GDExtensionClassFreeInstance free_instance_func; + /* Queries a virtual function by name and returns a callback to invoke the requested virtual function. */ + GDExtensionClassGetVirtual get_virtual_func; + GDExtensionClassGetRID get_rid_func; + /* Per-class user data, later accessible in instance bindings. */ + void *class_userdata; +} GDExtensionClassCreationInfo; /* Deprecated in Godot 4.2. Use `GDExtensionClassCreationInfo6` instead. */ + +typedef struct { + GDExtensionBool is_virtual; + GDExtensionBool is_abstract; + GDExtensionBool is_exposed; + GDExtensionClassSet set_func; + GDExtensionClassGet get_func; + GDExtensionClassGetPropertyList get_property_list_func; + GDExtensionClassFreePropertyList free_property_list_func; + GDExtensionClassPropertyCanRevert property_can_revert_func; + GDExtensionClassPropertyGetRevert property_get_revert_func; + GDExtensionClassValidateProperty validate_property_func; + GDExtensionClassNotification2 notification_func; + GDExtensionClassToString to_string_func; + GDExtensionClassReference reference_func; + GDExtensionClassUnreference unreference_func; + /* Class constructor. Required unless the class is virtual or abstract. */ + GDExtensionClassCreateInstance create_instance_func; + /* Destructor; mandatory. */ + GDExtensionClassFreeInstance free_instance_func; + GDExtensionClassRecreateInstance recreate_instance_func; + /* Queries a virtual function by name and returns a callback to invoke the requested virtual function. */ + GDExtensionClassGetVirtual get_virtual_func; + /* Paired with `call_virtual_with_data_func`, this is an alternative to `get_virtual_func` for extensions that + * need or benefit from extra data when calling virtual functions. + * Returns user data that will be passed to `call_virtual_with_data_func`. + * Returning `NULL` from this function signals to Godot that the virtual function is not overridden. + * Data returned from this function should be managed by the extension and must be valid until the extension is deinitialized. + * You should supply either `get_virtual_func`, or `get_virtual_call_data_func` with `call_virtual_with_data_func`. + */ + GDExtensionClassGetVirtualCallData get_virtual_call_data_func; + /* Used to call virtual functions when `get_virtual_call_data_func` is not null. */ + GDExtensionClassCallVirtualWithData call_virtual_with_data_func; + GDExtensionClassGetRID get_rid_func; + /* Per-class user data, later accessible in instance bindings. */ + void *class_userdata; +} GDExtensionClassCreationInfo2; /* Deprecated in Godot 4.3. Use `GDExtensionClassCreationInfo6` instead. */ + +typedef struct { + GDExtensionBool is_virtual; + GDExtensionBool is_abstract; + GDExtensionBool is_exposed; + GDExtensionBool is_runtime; + GDExtensionClassSet set_func; + GDExtensionClassGet get_func; + GDExtensionClassGetPropertyList get_property_list_func; + GDExtensionClassFreePropertyList2 free_property_list_func; + GDExtensionClassPropertyCanRevert property_can_revert_func; + GDExtensionClassPropertyGetRevert property_get_revert_func; + GDExtensionClassValidateProperty validate_property_func; + GDExtensionClassNotification2 notification_func; + GDExtensionClassToString to_string_func; + GDExtensionClassReference reference_func; + GDExtensionClassUnreference unreference_func; + /* Class constructor. Required unless the class is virtual or abstract. */ + GDExtensionClassCreateInstance create_instance_func; + /* Destructor; mandatory. */ + GDExtensionClassFreeInstance free_instance_func; + GDExtensionClassRecreateInstance recreate_instance_func; + /* Queries a virtual function by name and returns a callback to invoke the requested virtual function. */ + GDExtensionClassGetVirtual get_virtual_func; + /* Paired with `call_virtual_with_data_func`, this is an alternative to `get_virtual_func` for extensions that + * need or benefit from extra data when calling virtual functions. + * Returns user data that will be passed to `call_virtual_with_data_func`. + * Returning `NULL` from this function signals to Godot that the virtual function is not overridden. + * Data returned from this function should be managed by the extension and must be valid until the extension is deinitialized. + * You should supply either `get_virtual_func`, or `get_virtual_call_data_func` with `call_virtual_with_data_func`. + */ + GDExtensionClassGetVirtualCallData get_virtual_call_data_func; + /* Used to call virtual functions when `get_virtual_call_data_func` is not null. */ + GDExtensionClassCallVirtualWithData call_virtual_with_data_func; + GDExtensionClassGetRID get_rid_func; + /* Per-class user data, later accessible in instance bindings. */ + void *class_userdata; +} GDExtensionClassCreationInfo3; /* Deprecated in Godot 4.4. Use `GDExtensionClassCreationInfo6` instead. */ + +typedef struct { + GDExtensionBool is_virtual; + GDExtensionBool is_abstract; + GDExtensionBool is_exposed; + GDExtensionBool is_runtime; + GDExtensionConstStringPtr icon_path; + GDExtensionClassSet set_func; + GDExtensionClassGet get_func; + GDExtensionClassGetPropertyList get_property_list_func; + GDExtensionClassFreePropertyList2 free_property_list_func; + GDExtensionClassPropertyCanRevert property_can_revert_func; + GDExtensionClassPropertyGetRevert property_get_revert_func; + GDExtensionClassValidateProperty validate_property_func; + GDExtensionClassNotification2 notification_func; + GDExtensionClassToString to_string_func; + GDExtensionClassReference reference_func; + GDExtensionClassUnreference unreference_func; + /* Class constructor. Required unless the class is virtual or abstract. */ + GDExtensionClassCreateInstance2 create_instance_func; + /* Destructor; mandatory. */ + GDExtensionClassFreeInstance free_instance_func; + GDExtensionClassRecreateInstance recreate_instance_func; + /* Queries a virtual function by name and returns a callback to invoke the requested virtual function. */ + GDExtensionClassGetVirtual2 get_virtual_func; + /* Paired with `call_virtual_with_data_func`, this is an alternative to `get_virtual_func` for extensions that + * need or benefit from extra data when calling virtual functions. + * Returns user data that will be passed to `call_virtual_with_data_func`. + * Returning `NULL` from this function signals to Godot that the virtual function is not overridden. + * Data returned from this function should be managed by the extension and must be valid until the extension is deinitialized. + * You should supply either `get_virtual_func`, or `get_virtual_call_data_func` with `call_virtual_with_data_func`. + */ + GDExtensionClassGetVirtualCallData2 get_virtual_call_data_func; + /* Used to call virtual functions when `get_virtual_call_data_func` is not null. */ + GDExtensionClassCallVirtualWithData call_virtual_with_data_func; + /* Per-class user data, later accessible in instance bindings. */ + void *class_userdata; +} GDExtensionClassCreationInfo4; /* Deprecated in Godot 4.5. Use `GDExtensionClassCreationInfo6` instead. */ + +typedef GDExtensionClassCreationInfo4 GDExtensionClassCreationInfo5; /* Deprecated in Godot 4.7. Use `GDExtensionClassCreationInfo6` instead. */ +typedef struct { + GDExtensionBool is_virtual; + GDExtensionBool is_abstract; + GDExtensionBool is_exposed; + GDExtensionBool is_runtime; + GDExtensionConstStringPtr icon_path; + GDExtensionClassSet set_func; + GDExtensionClassGet get_func; + GDExtensionClassGetPropertyList get_property_list_func; + GDExtensionClassFreePropertyList2 free_property_list_func; + GDExtensionClassPropertyCanRevert property_can_revert_func; + GDExtensionClassPropertyGetRevert property_get_revert_func; + GDExtensionClassValidateProperty validate_property_func; + GDExtensionClassNotification2 notification_func; + GDExtensionClassToString to_string_func; + GDExtensionClassReference reference_func; + GDExtensionClassUnreference unreference_func; + /* Class constructor. Required unless the class is virtual or abstract. */ + GDExtensionClassCreateInstance3 create_instance_func; + /* Destructor; mandatory. */ + GDExtensionClassFreeInstance free_instance_func; + GDExtensionClassRecreateInstance recreate_instance_func; + /* Queries a virtual function by name and returns a callback to invoke the requested virtual function. */ + GDExtensionClassGetVirtual2 get_virtual_func; + /* Paired with `call_virtual_with_data_func`, this is an alternative to `get_virtual_func` for extensions that + * need or benefit from extra data when calling virtual functions. + * Returns user data that will be passed to `call_virtual_with_data_func`. + * Returning `NULL` from this function signals to Godot that the virtual function is not overridden. + * Data returned from this function should be managed by the extension and must be valid until the extension is deinitialized. + * You should supply either `get_virtual_func`, or `get_virtual_call_data_func` with `call_virtual_with_data_func`. + */ + GDExtensionClassGetVirtualCallData2 get_virtual_call_data_func; + /* Used to call virtual functions when `get_virtual_call_data_func` is not null. */ + GDExtensionClassCallVirtualWithData call_virtual_with_data_func; + /* Per-class user data, later accessible in instance bindings. */ + void *class_userdata; +} GDExtensionClassCreationInfo6; + +typedef void *GDExtensionClassLibraryPtr; +/* Passed a pointer to a PackedStringArray that should be filled with the classes that may be used by the GDExtension. */ +typedef void (*GDExtensionEditorGetClassesUsedCallback)(GDExtensionTypePtr p_packed_string_array); +typedef enum { + GDEXTENSION_METHOD_FLAG_NORMAL = 1, + GDEXTENSION_METHOD_FLAG_EDITOR = 2, + GDEXTENSION_METHOD_FLAG_CONST = 4, + GDEXTENSION_METHOD_FLAG_VIRTUAL = 8, + GDEXTENSION_METHOD_FLAG_VARARG = 16, + GDEXTENSION_METHOD_FLAG_STATIC = 32, + GDEXTENSION_METHOD_FLAG_VIRTUAL_REQUIRED = 128, + GDEXTENSION_METHOD_FLAGS_DEFAULT = 1, +} GDExtensionClassMethodFlags; + +typedef enum { + GDEXTENSION_METHOD_ARGUMENT_METADATA_NONE = 0, + GDEXTENSION_METHOD_ARGUMENT_METADATA_INT_IS_INT8 = 1, + GDEXTENSION_METHOD_ARGUMENT_METADATA_INT_IS_INT16 = 2, + GDEXTENSION_METHOD_ARGUMENT_METADATA_INT_IS_INT32 = 3, + GDEXTENSION_METHOD_ARGUMENT_METADATA_INT_IS_INT64 = 4, + GDEXTENSION_METHOD_ARGUMENT_METADATA_INT_IS_UINT8 = 5, + GDEXTENSION_METHOD_ARGUMENT_METADATA_INT_IS_UINT16 = 6, + GDEXTENSION_METHOD_ARGUMENT_METADATA_INT_IS_UINT32 = 7, + GDEXTENSION_METHOD_ARGUMENT_METADATA_INT_IS_UINT64 = 8, + GDEXTENSION_METHOD_ARGUMENT_METADATA_REAL_IS_FLOAT = 9, + GDEXTENSION_METHOD_ARGUMENT_METADATA_REAL_IS_DOUBLE = 10, + GDEXTENSION_METHOD_ARGUMENT_METADATA_INT_IS_CHAR16 = 11, + GDEXTENSION_METHOD_ARGUMENT_METADATA_INT_IS_CHAR32 = 12, + GDEXTENSION_METHOD_ARGUMENT_METADATA_OBJECT_IS_REQUIRED = 13, +} GDExtensionClassMethodArgumentMetadata; + +typedef void (*GDExtensionClassMethodCall)(void *method_userdata, GDExtensionClassInstancePtr p_instance, const GDExtensionConstVariantPtr *p_args, GDExtensionInt p_argument_count, GDExtensionVariantPtr r_return, GDExtensionCallError *r_error); +typedef void (*GDExtensionClassMethodValidatedCall)(void *method_userdata, GDExtensionClassInstancePtr p_instance, const GDExtensionConstVariantPtr *p_args, GDExtensionVariantPtr r_return); +typedef void (*GDExtensionClassMethodPtrCall)(void *method_userdata, GDExtensionClassInstancePtr p_instance, const GDExtensionConstTypePtr *p_args, GDExtensionTypePtr r_ret); +typedef struct { + GDExtensionStringNamePtr name; + void *method_userdata; + GDExtensionClassMethodCall call_func; + GDExtensionClassMethodPtrCall ptrcall_func; + /* Bitfield of `GDExtensionClassMethodFlags`. */ + uint32_t method_flags; + /* If `has_return_value` is false, `return_value_info` and `return_value_metadata` are ignored. + * + * @todo Consider dropping `has_return_value` and making the other two properties match `GDExtensionMethodInfo` and `GDExtensionClassVirtualMethod` for consistency in future version of this struct. + */ + GDExtensionBool has_return_value; + GDExtensionPropertyInfo *return_value_info; + GDExtensionClassMethodArgumentMetadata return_value_metadata; + /* Arguments: `arguments_info` and `arguments_metadata` are array of size `argument_count`. + * Name and hint information for the argument can be omitted in release builds. Class name should always be present if it applies. + * + * @todo Consider renaming `arguments_info` to `arguments` for consistency in future version of this struct. + */ + uint32_t argument_count; + GDExtensionPropertyInfo *arguments_info; + GDExtensionClassMethodArgumentMetadata *arguments_metadata; + /* Default arguments: `default_arguments` is an array of size `default_argument_count`. */ + uint32_t default_argument_count; + GDExtensionVariantPtr *default_arguments; +} GDExtensionClassMethodInfo; + +typedef struct { + GDExtensionStringNamePtr name; + /* Bitfield of `GDExtensionClassMethodFlags`. */ + uint32_t method_flags; + GDExtensionPropertyInfo return_value; + GDExtensionClassMethodArgumentMetadata return_value_metadata; + uint32_t argument_count; + GDExtensionPropertyInfo *arguments; + GDExtensionClassMethodArgumentMetadata *arguments_metadata; +} GDExtensionClassVirtualMethodInfo; + +typedef void (*GDExtensionCallableCustomCall)(void *callable_userdata, const GDExtensionConstVariantPtr *p_args, GDExtensionInt p_argument_count, GDExtensionVariantPtr r_return, GDExtensionCallError *r_error); +typedef GDExtensionBool (*GDExtensionCallableCustomIsValid)(void *callable_userdata); +typedef void (*GDExtensionCallableCustomFree)(void *callable_userdata); +typedef uint32_t (*GDExtensionCallableCustomHash)(void *callable_userdata); +typedef GDExtensionBool (*GDExtensionCallableCustomEqual)(void *callable_userdata_a, void *callable_userdata_b); +typedef GDExtensionBool (*GDExtensionCallableCustomLessThan)(void *callable_userdata_a, void *callable_userdata_b); +typedef void (*GDExtensionCallableCustomToString)(void *callable_userdata, GDExtensionBool *r_is_valid, GDExtensionStringPtr r_out); +typedef GDExtensionInt (*GDExtensionCallableCustomGetArgumentCount)(void *callable_userdata, GDExtensionBool *r_is_valid); +/* Only `call_func` and `token` are strictly required, however, `object_id` should be passed if its not a static method. + * + * `token` should point to an address that uniquely identifies the GDExtension (for example, the + * `GDExtensionClassLibraryPtr` passed to the entry symbol function. + * + * `hash_func`, `equal_func`, and `less_than_func` are optional. If not provided both `call_func` and + * `callable_userdata` together are used as the identity of the callable for hashing and comparison purposes. + * + * The hash returned by `hash_func` is cached, `hash_func` will not be called more than once per callable. + * + * `is_valid_func` is necessary if the validity of the callable can change before destruction. + * + * `free_func` is necessary if `callable_userdata` needs to be cleaned up when the callable is freed. + */ +typedef struct { + void *callable_userdata; + void *token; + GDObjectInstanceID object_id; + GDExtensionCallableCustomCall call_func; + GDExtensionCallableCustomIsValid is_valid_func; + GDExtensionCallableCustomFree free_func; + GDExtensionCallableCustomHash hash_func; + GDExtensionCallableCustomEqual equal_func; + GDExtensionCallableCustomLessThan less_than_func; + GDExtensionCallableCustomToString to_string_func; +} GDExtensionCallableCustomInfo; /* Deprecated in Godot 4.3. Use `GDExtensionCallableCustomInfo2` instead. */ + +/* Only `call_func` and `token` are strictly required, however, `object_id` should be passed if its not a static method. + * + * `token` should point to an address that uniquely identifies the GDExtension (for example, the + * `GDExtensionClassLibraryPtr` passed to the entry symbol function. + * + * `hash_func`, `equal_func`, and `less_than_func` are optional. If not provided both `call_func` and + * `callable_userdata` together are used as the identity of the callable for hashing and comparison purposes. + * + * The hash returned by `hash_func` is cached, `hash_func` will not be called more than once per callable. + * + * `is_valid_func` is necessary if the validity of the callable can change before destruction. + * + * `free_func` is necessary if `callable_userdata` needs to be cleaned up when the callable is freed. + */ +typedef struct { + void *callable_userdata; + void *token; + GDObjectInstanceID object_id; + GDExtensionCallableCustomCall call_func; + GDExtensionCallableCustomIsValid is_valid_func; + GDExtensionCallableCustomFree free_func; + GDExtensionCallableCustomHash hash_func; + GDExtensionCallableCustomEqual equal_func; + GDExtensionCallableCustomLessThan less_than_func; + GDExtensionCallableCustomToString to_string_func; + GDExtensionCallableCustomGetArgumentCount get_argument_count_func; +} GDExtensionCallableCustomInfo2; + +/* Pointer to custom ScriptInstance native implementation. */ +typedef void *GDExtensionScriptInstanceDataPtr; +typedef GDExtensionBool (*GDExtensionScriptInstanceSet)(GDExtensionScriptInstanceDataPtr p_instance, GDExtensionConstStringNamePtr p_name, GDExtensionConstVariantPtr p_value); +typedef GDExtensionBool (*GDExtensionScriptInstanceGet)(GDExtensionScriptInstanceDataPtr p_instance, GDExtensionConstStringNamePtr p_name, GDExtensionVariantPtr r_ret); +typedef const GDExtensionPropertyInfo *(*GDExtensionScriptInstanceGetPropertyList)(GDExtensionScriptInstanceDataPtr p_instance, uint32_t *r_count); +typedef void (*GDExtensionScriptInstanceFreePropertyList)(GDExtensionScriptInstanceDataPtr p_instance, const GDExtensionPropertyInfo *p_list); /* Deprecated in Godot 4.3. Use `GDExtensionScriptInstanceFreePropertyList2` instead. */ +typedef void (*GDExtensionScriptInstanceFreePropertyList2)(GDExtensionScriptInstanceDataPtr p_instance, const GDExtensionPropertyInfo *p_list, uint32_t p_count); +typedef GDExtensionBool (*GDExtensionScriptInstanceGetClassCategory)(GDExtensionScriptInstanceDataPtr p_instance, GDExtensionPropertyInfo *p_class_category); +typedef GDExtensionVariantType (*GDExtensionScriptInstanceGetPropertyType)(GDExtensionScriptInstanceDataPtr p_instance, GDExtensionConstStringNamePtr p_name, GDExtensionBool *r_is_valid); +typedef GDExtensionBool (*GDExtensionScriptInstanceValidateProperty)(GDExtensionScriptInstanceDataPtr p_instance, GDExtensionPropertyInfo *p_property); +typedef GDExtensionBool (*GDExtensionScriptInstancePropertyCanRevert)(GDExtensionScriptInstanceDataPtr p_instance, GDExtensionConstStringNamePtr p_name); +typedef GDExtensionBool (*GDExtensionScriptInstancePropertyGetRevert)(GDExtensionScriptInstanceDataPtr p_instance, GDExtensionConstStringNamePtr p_name, GDExtensionVariantPtr r_ret); +typedef GDExtensionObjectPtr (*GDExtensionScriptInstanceGetOwner)(GDExtensionScriptInstanceDataPtr p_instance); +typedef void (*GDExtensionScriptInstancePropertyStateAdd)(GDExtensionConstStringNamePtr p_name, GDExtensionConstVariantPtr p_value, void *p_userdata); +typedef void (*GDExtensionScriptInstanceGetPropertyState)(GDExtensionScriptInstanceDataPtr p_instance, GDExtensionScriptInstancePropertyStateAdd p_add_func, void *p_userdata); +typedef const GDExtensionMethodInfo *(*GDExtensionScriptInstanceGetMethodList)(GDExtensionScriptInstanceDataPtr p_instance, uint32_t *r_count); +typedef void (*GDExtensionScriptInstanceFreeMethodList)(GDExtensionScriptInstanceDataPtr p_instance, const GDExtensionMethodInfo *p_list); /* Deprecated in Godot 4.3. Use `GDExtensionScriptInstanceFreeMethodList2` instead. */ +typedef void (*GDExtensionScriptInstanceFreeMethodList2)(GDExtensionScriptInstanceDataPtr p_instance, const GDExtensionMethodInfo *p_list, uint32_t p_count); +typedef GDExtensionBool (*GDExtensionScriptInstanceHasMethod)(GDExtensionScriptInstanceDataPtr p_instance, GDExtensionConstStringNamePtr p_name); +typedef GDExtensionInt (*GDExtensionScriptInstanceGetMethodArgumentCount)(GDExtensionScriptInstanceDataPtr p_instance, GDExtensionConstStringNamePtr p_name, GDExtensionBool *r_is_valid); +typedef void (*GDExtensionScriptInstanceCall)(GDExtensionScriptInstanceDataPtr p_self, GDExtensionConstStringNamePtr p_method, const GDExtensionConstVariantPtr *p_args, GDExtensionInt p_argument_count, GDExtensionVariantPtr r_return, GDExtensionCallError *r_error); +typedef void (*GDExtensionScriptInstanceNotification)(GDExtensionScriptInstanceDataPtr p_instance, int32_t p_what); /* Deprecated in Godot 4.2. Use `GDExtensionScriptInstanceNotification2` instead. */ +typedef void (*GDExtensionScriptInstanceNotification2)(GDExtensionScriptInstanceDataPtr p_instance, int32_t p_what, GDExtensionBool p_reversed); +typedef void (*GDExtensionScriptInstanceToString)(GDExtensionScriptInstanceDataPtr p_instance, GDExtensionBool *r_is_valid, GDExtensionStringPtr r_out); +typedef void (*GDExtensionScriptInstanceRefCountIncremented)(GDExtensionScriptInstanceDataPtr p_instance); +typedef GDExtensionBool (*GDExtensionScriptInstanceRefCountDecremented)(GDExtensionScriptInstanceDataPtr p_instance); +typedef GDExtensionObjectPtr (*GDExtensionScriptInstanceGetScript)(GDExtensionScriptInstanceDataPtr p_instance); +typedef GDExtensionBool (*GDExtensionScriptInstanceIsPlaceholder)(GDExtensionScriptInstanceDataPtr p_instance); +typedef void *GDExtensionScriptLanguagePtr; +typedef GDExtensionScriptLanguagePtr (*GDExtensionScriptInstanceGetLanguage)(GDExtensionScriptInstanceDataPtr p_instance); +typedef void (*GDExtensionScriptInstanceFree)(GDExtensionScriptInstanceDataPtr p_instance); +/* Pointer to ScriptInstance. */ +typedef void *GDExtensionScriptInstancePtr; +typedef struct { + GDExtensionScriptInstanceSet set_func; + GDExtensionScriptInstanceGet get_func; + GDExtensionScriptInstanceGetPropertyList get_property_list_func; + GDExtensionScriptInstanceFreePropertyList free_property_list_func; + GDExtensionScriptInstancePropertyCanRevert property_can_revert_func; + GDExtensionScriptInstancePropertyGetRevert property_get_revert_func; + GDExtensionScriptInstanceGetOwner get_owner_func; + GDExtensionScriptInstanceGetPropertyState get_property_state_func; + GDExtensionScriptInstanceGetMethodList get_method_list_func; + GDExtensionScriptInstanceFreeMethodList free_method_list_func; + GDExtensionScriptInstanceGetPropertyType get_property_type_func; + GDExtensionScriptInstanceHasMethod has_method_func; + GDExtensionScriptInstanceCall call_func; + GDExtensionScriptInstanceNotification notification_func; + GDExtensionScriptInstanceToString to_string_func; + GDExtensionScriptInstanceRefCountIncremented refcount_incremented_func; + GDExtensionScriptInstanceRefCountDecremented refcount_decremented_func; + GDExtensionScriptInstanceGetScript get_script_func; + GDExtensionScriptInstanceIsPlaceholder is_placeholder_func; + GDExtensionScriptInstanceSet set_fallback_func; + GDExtensionScriptInstanceGet get_fallback_func; + GDExtensionScriptInstanceGetLanguage get_language_func; + GDExtensionScriptInstanceFree free_func; +} GDExtensionScriptInstanceInfo; /* Deprecated in Godot 4.2. Use `GDExtensionScriptInstanceInfo3` instead. */ + +typedef struct { + GDExtensionScriptInstanceSet set_func; + GDExtensionScriptInstanceGet get_func; + GDExtensionScriptInstanceGetPropertyList get_property_list_func; + GDExtensionScriptInstanceFreePropertyList free_property_list_func; + /* Optional. Set to NULL for the default behavior. */ + GDExtensionScriptInstanceGetClassCategory get_class_category_func; + GDExtensionScriptInstancePropertyCanRevert property_can_revert_func; + GDExtensionScriptInstancePropertyGetRevert property_get_revert_func; + GDExtensionScriptInstanceGetOwner get_owner_func; + GDExtensionScriptInstanceGetPropertyState get_property_state_func; + GDExtensionScriptInstanceGetMethodList get_method_list_func; + GDExtensionScriptInstanceFreeMethodList free_method_list_func; + GDExtensionScriptInstanceGetPropertyType get_property_type_func; + GDExtensionScriptInstanceValidateProperty validate_property_func; + GDExtensionScriptInstanceHasMethod has_method_func; + GDExtensionScriptInstanceCall call_func; + GDExtensionScriptInstanceNotification2 notification_func; + GDExtensionScriptInstanceToString to_string_func; + GDExtensionScriptInstanceRefCountIncremented refcount_incremented_func; + GDExtensionScriptInstanceRefCountDecremented refcount_decremented_func; + GDExtensionScriptInstanceGetScript get_script_func; + GDExtensionScriptInstanceIsPlaceholder is_placeholder_func; + GDExtensionScriptInstanceSet set_fallback_func; + GDExtensionScriptInstanceGet get_fallback_func; + GDExtensionScriptInstanceGetLanguage get_language_func; + GDExtensionScriptInstanceFree free_func; +} GDExtensionScriptInstanceInfo2; /* Deprecated in Godot 4.3. Use `GDExtensionScriptInstanceInfo3` instead. */ + +typedef struct { + GDExtensionScriptInstanceSet set_func; + GDExtensionScriptInstanceGet get_func; + GDExtensionScriptInstanceGetPropertyList get_property_list_func; + GDExtensionScriptInstanceFreePropertyList2 free_property_list_func; + /* Optional. Set to NULL for the default behavior. */ + GDExtensionScriptInstanceGetClassCategory get_class_category_func; + GDExtensionScriptInstancePropertyCanRevert property_can_revert_func; + GDExtensionScriptInstancePropertyGetRevert property_get_revert_func; + GDExtensionScriptInstanceGetOwner get_owner_func; + GDExtensionScriptInstanceGetPropertyState get_property_state_func; + GDExtensionScriptInstanceGetMethodList get_method_list_func; + GDExtensionScriptInstanceFreeMethodList2 free_method_list_func; + GDExtensionScriptInstanceGetPropertyType get_property_type_func; + GDExtensionScriptInstanceValidateProperty validate_property_func; + GDExtensionScriptInstanceHasMethod has_method_func; + GDExtensionScriptInstanceGetMethodArgumentCount get_method_argument_count_func; + GDExtensionScriptInstanceCall call_func; + GDExtensionScriptInstanceNotification2 notification_func; + GDExtensionScriptInstanceToString to_string_func; + GDExtensionScriptInstanceRefCountIncremented refcount_incremented_func; + GDExtensionScriptInstanceRefCountDecremented refcount_decremented_func; + GDExtensionScriptInstanceGetScript get_script_func; + GDExtensionScriptInstanceIsPlaceholder is_placeholder_func; + GDExtensionScriptInstanceSet set_fallback_func; + GDExtensionScriptInstanceGet get_fallback_func; + GDExtensionScriptInstanceGetLanguage get_language_func; + GDExtensionScriptInstanceFree free_func; +} GDExtensionScriptInstanceInfo3; + +typedef void (*GDExtensionWorkerThreadPoolGroupTask)(void *, uint32_t); +typedef void (*GDExtensionWorkerThreadPoolTask)(void *); +typedef enum { + GDEXTENSION_INITIALIZATION_CORE = 0, + GDEXTENSION_INITIALIZATION_SERVERS = 1, + GDEXTENSION_INITIALIZATION_SCENE = 2, + GDEXTENSION_INITIALIZATION_EDITOR = 3, + GDEXTENSION_MAX_INITIALIZATION_LEVEL = 4, +} GDExtensionInitializationLevel; + +typedef void (*GDExtensionInitializeCallback)(void *p_userdata, GDExtensionInitializationLevel p_level); +typedef void (*GDExtensionDeinitializeCallback)(void *p_userdata, GDExtensionInitializationLevel p_level); +typedef struct { + /* Minimum initialization level required. + * If Core or Servers, the extension needs editor or game restart to take effect + */ + GDExtensionInitializationLevel minimum_initialization_level; + /* Up to the user to supply when initializing */ + void *userdata; + /* This function will be called multiple times for each initialization level. */ + GDExtensionInitializeCallback initialize; + GDExtensionDeinitializeCallback deinitialize; +} GDExtensionInitialization; + +typedef void (*GDExtensionInterfaceFunctionPtr)(); +typedef GDExtensionInterfaceFunctionPtr (*GDExtensionInterfaceGetProcAddress)(const char *p_function_name); +/* Each GDExtension should define a C function that matches the signature of GDExtensionInitializationFunction, + * and export it so that it can be loaded via dlopen() or equivalent for the given platform. + * + * For example: + * + * GDExtensionBool my_extension_init(GDExtensionInterfaceGetProcAddress p_get_proc_address, GDExtensionClassLibraryPtr p_library, GDExtensionInitialization *r_initialization); + * + * This function's name must be specified as the 'entry_symbol' in the .gdextension file. + * + * This makes it the entry point of the GDExtension and will be called on initialization. + * + * The GDExtension can then modify the r_initialization structure, setting the minimum initialization level, + * and providing pointers to functions that will be called at various stages of initialization/shutdown. + * + * The rest of the GDExtension's interface to Godot consists of function pointers that can be loaded + * by calling p_get_proc_address("...") with the name of the function. + * + * For example: + * + * GDExtensionInterfaceGetGodotVersion get_godot_version = (GDExtensionInterfaceGetGodotVersion)p_get_proc_address("get_godot_version"); + * + * (Note that snippet may cause "cast between incompatible function types" on some compilers, you can + * silence this by adding an intermediary `void*` cast.) + * + * You can then call it like a normal function: + * + * GDExtensionGodotVersion godot_version; + * get_godot_version(&godot_version); + * printf("Godot v%d.%d.%d\n", godot_version.major, godot_version.minor, godot_version.patch); + * + * All of these interface functions are described below, together with the name that's used to load it, + * and the function pointer typedef that shows its signature. + */ +typedef GDExtensionBool (*GDExtensionInitializationFunction)(GDExtensionInterfaceGetProcAddress p_get_proc_address, GDExtensionClassLibraryPtr p_library, GDExtensionInitialization *r_initialization); +typedef struct { + uint32_t major; + uint32_t minor; + uint32_t patch; + const char *string; +} GDExtensionGodotVersion; /* Deprecated in Godot 4.5. Use `GDExtensionGodotVersion2` instead. */ + +typedef struct { + uint32_t major; + uint32_t minor; + uint32_t patch; + /* Full version encoded as hexadecimal with one byte (2 hex digits) per number (e.g. for "3.1.12" it would be 0x03010C) */ + uint32_t hex; + /* (e.g. "stable", "beta", "rc1", "rc2") */ + const char *status; + /* (e.g. "custom_build") */ + const char *build; + /* Full Git commit hash. */ + const char *hash; + /* Git commit date UNIX timestamp in seconds, or 0 if unavailable. */ + uint64_t timestamp; + /* (e.g. "Godot v3.1.4.stable.official.mono") */ + const char *string; +} GDExtensionGodotVersion2; + +/* Called when starting the main loop. */ +typedef void (*GDExtensionMainLoopStartupCallback)(); +/* Called when shutting down the main loop. */ +typedef void (*GDExtensionMainLoopShutdownCallback)(); +/* Called for every frame iteration of the main loop. */ +typedef void (*GDExtensionMainLoopFrameCallback)(); +typedef struct { + /* Will be called after Godot is started and is fully initialized. */ + GDExtensionMainLoopStartupCallback startup_func; + /* Will be called before Godot is shutdown when it is still fully initialized. */ + GDExtensionMainLoopShutdownCallback shutdown_func; + /* Will be called for each process frame. This will run after all `_process()` methods on Node, and before `ScriptServer::frame()`. + * This is intended to be the equivalent of `ScriptLanguage::frame()` for GDExtension language bindings that don't use the script API. + */ + GDExtensionMainLoopFrameCallback frame_func; +} GDExtensionMainLoopCallbacks; + +/** + * @name get_godot_version + * @since 4.1 + * @deprecated Deprecated in Godot 4.5. Use `get_godot_version2` instead. + * + * Gets the Godot version that the GDExtension was loaded into. + * + * @param r_godot_version A pointer to the structure to write the version information into. + */ +typedef void (*GDExtensionInterfaceGetGodotVersion)(GDExtensionGodotVersion *r_godot_version); + +/** + * @name get_godot_version2 + * @since 4.5 + * + * Gets the Godot version that the GDExtension was loaded into. + * + * @param r_godot_version A pointer to the structure to write the version information into. + */ +typedef void (*GDExtensionInterfaceGetGodotVersion2)(GDExtensionGodotVersion2 *r_godot_version); + +/** + * @name mem_alloc + * @since 4.1 + * @deprecated Deprecated in Godot 4.6. Does not allow explicitly requesting padding. Use `mem_alloc2` instead. + * + * Allocates memory. + * + * @param p_bytes The amount of memory to allocate in bytes. + * + * @return A pointer to the allocated memory, or NULL if unsuccessful. + */ +typedef void *(*GDExtensionInterfaceMemAlloc)(size_t p_bytes); + +/** + * @name mem_realloc + * @since 4.1 + * @deprecated Deprecated in Godot 4.6. Does not allow explicitly requesting padding. Use `mem_realloc2` instead. + * + * Reallocates memory. + * + * @param p_ptr A pointer to the previously allocated memory. + * @param p_bytes The number of bytes to resize the memory block to. + * + * @return A pointer to the allocated memory, or NULL if unsuccessful. + */ +typedef void *(*GDExtensionInterfaceMemRealloc)(void *p_ptr, size_t p_bytes); + +/** + * @name mem_free + * @since 4.1 + * @deprecated Deprecated in Godot 4.6. Does not allow explicitly requesting padding. Use `mem_free2` instead. + * + * Frees memory. + * + * @param p_ptr A pointer to the previously allocated memory. + */ +typedef void (*GDExtensionInterfaceMemFree)(void *p_ptr); + +/** + * @name mem_alloc2 + * @since 4.6 + * + * Allocates memory. + * + * @param p_bytes The amount of memory to allocate in bytes. + * @param p_pad_align If true, the returned memory will have prepadding of at least 8 bytes. + * + * @return A pointer to the allocated memory, or NULL if unsuccessful. + */ +typedef void *(*GDExtensionInterfaceMemAlloc2)(size_t p_bytes, GDExtensionBool p_pad_align); + +/** + * @name mem_realloc2 + * @since 4.6 + * + * Reallocates memory. + * + * @param p_ptr A pointer to the previously allocated memory. + * @param p_bytes The number of bytes to resize the memory block to. + * @param p_pad_align If true, the returned memory will have prepadding of at least 8 bytes. + * + * @return A pointer to the allocated memory, or NULL if unsuccessful. + */ +typedef void *(*GDExtensionInterfaceMemRealloc2)(void *p_ptr, size_t p_bytes, GDExtensionBool p_pad_align); + +/** + * @name mem_free2 + * @since 4.6 + * + * Frees memory. + * + * @param p_ptr A pointer to the previously allocated memory. + * @param p_pad_align If true, the given memory was allocated with prepadding. + */ +typedef void (*GDExtensionInterfaceMemFree2)(void *p_ptr, GDExtensionBool p_pad_align); + +/** + * @name print_error + * @since 4.1 + * + * Logs an error to Godot's built-in debugger and to the OS terminal. + * + * @param p_description The code triggering the error. + * @param p_function The function name where the error occurred. + * @param p_file The file where the error occurred. + * @param p_line The line where the error occurred. + * @param p_editor_notify Whether or not to notify the editor. + */ +typedef void (*GDExtensionInterfacePrintError)(const char *p_description, const char *p_function, const char *p_file, int32_t p_line, GDExtensionBool p_editor_notify); + +/** + * @name print_error_with_message + * @since 4.1 + * + * Logs an error with a message to Godot's built-in debugger and to the OS terminal. + * + * @param p_description The code triggering the error. + * @param p_message The message to show along with the error. + * @param p_function The function name where the error occurred. + * @param p_file The file where the error occurred. + * @param p_line The line where the error occurred. + * @param p_editor_notify Whether or not to notify the editor. + */ +typedef void (*GDExtensionInterfacePrintErrorWithMessage)(const char *p_description, const char *p_message, const char *p_function, const char *p_file, int32_t p_line, GDExtensionBool p_editor_notify); + +/** + * @name print_warning + * @since 4.1 + * + * Logs a warning to Godot's built-in debugger and to the OS terminal. + * + * @param p_description The code triggering the warning. + * @param p_function The function name where the warning occurred. + * @param p_file The file where the warning occurred. + * @param p_line The line where the warning occurred. + * @param p_editor_notify Whether or not to notify the editor. + */ +typedef void (*GDExtensionInterfacePrintWarning)(const char *p_description, const char *p_function, const char *p_file, int32_t p_line, GDExtensionBool p_editor_notify); + +/** + * @name print_warning_with_message + * @since 4.1 + * + * Logs a warning with a message to Godot's built-in debugger and to the OS terminal. + * + * @param p_description The code triggering the warning. + * @param p_message The message to show along with the warning. + * @param p_function The function name where the warning occurred. + * @param p_file The file where the warning occurred. + * @param p_line The line where the warning occurred. + * @param p_editor_notify Whether or not to notify the editor. + */ +typedef void (*GDExtensionInterfacePrintWarningWithMessage)(const char *p_description, const char *p_message, const char *p_function, const char *p_file, int32_t p_line, GDExtensionBool p_editor_notify); + +/** + * @name print_script_error + * @since 4.1 + * + * Logs a script error to Godot's built-in debugger and to the OS terminal. + * + * @param p_description The code triggering the error. + * @param p_function The function name where the error occurred. + * @param p_file The file where the error occurred. + * @param p_line The line where the error occurred. + * @param p_editor_notify Whether or not to notify the editor. + */ +typedef void (*GDExtensionInterfacePrintScriptError)(const char *p_description, const char *p_function, const char *p_file, int32_t p_line, GDExtensionBool p_editor_notify); + +/** + * @name print_script_error_with_message + * @since 4.1 + * + * Logs a script error with a message to Godot's built-in debugger and to the OS terminal. + * + * @param p_description The code triggering the error. + * @param p_message The message to show along with the error. + * @param p_function The function name where the error occurred. + * @param p_file The file where the error occurred. + * @param p_line The line where the error occurred. + * @param p_editor_notify Whether or not to notify the editor. + */ +typedef void (*GDExtensionInterfacePrintScriptErrorWithMessage)(const char *p_description, const char *p_message, const char *p_function, const char *p_file, int32_t p_line, GDExtensionBool p_editor_notify); + +/** + * @name get_native_struct_size + * @since 4.1 + * + * Gets the size of a native struct (ex. ObjectID) in bytes. + * + * @param p_name A pointer to a StringName identifying the struct name. + * + * @return The size in bytes. + */ +typedef uint64_t (*GDExtensionInterfaceGetNativeStructSize)(GDExtensionConstStringNamePtr p_name); + +/** + * @name variant_new_copy + * @since 4.1 + * + * Copies one Variant into a another. + * + * @param r_dest A pointer to the destination Variant. + * @param p_src A pointer to the source Variant. + */ +typedef void (*GDExtensionInterfaceVariantNewCopy)(GDExtensionUninitializedVariantPtr r_dest, GDExtensionConstVariantPtr p_src); + +/** + * @name variant_new_nil + * @since 4.1 + * + * Creates a new Variant containing nil. + * + * @param r_dest A pointer to the destination Variant. + */ +typedef void (*GDExtensionInterfaceVariantNewNil)(GDExtensionUninitializedVariantPtr r_dest); + +/** + * @name variant_destroy + * @since 4.1 + * + * Destroys a Variant. + * + * @param p_self A pointer to the Variant to destroy. + */ +typedef void (*GDExtensionInterfaceVariantDestroy)(GDExtensionVariantPtr p_self); + +/** + * @name variant_call + * @since 4.1 + * + * Calls a method on a Variant. + * + * @param p_self A pointer to the Variant. + * @param p_method A pointer to a StringName identifying the method. + * @param p_args A pointer to a C array of Variant. + * @param p_argument_count The number of arguments. + * @param r_return A pointer a Variant which will be assigned the return value. + * @param r_error A pointer the structure which will hold error information. + * + * @see Variant::callp() + */ +typedef void (*GDExtensionInterfaceVariantCall)(GDExtensionVariantPtr p_self, GDExtensionConstStringNamePtr p_method, const GDExtensionConstVariantPtr *p_args, GDExtensionInt p_argument_count, GDExtensionUninitializedVariantPtr r_return, GDExtensionCallError *r_error); + +/** + * @name variant_call_static + * @since 4.1 + * + * Calls a static method on a Variant. + * + * @param p_type The variant type. + * @param p_method A pointer to a StringName identifying the method. + * @param p_args A pointer to a C array of Variant. + * @param p_argument_count The number of arguments. + * @param r_return A pointer a Variant which will be assigned the return value. + * @param r_error A pointer the structure which will be updated with error information. + * + * @see Variant::call_static() + */ +typedef void (*GDExtensionInterfaceVariantCallStatic)(GDExtensionVariantType p_type, GDExtensionConstStringNamePtr p_method, const GDExtensionConstVariantPtr *p_args, GDExtensionInt p_argument_count, GDExtensionUninitializedVariantPtr r_return, GDExtensionCallError *r_error); + +/** + * @name variant_evaluate + * @since 4.1 + * + * Evaluate an operator on two Variants. + * + * @param p_op The operator to evaluate. + * @param p_a The first Variant. + * @param p_b The second Variant. + * @param r_return A pointer a Variant which will be assigned the return value. + * @param r_valid A pointer to a boolean which will be set to false if the operation is invalid. + * + * @see Variant::evaluate() + */ +typedef void (*GDExtensionInterfaceVariantEvaluate)(GDExtensionVariantOperator p_op, GDExtensionConstVariantPtr p_a, GDExtensionConstVariantPtr p_b, GDExtensionUninitializedVariantPtr r_return, GDExtensionBool *r_valid); + +/** + * @name variant_set + * @since 4.1 + * + * Sets a key on a Variant to a value. + * + * @param p_self A pointer to the Variant. + * @param p_key A pointer to a Variant representing the key. + * @param p_value A pointer to a Variant representing the value. + * @param r_valid A pointer to a boolean which will be set to false if the operation is invalid. + * + * @see Variant::set() + */ +typedef void (*GDExtensionInterfaceVariantSet)(GDExtensionVariantPtr p_self, GDExtensionConstVariantPtr p_key, GDExtensionConstVariantPtr p_value, GDExtensionBool *r_valid); + +/** + * @name variant_set_named + * @since 4.1 + * + * Sets a named key on a Variant to a value. + * + * @param p_self A pointer to the Variant. + * @param p_key A pointer to a StringName representing the key. + * @param p_value A pointer to a Variant representing the value. + * @param r_valid A pointer to a boolean which will be set to false if the operation is invalid. + * + * @see Variant::set_named() + */ +typedef void (*GDExtensionInterfaceVariantSetNamed)(GDExtensionVariantPtr p_self, GDExtensionConstStringNamePtr p_key, GDExtensionConstVariantPtr p_value, GDExtensionBool *r_valid); + +/** + * @name variant_set_keyed + * @since 4.1 + * + * Sets a keyed property on a Variant to a value. + * + * @param p_self A pointer to the Variant. + * @param p_key A pointer to a Variant representing the key. + * @param p_value A pointer to a Variant representing the value. + * @param r_valid A pointer to a boolean which will be set to false if the operation is invalid. + * + * @see Variant::set_keyed() + */ +typedef void (*GDExtensionInterfaceVariantSetKeyed)(GDExtensionVariantPtr p_self, GDExtensionConstVariantPtr p_key, GDExtensionConstVariantPtr p_value, GDExtensionBool *r_valid); + +/** + * @name variant_set_indexed + * @since 4.1 + * + * Sets an index on a Variant to a value. + * + * @param p_self A pointer to the Variant. + * @param p_index The index. + * @param p_value A pointer to a Variant representing the value. + * @param r_valid A pointer to a boolean which will be set to false if the operation is invalid. + * @param r_oob A pointer to a boolean which will be set to true if the index is out of bounds. + */ +typedef void (*GDExtensionInterfaceVariantSetIndexed)(GDExtensionVariantPtr p_self, GDExtensionInt p_index, GDExtensionConstVariantPtr p_value, GDExtensionBool *r_valid, GDExtensionBool *r_oob); + +/** + * @name variant_get + * @since 4.1 + * + * Gets the value of a key from a Variant. + * + * @param p_self A pointer to the Variant. + * @param p_key A pointer to a Variant representing the key. + * @param r_ret A pointer to a Variant which will be assigned the value. + * @param r_valid A pointer to a boolean which will be set to false if the operation is invalid. + */ +typedef void (*GDExtensionInterfaceVariantGet)(GDExtensionConstVariantPtr p_self, GDExtensionConstVariantPtr p_key, GDExtensionUninitializedVariantPtr r_ret, GDExtensionBool *r_valid); + +/** + * @name variant_get_named + * @since 4.1 + * + * Gets the value of a named key from a Variant. + * + * @param p_self A pointer to the Variant. + * @param p_key A pointer to a StringName representing the key. + * @param r_ret A pointer to a Variant which will be assigned the value. + * @param r_valid A pointer to a boolean which will be set to false if the operation is invalid. + */ +typedef void (*GDExtensionInterfaceVariantGetNamed)(GDExtensionConstVariantPtr p_self, GDExtensionConstStringNamePtr p_key, GDExtensionUninitializedVariantPtr r_ret, GDExtensionBool *r_valid); + +/** + * @name variant_get_keyed + * @since 4.1 + * + * Gets the value of a keyed property from a Variant. + * + * @param p_self A pointer to the Variant. + * @param p_key A pointer to a Variant representing the key. + * @param r_ret A pointer to a Variant which will be assigned the value. + * @param r_valid A pointer to a boolean which will be set to false if the operation is invalid. + */ +typedef void (*GDExtensionInterfaceVariantGetKeyed)(GDExtensionConstVariantPtr p_self, GDExtensionConstVariantPtr p_key, GDExtensionUninitializedVariantPtr r_ret, GDExtensionBool *r_valid); + +/** + * @name variant_get_indexed + * @since 4.1 + * + * Gets the value of an index from a Variant. + * + * @param p_self A pointer to the Variant. + * @param p_index The index. + * @param r_ret A pointer to a Variant which will be assigned the value. + * @param r_valid A pointer to a boolean which will be set to false if the operation is invalid. + * @param r_oob A pointer to a boolean which will be set to true if the index is out of bounds. + */ +typedef void (*GDExtensionInterfaceVariantGetIndexed)(GDExtensionConstVariantPtr p_self, GDExtensionInt p_index, GDExtensionUninitializedVariantPtr r_ret, GDExtensionBool *r_valid, GDExtensionBool *r_oob); + +/** + * @name variant_iter_init + * @since 4.1 + * + * Initializes an iterator over a Variant. + * + * @param p_self A pointer to the Variant. + * @param r_iter A pointer to a Variant which will be assigned the iterator. + * @param r_valid A pointer to a boolean which will be set to false if the operation is invalid. + * + * @return true if the operation is valid; otherwise false. + * + * @see Variant::iter_init() + */ +typedef GDExtensionBool (*GDExtensionInterfaceVariantIterInit)(GDExtensionConstVariantPtr p_self, GDExtensionUninitializedVariantPtr r_iter, GDExtensionBool *r_valid); + +/** + * @name variant_iter_next + * @since 4.1 + * + * Gets the next value for an iterator over a Variant. + * + * @param p_self A pointer to the Variant. + * @param r_iter A pointer to a Variant which will be assigned the iterator. + * @param r_valid A pointer to a boolean which will be set to false if the operation is invalid. + * + * @return true if the operation is valid; otherwise false. + * + * @see Variant::iter_next() + */ +typedef GDExtensionBool (*GDExtensionInterfaceVariantIterNext)(GDExtensionConstVariantPtr p_self, GDExtensionVariantPtr r_iter, GDExtensionBool *r_valid); + +/** + * @name variant_iter_get + * @since 4.1 + * + * Gets the next value for an iterator over a Variant. + * + * @param p_self A pointer to the Variant. + * @param r_iter A pointer to a Variant which will be assigned the iterator. + * @param r_ret A pointer to a Variant which will be assigned false if the operation is invalid. + * @param r_valid A pointer to a boolean which will be set to false if the operation is invalid. + * + * @see Variant::iter_get() + */ +typedef void (*GDExtensionInterfaceVariantIterGet)(GDExtensionConstVariantPtr p_self, GDExtensionVariantPtr r_iter, GDExtensionUninitializedVariantPtr r_ret, GDExtensionBool *r_valid); + +/** + * @name variant_hash + * @since 4.1 + * + * Gets the hash of a Variant. + * + * @param p_self A pointer to the Variant. + * + * @return The hash value. + * + * @see Variant::hash() + */ +typedef GDExtensionInt (*GDExtensionInterfaceVariantHash)(GDExtensionConstVariantPtr p_self); + +/** + * @name variant_recursive_hash + * @since 4.1 + * + * Gets the recursive hash of a Variant. + * + * @param p_self A pointer to the Variant. + * @param p_recursion_count The number of recursive loops so far. + * + * @return The hash value. + * + * @see Variant::recursive_hash() + */ +typedef GDExtensionInt (*GDExtensionInterfaceVariantRecursiveHash)(GDExtensionConstVariantPtr p_self, GDExtensionInt p_recursion_count); + +/** + * @name variant_hash_compare + * @since 4.1 + * + * Compares two Variants by their hash. + * + * @param p_self A pointer to the Variant. + * @param p_other A pointer to the other Variant to compare it to. + * + * @return The hash value. + * + * @see Variant::hash_compare() + */ +typedef GDExtensionBool (*GDExtensionInterfaceVariantHashCompare)(GDExtensionConstVariantPtr p_self, GDExtensionConstVariantPtr p_other); + +/** + * @name variant_booleanize + * @since 4.1 + * + * Converts a Variant to a boolean. + * + * @param p_self A pointer to the Variant. + * + * @return The boolean value of the Variant. + */ +typedef GDExtensionBool (*GDExtensionInterfaceVariantBooleanize)(GDExtensionConstVariantPtr p_self); + +/** + * @name variant_duplicate + * @since 4.1 + * + * Duplicates a Variant. + * + * @param p_self A pointer to the Variant. + * @param r_ret A pointer to a Variant to store the duplicated value. + * @param p_deep Whether or not to duplicate deeply (when supported by the Variant type). + */ +typedef void (*GDExtensionInterfaceVariantDuplicate)(GDExtensionConstVariantPtr p_self, GDExtensionVariantPtr r_ret, GDExtensionBool p_deep); + +/** + * @name variant_stringify + * @since 4.1 + * + * Converts a Variant to a string. + * + * @param p_self A pointer to the Variant. + * @param r_ret A pointer to a String to store the resulting value. + */ +typedef void (*GDExtensionInterfaceVariantStringify)(GDExtensionConstVariantPtr p_self, GDExtensionStringPtr r_ret); + +/** + * @name variant_get_type + * @since 4.1 + * + * Gets the type of a Variant. + * + * @param p_self A pointer to the Variant. + * + * @return The variant type. + */ +typedef GDExtensionVariantType (*GDExtensionInterfaceVariantGetType)(GDExtensionConstVariantPtr p_self); + +/** + * @name variant_has_method + * @since 4.1 + * + * Checks if a Variant has the given method. + * + * @param p_self A pointer to the Variant. + * @param p_method A pointer to a StringName with the method name. + * + * @return true if the variant has the given method; otherwise false. + */ +typedef GDExtensionBool (*GDExtensionInterfaceVariantHasMethod)(GDExtensionConstVariantPtr p_self, GDExtensionConstStringNamePtr p_method); + +/** + * @name variant_has_member + * @since 4.1 + * + * Checks if a type of Variant has the given member. + * + * @param p_type The Variant type. + * @param p_member A pointer to a StringName with the member name. + * + * @return true if the variant has the given method; otherwise false. + */ +typedef GDExtensionBool (*GDExtensionInterfaceVariantHasMember)(GDExtensionVariantType p_type, GDExtensionConstStringNamePtr p_member); + +/** + * @name variant_has_key + * @since 4.1 + * + * Checks if a Variant has a key. + * + * @param p_self A pointer to the Variant. + * @param p_key A pointer to a Variant representing the key. + * @param r_valid A pointer to a boolean which will be set to false if the key doesn't exist. + * + * @return true if the key exists; otherwise false. + */ +typedef GDExtensionBool (*GDExtensionInterfaceVariantHasKey)(GDExtensionConstVariantPtr p_self, GDExtensionConstVariantPtr p_key, GDExtensionBool *r_valid); + +/** + * @name variant_get_object_instance_id + * @since 4.4 + * + * Gets the object instance ID from a variant of type GDEXTENSION_VARIANT_TYPE_OBJECT. + * + * If the variant isn't of type GDEXTENSION_VARIANT_TYPE_OBJECT, then zero will be returned. + * The instance ID will be returned even if the object is no longer valid - use `object_get_instance_by_id()` to check if the object is still valid. + * + * @param p_self A pointer to the Variant. + * + * @return The instance ID for the contained object. + */ +typedef GDObjectInstanceID (*GDExtensionInterfaceVariantGetObjectInstanceId)(GDExtensionConstVariantPtr p_self); + +/** + * @name variant_get_type_name + * @since 4.1 + * + * Gets the name of a Variant type. + * + * @param p_type The Variant type. + * @param r_name A pointer to a String to store the Variant type name. + */ +typedef void (*GDExtensionInterfaceVariantGetTypeName)(GDExtensionVariantType p_type, GDExtensionUninitializedStringPtr r_name); + +/** + * @name variant_get_type_by_name + * @since 4.7 + * + * Gets the Variant type by name. + * + * @param p_type_name The variant type name. + * + * @return The variant type for the given name; otherwise VARIANT_MAX if name is invalid. + */ +typedef GDExtensionVariantType (*GDExtensionInterfaceVariantGetTypeByName)(GDExtensionConstStringPtr p_type_name); + +/** + * @name variant_can_convert + * @since 4.1 + * + * Checks if Variants can be converted from one type to another. + * + * @param p_from The Variant type to convert from. + * @param p_to The Variant type to convert to. + * + * @return true if the conversion is possible; otherwise false. + */ +typedef GDExtensionBool (*GDExtensionInterfaceVariantCanConvert)(GDExtensionVariantType p_from, GDExtensionVariantType p_to); + +/** + * @name variant_can_convert_strict + * @since 4.1 + * + * Checks if Variant can be converted from one type to another using stricter rules. + * + * @param p_from The Variant type to convert from. + * @param p_to The Variant type to convert to. + * + * @return true if the conversion is possible; otherwise false. + */ +typedef GDExtensionBool (*GDExtensionInterfaceVariantCanConvertStrict)(GDExtensionVariantType p_from, GDExtensionVariantType p_to); + +/** + * @name get_variant_from_type_constructor + * @since 4.1 + * + * Gets a pointer to a function that can create a Variant of the given type from a raw value. + * + * @param p_type The Variant type. + * + * @return A pointer to a function that can create a Variant of the given type from a raw value. + */ +typedef GDExtensionVariantFromTypeConstructorFunc (*GDExtensionInterfaceGetVariantFromTypeConstructor)(GDExtensionVariantType p_type); + +/** + * @name get_variant_to_type_constructor + * @since 4.1 + * + * Gets a pointer to a function that can get the raw value from a Variant of the given type. + * + * @param p_type The Variant type. + * + * @return A pointer to a function that can get the raw value from a Variant of the given type. + */ +typedef GDExtensionTypeFromVariantConstructorFunc (*GDExtensionInterfaceGetVariantToTypeConstructor)(GDExtensionVariantType p_type); + +/** + * @name variant_get_ptr_internal_getter + * @since 4.4 + * + * Provides a function pointer for retrieving a pointer to a variant's internal value. + * + * Access to a variant's internal value can be used to modify it in-place, or to retrieve its value without the overhead of variant conversion functions. + * It is recommended to cache the getter for all variant types in a function table to avoid retrieval overhead upon use. + * + * Each function assumes the variant's type has already been determined and matches the function. + * Invoking the function with a variant of a mismatched type has undefined behavior, and may lead to a segmentation fault. + * + * @param p_type The Variant type. + * + * @return A pointer to a type-specific function that returns a pointer to the internal value of a variant. Check the implementation of this function (gdextension_variant_get_ptr_internal_getter) for pointee type info of each variant type. + */ +typedef GDExtensionVariantGetInternalPtrFunc (*GDExtensionInterfaceVariantGetPtrInternalGetter)(GDExtensionVariantType p_type); + +/** + * @name variant_get_ptr_operator_evaluator + * @since 4.1 + * + * Gets a pointer to a function that can evaluate the given Variant operator on the given Variant types. + * + * @param p_operator The variant operator. + * @param p_type_a The type of the first Variant. + * @param p_type_b The type of the second Variant. + * + * @return A pointer to a function that can evaluate the given Variant operator on the given Variant types. + */ +typedef GDExtensionPtrOperatorEvaluator (*GDExtensionInterfaceVariantGetPtrOperatorEvaluator)(GDExtensionVariantOperator p_operator, GDExtensionVariantType p_type_a, GDExtensionVariantType p_type_b); + +/** + * @name variant_get_ptr_builtin_method + * @since 4.1 + * + * Gets a pointer to a function that can call a builtin method on a type of Variant. + * + * @param p_type The Variant type. + * @param p_method A pointer to a StringName with the method name. + * @param p_hash A hash representing the method signature. + * + * @return A pointer to a function that can call a builtin method on a type of Variant. + */ +typedef GDExtensionPtrBuiltInMethod (*GDExtensionInterfaceVariantGetPtrBuiltinMethod)(GDExtensionVariantType p_type, GDExtensionConstStringNamePtr p_method, GDExtensionInt p_hash); + +/** + * @name variant_get_ptr_constructor + * @since 4.1 + * + * Gets a pointer to a function that can call one of the constructors for a type of Variant. + * + * @param p_type The Variant type. + * @param p_constructor The index of the constructor. + * + * @return A pointer to a function that can call one of the constructors for a type of Variant. + */ +typedef GDExtensionPtrConstructor (*GDExtensionInterfaceVariantGetPtrConstructor)(GDExtensionVariantType p_type, int32_t p_constructor); + +/** + * @name variant_get_ptr_destructor + * @since 4.1 + * + * Gets a pointer to a function than can call the destructor for a type of Variant. + * + * @param p_type The Variant type. + * + * @return A pointer to a function than can call the destructor for a type of Variant. + */ +typedef GDExtensionPtrDestructor (*GDExtensionInterfaceVariantGetPtrDestructor)(GDExtensionVariantType p_type); + +/** + * @name variant_construct + * @since 4.1 + * + * Constructs a Variant of the given type, using the first constructor that matches the given arguments. + * + * @param p_type The Variant type. + * @param r_base A pointer to a Variant to store the constructed value. + * @param p_args A pointer to a C array of Variant pointers representing the arguments for the constructor. + * @param p_argument_count The number of arguments to pass to the constructor. + * @param r_error A pointer the structure which will be updated with error information. + */ +typedef void (*GDExtensionInterfaceVariantConstruct)(GDExtensionVariantType p_type, GDExtensionUninitializedVariantPtr r_base, const GDExtensionConstVariantPtr *p_args, int32_t p_argument_count, GDExtensionCallError *r_error); + +/** + * @name variant_get_ptr_setter + * @since 4.1 + * + * Gets a pointer to a function that can call a member's setter on the given Variant type. + * + * @param p_type The Variant type. + * @param p_member A pointer to a StringName with the member name. + * + * @return A pointer to a function that can call a member's setter on the given Variant type. + */ +typedef GDExtensionPtrSetter (*GDExtensionInterfaceVariantGetPtrSetter)(GDExtensionVariantType p_type, GDExtensionConstStringNamePtr p_member); + +/** + * @name variant_get_ptr_getter + * @since 4.1 + * + * Gets a pointer to a function that can call a member's getter on the given Variant type. + * + * @param p_type The Variant type. + * @param p_member A pointer to a StringName with the member name. + * + * @return A pointer to a function that can call a member's getter on the given Variant type. + */ +typedef GDExtensionPtrGetter (*GDExtensionInterfaceVariantGetPtrGetter)(GDExtensionVariantType p_type, GDExtensionConstStringNamePtr p_member); + +/** + * @name variant_get_ptr_indexed_setter + * @since 4.1 + * + * Gets a pointer to a function that can set an index on the given Variant type. + * + * @param p_type The Variant type. + * + * @return A pointer to a function that can set an index on the given Variant type. + */ +typedef GDExtensionPtrIndexedSetter (*GDExtensionInterfaceVariantGetPtrIndexedSetter)(GDExtensionVariantType p_type); + +/** + * @name variant_get_ptr_indexed_getter + * @since 4.1 + * + * Gets a pointer to a function that can get an index on the given Variant type. + * + * @param p_type The Variant type. + * + * @return A pointer to a function that can get an index on the given Variant type. + */ +typedef GDExtensionPtrIndexedGetter (*GDExtensionInterfaceVariantGetPtrIndexedGetter)(GDExtensionVariantType p_type); + +/** + * @name variant_get_ptr_keyed_setter + * @since 4.1 + * + * Gets a pointer to a function that can set a key on the given Variant type. + * + * @param p_type The Variant type. + * + * @return A pointer to a function that can set a key on the given Variant type. + */ +typedef GDExtensionPtrKeyedSetter (*GDExtensionInterfaceVariantGetPtrKeyedSetter)(GDExtensionVariantType p_type); + +/** + * @name variant_get_ptr_keyed_getter + * @since 4.1 + * + * Gets a pointer to a function that can get a key on the given Variant type. + * + * @param p_type The Variant type. + * + * @return A pointer to a function that can get a key on the given Variant type. + */ +typedef GDExtensionPtrKeyedGetter (*GDExtensionInterfaceVariantGetPtrKeyedGetter)(GDExtensionVariantType p_type); + +/** + * @name variant_get_ptr_keyed_checker + * @since 4.1 + * + * Gets a pointer to a function that can check a key on the given Variant type. + * + * @param p_type The Variant type. + * + * @return A pointer to a function that can check a key on the given Variant type. + */ +typedef GDExtensionPtrKeyedChecker (*GDExtensionInterfaceVariantGetPtrKeyedChecker)(GDExtensionVariantType p_type); + +/** + * @name variant_get_constant_value + * @since 4.1 + * + * Gets the value of a constant from the given Variant type. + * + * @param p_type The Variant type. + * @param p_constant A pointer to a StringName with the constant name. + * @param r_ret A pointer to a Variant to store the value. + */ +typedef void (*GDExtensionInterfaceVariantGetConstantValue)(GDExtensionVariantType p_type, GDExtensionConstStringNamePtr p_constant, GDExtensionUninitializedVariantPtr r_ret); + +/** + * @name variant_get_ptr_utility_function + * @since 4.1 + * + * Gets a pointer to a function that can call a Variant utility function. + * + * @param p_function A pointer to a StringName with the function name. + * @param p_hash A hash representing the function signature. + * + * @return A pointer to a function that can call a Variant utility function. + */ +typedef GDExtensionPtrUtilityFunction (*GDExtensionInterfaceVariantGetPtrUtilityFunction)(GDExtensionConstStringNamePtr p_function, GDExtensionInt p_hash); + +/** + * @name string_new_with_latin1_chars + * @since 4.1 + * + * Creates a String from a Latin-1 encoded C string. + * + * @param r_dest A pointer to a Variant to hold the newly created String. + * @param p_contents A pointer to a Latin-1 encoded C string (null terminated). + */ +typedef void (*GDExtensionInterfaceStringNewWithLatin1Chars)(GDExtensionUninitializedStringPtr r_dest, const char *p_contents); + +/** + * @name string_new_with_utf8_chars + * @since 4.1 + * + * Creates a String from a UTF-8 encoded C string. + * + * @param r_dest A pointer to a Variant to hold the newly created String. + * @param p_contents A pointer to a UTF-8 encoded C string (null terminated). + */ +typedef void (*GDExtensionInterfaceStringNewWithUtf8Chars)(GDExtensionUninitializedStringPtr r_dest, const char *p_contents); + +/** + * @name string_new_with_utf16_chars + * @since 4.1 + * + * Creates a String from a UTF-16 encoded C string. + * + * @param r_dest A pointer to a Variant to hold the newly created String. + * @param p_contents A pointer to a UTF-16 encoded C string (null terminated). + */ +typedef void (*GDExtensionInterfaceStringNewWithUtf16Chars)(GDExtensionUninitializedStringPtr r_dest, const char16_t *p_contents); + +/** + * @name string_new_with_utf32_chars + * @since 4.1 + * + * Creates a String from a UTF-32 encoded C string. + * + * @param r_dest A pointer to a Variant to hold the newly created String. + * @param p_contents A pointer to a UTF-32 encoded C string (null terminated). + */ +typedef void (*GDExtensionInterfaceStringNewWithUtf32Chars)(GDExtensionUninitializedStringPtr r_dest, const char32_t *p_contents); + +/** + * @name string_new_with_wide_chars + * @since 4.1 + * + * Creates a String from a wide C string. + * + * @param r_dest A pointer to a Variant to hold the newly created String. + * @param p_contents A pointer to a wide C string (null terminated). + */ +typedef void (*GDExtensionInterfaceStringNewWithWideChars)(GDExtensionUninitializedStringPtr r_dest, const wchar_t *p_contents); + +/** + * @name string_new_with_latin1_chars_and_len + * @since 4.1 + * + * Creates a String from a Latin-1 encoded C string with the given length. + * + * @param r_dest A pointer to a Variant to hold the newly created String. + * @param p_contents A pointer to a Latin-1 encoded C string. + * @param p_size The number of characters (= number of bytes). + */ +typedef void (*GDExtensionInterfaceStringNewWithLatin1CharsAndLen)(GDExtensionUninitializedStringPtr r_dest, const char *p_contents, GDExtensionInt p_size); + +/** + * @name string_new_with_utf8_chars_and_len + * @since 4.1 + * @deprecated Deprecated in Godot 4.3. Use `string_new_with_utf8_chars_and_len2` instead. + * + * Creates a String from a UTF-8 encoded C string with the given length. + * + * @param r_dest A pointer to a Variant to hold the newly created String. + * @param p_contents A pointer to a UTF-8 encoded C string. + * @param p_size The number of bytes (not code units). + */ +typedef void (*GDExtensionInterfaceStringNewWithUtf8CharsAndLen)(GDExtensionUninitializedStringPtr r_dest, const char *p_contents, GDExtensionInt p_size); + +/** + * @name string_new_with_utf8_chars_and_len2 + * @since 4.3 + * + * Creates a String from a UTF-8 encoded C string with the given length. + * + * @param r_dest A pointer to a Variant to hold the newly created String. + * @param p_contents A pointer to a UTF-8 encoded C string. + * @param p_size The number of bytes (not code units). + * + * @return Error code signifying if the operation successful. + */ +typedef GDExtensionInt (*GDExtensionInterfaceStringNewWithUtf8CharsAndLen2)(GDExtensionUninitializedStringPtr r_dest, const char *p_contents, GDExtensionInt p_size); + +/** + * @name string_new_with_utf16_chars_and_len + * @since 4.1 + * @deprecated Deprecated in Godot 4.3. Use `string_new_with_utf16_chars_and_len2` instead. + * + * Creates a String from a UTF-16 encoded C string with the given length. + * + * @param r_dest A pointer to a Variant to hold the newly created String. + * @param p_contents A pointer to a UTF-16 encoded C string. + * @param p_char_count The number of characters (not bytes). + */ +typedef void (*GDExtensionInterfaceStringNewWithUtf16CharsAndLen)(GDExtensionUninitializedStringPtr r_dest, const char16_t *p_contents, GDExtensionInt p_char_count); + +/** + * @name string_new_with_utf16_chars_and_len2 + * @since 4.3 + * + * Creates a String from a UTF-16 encoded C string with the given length. + * + * @param r_dest A pointer to a Variant to hold the newly created String. + * @param p_contents A pointer to a UTF-16 encoded C string. + * @param p_char_count The number of characters (not bytes). + * @param p_default_little_endian If true, UTF-16 use little endian. + * + * @return Error code signifying if the operation successful. + */ +typedef GDExtensionInt (*GDExtensionInterfaceStringNewWithUtf16CharsAndLen2)(GDExtensionUninitializedStringPtr r_dest, const char16_t *p_contents, GDExtensionInt p_char_count, GDExtensionBool p_default_little_endian); + +/** + * @name string_new_with_utf32_chars_and_len + * @since 4.1 + * + * Creates a String from a UTF-32 encoded C string with the given length. + * + * @param r_dest A pointer to a Variant to hold the newly created String. + * @param p_contents A pointer to a UTF-32 encoded C string. + * @param p_char_count The number of characters (not bytes). + */ +typedef void (*GDExtensionInterfaceStringNewWithUtf32CharsAndLen)(GDExtensionUninitializedStringPtr r_dest, const char32_t *p_contents, GDExtensionInt p_char_count); + +/** + * @name string_new_with_wide_chars_and_len + * @since 4.1 + * + * Creates a String from a wide C string with the given length. + * + * @param r_dest A pointer to a Variant to hold the newly created String. + * @param p_contents A pointer to a wide C string. + * @param p_char_count The number of characters (not bytes). + */ +typedef void (*GDExtensionInterfaceStringNewWithWideCharsAndLen)(GDExtensionUninitializedStringPtr r_dest, const wchar_t *p_contents, GDExtensionInt p_char_count); + +/** + * @name string_to_latin1_chars + * @since 4.1 + * + * Converts a String to a Latin-1 encoded C string. + * + * It doesn't write a null terminator. + * + * @param p_self A pointer to the String. + * @param r_text A pointer to the buffer to hold the resulting data. If NULL is passed in, only the length will be computed. + * @param p_max_write_length The maximum number of characters that can be written to r_text. It has no affect on the return value. + * + * @return The resulting encoded string length in characters, not including a null terminator. Characters that cannot be converted to Latin-1 are replaced with a space. + */ +typedef GDExtensionInt (*GDExtensionInterfaceStringToLatin1Chars)(GDExtensionConstStringPtr p_self, char *r_text, GDExtensionInt p_max_write_length); + +/** + * @name string_to_utf8_chars + * @since 4.1 + * + * Converts a String to a UTF-8 encoded C string. + * + * It doesn't write a null terminator. + * + * @param p_self A pointer to the String. + * @param r_text A pointer to the buffer to hold the resulting data. If NULL is passed in, only the length will be computed. + * @param p_max_write_length The maximum number of characters that can be written to r_text. It has no affect on the return value. + * + * @return The resulting encoded string length in bytes (not characters), not including a null terminator. + */ +typedef GDExtensionInt (*GDExtensionInterfaceStringToUtf8Chars)(GDExtensionConstStringPtr p_self, char *r_text, GDExtensionInt p_max_write_length); + +/** + * @name string_to_utf16_chars + * @since 4.1 + * + * Converts a String to a UTF-16 encoded C string. + * + * It doesn't write a null terminator. + * + * @param p_self A pointer to the String. + * @param r_text A pointer to the buffer to hold the resulting data. If NULL is passed in, only the length will be computed. + * @param p_max_write_length The maximum number of characters that can be written to r_text. It has no affect on the return value. + * + * @return The resulting encoded string length in 16-bit code units (not bytes or characters), not including a null terminator. + */ +typedef GDExtensionInt (*GDExtensionInterfaceStringToUtf16Chars)(GDExtensionConstStringPtr p_self, char16_t *r_text, GDExtensionInt p_max_write_length); + +/** + * @name string_to_utf32_chars + * @since 4.1 + * + * Converts a String to a UTF-32 encoded C string. + * + * It doesn't write a null terminator. + * + * @param p_self A pointer to the String. + * @param r_text A pointer to the buffer to hold the resulting data. If NULL is passed in, only the length will be computed. + * @param p_max_write_length The maximum number of characters that can be written to r_text. It has no affect on the return value. + * + * @return The resulting encoded string length in characters (not bytes), not including a null terminator. + */ +typedef GDExtensionInt (*GDExtensionInterfaceStringToUtf32Chars)(GDExtensionConstStringPtr p_self, char32_t *r_text, GDExtensionInt p_max_write_length); + +/** + * @name string_to_wide_chars + * @since 4.1 + * + * Converts a String to a wide C string. + * + * It doesn't write a null terminator. + * + * @param p_self A pointer to the String. + * @param r_text A pointer to the buffer to hold the resulting data. If NULL is passed in, only the length will be computed. + * @param p_max_write_length The maximum number of characters that can be written to r_text. It has no affect on the return value. + * + * @return The resulting encoded string length in characters (for UTF-32) or 16-bit code units (for UTF-16), depending on the wchar_t representation. Does not include a null terminator. + */ +typedef GDExtensionInt (*GDExtensionInterfaceStringToWideChars)(GDExtensionConstStringPtr p_self, wchar_t *r_text, GDExtensionInt p_max_write_length); + +/** + * @name string_operator_index + * @since 4.1 + * + * Gets a pointer to the character at the given index from a String. + * + * @param p_self A pointer to the String. + * @param p_index The index. + * + * @return A pointer to the requested character. + */ +typedef char32_t *(*GDExtensionInterfaceStringOperatorIndex)(GDExtensionStringPtr p_self, GDExtensionInt p_index); + +/** + * @name string_operator_index_const + * @since 4.1 + * + * Gets a const pointer to the character at the given index from a String. + * + * @param p_self A pointer to the String. + * @param p_index The index. + * + * @return A const pointer to the requested character. + */ +typedef const char32_t *(*GDExtensionInterfaceStringOperatorIndexConst)(GDExtensionConstStringPtr p_self, GDExtensionInt p_index); + +/** + * @name string_operator_plus_eq_string + * @since 4.1 + * + * Appends another String to a String. + * + * @param p_self A pointer to the String. + * @param p_b A pointer to the other String to append. + */ +typedef void (*GDExtensionInterfaceStringOperatorPlusEqString)(GDExtensionStringPtr p_self, GDExtensionConstStringPtr p_b); + +/** + * @name string_operator_plus_eq_char + * @since 4.1 + * + * Appends a character to a String. + * + * @param p_self A pointer to the String. + * @param p_b A pointer to the character to append. + */ +typedef void (*GDExtensionInterfaceStringOperatorPlusEqChar)(GDExtensionStringPtr p_self, char32_t p_b); + +/** + * @name string_operator_plus_eq_cstr + * @since 4.1 + * + * Appends a Latin-1 encoded C string to a String. + * + * @param p_self A pointer to the String. + * @param p_b A pointer to a Latin-1 encoded C string (null terminated). + */ +typedef void (*GDExtensionInterfaceStringOperatorPlusEqCstr)(GDExtensionStringPtr p_self, const char *p_b); + +/** + * @name string_operator_plus_eq_wcstr + * @since 4.1 + * + * Appends a wide C string to a String. + * + * @param p_self A pointer to the String. + * @param p_b A pointer to a wide C string (null terminated). + */ +typedef void (*GDExtensionInterfaceStringOperatorPlusEqWcstr)(GDExtensionStringPtr p_self, const wchar_t *p_b); + +/** + * @name string_operator_plus_eq_c32str + * @since 4.1 + * + * Appends a UTF-32 encoded C string to a String. + * + * @param p_self A pointer to the String. + * @param p_b A pointer to a UTF-32 encoded C string (null terminated). + */ +typedef void (*GDExtensionInterfaceStringOperatorPlusEqC32str)(GDExtensionStringPtr p_self, const char32_t *p_b); + +/** + * @name string_resize + * @since 4.2 + * + * Resizes the underlying string data to the given number of characters. + * + * Space needs to be allocated for the null terminating character ('\0') which + * also must be added manually, in order for all string functions to work correctly. + * + * Warning: This is an error-prone operation - only use it if there's no other + * efficient way to accomplish your goal. + * + * @param p_self A pointer to the String. + * @param p_resize The new length for the String. + * + * @return Error code signifying if the operation successful. + */ +typedef GDExtensionInt (*GDExtensionInterfaceStringResize)(GDExtensionStringPtr p_self, GDExtensionInt p_resize); + +/** + * @name string_name_new_with_latin1_chars + * @since 4.2 + * + * Creates a StringName from a Latin-1 encoded C string. + * + * If `p_is_static` is true, then: + * - The StringName will reuse the `p_contents` buffer instead of copying it. + * - You must guarantee that the buffer remains valid for the duration of the application (e.g. string literal). + * - You must not call a destructor for this StringName. Incrementing the initial reference once should achieve this. + * + * `p_is_static` is purely an optimization and can easily introduce undefined behavior if used wrong. In case of doubt, set it to false. + * + * @param r_dest A pointer to uninitialized storage, into which the newly created StringName is constructed. + * @param p_contents A pointer to a C string (null terminated and Latin-1 or ASCII encoded). + * @param p_is_static Whether the StringName reuses the buffer directly (see above). + */ +typedef void (*GDExtensionInterfaceStringNameNewWithLatin1Chars)(GDExtensionUninitializedStringNamePtr r_dest, const char *p_contents, GDExtensionBool p_is_static); + +/** + * @name string_name_new_with_utf8_chars + * @since 4.2 + * + * Creates a StringName from a UTF-8 encoded C string. + * + * @param r_dest A pointer to uninitialized storage, into which the newly created StringName is constructed. + * @param p_contents A pointer to a C string (null terminated and UTF-8 encoded). + */ +typedef void (*GDExtensionInterfaceStringNameNewWithUtf8Chars)(GDExtensionUninitializedStringNamePtr r_dest, const char *p_contents); + +/** + * @name string_name_new_with_utf8_chars_and_len + * @since 4.2 + * + * Creates a StringName from a UTF-8 encoded string with a given number of characters. + * + * @param r_dest A pointer to uninitialized storage, into which the newly created StringName is constructed. + * @param p_contents A pointer to a C string (null terminated and UTF-8 encoded). + * @param p_size The number of bytes (not UTF-8 code points). + */ +typedef void (*GDExtensionInterfaceStringNameNewWithUtf8CharsAndLen)(GDExtensionUninitializedStringNamePtr r_dest, const char *p_contents, GDExtensionInt p_size); + +/** + * @name xml_parser_open_buffer + * @since 4.1 + * + * Opens a raw XML buffer on an XMLParser instance. + * + * @param p_instance A pointer to an XMLParser object. + * @param p_buffer A pointer to the buffer. + * @param p_size The size of the buffer. + * + * @return A Godot error code (ex. OK, ERR_INVALID_DATA, etc). + * + * @see XMLParser::open_buffer() + */ +typedef GDExtensionInt (*GDExtensionInterfaceXmlParserOpenBuffer)(GDExtensionObjectPtr p_instance, const uint8_t *p_buffer, size_t p_size); + +/** + * @name file_access_store_buffer + * @since 4.1 + * + * Stores the given buffer using an instance of FileAccess. + * + * @param p_instance A pointer to a FileAccess object. + * @param p_src A pointer to the buffer. + * @param p_length The size of the buffer. + * + * @see FileAccess::store_buffer() + */ +typedef void (*GDExtensionInterfaceFileAccessStoreBuffer)(GDExtensionObjectPtr p_instance, const uint8_t *p_src, uint64_t p_length); + +/** + * @name file_access_get_buffer + * @since 4.1 + * + * Reads the next p_length bytes into the given buffer using an instance of FileAccess. + * + * @param p_instance A pointer to a FileAccess object. + * @param p_dst A pointer to the buffer to store the data. + * @param p_length The requested number of bytes to read. + * + * @return The actual number of bytes read (may be less than requested). + */ +typedef uint64_t (*GDExtensionInterfaceFileAccessGetBuffer)(GDExtensionConstObjectPtr p_instance, uint8_t *p_dst, uint64_t p_length); + +/** + * @name image_ptrw + * @since 4.3 + * + * Returns writable pointer to internal Image buffer. + * + * @param p_instance A pointer to a Image object. + * + * @return Pointer to internal Image buffer. + * + * @see Image::ptrw() + */ +typedef uint8_t *(*GDExtensionInterfaceImagePtrw)(GDExtensionObjectPtr p_instance); + +/** + * @name image_ptr + * @since 4.3 + * + * Returns read only pointer to internal Image buffer. + * + * @param p_instance A pointer to a Image object. + * + * @return Pointer to internal Image buffer. + * + * @see Image::ptr() + */ +typedef const uint8_t *(*GDExtensionInterfaceImagePtr)(GDExtensionObjectPtr p_instance); + +/** + * @name worker_thread_pool_add_native_group_task + * @since 4.1 + * + * Adds a group task to an instance of WorkerThreadPool. + * + * @param p_instance A pointer to a WorkerThreadPool object. + * @param p_func A pointer to a function to run in the thread pool. + * @param p_userdata A pointer to arbitrary data which will be passed to p_func. + * @param p_elements The number of element needed in the group. + * @param p_tasks The number of tasks needed in the group. + * @param p_high_priority Whether or not this is a high priority task. + * @param p_description A pointer to a String with the task description. + * + * @return The task group ID. + * + * @see WorkerThreadPool::add_group_task() + */ +typedef int64_t (*GDExtensionInterfaceWorkerThreadPoolAddNativeGroupTask)(GDExtensionObjectPtr p_instance, GDExtensionWorkerThreadPoolGroupTask p_func, void *p_userdata, int32_t p_elements, int32_t p_tasks, GDExtensionBool p_high_priority, GDExtensionConstStringPtr p_description); + +/** + * @name worker_thread_pool_add_native_task + * @since 4.1 + * + * Adds a task to an instance of WorkerThreadPool. + * + * @param p_instance A pointer to a WorkerThreadPool object. + * @param p_func A pointer to a function to run in the thread pool. + * @param p_userdata A pointer to arbitrary data which will be passed to p_func. + * @param p_high_priority Whether or not this is a high priority task. + * @param p_description A pointer to a String with the task description. + * + * @return The task ID. + */ +typedef int64_t (*GDExtensionInterfaceWorkerThreadPoolAddNativeTask)(GDExtensionObjectPtr p_instance, GDExtensionWorkerThreadPoolTask p_func, void *p_userdata, GDExtensionBool p_high_priority, GDExtensionConstStringPtr p_description); + +/** + * @name packed_byte_array_operator_index + * @since 4.1 + * + * Gets a pointer to a byte in a PackedByteArray. + * + * @param p_self A pointer to a PackedByteArray object. + * @param p_index The index of the byte to get. + * + * @return A pointer to the requested byte. + */ +typedef uint8_t *(*GDExtensionInterfacePackedByteArrayOperatorIndex)(GDExtensionTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_byte_array_operator_index_const + * @since 4.1 + * + * Gets a const pointer to a byte in a PackedByteArray. + * + * @param p_self A const pointer to a PackedByteArray object. + * @param p_index The index of the byte to get. + * + * @return A const pointer to the requested byte. + */ +typedef const uint8_t *(*GDExtensionInterfacePackedByteArrayOperatorIndexConst)(GDExtensionConstTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_float32_array_operator_index + * @since 4.1 + * + * Gets a pointer to a 32-bit float in a PackedFloat32Array. + * + * @param p_self A pointer to a PackedFloat32Array object. + * @param p_index The index of the float to get. + * + * @return A pointer to the requested 32-bit float. + */ +typedef float *(*GDExtensionInterfacePackedFloat32ArrayOperatorIndex)(GDExtensionTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_float32_array_operator_index_const + * @since 4.1 + * + * Gets a const pointer to a 32-bit float in a PackedFloat32Array. + * + * @param p_self A const pointer to a PackedFloat32Array object. + * @param p_index The index of the float to get. + * + * @return A const pointer to the requested 32-bit float. + */ +typedef const float *(*GDExtensionInterfacePackedFloat32ArrayOperatorIndexConst)(GDExtensionConstTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_float64_array_operator_index + * @since 4.1 + * + * Gets a pointer to a 64-bit float in a PackedFloat64Array. + * + * @param p_self A pointer to a PackedFloat64Array object. + * @param p_index The index of the float to get. + * + * @return A pointer to the requested 64-bit float. + */ +typedef double *(*GDExtensionInterfacePackedFloat64ArrayOperatorIndex)(GDExtensionTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_float64_array_operator_index_const + * @since 4.1 + * + * Gets a const pointer to a 64-bit float in a PackedFloat64Array. + * + * @param p_self A const pointer to a PackedFloat64Array object. + * @param p_index The index of the float to get. + * + * @return A const pointer to the requested 64-bit float. + */ +typedef const double *(*GDExtensionInterfacePackedFloat64ArrayOperatorIndexConst)(GDExtensionConstTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_int32_array_operator_index + * @since 4.1 + * + * Gets a pointer to a 32-bit integer in a PackedInt32Array. + * + * @param p_self A pointer to a PackedInt32Array object. + * @param p_index The index of the integer to get. + * + * @return A pointer to the requested 32-bit integer. + */ +typedef int32_t *(*GDExtensionInterfacePackedInt32ArrayOperatorIndex)(GDExtensionTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_int32_array_operator_index_const + * @since 4.1 + * + * Gets a const pointer to a 32-bit integer in a PackedInt32Array. + * + * @param p_self A const pointer to a PackedInt32Array object. + * @param p_index The index of the integer to get. + * + * @return A const pointer to the requested 32-bit integer. + */ +typedef const int32_t *(*GDExtensionInterfacePackedInt32ArrayOperatorIndexConst)(GDExtensionConstTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_int64_array_operator_index + * @since 4.1 + * + * Gets a pointer to a 64-bit integer in a PackedInt64Array. + * + * @param p_self A pointer to a PackedInt64Array object. + * @param p_index The index of the integer to get. + * + * @return A pointer to the requested 64-bit integer. + */ +typedef int64_t *(*GDExtensionInterfacePackedInt64ArrayOperatorIndex)(GDExtensionTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_int64_array_operator_index_const + * @since 4.1 + * + * Gets a const pointer to a 64-bit integer in a PackedInt64Array. + * + * @param p_self A const pointer to a PackedInt64Array object. + * @param p_index The index of the integer to get. + * + * @return A const pointer to the requested 64-bit integer. + */ +typedef const int64_t *(*GDExtensionInterfacePackedInt64ArrayOperatorIndexConst)(GDExtensionConstTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_string_array_operator_index + * @since 4.1 + * + * Gets a pointer to a string in a PackedStringArray. + * + * @param p_self A pointer to a PackedStringArray object. + * @param p_index The index of the String to get. + * + * @return A pointer to the requested String. + */ +typedef GDExtensionStringPtr (*GDExtensionInterfacePackedStringArrayOperatorIndex)(GDExtensionTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_string_array_operator_index_const + * @since 4.1 + * + * Gets a const pointer to a string in a PackedStringArray. + * + * @param p_self A const pointer to a PackedStringArray object. + * @param p_index The index of the String to get. + * + * @return A const pointer to the requested String. + */ +typedef GDExtensionStringPtr (*GDExtensionInterfacePackedStringArrayOperatorIndexConst)(GDExtensionConstTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_vector2_array_operator_index + * @since 4.1 + * + * Gets a pointer to a Vector2 in a PackedVector2Array. + * + * @param p_self A pointer to a PackedVector2Array object. + * @param p_index The index of the Vector2 to get. + * + * @return A pointer to the requested Vector2. + */ +typedef GDExtensionTypePtr (*GDExtensionInterfacePackedVector2ArrayOperatorIndex)(GDExtensionTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_vector2_array_operator_index_const + * @since 4.1 + * + * Gets a const pointer to a Vector2 in a PackedVector2Array. + * + * @param p_self A const pointer to a PackedVector2Array object. + * @param p_index The index of the Vector2 to get. + * + * @return A const pointer to the requested Vector2. + */ +typedef GDExtensionTypePtr (*GDExtensionInterfacePackedVector2ArrayOperatorIndexConst)(GDExtensionConstTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_vector3_array_operator_index + * @since 4.1 + * + * Gets a pointer to a Vector3 in a PackedVector3Array. + * + * @param p_self A pointer to a PackedVector3Array object. + * @param p_index The index of the Vector3 to get. + * + * @return A pointer to the requested Vector3. + */ +typedef GDExtensionTypePtr (*GDExtensionInterfacePackedVector3ArrayOperatorIndex)(GDExtensionTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_vector3_array_operator_index_const + * @since 4.1 + * + * Gets a const pointer to a Vector3 in a PackedVector3Array. + * + * @param p_self A const pointer to a PackedVector3Array object. + * @param p_index The index of the Vector3 to get. + * + * @return A const pointer to the requested Vector3. + */ +typedef GDExtensionTypePtr (*GDExtensionInterfacePackedVector3ArrayOperatorIndexConst)(GDExtensionConstTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_vector4_array_operator_index + * @since 4.3 + * + * Gets a pointer to a Vector4 in a PackedVector4Array. + * + * @param p_self A pointer to a PackedVector4Array object. + * @param p_index The index of the Vector4 to get. + * + * @return A pointer to the requested Vector4. + */ +typedef GDExtensionTypePtr (*GDExtensionInterfacePackedVector4ArrayOperatorIndex)(GDExtensionTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_vector4_array_operator_index_const + * @since 4.3 + * + * Gets a const pointer to a Vector4 in a PackedVector4Array. + * + * @param p_self A const pointer to a PackedVector4Array object. + * @param p_index The index of the Vector4 to get. + * + * @return A const pointer to the requested Vector4. + */ +typedef GDExtensionTypePtr (*GDExtensionInterfacePackedVector4ArrayOperatorIndexConst)(GDExtensionConstTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_color_array_operator_index + * @since 4.1 + * + * Gets a pointer to a color in a PackedColorArray. + * + * @param p_self A pointer to a PackedColorArray object. + * @param p_index The index of the Color to get. + * + * @return A pointer to the requested Color. + */ +typedef GDExtensionTypePtr (*GDExtensionInterfacePackedColorArrayOperatorIndex)(GDExtensionTypePtr p_self, GDExtensionInt p_index); + +/** + * @name packed_color_array_operator_index_const + * @since 4.1 + * + * Gets a const pointer to a color in a PackedColorArray. + * + * @param p_self A const pointer to a PackedColorArray object. + * @param p_index The index of the Color to get. + * + * @return A const pointer to the requested Color. + */ +typedef GDExtensionTypePtr (*GDExtensionInterfacePackedColorArrayOperatorIndexConst)(GDExtensionConstTypePtr p_self, GDExtensionInt p_index); + +/** + * @name array_operator_index + * @since 4.1 + * + * Gets a pointer to a Variant in an Array. + * + * @param p_self A pointer to an Array object. + * @param p_index The index of the Variant to get. + * + * @return A pointer to the requested Variant. + */ +typedef GDExtensionVariantPtr (*GDExtensionInterfaceArrayOperatorIndex)(GDExtensionTypePtr p_self, GDExtensionInt p_index); + +/** + * @name array_operator_index_const + * @since 4.1 + * + * Gets a const pointer to a Variant in an Array. + * + * @param p_self A const pointer to an Array object. + * @param p_index The index of the Variant to get. + * + * @return A const pointer to the requested Variant. + */ +typedef GDExtensionVariantPtr (*GDExtensionInterfaceArrayOperatorIndexConst)(GDExtensionConstTypePtr p_self, GDExtensionInt p_index); + +/** + * @name array_ref + * @since 4.1 + * @deprecated Deprecated in Godot 4.5. Removed from interface. Use copy constructor instead. + * + * Sets an Array to be a reference to another Array object. + * + * @param p_self A pointer to the Array object to update. + * @param p_from A pointer to the Array object to reference. + */ +typedef void (*GDExtensionInterfaceArrayRef)(GDExtensionTypePtr p_self, GDExtensionConstTypePtr p_from); + +/** + * @name array_set_typed + * @since 4.1 + * + * Makes an Array into a typed Array. + * + * @param p_self A pointer to the Array. + * @param p_type The type of Variant the Array will store. + * @param p_class_name A pointer to a StringName with the name of the object (if p_type is GDEXTENSION_VARIANT_TYPE_OBJECT). + * @param p_script A pointer to a Script object (if p_type is GDEXTENSION_VARIANT_TYPE_OBJECT and the base class is extended by a script). + */ +typedef void (*GDExtensionInterfaceArraySetTyped)(GDExtensionTypePtr p_self, GDExtensionVariantType p_type, GDExtensionConstStringNamePtr p_class_name, GDExtensionConstVariantPtr p_script); + +/** + * @name dictionary_operator_index + * @since 4.1 + * + * Gets a pointer to a Variant in a Dictionary with the given key. + * + * @param p_self A pointer to a Dictionary object. + * @param p_key A pointer to a Variant representing the key. + * + * @return A pointer to a Variant representing the value at the given key. + */ +typedef GDExtensionVariantPtr (*GDExtensionInterfaceDictionaryOperatorIndex)(GDExtensionTypePtr p_self, GDExtensionConstVariantPtr p_key); + +/** + * @name dictionary_operator_index_const + * @since 4.1 + * + * Gets a const pointer to a Variant in a Dictionary with the given key. + * + * @param p_self A const pointer to a Dictionary object. + * @param p_key A pointer to a Variant representing the key. + * + * @return A const pointer to a Variant representing the value at the given key. + */ +typedef GDExtensionVariantPtr (*GDExtensionInterfaceDictionaryOperatorIndexConst)(GDExtensionConstTypePtr p_self, GDExtensionConstVariantPtr p_key); + +/** + * @name dictionary_set_typed + * @since 4.4 + * + * Makes a Dictionary into a typed Dictionary. + * + * @param p_self A pointer to the Dictionary. + * @param p_key_type The type of Variant the Dictionary key will store. + * @param p_key_class_name A pointer to a StringName with the name of the object (if p_key_type is GDEXTENSION_VARIANT_TYPE_OBJECT). + * @param p_key_script A pointer to a Script object (if p_key_type is GDEXTENSION_VARIANT_TYPE_OBJECT and the base class is extended by a script). + * @param p_value_type The type of Variant the Dictionary value will store. + * @param p_value_class_name A pointer to a StringName with the name of the object (if p_value_type is GDEXTENSION_VARIANT_TYPE_OBJECT). + * @param p_value_script A pointer to a Script object (if p_value_type is GDEXTENSION_VARIANT_TYPE_OBJECT and the base class is extended by a script). + */ +typedef void (*GDExtensionInterfaceDictionarySetTyped)(GDExtensionTypePtr p_self, GDExtensionVariantType p_key_type, GDExtensionConstStringNamePtr p_key_class_name, GDExtensionConstVariantPtr p_key_script, GDExtensionVariantType p_value_type, GDExtensionConstStringNamePtr p_value_class_name, GDExtensionConstVariantPtr p_value_script); + +/** + * @name object_method_bind_call + * @since 4.1 + * + * Calls a method on an Object. + * + * @param p_method_bind A pointer to the MethodBind representing the method on the Object's class. + * @param p_instance A pointer to the Object. + * @param p_args A pointer to a C array of Variants representing the arguments. + * @param p_arg_count The number of arguments. + * @param r_ret A pointer to Variant which will receive the return value. + * @param r_error A pointer to a GDExtensionCallError struct that will receive error information. + */ +typedef void (*GDExtensionInterfaceObjectMethodBindCall)(GDExtensionMethodBindPtr p_method_bind, GDExtensionObjectPtr p_instance, const GDExtensionConstVariantPtr *p_args, GDExtensionInt p_arg_count, GDExtensionUninitializedVariantPtr r_ret, GDExtensionCallError *r_error); + +/** + * @name object_method_bind_ptrcall + * @since 4.1 + * + * Calls a method on an Object (using a "ptrcall"). + * + * @param p_method_bind A pointer to the MethodBind representing the method on the Object's class. + * @param p_instance A pointer to the Object. + * @param p_args A pointer to a C array representing the arguments. + * @param r_ret A pointer to the Object that will receive the return value. + */ +typedef void (*GDExtensionInterfaceObjectMethodBindPtrcall)(GDExtensionMethodBindPtr p_method_bind, GDExtensionObjectPtr p_instance, const GDExtensionConstTypePtr *p_args, GDExtensionTypePtr r_ret); + +/** + * @name object_destroy + * @since 4.1 + * + * Destroys an Object. + * + * @param p_o A pointer to the Object. + */ +typedef void (*GDExtensionInterfaceObjectDestroy)(GDExtensionObjectPtr p_o); + +/** + * @name global_get_singleton + * @since 4.1 + * + * Gets a global singleton by name. + * + * @param p_name A pointer to a StringName with the singleton name. + * + * @return A pointer to the singleton Object. + */ +typedef GDExtensionObjectPtr (*GDExtensionInterfaceGlobalGetSingleton)(GDExtensionConstStringNamePtr p_name); + +/** + * @name object_get_instance_binding + * @since 4.1 + * + * Gets a pointer representing an Object's instance binding. + * + * @param p_o A pointer to the Object. + * @param p_token A token the library received by the GDExtension's entry point function. + * @param p_callbacks A pointer to a GDExtensionInstanceBindingCallbacks struct. + * + * @return A pointer to the instance binding. + */ +typedef void *(*GDExtensionInterfaceObjectGetInstanceBinding)(GDExtensionObjectPtr p_o, void *p_token, const GDExtensionInstanceBindingCallbacks *p_callbacks); + +/** + * @name object_set_instance_binding + * @since 4.1 + * + * Sets an Object's instance binding. + * + * @param p_o A pointer to the Object. + * @param p_token A token the library received by the GDExtension's entry point function. + * @param p_binding A pointer to the instance binding. + * @param p_callbacks A pointer to a GDExtensionInstanceBindingCallbacks struct. + */ +typedef void (*GDExtensionInterfaceObjectSetInstanceBinding)(GDExtensionObjectPtr p_o, void *p_token, void *p_binding, const GDExtensionInstanceBindingCallbacks *p_callbacks); + +/** + * @name object_free_instance_binding + * @since 4.2 + * + * Free an Object's instance binding. + * + * @param p_o A pointer to the Object. + * @param p_token A token the library received by the GDExtension's entry point function. + */ +typedef void (*GDExtensionInterfaceObjectFreeInstanceBinding)(GDExtensionObjectPtr p_o, void *p_token); + +/** + * @name object_set_instance + * @since 4.1 + * + * Sets an extension class instance on a Object. + * + * `p_classname` should be a registered extension class and should extend the `p_o` Object's class. + * + * @param p_o A pointer to the Object. + * @param p_classname A pointer to a StringName with the registered extension class's name. + * @param p_instance A pointer to the extension class instance. + */ +typedef void (*GDExtensionInterfaceObjectSetInstance)(GDExtensionObjectPtr p_o, GDExtensionConstStringNamePtr p_classname, GDExtensionClassInstancePtr p_instance); + +/** + * @name object_get_class_name + * @since 4.1 + * + * Gets the class name of an Object. + * + * If the GDExtension wraps the Godot object in an abstraction specific to its class, this is the + * function that should be used to determine which wrapper to use. + * + * @param p_object A pointer to the Object. + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param r_class_name A pointer to a String to receive the class name. + * + * @return true if successful in getting the class name; otherwise false. + */ +typedef GDExtensionBool (*GDExtensionInterfaceObjectGetClassName)(GDExtensionConstObjectPtr p_object, GDExtensionClassLibraryPtr p_library, GDExtensionUninitializedStringNamePtr r_class_name); + +/** + * @name object_cast_to + * @since 4.1 + * @deprecated Deprecated in Godot 4.7. Use the `is_class` method on `Object` to check if an object can be cast instead. If true, the previous pointer can be reinterpreted as a pointer to the target type. + * + * Casts an Object to a different type. + * + * @param p_object A pointer to the Object. + * @param p_class_tag A pointer uniquely identifying a built-in class in the ClassDB. + * + * @return Returns a pointer to the Object, or NULL if it can't be cast to the requested type. + */ +typedef GDExtensionObjectPtr (*GDExtensionInterfaceObjectCastTo)(GDExtensionConstObjectPtr p_object, void *p_class_tag); + +/** + * @name object_get_instance_from_id + * @since 4.1 + * + * Gets an Object by its instance ID. + * + * @param p_instance_id The instance ID. + * + * @return A pointer to the Object. + */ +typedef GDExtensionObjectPtr (*GDExtensionInterfaceObjectGetInstanceFromId)(GDObjectInstanceID p_instance_id); + +/** + * @name object_get_instance_id + * @since 4.1 + * + * Gets the instance ID from an Object. + * + * @param p_object A pointer to the Object. + * + * @return The instance ID. + */ +typedef GDObjectInstanceID (*GDExtensionInterfaceObjectGetInstanceId)(GDExtensionConstObjectPtr p_object); + +/** + * @name object_has_script_method + * @since 4.3 + * + * Checks if this object has a script with the given method. + * + * @param p_object A pointer to the Object. + * @param p_method A pointer to a StringName identifying the method. + * + * @return true if the object has a script and that script has a method with the given name. Returns false if the object has no script. + */ +typedef GDExtensionBool (*GDExtensionInterfaceObjectHasScriptMethod)(GDExtensionConstObjectPtr p_object, GDExtensionConstStringNamePtr p_method); + +/** + * @name object_call_script_method + * @since 4.3 + * + * Call the given script method on this object. + * + * @param p_object A pointer to the Object. + * @param p_method A pointer to a StringName identifying the method. + * @param p_args A pointer to a C array of Variant. + * @param p_argument_count The number of arguments. + * @param r_return A pointer a Variant which will be assigned the return value. + * @param r_error A pointer the structure which will hold error information. + */ +typedef void (*GDExtensionInterfaceObjectCallScriptMethod)(GDExtensionObjectPtr p_object, GDExtensionConstStringNamePtr p_method, const GDExtensionConstVariantPtr *p_args, GDExtensionInt p_argument_count, GDExtensionUninitializedVariantPtr r_return, GDExtensionCallError *r_error); + +/** + * @name ref_get_object + * @since 4.1 + * + * Gets the Object from a reference. + * + * @param p_ref A pointer to the reference. + * + * @return A pointer to the Object from the reference or NULL. + */ +typedef GDExtensionObjectPtr (*GDExtensionInterfaceRefGetObject)(GDExtensionConstRefPtr p_ref); + +/** + * @name ref_set_object + * @since 4.1 + * + * Sets the Object referred to by a reference. + * + * @param p_ref A pointer to the reference. + * @param p_object A pointer to the Object to refer to. + */ +typedef void (*GDExtensionInterfaceRefSetObject)(GDExtensionRefPtr p_ref, GDExtensionObjectPtr p_object); + +/** + * @name script_instance_create + * @since 4.1 + * @deprecated Deprecated in Godot 4.2. Use `script_instance_create3` instead. + * + * Creates a script instance that contains the given info and instance data. + * + * @param p_info A pointer to a GDExtensionScriptInstanceInfo struct. + * @param p_instance_data A pointer to a data representing the script instance in the GDExtension. This will be passed to all the function pointers on p_info. + * + * @return A pointer to a ScriptInstanceExtension object. + */ +typedef GDExtensionScriptInstancePtr (*GDExtensionInterfaceScriptInstanceCreate)(const GDExtensionScriptInstanceInfo *p_info, GDExtensionScriptInstanceDataPtr p_instance_data); + +/** + * @name script_instance_create2 + * @since 4.2 + * @deprecated Deprecated in Godot 4.3. Use `script_instance_create3` instead. + * + * Creates a script instance that contains the given info and instance data. + * + * @param p_info A pointer to a GDExtensionScriptInstanceInfo2 struct. + * @param p_instance_data A pointer to a data representing the script instance in the GDExtension. This will be passed to all the function pointers on p_info. + * + * @return A pointer to a ScriptInstanceExtension object. + */ +typedef GDExtensionScriptInstancePtr (*GDExtensionInterfaceScriptInstanceCreate2)(const GDExtensionScriptInstanceInfo2 *p_info, GDExtensionScriptInstanceDataPtr p_instance_data); + +/** + * @name script_instance_create3 + * @since 4.3 + * + * Creates a script instance that contains the given info and instance data. + * + * @param p_info A pointer to a GDExtensionScriptInstanceInfo3 struct. + * @param p_instance_data A pointer to a data representing the script instance in the GDExtension. This will be passed to all the function pointers on p_info. + * + * @return A pointer to a ScriptInstanceExtension object. + */ +typedef GDExtensionScriptInstancePtr (*GDExtensionInterfaceScriptInstanceCreate3)(const GDExtensionScriptInstanceInfo3 *p_info, GDExtensionScriptInstanceDataPtr p_instance_data); + +/** + * @name placeholder_script_instance_create + * @since 4.2 + * + * Creates a placeholder script instance for a given script and instance. + * + * This interface is optional as a custom placeholder could also be created with script_instance_create(). + * + * @param p_language A pointer to a ScriptLanguage. + * @param p_script A pointer to a Script. + * @param p_owner A pointer to an Object. + * + * @return A pointer to a PlaceHolderScriptInstance object. + */ +typedef GDExtensionScriptInstancePtr (*GDExtensionInterfacePlaceholderScriptInstanceCreate)(GDExtensionObjectPtr p_language, GDExtensionObjectPtr p_script, GDExtensionObjectPtr p_owner); + +/** + * @name placeholder_script_instance_update + * @since 4.2 + * + * Updates a placeholder script instance with the given properties and values. + * + * The passed in placeholder must be an instance of PlaceHolderScriptInstance + * such as the one returned by placeholder_script_instance_create(). + * + * @param p_placeholder A pointer to a PlaceHolderScriptInstance. + * @param p_properties A pointer to an Array of Dictionary representing PropertyInfo. + * @param p_values A pointer to a Dictionary mapping StringName to Variant values. + */ +typedef void (*GDExtensionInterfacePlaceholderScriptInstanceUpdate)(GDExtensionScriptInstancePtr p_placeholder, GDExtensionConstTypePtr p_properties, GDExtensionConstTypePtr p_values); + +/** + * @name object_get_script_instance + * @since 4.2 + * + * Get the script instance data attached to this object. + * + * @param p_object A pointer to the Object. + * @param p_language A pointer to the language expected for this script instance. + * + * @return A GDExtensionScriptInstanceDataPtr that was attached to this object as part of script_instance_create. + */ +typedef GDExtensionScriptInstanceDataPtr (*GDExtensionInterfaceObjectGetScriptInstance)(GDExtensionConstObjectPtr p_object, GDExtensionObjectPtr p_language); + +/** + * @name object_set_script_instance + * @since 4.5 + * + * Set the script instance data attached to this object. + * + * @param p_object A pointer to the Object. + * @param p_script_instance A pointer to the script instance data to attach to this object. + */ +typedef void (*GDExtensionInterfaceObjectSetScriptInstance)(GDExtensionObjectPtr p_object, GDExtensionScriptInstanceDataPtr p_script_instance); + +/** + * @name callable_custom_create + * @since 4.2 + * @deprecated Deprecated in Godot 4.3. Use `callable_custom_create2` instead. + * + * Creates a custom Callable object from a function pointer. + * + * Provided struct can be safely freed once the function returns. + * + * @param r_callable A pointer that will receive the new Callable. + * @param p_callable_custom_info The info required to construct a Callable. + */ +typedef void (*GDExtensionInterfaceCallableCustomCreate)(GDExtensionUninitializedTypePtr r_callable, GDExtensionCallableCustomInfo *p_callable_custom_info); + +/** + * @name callable_custom_create2 + * @since 4.3 + * + * Creates a custom Callable object from a function pointer. + * + * Provided struct can be safely freed once the function returns. + * + * @param r_callable A pointer that will receive the new Callable. + * @param p_callable_custom_info The info required to construct a Callable. + */ +typedef void (*GDExtensionInterfaceCallableCustomCreate2)(GDExtensionUninitializedTypePtr r_callable, GDExtensionCallableCustomInfo2 *p_callable_custom_info); + +/** + * @name callable_custom_get_userdata + * @since 4.2 + * + * Retrieves the userdata pointer from a custom Callable. + * + * If the Callable is not a custom Callable or the token does not match the one provided to callable_custom_create() via GDExtensionCallableCustomInfo then NULL will be returned. + * + * @param p_callable A pointer to a Callable. + * @param p_token A pointer to an address that uniquely identifies the GDExtension. + * + * @return The userdata pointer given when creating this custom Callable. + */ +typedef void *(*GDExtensionInterfaceCallableCustomGetUserdata)(GDExtensionConstTypePtr p_callable, void *p_token); + +/** + * @name classdb_construct_object + * @since 4.1 + * @deprecated Deprecated in Godot 4.4. Use `classdb_construct_object3` instead. + * + * Constructs an Object of the requested class. + * + * The passed class must be a built-in godot class, or an already-registered extension class. In both cases, object_set_instance() should be called to fully initialize the object. + * + * @param p_classname A pointer to a StringName with the class name. + * + * @return A pointer to the newly created Object. + */ +typedef GDExtensionObjectPtr (*GDExtensionInterfaceClassdbConstructObject)(GDExtensionConstStringNamePtr p_classname); + +/** + * @name classdb_construct_object2 + * @since 4.4 + * @deprecated Deprecated in Godot 4.7. Use `classdb_construct_object3` instead. + * + * Constructs an Object of the requested class. + * + * The passed class must be a built-in godot class, or an already-registered extension class. In both cases, object_set_instance() should be called to fully initialize the object. + * + * "NOTIFICATION_POSTINITIALIZE" must be sent after construction. + * + * @param p_classname A pointer to a StringName with the class name. + * + * @return A pointer to the newly created Object. + */ +typedef GDExtensionObjectPtr (*GDExtensionInterfaceClassdbConstructObject2)(GDExtensionConstStringNamePtr p_classname); + +/** + * @name classdb_construct_object3 + * @since 4.7 + * + * Constructs an Object of the requested class. + * + * The passed class must be a built-in godot class, or an already-registered extension class. In both cases, object_set_instance() should be called to fully initialize the object. + * If the type is a subtype of RefCounted, it already has a refcount of 1. The caller must take ownership the refcount and is responsible for decrementing it again when the object is no longer needed. + * + * "NOTIFICATION_POSTINITIALIZE" must be sent after construction. + * + * @param p_classname A pointer to a StringName with the class name. + * + * @return A pointer to the newly created Object. + */ +typedef GDExtensionObjectPtr (*GDExtensionInterfaceClassdbConstructObject3)(GDExtensionConstStringNamePtr p_classname); + +/** + * @name classdb_get_method_bind + * @since 4.1 + * + * Gets a pointer to the MethodBind in ClassDB for the given class, method and hash. + * + * @param p_classname A pointer to a StringName with the class name. + * @param p_methodname A pointer to a StringName with the method name. + * @param p_hash A hash representing the function signature. + * + * @return A pointer to the MethodBind from ClassDB. + */ +typedef GDExtensionMethodBindPtr (*GDExtensionInterfaceClassdbGetMethodBind)(GDExtensionConstStringNamePtr p_classname, GDExtensionConstStringNamePtr p_methodname, GDExtensionInt p_hash); + +/** + * @name classdb_get_class_tag + * @since 4.1 + * @deprecated Deprecated in Godot 4.7. No longer needed. Use the `is_class` method on `Object` instead. + * + * Gets a pointer uniquely identifying the given built-in class in the ClassDB. + * + * @param p_classname A pointer to a StringName with the class name. + * + * @return A pointer uniquely identifying the built-in class in the ClassDB. + */ +typedef void *(*GDExtensionInterfaceClassdbGetClassTag)(GDExtensionConstStringNamePtr p_classname); + +/** + * @name classdb_register_extension_class + * @since 4.1 + * @deprecated Deprecated in Godot 4.2. Use `classdb_register_extension_class6` instead. + * + * Registers an extension class in the ClassDB. + * + * Provided struct can be safely freed once the function returns. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_parent_class_name A pointer to a StringName with the parent class name. + * @param p_extension_funcs A pointer to a GDExtensionClassCreationInfo struct. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClass)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, GDExtensionConstStringNamePtr p_parent_class_name, const GDExtensionClassCreationInfo *p_extension_funcs); + +/** + * @name classdb_register_extension_class2 + * @since 4.2 + * @deprecated Deprecated in Godot 4.3. Use `classdb_register_extension_class6` instead. + * + * Registers an extension class in the ClassDB. + * + * Provided struct can be safely freed once the function returns. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_parent_class_name A pointer to a StringName with the parent class name. + * @param p_extension_funcs A pointer to a GDExtensionClassCreationInfo2 struct. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClass2)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, GDExtensionConstStringNamePtr p_parent_class_name, const GDExtensionClassCreationInfo2 *p_extension_funcs); + +/** + * @name classdb_register_extension_class3 + * @since 4.3 + * @deprecated Deprecated in Godot 4.4. Use `classdb_register_extension_class6` instead. + * + * Registers an extension class in the ClassDB. + * + * Provided struct can be safely freed once the function returns. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_parent_class_name A pointer to a StringName with the parent class name. + * @param p_extension_funcs A pointer to a GDExtensionClassCreationInfo3 struct. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClass3)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, GDExtensionConstStringNamePtr p_parent_class_name, const GDExtensionClassCreationInfo3 *p_extension_funcs); + +/** + * @name classdb_register_extension_class4 + * @since 4.4 + * @deprecated Deprecated in Godot 4.5. Use `classdb_register_extension_class6` instead. + * + * Registers an extension class in the ClassDB. + * + * Provided struct can be safely freed once the function returns. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_parent_class_name A pointer to a StringName with the parent class name. + * @param p_extension_funcs A pointer to a GDExtensionClassCreationInfo4 struct. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClass4)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, GDExtensionConstStringNamePtr p_parent_class_name, const GDExtensionClassCreationInfo4 *p_extension_funcs); + +/** + * @name classdb_register_extension_class5 + * @since 4.5 + * @deprecated Deprecated in Godot 4.7. Use `classdb_register_extension_class6` instead. + * + * Registers an extension class in the ClassDB. + * + * Provided struct can be safely freed once the function returns. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_parent_class_name A pointer to a StringName with the parent class name. + * @param p_extension_funcs A pointer to a GDExtensionClassCreationInfo5 struct. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClass5)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, GDExtensionConstStringNamePtr p_parent_class_name, const GDExtensionClassCreationInfo5 *p_extension_funcs); + +/** + * @name classdb_register_extension_class6 + * @since 4.7 + * + * Registers an extension class in the ClassDB. + * + * Provided struct can be safely freed once the function returns. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_parent_class_name A pointer to a StringName with the parent class name. + * @param p_extension_funcs A pointer to a GDExtensionClassCreationInfo6 struct. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClass6)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, GDExtensionConstStringNamePtr p_parent_class_name, const GDExtensionClassCreationInfo6 *p_extension_funcs); + +/** + * @name classdb_register_extension_class_method + * @since 4.1 + * + * Registers a method on an extension class in the ClassDB. + * + * Provided struct can be safely freed once the function returns. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_method_info A pointer to a GDExtensionClassMethodInfo struct. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClassMethod)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, const GDExtensionClassMethodInfo *p_method_info); + +/** + * @name classdb_register_extension_class_virtual_method + * @since 4.3 + * + * Registers a virtual method on an extension class in ClassDB, that can be implemented by scripts or other extensions. + * + * Provided struct can be safely freed once the function returns. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_method_info A pointer to a GDExtensionClassMethodInfo struct. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClassVirtualMethod)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, const GDExtensionClassVirtualMethodInfo *p_method_info); + +/** + * @name classdb_register_extension_class_integer_constant + * @since 4.1 + * + * Registers an integer constant on an extension class in the ClassDB. + * + * Note about registering bitfield values (if p_is_bitfield is true): even though p_constant_value is signed, language bindings are + * advised to treat bitfields as uint64_t, since this is generally clearer and can prevent mistakes like using -1 for setting all bits. + * Language APIs should thus provide an abstraction that registers bitfields (uint64_t) separately from regular constants (int64_t). + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_enum_name A pointer to a StringName with the enum name. + * @param p_constant_name A pointer to a StringName with the constant name. + * @param p_constant_value The constant value. + * @param p_is_bitfield Whether or not this constant is part of a bitfield. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClassIntegerConstant)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, GDExtensionConstStringNamePtr p_enum_name, GDExtensionConstStringNamePtr p_constant_name, GDExtensionInt p_constant_value, GDExtensionBool p_is_bitfield); + +/** + * @name classdb_register_extension_class_property + * @since 4.1 + * + * Registers a property on an extension class in the ClassDB. + * + * Provided struct can be safely freed once the function returns. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_info A pointer to a GDExtensionPropertyInfo struct. + * @param p_setter A pointer to a StringName with the name of the setter method. + * @param p_getter A pointer to a StringName with the name of the getter method. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClassProperty)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, const GDExtensionPropertyInfo *p_info, GDExtensionConstStringNamePtr p_setter, GDExtensionConstStringNamePtr p_getter); + +/** + * @name classdb_register_extension_class_property_indexed + * @since 4.2 + * + * Registers an indexed property on an extension class in the ClassDB. + * + * Provided struct can be safely freed once the function returns. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_info A pointer to a GDExtensionPropertyInfo struct. + * @param p_setter A pointer to a StringName with the name of the setter method. + * @param p_getter A pointer to a StringName with the name of the getter method. + * @param p_index The index to pass as the first argument to the getter and setter methods. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClassPropertyIndexed)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, const GDExtensionPropertyInfo *p_info, GDExtensionConstStringNamePtr p_setter, GDExtensionConstStringNamePtr p_getter, GDExtensionInt p_index); + +/** + * @name classdb_register_extension_class_property_group + * @since 4.1 + * + * Registers a property group on an extension class in the ClassDB. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_group_name A pointer to a String with the group name. + * @param p_prefix A pointer to a String with the prefix used by properties in this group. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClassPropertyGroup)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, GDExtensionConstStringPtr p_group_name, GDExtensionConstStringPtr p_prefix); + +/** + * @name classdb_register_extension_class_property_subgroup + * @since 4.1 + * + * Registers a property subgroup on an extension class in the ClassDB. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_subgroup_name A pointer to a String with the subgroup name. + * @param p_prefix A pointer to a String with the prefix used by properties in this subgroup. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClassPropertySubgroup)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, GDExtensionConstStringPtr p_subgroup_name, GDExtensionConstStringPtr p_prefix); + +/** + * @name classdb_register_extension_class_signal + * @since 4.1 + * + * Registers a signal on an extension class in the ClassDB. + * + * Provided structs can be safely freed once the function returns. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + * @param p_signal_name A pointer to a StringName with the signal name. + * @param p_argument_info A pointer to a GDExtensionPropertyInfo struct. + * @param p_argument_count The number of arguments the signal receives. + */ +typedef void (*GDExtensionInterfaceClassdbRegisterExtensionClassSignal)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name, GDExtensionConstStringNamePtr p_signal_name, const GDExtensionPropertyInfo *p_argument_info, GDExtensionInt p_argument_count); + +/** + * @name classdb_unregister_extension_class + * @since 4.1 + * + * Unregisters an extension class in the ClassDB. + * + * Unregistering a parent class before a class that inherits it will result in failure. Inheritors must be unregistered first. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_class_name A pointer to a StringName with the class name. + */ +typedef void (*GDExtensionInterfaceClassdbUnregisterExtensionClass)(GDExtensionClassLibraryPtr p_library, GDExtensionConstStringNamePtr p_class_name); + +/** + * @name get_library_path + * @since 4.1 + * + * Gets the path to the current GDExtension library. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param r_path A pointer to a String which will receive the path. + */ +typedef void (*GDExtensionInterfaceGetLibraryPath)(GDExtensionClassLibraryPtr p_library, GDExtensionUninitializedStringPtr r_path); + +/** + * @name editor_add_plugin + * @since 4.1 + * + * Adds an editor plugin. + * + * It's safe to call during initialization. + * + * @param p_class_name A pointer to a StringName with the name of a class (descending from EditorPlugin) which is already registered with ClassDB. + */ +typedef void (*GDExtensionInterfaceEditorAddPlugin)(GDExtensionConstStringNamePtr p_class_name); + +/** + * @name editor_remove_plugin + * @since 4.1 + * + * Removes an editor plugin. + * + * @param p_class_name A pointer to a StringName with the name of a class that was previously added as an editor plugin. + */ +typedef void (*GDExtensionInterfaceEditorRemovePlugin)(GDExtensionConstStringNamePtr p_class_name); + +/** + * @name editor_help_load_xml_from_utf8_chars + * @since 4.3 + * + * Loads new XML-formatted documentation data in the editor. + * + * The provided pointer can be immediately freed once the function returns. + * + * @param p_data A pointer to a UTF-8 encoded C string (null terminated). + */ +typedef void (*GDExtensionInterfaceEditorHelpLoadXmlFromUtf8Chars)(const char *p_data); + +/** + * @name editor_help_load_xml_from_utf8_chars_and_len + * @since 4.3 + * + * Loads new XML-formatted documentation data in the editor. + * + * The provided pointer can be immediately freed once the function returns. + * + * @param p_data A pointer to a UTF-8 encoded C string. + * @param p_size The number of bytes (not code units). + */ +typedef void (*GDExtensionInterfaceEditorHelpLoadXmlFromUtf8CharsAndLen)(const char *p_data, GDExtensionInt p_size); + +/** + * @name editor_register_get_classes_used_callback + * @since 4.5 + * + * Registers a callback that Godot can call to get the list of all classes (from ClassDB) that may be used by the calling GDExtension. + * + * This is used by the editor to generate a build profile (in "Tools" > "Engine Compilation Configuration Editor..." > "Detect from project"), + * in order to recompile Godot with only the classes used. + * In the provided callback, the GDExtension should provide the list of classes that _may_ be used statically, thus the time of invocation shouldn't matter. + * If a GDExtension doesn't register a callback, Godot will assume that it could be using any classes. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_callback The callback to retrieve the list of classes used. + */ +typedef void (*GDExtensionInterfaceEditorRegisterGetClassesUsedCallback)(GDExtensionClassLibraryPtr p_library, GDExtensionEditorGetClassesUsedCallback p_callback); + +/** + * @name register_main_loop_callbacks + * @since 4.5 + * + * Registers callbacks to be called at different phases of the main loop. + * + * @param p_library A pointer the library received by the GDExtension's entry point function. + * @param p_callbacks A pointer to the structure that contains the callbacks. + */ +typedef void (*GDExtensionInterfaceRegisterMainLoopCallbacks)(GDExtensionClassLibraryPtr p_library, const GDExtensionMainLoopCallbacks *p_callbacks); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/plugins/agc-godot-editor/native/gdextension/vendor/provenance.json b/plugins/agc-godot-editor/native/gdextension/vendor/provenance.json new file mode 100644 index 000000000..c08bfa9dd --- /dev/null +++ b/plugins/agc-godot-editor/native/gdextension/vendor/provenance.json @@ -0,0 +1,10 @@ +{ + "project": "Godot Engine", + "version": "4.7.2-stable", + "commit": "ed1daf0bf001b61586d9930840f2f1394092c079", + "license": "MIT", + "licenseFile": "LICENSE.txt", + "interfaceSource": "https://github.com/godotengine/godot/blob/ed1daf0bf001b61586d9930840f2f1394092c079/core/extension/gdextension_interface.json", + "headerGenerator": "https://github.com/godotengine/godot/blob/ed1daf0bf001b61586d9930840f2f1394092c079/core/extension/make_interface_header.py", + "generation": "Official unmodified generator using local file IO helpers; include guard and provenance comments added. No godot-cpp dependency." +} diff --git a/plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.lock b/plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.lock new file mode 100644 index 000000000..6c9b20f82 --- /dev/null +++ b/plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.lock @@ -0,0 +1,294 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "editor-adapter-api" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "godot-editor-bridge" +version = "0.1.0" +dependencies = [ + "editor-adapter-api", + "serde", + "serde_json", + "sha2", + "tempfile", + "windows-sys", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml b/plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml new file mode 100644 index 000000000..293f77ecc --- /dev/null +++ b/plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "godot-editor-bridge" +version = "0.1.0" +edition = "2021" +license = "UNLICENSED" +publish = false +description = "AGC Godot GDExtension 的受控原生适配器" + +[dependencies] +editor-adapter-api = { path = "../../../../server-rs/crates/editor-adapter-api" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tempfile = "3" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ProcessStatus", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_System_Memory"] } + +[workspace] diff --git a/plugins/agc-godot-editor/native/godot-editor-bridge/examples/install_location_smoke.rs b/plugins/agc-godot-editor/native/godot-editor-bridge/examples/install_location_smoke.rs new file mode 100644 index 000000000..cf65d1f6e --- /dev/null +++ b/plugins/agc-godot-editor/native/godot-editor-bridge/examples/install_location_smoke.rs @@ -0,0 +1,461 @@ +//! 仅操作调用者拥有、已打开且可丢弃的 Godot fixture,不启动或关闭编辑器。 +//! 用法:install_location_smoke --allow-fixture-mutations +//! 两个安装源必须事先存在且属于同一可信包;本程序不复制、修改或删除安装源。 +//! 任一步失败立即停止,不重放 execute,不在未知卸载结果后继续配置或执行。 +use editor_adapter_api::EditorAdapter; +use godot_editor_bridge::{ + configure_payload_candidates, configure_runtime_cache_dir, GodotEditorAdapter, PROTOCOL_VERSION, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +const DESCRIPTOR: &str = "agc-editor-bridge.gdextension"; +const UID: &str = "agc-editor-bridge.gdextension.uid"; + +fn ensure(condition: bool, message: &str) -> Result<(), String> { + if condition { + Ok(()) + } else { + Err(message.into()) + } +} + +fn no_links(path: &Path) -> Result<(), String> { + ensure(path.is_absolute(), "所有参数路径必须为绝对路径")?; + ensure( + !path + .components() + .any(|part| matches!(part, Component::ParentDir)), + "参数路径不得包含父目录跳转", + )?; + for ancestor in path.ancestors() { + match fs::symlink_metadata(ancestor) { + Ok(metadata) => { + ensure( + !metadata.file_type().is_symlink(), + "fixture 路径不能经过链接", + )?; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + ensure( + metadata.file_attributes() & 0x400 == 0, + "fixture 路径不能经过 reparse point", + )?; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("无法检查 fixture 路径:{error}")), + } + } + Ok(()) +} + +fn canonical(path: &Path) -> Result { + no_links(path)?; + fs::canonicalize(path).map_err(|error| format!("fixture 路径不可访问:{error}")) +} + +fn canonical_cache(path: &Path) -> Result { + no_links(path)?; + let ancestor = path + .ancestors() + .find(|candidate| candidate.exists()) + .ok_or("缓存没有已存在父目录")?; + ensure(ancestor.is_dir(), "缓存的已存在父路径不是目录")?; + Ok(canonical(ancestor)?.join( + path.strip_prefix(ancestor) + .map_err(|_| "缓存路径无法规范化")?, + )) +} + +fn sha256(path: &Path) -> Result { + no_links(path)?; + let mut file = fs::File::open(path).map_err(|error| format!("读取 hash 文件失败:{error}"))?; + ensure( + file.metadata() + .map_err(|error| error.to_string())? + .is_file(), + "hash 目标不是普通文件", + )?; + let mut digest = Sha256::new(); + let mut buffer = [0u8; 65536]; + loop { + let length = file.read(&mut buffer).map_err(|error| error.to_string())?; + if length == 0 { + break; + } + digest.update(&buffer[..length]); + } + Ok(format!("{:x}", digest.finalize())) +} + +fn project_root(workspace: &Path) -> Result { + if workspace.join("project.godot").is_file() { + canonical(&workspace.join("project.godot"))?; + return Ok(workspace.into()); + } + let mut candidates = Vec::new(); + for entry in fs::read_dir(workspace).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + if entry + .file_type() + .map_err(|error| error.to_string())? + .is_dir() + && entry.path().join("project.godot").is_file() + { + canonical(&entry.path().join("project.godot"))?; + candidates.push(canonical(&entry.path())?); + } + } + ensure( + candidates.len() == 1, + "根或唯一一层子目录必须有普通 project.godot", + )?; + Ok(candidates.remove(0)) +} + +fn original_files( + root: &Path, + directory: &Path, + files: &mut BTreeMap, +) -> Result<(), String> { + for entry in fs::read_dir(directory).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let name = entry.file_name(); + if matches!( + name.to_str(), + Some(".godot" | ".agent" | ".git" | DESCRIPTOR | UID) + ) { + continue; + } + let path = entry.path(); + no_links(&path)?; + if path.is_dir() { + original_files(root, &path, files)?; + } else if path.is_file() { + let relative = path + .strip_prefix(root) + .map_err(|_| "快照越过工作区")? + .to_string_lossy() + .into_owned(); + files.insert(relative, sha256(&path)?); + } + } + Ok(()) +} + +fn snapshot(workspace: &Path) -> Result, String> { + let mut files = BTreeMap::new(); + original_files(workspace, workspace, &mut files)?; + Ok(files) +} + +fn no_workspace_dll(directory: &Path) -> Result<(), String> { + for entry in fs::read_dir(directory).map_err(|error| error.to_string())? { + let path = entry.map_err(|error| error.to_string())?.path(); + no_links(&path)?; + ensure( + !path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("dll")), + "fixture 工作区中出现 DLL", + )?; + if path.is_dir() { + no_workspace_dll(&path)?; + } + } + Ok(()) +} + +#[derive(Debug, PartialEq, Eq)] +struct SourceSnapshot { + dll_sha256: String, + metadata_sha256: String, + dll_readonly: bool, + metadata_readonly: bool, +} + +fn source_snapshot(dll: &Path) -> Result { + let metadata = dll + .parent() + .ok_or("安装 DLL 缺少父目录")? + .join("metadata.json"); + Ok(SourceSnapshot { + dll_sha256: sha256(dll)?, + metadata_sha256: sha256(&metadata)?, + dll_readonly: fs::metadata(dll) + .map_err(|error| error.to_string())? + .permissions() + .readonly(), + metadata_readonly: fs::metadata(metadata) + .map_err(|error| error.to_string())? + .permissions() + .readonly(), + }) +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ObservedDescriptor { + protocol: String, + build_id: String, + sha256: String, + source_dll_path: PathBuf, + runtime_dll_path: PathBuf, +} + +fn descriptor( + project: &Path, + source: &Path, + cache: &Path, + workspace: &Path, + source_hash: &str, +) -> Result { + let file = project.join(DESCRIPTOR); + no_links(&file)?; + ensure( + fs::metadata(&file) + .map_err(|error| error.to_string())? + .len() + <= 65536, + "描述文件超过 64 KiB", + )?; + let text = fs::read_to_string(file).map_err(|error| error.to_string())?; + let encoded = text + .strip_prefix("; AGC managed Godot editor bridge v1\n; ") + .and_then(|rest| rest.lines().next()) + .ok_or("描述文件不是当前受管格式")?; + let descriptor: ObservedDescriptor = + serde_json::from_str(encoded).map_err(|error| error.to_string())?; + ensure( + descriptor.protocol == PROTOCOL_VERSION, + "描述文件协议不匹配", + )?; + ensure( + canonical(&descriptor.source_dll_path)? == source, + "描述文件未引用期望安装来源", + )?; + let runtime = canonical(&descriptor.runtime_dll_path)?; + ensure( + runtime.starts_with(canonical(cache)?) && !runtime.starts_with(workspace), + "加载副本没有处于工程外私有缓存", + )?; + ensure( + descriptor.sha256 == source_hash && sha256(&runtime)? == source_hash, + "加载副本与安装原件字节身份不符", + )?; + let godot_path = descriptor + .runtime_dll_path + .to_str() + .ok_or("加载副本路径不是 UTF-8")? + .strip_prefix(r"\\?\") + .unwrap_or(descriptor.runtime_dll_path.to_str().unwrap()) + .replace('\\', "/"); + ensure( + text.lines() + .any(|line| line == format!("windows.editor.x86_64 = \"{godot_path}\"")), + "描述文件 libraries 未引用当前加载副本", + )?; + Ok(descriptor) +} + +fn no_descriptor(project: &Path) -> Result<(), String> { + for name in [DESCRIPTOR, UID] { + match fs::symlink_metadata(project.join(name)) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => return Err(format!("仍有 {name},保留文件并停止后续操作")), + Err(error) => return Err(format!("无法确认 {name} 已清理:{error}")), + } + } + Ok(()) +} + +fn connect(adapter: &GodotEditorAdapter, workspace: &Path, pid: u32) -> Result { + let response = adapter.rpc( + "connect", + json!({"projectPath":workspace,"processId":pid,"timeoutMs":30000}), + )?; + ensure( + response["connected"] == true && response["pid"] == pid, + "连接未确认指定 fixture PID", + )?; + ensure( + response["generation"] + .as_str() + .is_some_and(|generation| !generation.is_empty()), + "连接缺少会话代次", + )?; + Ok(response) +} + +fn execute_42( + adapter: &GodotEditorAdapter, + workspace: &Path, + pid: u32, + label: &str, +) -> Result<(), String> { + let response = adapter.rpc( + "execute", + json!({"projectPath":workspace,"processId":pid,"timeoutMs":10000,"code":"return 42"}), + )?; + println!("{}", json!({"event":label,"receipt":response})); + ensure( + response["status"] == "completed" + && response["ok"] == true + && response["dispatched"] == true + && response["retryAllowed"] == false + && response["result"] == 42, + "未收到可信 42 执行回执;保留现场,不重发代码、不继续切换来源", + ) +} + +fn main() -> Result<(), String> { + let args: Vec = std::env::args().skip(1).collect(); + ensure(args.len() == 6 && args[5] == "--allow-fixture-mutations", "需要 workspace、pid、old-dll、new-dll、工程外 cache 和 --allow-fixture-mutations;仅限自有可丢弃 fixture")?; + ensure( + cfg!(all(windows, target_arch = "x86_64")), + "该实机示例仅支持 Windows x64", + )?; + let workspace = canonical(Path::new(&args[0]))?; + let pid: u32 = args[1].parse().map_err(|_| "pid 必须是正整数")?; + ensure(pid > 0, "pid 必须大于 0")?; + let original = canonical(Path::new(&args[2]))?; + let relocated = canonical(Path::new(&args[3]))?; + let cache = canonical_cache(Path::new(&args[4]))?; + ensure(original != relocated, "两个安装来源必须是不同绝对路径")?; + ensure( + !cache.starts_with(&workspace) + && !original.starts_with(&workspace) + && !relocated.starts_with(&workspace), + "安装来源和运行缓存必须位于整个 fixture 工作区外", + )?; + ensure( + !original.starts_with(&cache) && !relocated.starts_with(&cache), + "安装来源不能放在测试运行缓存内", + )?; + let project = project_root(&workspace)?; + no_descriptor(&project)?; + no_workspace_dll(&workspace)?; + let baseline = snapshot(&workspace)?; + ensure( + baseline + .keys() + .any(|file| file.ends_with(".tscn") || file.ends_with(".scn")), + "fixture 必须已有原始场景文件以核对场景 hash", + )?; + let original_before = source_snapshot(&original)?; + let relocated_before = source_snapshot(&relocated)?; + ensure( + original_before.dll_sha256 == relocated_before.dll_sha256 + && original_before.metadata_sha256 == relocated_before.metadata_sha256, + "两个安装来源不是同一可信包的相同 DLL 和元数据", + )?; + println!( + "{}", + json!({"event":"baseline","pid":pid,"workspace":workspace,"project":project,"files":baseline,"dllSha256":original_before.dll_sha256}) + ); + + configure_runtime_cache_dir(cache.clone())?; + configure_payload_candidates(vec![original.clone()])?; + let adapter = GodotEditorAdapter::new(Vec::new()); + let before = connect(&adapter, &workspace, pid)?; + let old_descriptor = descriptor( + &project, + &original, + &cache, + &workspace, + &original_before.dll_sha256, + )?; + let old_directory = old_descriptor + .runtime_dll_path + .parent() + .ok_or("旧加载副本缺少父目录")? + .to_path_buf(); + println!( + "{}", + json!({"event":"original-connected","connection":before,"descriptor":old_descriptor}) + ); + execute_42(&adapter, &workspace, pid, "original-execute")?; + + // 故意保持旧连接,由生产 configure API 负责先停机、确认卸载及清理。 + configure_payload_candidates(vec![relocated.clone()])?; + ensure( + !old_directory.exists(), + "安装来源切换后旧加载目录仍在,停止重新连接", + )?; + no_descriptor(&project)?; + println!( + "{}", + json!({"event":"source-reconfigured","oldRuntimeDirectoryRemoved":true,"oldGeneration":before["generation"]}) + ); + + let after = connect(&adapter, &workspace, pid)?; + ensure( + before["generation"] != after["generation"], + "新来源连接错误复用了旧 generation", + )?; + ensure( + before["startedFileTime"] == after["startedFileTime"], + "fixture 编辑器启动身份发生变化", + )?; + let new_descriptor = descriptor( + &project, + &relocated, + &cache, + &workspace, + &relocated_before.dll_sha256, + )?; + ensure( + old_descriptor.source_dll_path != new_descriptor.source_dll_path + && old_descriptor.runtime_dll_path != new_descriptor.runtime_dll_path, + "安装来源或加载缓存路径没有改变", + )?; + ensure(!old_directory.exists(), "重连后旧加载目录被复用")?; + let new_directory = new_descriptor + .runtime_dll_path + .parent() + .ok_or("新加载副本缺少父目录")? + .to_path_buf(); + println!( + "{}", + json!({"event":"relocated-connected","connection":after,"descriptor":new_descriptor,"oldGeneration":before["generation"],"newGeneration":after["generation"]}) + ); + execute_42(&adapter, &workspace, pid, "relocated-execute")?; + no_workspace_dll(&workspace)?; + + let disconnected = adapter.rpc( + "disconnect", + json!({"projectPath":workspace,"processId":pid,"timeoutMs":30000}), + )?; + ensure( + disconnected["connected"] == false, + "断开没有确认完成;保留现场,不继续操作", + )?; + no_descriptor(&project)?; + ensure( + !old_directory.exists() && !new_directory.exists(), + "断开后仍有本测试加载目录", + )?; + no_workspace_dll(&workspace)?; + let final_files = snapshot(&workspace)?; + ensure( + final_files == baseline, + "原有工程文件或主场景 hash 发生变化", + )?; + ensure( + source_snapshot(&original)? == original_before + && source_snapshot(&relocated)? == relocated_before, + "安装原件字节或只读属性发生变化", + )?; + println!( + "{}", + json!({"event":"complete","passed":true,"pid":pid,"oldGeneration":before["generation"],"newGeneration":after["generation"],"oldDescriptor":old_descriptor,"newDescriptor":new_descriptor,"oldRuntimeDirectoryRemoved":true,"newRuntimeDirectoryRemoved":true,"descriptorAndUidRemoved":true,"noDllInWorkspace":true,"originalFilesUnchanged":true,"installationSourcesUnchanged":true,"finalFiles":final_files,"godotWasNotTerminated":true}) + ); + Ok(()) +} diff --git a/plugins/agc-godot-editor/native/godot-editor-bridge/examples/live_smoke.rs b/plugins/agc-godot-editor/native/godot-editor-bridge/examples/live_smoke.rs new file mode 100644 index 000000000..93a487d05 --- /dev/null +++ b/plugins/agc-godot-editor/native/godot-editor-bridge/examples/live_smoke.rs @@ -0,0 +1,85 @@ +//! 只连接调用者明确指定的、已经打开的可丢弃 fixture,不启动或关闭 Godot。 +//! 首次引导或重连时,已处于前台的目标窗口会短暂最小化并恢复,以触发 Godot 的 FocusIn 扫描。 +//! 用法:cargo run --example live_smoke -- --allow-fixture-mutations +use editor_adapter_api::EditorAdapter; +use godot_editor_bridge::{ + configure_runtime_cache_dir, disconnect_godot_editor, GodotEditorAdapter, +}; +use serde_json::json; +use std::path::PathBuf; + +fn main() -> Result<(), String> { + let args: Vec = std::env::args().skip(1).collect(); + if !(5..=6).contains(&args.len()) || args[4] != "--allow-fixture-mutations" { + return Err("需要显式 fixture-workspace、pid、packaged-dll、工程外 private-cache-dir 和 --allow-fixture-mutations;仅使用可丢弃测试工程".into()); + } + let project = PathBuf::from(&args[0]); + let hold_ms = args + .get(5) + .map(|value| { + value + .strip_prefix("--hold-ms=") + .ok_or("只接受 --hold-ms=1000..60000")? + .parse::() + .map_err(|_| "hold-ms 必须为整数") + }) + .transpose()?; + if hold_ms.is_some_and(|ms| !(1000..=60000).contains(&ms)) { + return Err("hold-ms 必须在 1000..60000".into()); + } + let pid: u32 = args[1].parse().map_err(|_| "pid 必须为正整数")?; + configure_runtime_cache_dir(PathBuf::from(&args[3]))?; + let adapter = GodotEditorAdapter::new(vec![PathBuf::from(&args[2])]); + let connected = adapter.rpc( + "connect", + json!({"projectPath":project,"processId":pid,"timeoutMs":20000}), + )?; + if connected["pid"] != pid { + return Err("fixture PID 不匹配".into()); + } + println!("connected: {}", connected); + if let Some(ms) = hold_ms { + let result = adapter.rpc( + "execute", + json!({"projectPath":project,"processId":pid,"code":"return 42","timeoutMs":10000}), + )?; + if result["status"] != "completed" || result["result"] != 42 { + return Err("实例占用验证未收到真实执行回执".into()); + } + println!("holding-live-instance: {result}"); + std::thread::sleep(std::time::Duration::from_millis(ms)); + disconnect_godot_editor()?; + println!("held-instance-unloaded"); + return Ok(()); + } + for (label,code,expected_status,expected) in [ + ("arithmetic","return 6 * 7","completed",Some(json!(42))), + ("null","return null","completed",Some(serde_json::Value::Null)), + ("scene-read","var scene = EditorInterface.get_edited_scene_root()\nreturn null if scene == null else scene.name","completed",None), + ("scene-undo", "var scene = EditorInterface.get_edited_scene_root()\nvar before = scene.get_child_count()\nvar probe = Node.new()\nprobe.name = \"AGC_Isolated_Verification\"\nvar history = UndoRedo.new()\nhistory.create_action(\"AGC isolated verification\")\nhistory.add_do_method(scene.add_child.bind(probe))\nhistory.add_do_property(probe, \"owner\", scene)\nhistory.add_undo_method(scene.remove_child.bind(probe))\nhistory.add_do_reference(probe)\nhistory.commit_action()\nvar added = probe.get_parent() == scene\nhistory.undo()\nvar undone = scene.get_child_count() == before and probe.get_parent() == null\nhistory.clear_history()\nif is_instance_valid(probe):\n\tprobe.free()\nreturn {\"added\": added, \"undone\": undone}", "completed", Some(json!({"added":true,"undone":true}))), + ("async","await EditorInterface.get_base_control().get_tree().process_frame\nreturn 42","completed",Some(json!(42))), + ("compile-error","var broken = ","failed",None), + ("runtime-error","var node: Node = null\nreturn node.get_name()","failed",None), + ] { + let result=adapter.rpc("execute",json!({"projectPath":project,"processId":pid,"code":code,"timeoutMs":10000}))?; + println!("{label}: {result}"); + if result["status"]!=expected_status || expected.is_some_and(|value| result["result"]!=value) {return Err(format!("{label} 没有产生预期真实回执;保留连接供诊断"));} + } + disconnect_godot_editor()?; + println!("模块卸载和受管文件清理已确认"); + // 再次连接应触发正式扫描加载,证明生命周期可复用。 + adapter.rpc( + "connect", + json!({"projectPath":project,"processId":pid,"timeoutMs":20000}), + )?; + let result = adapter.rpc( + "execute", + json!({"projectPath":project,"processId":pid,"code":"return 42","timeoutMs":10000}), + )?; + if result["result"] != 42 { + return Err("重连执行失败".into()); + } + disconnect_godot_editor()?; + println!("重连、执行和二次卸载完成"); + Ok(()) +} diff --git a/plugins/agc-godot-editor/native/godot-editor-bridge/src/files.rs b/plugins/agc-godot-editor/native/godot-editor-bridge/src/files.rs new file mode 100644 index 000000000..0cc8e5a53 --- /dev/null +++ b/plugins/agc-godot-editor/native/godot-editor-bridge/src/files.rs @@ -0,0 +1,543 @@ +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{platform::ProcessIdentity, PROTOCOL_VERSION}; + +pub const DESCRIPTOR_NAME: &str = "agc-editor-bridge.gdextension"; +const UID_NAME: &str = "agc-editor-bridge.gdextension.uid"; +const MAX_FILE_BYTES: u64 = 64 * 1024; +const MARKER: &str = "; AGC managed Godot editor bridge v1\n; "; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PayloadMetadata { + pub protocol: String, + pub build_id: String, + pub sha256: String, + pub platform: String, + pub arch: String, + pub entry_symbol: String, + pub minimum_godot_version: String, +} + +#[derive(Clone, Debug)] +pub struct Payload { + pub path: PathBuf, + pub source_path: PathBuf, + pub metadata: PayloadMetadata, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Descriptor { + pub protocol: String, + pub build_id: String, + pub sha256: String, + pub source_dll_path: PathBuf, + #[serde(rename = "runtimeDllPath")] + pub dll_path: PathBuf, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Session { + pub protocol: String, + pub build_id: String, + pub pid: u32, + pub started_file_time: String, + pub generation: String, + pub project_path: String, + pub version: String, + pub port: u16, + pub token: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Ownership { + protocol: String, + descriptor_sha256: String, + uid_content: Option, + session_identity: Option, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OwnedSessionIdentity { + pub pid: u32, + pub started_file_time: String, + pub generation: String, + pub project_path: PathBuf, + pub version: String, + pub build_id: String, +} + +pub fn snapshot_session( + project: &Path, + descriptor: &Descriptor, + session: &Session, +) -> Result<(), String> { + let mut record = ownership(project, descriptor)?; + record.session_identity = Some(OwnedSessionIdentity { + pid: session.pid, + started_file_time: session.started_file_time.clone(), + generation: session.generation.clone(), + project_path: project.into(), + version: session.version.clone(), + build_id: session.build_id.clone(), + }); + write_ownership(project, &record, false) +} + +pub fn owned_session_identity( + project: &Path, + descriptor: &Descriptor, +) -> Result, String> { + let record = ownership(project, descriptor)?; + if let Some(identity) = record.session_identity.as_ref() { + if identity.pid == 0 + || identity.started_file_time.parse::().is_err() + || !hex64(&identity.generation) + || identity.project_path != project + || identity.build_id != descriptor.build_id + || crate::platform::supported_version(&identity.version).as_deref() + != Some(&identity.version) + { + return Err("Godot 持久会话归属身份无效,不能恢复清理".into()); + } + } + Ok(record.session_identity) +} + +pub fn has_owned_artifacts(project: &Path) -> Result { + no_links(&ownership_path(project))?; + Ok(read_descriptor(project)?.is_some() + || read_uid(project)?.is_some() + || ownership_path(project).exists()) +} + +fn ownership_path(project: &Path) -> PathBuf { + project.join(".godot/agc/bridge-ownership.json") +} + +fn descriptor_hash(descriptor: &Descriptor) -> Result { + Ok(format!( + "{:x}", + Sha256::digest(descriptor.render()?.as_bytes()) + )) +} + +fn ownership(project: &Path, descriptor: &Descriptor) -> Result { + let record: Ownership = serde_json::from_slice(&read_small(&ownership_path(project))?) + .map_err(|_| "Godot 引导文件归属记录无效,保留文件供核对")?; + if record.protocol != PROTOCOL_VERSION + || record.descriptor_sha256 != descriptor_hash(descriptor)? + { + return Err("Godot 引导文件归属记录不匹配,保留文件供核对".into()); + } + Ok(record) +} + +fn write_ownership(project: &Path, record: &Ownership, create: bool) -> Result<(), String> { + let path = ownership_path(project); + no_links(&path)?; + let parent = path.parent().ok_or("Godot 归属记录路径无效")?; + fs::create_dir_all(parent).map_err(|_| "无法创建 Godot 归属记录目录")?; + no_links(&path)?; + let mut temp = + tempfile::NamedTempFile::new_in(parent).map_err(|_| "无法创建 Godot 归属记录")?; + temp.write_all(&serde_json::to_vec(record).map_err(|_| "Godot 归属记录编码失败")?) + .map_err(|_| "无法写入 Godot 归属记录")?; + temp.as_file() + .sync_all() + .map_err(|_| "无法同步 Godot 归属记录")?; + if create { + temp.persist_noclobber(path) + .map_err(|_| "Godot 归属记录已存在,拒绝接管未知文件")?; + } else { + temp.persist(path).map_err(|_| "无法更新 Godot 归属记录")?; + } + Ok(()) +} + +fn read_uid(project: &Path) -> Result, String> { + let path = project.join(UID_NAME); + no_links(&path)?; + if !path.exists() { + return Ok(None); + } + let bytes = read_small(&path)?; + let value = String::from_utf8(bytes).map_err(|_| "Godot UID 伴生文件不是 UTF-8")?; + let trimmed = value.trim(); + if !trimmed.strip_prefix("uid://").is_some_and(|body| { + !body.is_empty() + && body + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }) { + return Err("Godot UID 伴生文件内容无效,保留文件".into()); + } + Ok(Some(value)) +} + +/// 仅当前进程亲自创建描述文件后允许首次认领新生成 UID;重启不能把未知 UID 当作归属证据。 +pub fn snapshot_uid( + project: &Path, + descriptor: &Descriptor, + created_here: bool, +) -> Result<(), String> { + let mut record = ownership(project, descriptor)?; + let actual = read_uid(project)?; + if record.uid_content == actual { + return Ok(()); + } + if record.uid_content.is_none() && created_here { + record.uid_content = actual; + return write_ownership(project, &record, false); + } + Err("Godot UID 伴生文件缺少可信快照或已被修改,保留文件供核对".into()) +} + +pub fn no_links(path: &Path) -> Result<(), String> { + if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir)) { + return Err("Godot 路径必须是无父目录跳转的绝对路径".into()); + } + for ancestor in path.ancestors() { + match fs::symlink_metadata(ancestor) { + Ok(meta) => { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if meta.file_attributes() & 0x400 != 0 { + return Err("Godot 受管路径不能经过链接或 reparse point".into()); + } + } + if meta.file_type().is_symlink() { + return Err("Godot 受管路径不能经过符号链接".into()); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("Godot 受管路径不可检查".into()), + } + } + Ok(()) +} + +pub fn canonical(path: &Path) -> Result { + no_links(path)?; + fs::canonicalize(path).map_err(|_| "Godot 项目路径不可访问".into()) +} + +pub fn normalize_project(path: &Path) -> Result { + #[cfg(windows)] + if !matches!(path.components().next(), Some(Component::Prefix(prefix)) if matches!(prefix.kind(), std::path::Prefix::Disk(_) | std::path::Prefix::VerbatimDisk(_))) + { + return Err("Godot 原生桥仅支持 Windows 本地盘符项目,不支持 UNC 工程".into()); + } + let path = canonical(path)?; + if !path.is_dir() { + return Err("Godot 工作区不是目录".into()); + } + if ordinary_project(&path)? { + return Ok(path); + } + let mut roots = Vec::new(); + for entry in fs::read_dir(&path).map_err(|_| "Godot 工作区不可读取")? { + let entry = entry.map_err(|_| "Godot 工作区目录项不可读取")?; + let p = entry.path(); + if entry.file_name().to_string_lossy().starts_with('.') { + continue; + } + if p.join("project.godot").exists() { + no_links(&p)?; + if ordinary_project(&p)? { + roots.push(canonical(&p)?); + } + } + } + match roots.len() { + 1 => Ok(roots.remove(0)), + 0 => Err("工作区根或一层子目录中没有 Godot 项目".into()), + _ => Err("工作区有多个 Godot 项目,无法确定目标".into()), + } +} + +fn ordinary_project(path: &Path) -> Result { + let marker = path.join("project.godot"); + no_links(&marker)?; + match fs::symlink_metadata(marker) { + Ok(m) if m.is_file() => Ok(true), + Ok(_) => Err("project.godot 必须是普通文件".into()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err("project.godot 不可读取".into()), + } +} + +pub fn validate_candidate(path: &Path) -> Result<(), String> { + no_links(path)?; + let suffix = Path::new("native") + .join("gdextension") + .join("bin") + .join("win-x64") + .join("agc_godot_editor.dll"); + if !path.ends_with(suffix) { + return Err("Godot DLL 必须位于插件 native/gdextension/bin/win-x64 资源目录".into()); + } + Ok(()) +} + +pub fn load_payload(path: &Path) -> Result { + validate_candidate(path)?; + let meta_path = path + .parent() + .ok_or("Godot DLL 路径无效")? + .join("metadata.json"); + let metadata: PayloadMetadata = + serde_json::from_slice(&read_small(&meta_path)?).map_err(|_| "Godot DLL 元数据无效")?; + validate_payload_metadata(&metadata)?; + verify_dll_hash(path, &metadata.sha256)?; + Ok(Payload { + path: canonical(path)?, + source_path: canonical(path)?, + metadata, + }) +} + +pub fn validate_payload_metadata(metadata: &PayloadMetadata) -> Result<(), String> { + if metadata.protocol != PROTOCOL_VERSION + || metadata.platform != "windows" + || metadata.arch != "x86_64" + || metadata.entry_symbol != "agc_godot_editor_init" + || metadata.minimum_godot_version != "4.7" + || !hex64(&metadata.sha256) + || !metadata.build_id.strip_prefix("sha256:").is_some_and(hex64) + { + return Err("Godot DLL 元数据与受支持协议或平台不符".into()); + } + Ok(()) +} + +pub fn verify_dll_hash(path: &Path, expected: &str) -> Result<(), String> { + no_links(path)?; + let mut file = File::open(path).map_err(|_| "Godot DLL 不可读取")?; + if !file.metadata().map_err(|_| "Godot DLL 不可检查")?.is_file() { + return Err("Godot DLL 不是普通文件".into()); + } + let mut hasher = Sha256::new(); + let mut buf = [0; 64 * 1024]; + loop { + let size = file.read(&mut buf).map_err(|_| "Godot DLL 读取失败")?; + if size == 0 { + break; + } + hasher.update(&buf[..size]); + } + if format!("{:x}", hasher.finalize()) != expected { + return Err("Godot DLL SHA256 校验失败".into()); + } + Ok(()) +} + +/// Godot 4.7 的 Windows 编辑器 loader 只允许在同目录派生 ~ 前缀副本。 +pub fn verified_module_path(actual: &Path, descriptor: &Descriptor) -> Result { + let key = |p: &Path| { + p.to_string_lossy() + .trim_start_matches(r"\\?\") + .replace('/', "\\") + .to_lowercase() + }; + let original = &descriptor.dll_path; + let copy = original.with_file_name("~agc_godot_editor.dll"); + if key(actual) == key(original) || key(actual) == key(©) { + verify_dll_hash(actual, &descriptor.sha256)?; + return Ok(true); + } + if actual.file_name().is_some_and(|name| { + matches!( + name.to_string_lossy().to_ascii_lowercase().as_str(), + "agc_godot_editor.dll" | "~agc_godot_editor.dll" + ) + }) { + return Err("Godot 加载了其它路径的 AGC 模块,拒绝将文件清理冒充卸载".into()); + } + Ok(false) +} + +pub fn hex64(s: &str) -> bool { + s.len() == 64 + && s.bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) +} + +impl Descriptor { + pub fn from_payload(payload: &Payload) -> Self { + Self { + protocol: PROTOCOL_VERSION.into(), + build_id: payload.metadata.build_id.clone(), + sha256: payload.metadata.sha256.clone(), + source_dll_path: payload.source_path.clone(), + dll_path: payload.path.clone(), + } + } + pub fn render(&self) -> Result { + let path = self.dll_path.to_str().ok_or("Godot DLL 路径不是 UTF-8")?; + let path = path + .strip_prefix(r"\\?\") + .unwrap_or(path) + .replace('\\', "/"); + if path.contains(['\n', '\r', '\0', '"']) { + return Err("Godot DLL 路径含不支持的字符".into()); + } + Ok(format!("{MARKER}{}\n[configuration]\nentry_symbol = \"agc_godot_editor_init\"\ncompatibility_minimum = \"4.7\"\nreloadable = false\n\n[libraries]\nwindows.editor.x86_64 = \"{path}\"\n", serde_json::to_string(self).map_err(|_| "Godot 描述文件编码失败")?)) + } +} + +pub fn read_descriptor(project: &Path) -> Result, String> { + let path = project.join(DESCRIPTOR_NAME); + no_links(&path)?; + if !path.exists() { + return Ok(None); + } + let bytes = read_small(&path)?; + let text = std::str::from_utf8(&bytes).map_err(|_| "Godot 描述文件不是受管 UTF-8 文件")?; + let json = text + .strip_prefix(MARKER) + .and_then(|s| s.lines().next()) + .ok_or("同名 Godot 描述文件不属于 AGC,拒绝覆盖")?; + let descriptor: Descriptor = + serde_json::from_str(json).map_err(|_| "Godot 受管描述文件身份无效")?; + if descriptor.protocol != PROTOCOL_VERSION + || !hex64(&descriptor.sha256) + || !descriptor + .build_id + .strip_prefix("sha256:") + .is_some_and(hex64) + || descriptor.render()? != text + { + return Err("Godot 受管描述文件被修改,保留文件并拒绝覆盖".into()); + } + validate_candidate(&descriptor.source_dll_path)?; + no_links(&descriptor.dll_path)?; + Ok(Some(descriptor)) +} + +/// 调用方必须先证实旧模块已卸载;比较原始内容防止覆盖用户随后做出的修改。 +pub fn write_descriptor( + project: &Path, + expected: Option<&Descriptor>, + new: &Descriptor, +) -> Result<(), String> { + if read_descriptor(project)?.as_ref() != expected { + return Err("Godot 描述文件在操作期间改变".into()); + } + let previous_ownership = if let Some(old) = expected { + Some(ownership(project, old)?) + } else { + if read_uid(project)?.is_some() { + return Err("Godot 描述文件尚不存在但 UID 文件已存在,拒绝接管".into()); + } + None + }; + if expected == Some(new) { + return Ok(()); + } + let record = Ownership { + protocol: PROTOCOL_VERSION.into(), + descriptor_sha256: descriptor_hash(new)?, + uid_content: previous_ownership.and_then(|record| record.uid_content), + session_identity: None, + }; + // 先保存归属,异常中止最多留下需核对缓存,不能产生可被误认领的工程文件。 + write_ownership(project, &record, expected.is_none())?; + let mut temp = + tempfile::NamedTempFile::new_in(project).map_err(|_| "无法创建 Godot 描述文件")?; + temp.write_all(new.render()?.as_bytes()) + .map_err(|_| "无法写入 Godot 描述文件")?; + temp.as_file() + .sync_all() + .map_err(|_| "无法同步 Godot 描述文件")?; + if read_descriptor(project)?.as_ref() != expected { + return Err("Godot 描述文件在操作期间改变".into()); + } + if expected.is_some() { + temp.persist(project.join(DESCRIPTOR_NAME)) + .map_err(|_| "无法原子更新 Godot 描述文件")?; + } else { + temp.persist_noclobber(project.join(DESCRIPTOR_NAME)) + .map_err(|_| "Godot 描述文件已存在,拒绝覆盖")?; + } + Ok(()) +} + +pub fn remove_descriptor(project: &Path, expected: &Descriptor) -> Result<(), String> { + match read_descriptor(project)? { + Some(ref current) if current == expected => { + snapshot_uid(project, expected, false)?; + if read_uid(project)?.is_some() { + fs::remove_file(project.join(UID_NAME)) + .map_err(|_| "无法清理 Godot UID 伴生文件")?; + } + fs::remove_file(project.join(DESCRIPTOR_NAME)) + .map_err(|_| "无法清理 Godot 描述文件")?; + fs::remove_file(ownership_path(project)).map_err(|_| "无法清理 Godot 引导归属记录")?; + Ok(()) + } + None => Ok(()), + _ => Err("Godot 描述文件已变化,保留用户文件".into()), + } +} + +pub fn session_path(project: &Path, pid: u32) -> PathBuf { + project + .join(".godot") + .join("agc") + .join(format!("editor-bridge-{pid}.json")) +} + +pub fn read_session( + project: &Path, + identity: &ProcessIdentity, + descriptor: &Descriptor, +) -> Result, String> { + let path = session_path(project, identity.pid); + no_links(&path)?; + if !path.exists() { + return Ok(None); + } + let session: Session = + serde_json::from_slice(&read_small(&path)?).map_err(|_| "Godot 会话文件无效")?; + if session.protocol != PROTOCOL_VERSION + || session.pid != identity.pid + || session.started_file_time != identity.started_file_time + || session.build_id != descriptor.build_id + || !hex64(&session.generation) + || !hex64(&session.token) + || session.port == 0 + || canonical(Path::new(&session.project_path))? != project + || session.version != identity.version + { + return Err("Godot 会话的进程/启动时间/项目/版本/构建身份不匹配".into()); + } + Ok(Some(session)) +} + +pub fn read_small(path: &Path) -> Result, String> { + no_links(path)?; + let file = File::open(path).map_err(|_| "Godot 受管文件不可读取")?; + let meta = file.metadata().map_err(|_| "Godot 受管文件不可检查")?; + if !meta.is_file() || meta.len() > MAX_FILE_BYTES { + return Err("Godot 受管文件类型或大小无效".into()); + } + let mut bytes = Vec::new(); + file.take(MAX_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| "Godot 受管文件读取失败")?; + if bytes.len() as u64 > MAX_FILE_BYTES { + return Err("Godot 受管文件超过 64 KiB".into()); + } + Ok(bytes) +} diff --git a/plugins/agc-godot-editor/native/godot-editor-bridge/src/lib.rs b/plugins/agc-godot-editor/native/godot-editor-bridge/src/lib.rs new file mode 100644 index 000000000..f43dfe81f --- /dev/null +++ b/plugins/agc-godot-editor/native/godot-editor-bridge/src/lib.rs @@ -0,0 +1,723 @@ +//! Godot 插件与 Agent Runtime 共用的进程级服务。 +//! 受管工程描述文件与安装资源 DLL 分离;连接变更不清除不确定执行门闩。 + +mod files; +mod platform; +mod runtime_cache; +mod transport; + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use editor_adapter_api::{EditorAdapter, EditorConnectionInfo}; +use files::{Descriptor, Session}; +use platform::ProcessIdentity; +use serde::Deserialize; +use serde_json::{json, Value}; + +pub const GODOT_EDITOR_ADAPTER_ID: &str = "godot-editor"; +pub const PROTOCOL_VERSION: &str = "agc.godot.editor.v1"; +pub const MAX_EXECUTE_CODE_BYTES: usize = 128 * 1024; +pub const MAX_MESSAGE_BYTES: usize = 2 * 1024 * 1024; +pub const DEFAULT_COMMAND_TIMEOUT_MS: u32 = 60_000; + +pub fn is_supported_platform() -> bool { + cfg!(all(target_os = "windows", target_arch = "x86_64")) +} + +fn service() -> &'static GodotEditorService { + static SERVICE: OnceLock = OnceLock::new(); + SERVICE.get_or_init(GodotEditorService::default) +} + +/// 仅宿主注册内置插件时提供候选;RPC 不接受载荷路径。 +pub fn configure_payload_candidates(candidates: Vec) -> Result<(), String> { + service().configure(candidates) +} + +/// 宿主提供的私有可写目录,绝不接受模型或编辑器 RPC 覆盖。 +pub fn configure_runtime_cache_dir(root: PathBuf) -> Result<(), String> { + service().configure_cache(root) +} + +/// 先禁止新增旧连接请求,再核验停机、模块卸载和受管文件清理;不关闭编辑器。 +pub fn disconnect_godot_editor() -> Result<(), String> { + service().disconnect(Instant::now() + Duration::from_secs(10)) +} + +/// Runner 重启后仍能按宿主持有的旧受控项目恢复并验证清理。 +pub fn disconnect_godot_editor_for_project(project_path: &Path) -> Result<(), String> { + let project = files::normalize_project(project_path)?; + service().disconnect_project( + Some(&project), + Instant::now() + Duration::from_secs(10), + false, + ) +} + +pub fn execute_godot_editor_code_for_project( + project_path: &str, + code: &str, + timeout_ms: u32, +) -> Result { + execution_result(service().call( + "execute", + RpcParams { + project_path: Some(project_path.into()), + code: Some(code.into()), + timeout_ms: Some(timeout_ms), + ..Default::default() + }, + )) +} + +#[derive(Default)] +struct GodotEditorService { + candidates: Mutex>, + runtime_cache: Mutex>, + state: Mutex, + epoch: AtomicU64, + uncertain: AtomicBool, +} + +#[derive(Default)] +struct ServiceState { + connection: Option, + next_id: u64, +} + +struct Connection { + identity: ProcessIdentity, + descriptor: Descriptor, + session: Option, + created_here: bool, + cache_root: PathBuf, +} + +impl ServiceState { + fn id(&mut self) -> Result { + // 每个操作预留两个序号,shutdown 的状态预检和停机指令也不复用 id。 + self.next_id = self.next_id.checked_add(2).ok_or("Godot 请求序号已耗尽")?; + Ok(self.next_id) + } +} + +impl GodotEditorService { + fn configure_cache(&self, root: PathBuf) -> Result<(), String> { + let root = runtime_cache::configure_root(&root)?; + let previous = self + .runtime_cache + .lock() + .map_err(|_| "Godot 缓存配置锁损坏")? + .clone(); + if previous.as_ref() != Some(&root) { + self.disconnect_project(None, Instant::now() + Duration::from_secs(10), true)?; + *self + .runtime_cache + .lock() + .map_err(|_| "Godot 缓存配置锁损坏")? = Some(root); + } + Ok(()) + } + + fn cache_root(&self) -> Result { + self.runtime_cache + .lock() + .map_err(|_| "Godot 缓存配置锁损坏")? + .clone() + .ok_or_else(|| "Godot 宿主尚未配置私有运行缓存".into()) + } + + fn configure(&self, candidates: Vec) -> Result<(), String> { + for candidate in &candidates { + files::validate_candidate(candidate)?; + } + let mut current = self.candidates.lock().map_err(|_| "Godot 资源配置锁损坏")?; + if *current != candidates { + self.disconnect_project(None, Instant::now() + Duration::from_secs(10), true)?; + *current = candidates; + } + Ok(()) + } + + fn disconnect(&self, deadline: Instant) -> Result<(), String> { + self.disconnect_project(None, deadline, false) + } + + fn disconnect_project( + &self, + project: Option<&Path>, + deadline: Instant, + allow_empty: bool, + ) -> Result<(), String> { + self.epoch.fetch_add(1, Ordering::SeqCst); + let mut state = self + .state + .try_lock() + .map_err(|_| "Godot 请求正在执行或状态异常,暂不能卸载;已使旧连接请求失效")?; + if self.uncertain.load(Ordering::SeqCst) { + return Err("Godot 执行结果待核对,不能卸载或清除阻断".into()); + } + if let Some(project) = project { + if state + .connection + .as_ref() + .is_some_and(|connection| connection.identity.project != project) + { + // 宿主可以按持久授权清单清理其它旧工程;不能因此改写当前工程连接。 + if let Some(previous) = self.recover_connection(project, deadline)? { + let id = state.id()?; + shutdown(&previous, id, deadline)?; + } + return Ok(()); + } + if state.connection.is_none() { + state.connection = self.recover_connection(project, deadline)?; + if state.connection.is_none() { + return Ok(()); + } + } + } else if state.connection.is_none() && !allow_empty { + return Err("Godot 连接登记为空,需提供旧受控项目路径才能确认清理".into()); + } + let id = state.id()?; + if let Some(connection) = state.connection.as_ref() { + shutdown(connection, id, deadline)?; + } + state.connection = None; + Ok(()) + } + + fn recover_connection( + &self, + project: &Path, + deadline: Instant, + ) -> Result, String> { + let Some(descriptor) = files::read_descriptor(project)? else { + return if files::has_owned_artifacts(project)? { + Err("Godot 描述文件缺失但归属痕迹仍在,无法确认卸载".into()) + } else { + Ok(None) + }; + }; + files::snapshot_uid(project, &descriptor, false)?; + // 只从宿主私有缓存恢复旧来源快照;安装原件升级后仍可卸载原来已验证的旧副本。 + let cache_root = self.cache_root()?; + let owned = files::owned_session_identity(project, &descriptor)?; + let identity = if let Some(owned) = owned.as_ref() { + if platform::started(owned.pid)?.as_deref() != Some(&owned.started_file_time) { + ProcessIdentity { + pid: owned.pid, + started_file_time: owned.started_file_time.clone(), + project: project.into(), + version: owned.version.clone(), + } + } else { + platform::detect(project, Some(owned.pid), deadline)? + } + } else { + let cached = runtime_cache::identity(&cache_root, &descriptor)?; + if cached.project != project { + return Err("Godot 缓存所属项目与清理目标不符".into()); + } + if platform::started(cached.pid)?.as_deref() != Some(&cached.started_file_time) { + cached + } else { + platform::detect(project, Some(cached.pid), deadline)? + } + }; + runtime_cache::verify_owned(&cache_root, &identity, &descriptor)?; + let session = files::read_session(project, &identity, &descriptor)?; + if owned + .as_ref() + .zip(session.as_ref()) + .is_some_and(|(owned, session)| owned.generation != session.generation) + { + return Err("Godot 当前会话与持久归属代次不同,拒绝恢复清理".into()); + } + Ok(Some(Connection { + identity, + descriptor, + session, + created_here: false, + cache_root, + })) + } + + fn payload(&self) -> Result { + let candidates = self.candidates.lock().map_err(|_| "Godot 资源配置锁损坏")?; + for candidate in candidates.iter() { + if candidate.exists() { + return files::load_payload(candidate); + } + } + Err("Godot 插件缺少原生 DLL 资源,请安装完整插件或先构建原生资源".into()) + } + + fn call(&self, method: &str, params: RpcParams) -> Result { + let timeout = params.timeout_ms.unwrap_or(DEFAULT_COMMAND_TIMEOUT_MS); + if timeout == 0 || timeout > 60_000 { + return Err("timeoutMs 必须在 1..=60000".into()); + } + let deadline = Instant::now() + Duration::from_millis(timeout.into()); + if params.process_id == Some(0) { + return Err("processId 必须大于 0".into()); + } + if method == "execute" { + validate_code(params.code.as_deref().ok_or("缺少 code")?)?; + if self.uncertain.load(Ordering::SeqCst) { + return Ok(reconciliation( + "先前 Godot 执行结果待核对,当前宿主不再发送代码", + )); + } + } else if params.code.is_some() { + return Err("只有 execute 可以提供 code".into()); + } + if method == "connect" && self.uncertain.load(Ordering::SeqCst) { + return Err("Godot 执行结果待核对,不能重新安装、连接或升级桥".into()); + } + if !is_supported_platform() { + return Err("Godot 原生桥仅支持 Windows x64 标准编辑器".into()); + } + let requested_workspace = + Path::new(params.project_path.as_deref().ok_or("缺少 projectPath")?); + let project = files::normalize_project(requested_workspace)?; + let workspace = files::canonical(requested_workspace)?; + if method == "disconnect" { + self.disconnect_project(Some(&project), deadline, false)?; + return Ok( + json!({"adapter":GODOT_EDITOR_ADAPTER_ID,"connected":false,"pid":null,"projectPath":project,"version":null}), + ); + } + let epoch = self.epoch.load(Ordering::SeqCst); + let mut state = self + .state + .try_lock() + .map_err(|_| "Godot 编辑器已有请求正在执行,请等待回执")?; + if method == "execute" && self.uncertain.load(Ordering::SeqCst) { + return Ok(reconciliation("先前 Godot 执行结果待核对")); + } + if method == "connect" && self.uncertain.load(Ordering::SeqCst) { + return Err("Godot 执行结果待核对,不能重新连接桥".into()); + } + let identity = platform::detect(&project, params.process_id, deadline)?; + if method == "detect" { + return Ok(connection_info(&identity, None, false)); + } + if method == "status" { + // 状态查询不安装、升级或清理引导文件,也不转移原连接的所有权。 + let Some(descriptor) = files::read_descriptor(&project)? else { + return Ok(connection_info(&identity, None, false)); + }; + let payload = self.payload()?; + let cache_root = self.cache_root()?; + if descriptor != runtime_cache::desired(&cache_root, &payload, &identity, &workspace)? { + let mut info = connection_info(&identity, None, false); + info["diagnostic"] = json!("Godot 原生资源需要通过 connect 完成升级"); + return Ok(info); + } + runtime_cache::verify(&cache_root, &payload, &identity, &descriptor)?; + let Some(session) = files::read_session(&project, &identity, &descriptor)? else { + return Ok(connection_info(&identity, None, false)); + }; + let id = state.id()?; + let status = transport::exchange(&session, id, "status", json!({}), deadline) + .map_err(|error| error.message)?; + if !platform::module_loaded(&identity, &descriptor)? { + return Err("Godot 会话没有对应的已加载原生模块".into()); + } + if self.epoch.load(Ordering::SeqCst) != epoch { + return Err("Godot 状态查询期间连接已失效".into()); + } + let mut info = connection_info(&identity, Some(&session), true); + info["executing"] = status["executing"].clone(); + return Ok(info); + } + if self.epoch.load(Ordering::SeqCst) != epoch { + return Err("Godot 连接在派发前已失效,未发送请求".into()); + } + if let Some(previous) = &state.connection { + if previous.identity.pid != identity.pid + || previous.identity.started_file_time != identity.started_file_time + || previous.identity.project != project + { + if self.uncertain.load(Ordering::SeqCst) { + return Err("Godot 旧执行待核对,不能切换目标编辑器".into()); + } + let id = state.id()?; + shutdown(state.connection.as_ref().unwrap(), id, deadline)?; + state.connection = None; + } + } + let payload = self.payload()?; + let cache_root = runtime_cache::prepare_root(&self.cache_root()?, &workspace, &project)?; + remaining(deadline)?; + if project.join(".gdignore").exists() { + return Err("Godot 工程根含 .gdignore,原生描述文件无法被扫描;未写入引导文件".into()); + } + let desired = runtime_cache::desired(&cache_root, &payload, &identity, &workspace)?; + let existing = files::read_descriptor(&project)?; + let mut created_here = existing.is_none() + || state.connection.as_ref().is_some_and(|connection| { + connection.descriptor == desired && connection.created_here + }); + if existing.as_ref().is_some_and(|old| old != &desired) { + if self.uncertain.load(Ordering::SeqCst) { + return Err("Godot 旧执行待核对,不能升级原生资源".into()); + } + let old = existing.as_ref().unwrap(); + // 旧描述文件不能给模型增加任意 DLL 加载权限;这里只核验并卸载已经加载的旧模块。 + let session = files::read_session(&project, &identity, old)?; + runtime_cache::verify_owned(&cache_root, &identity, old)?; + let old_connection = Connection { + identity: identity.clone(), + descriptor: old.clone(), + session, + created_here: false, + cache_root: cache_root.clone(), + }; + let id = state.id()?; + shutdown(&old_connection, id, deadline)?; + state.connection = None; + runtime_cache::prepare(&cache_root, &payload, &identity, &workspace)?; + files::write_descriptor(&project, None, &desired)?; + created_here = true; + } else { + runtime_cache::prepare(&cache_root, &payload, &identity, &workspace)?; + files::write_descriptor(&project, existing.as_ref(), &desired)?; + } + let session = files::read_session(&project, &identity, &desired)?; + state.connection = Some(Connection { + identity: identity.clone(), + descriptor: desired, + session, + created_here, + cache_root, + }); + if state.connection.as_ref().unwrap().session.is_none() { + let _ = platform::focus(&identity, deadline)?; + // 等待仅用于首次扫描和 status 握手,从不自动重发 execute。 + let attach_deadline = deadline.min(Instant::now() + Duration::from_secs(10)); + loop { + let connection = state.connection.as_mut().unwrap(); + connection.session = + files::read_session(&project, &identity, &connection.descriptor)?; + if connection.session.is_some() { + break; + } + if platform::started(identity.pid)?.as_deref() != Some(&identity.started_file_time) + { + return Err("Godot 编辑器在连接期间退出".into()); + } + if Instant::now() >= attach_deadline { + return Err( + "Godot 原生桥尚未就绪,未派发代码;请重新聚焦 Godot 编辑器后连接".into(), + ); + } + std::thread::sleep(Duration::from_millis(25)); + } + } + let session = state + .connection + .as_ref() + .unwrap() + .session + .as_ref() + .unwrap() + .clone(); + let id = state.id()?; + let status = transport::exchange(&session, id, "status", json!({}), deadline) + .map_err(|e| e.message)?; + if !platform::module_loaded(&identity, &state.connection.as_ref().unwrap().descriptor)? { + return Err("Godot 会话没有对应的已加载原生模块".into()); + } + files::snapshot_uid( + &project, + &state.connection.as_ref().unwrap().descriptor, + created_here, + )?; + files::snapshot_session( + &project, + &state.connection.as_ref().unwrap().descriptor, + &session, + )?; + if method != "execute" { + if self.epoch.load(Ordering::SeqCst) != epoch { + return Err("Godot 握手期间连接已失效,旧目标不能投影为当前连接".into()); + } + let mut info = connection_info(&identity, Some(&session), true); + info["executing"] = status["executing"].clone(); + return Ok(info); + } + if status["executing"] == true { + return Err("Godot 编辑器仍有执行在途,拒绝并发派发".into()); + } + if self.epoch.load(Ordering::SeqCst) != epoch { + return Err("Godot 连接在派发前已切换,未发送请求".into()); + } + if platform::started(identity.pid)?.as_deref() != Some(&identity.started_file_time) { + return Err("Godot 目标进程在派发前退出".into()); + } + // 会话代次也必须在发送前保持一致,不能使用已重新加载的桥。 + let current = files::read_session( + &project, + &identity, + &state.connection.as_ref().unwrap().descriptor, + )? + .ok_or("Godot 会话在派发前消失")?; + if current.generation != session.generation + || current.token != session.token + || current.port != session.port + { + return Err("Godot 会话代次在派发前变化".into()); + } + let id = state.id()?; + let remaining_ms = remaining(deadline)?.as_millis().clamp(1, 60_000) as u32; + let result = transport::exchange( + &session, + id, + "execute", + json!({"code":params.code.unwrap(),"timeoutMs":remaining_ms}), + deadline, + ); + let result = match result { + Ok(value) => { + if platform::started(identity.pid).ok().flatten().as_deref() + != Some(&identity.started_file_time) + { + self.uncertain.store(true, Ordering::SeqCst); + reconciliation("Godot 回执后目标进程身份改变,执行结果待核对") + } else if value["status"] == "needs-reconciliation" { + self.uncertain.store(true, Ordering::SeqCst); + value + } else { + value + } + } + Err(error) => return self.finish_transport_error(error), + }; + // 即使项目切换发生在执行中,连接仍保留用于之后可信的清理,不投影为新项目连接。 + Ok(result) + } + + fn finish_transport_error(&self, error: transport::ExchangeError) -> Result { + if error.dispatched { + self.uncertain.store(true, Ordering::SeqCst); + Ok(reconciliation(&error.message)) + } else { + Err(error.message) + } + } +} + +fn shutdown(connection: &Connection, id: u64, deadline: Instant) -> Result<(), String> { + let identity = &connection.identity; + runtime_cache::verify_owned(&connection.cache_root, identity, &connection.descriptor)?; + files::snapshot_uid( + &identity.project, + &connection.descriptor, + connection.created_here, + )?; + if platform::started(identity.pid)?.as_deref() != Some(&identity.started_file_time) { + // 已退出旧进程的缓存只在完整身份匹配后删除,PID 重用也不会删新一代缓存。 + if let Some(session) = + files::read_session(&identity.project, identity, &connection.descriptor)? + { + if connection + .session + .as_ref() + .is_some_and(|known| known.generation != session.generation) + { + return Err("Godot 会话已被新代次替换,保留文件".into()); + } + std::fs::remove_file(files::session_path(&identity.project, identity.pid)) + .map_err(|_| "无法清理已退出 Godot 的会话缓存")?; + } + return cleanup_confirmed(connection); + } + let current = files::read_session(&identity.project, identity, &connection.descriptor)?; + let loaded = platform::module_loaded(identity, &connection.descriptor)?; + if !loaded && current.is_none() { + return cleanup_confirmed(connection); + } + if let Some(session) = current.as_ref() { + if let Some(known) = connection.session.as_ref() { + if known.generation != session.generation + || known.token != session.token + || known.port != session.port + { + return Err("Godot 会话改变,拒绝卸载其它代次".into()); + } + } + if !loaded { + return Err("Godot 缓存仍存在但未找到对应原生模块,保留文件".into()); + } + let status = transport::exchange(session, id, "status", json!({}), deadline) + .map_err(|e| e.message)?; + if status["executing"] == true { + return Err("Godot 执行仍在途,不能卸载原生桥".into()); + } + let reply = transport::exchange( + session, + id.checked_add(1).ok_or("Godot 请求序号已耗尽")?, + "shutdown", + json!({}), + deadline, + ) + .map_err(|e| format!("Godot 卸载结果未确认,保留受管文件:{}", e.message))?; + if reply["accepted"] != true { + return Err("Godot 拒绝卸载原生桥,保留受管文件".into()); + } + } else if loaded { + return Err("Godot 旧 DLL 仍加载但缺少可信会话,不能升级或删除描述文件".into()); + } + loop { + let loaded = platform::module_loaded(identity, &connection.descriptor)?; + let session = files::read_session(&identity.project, identity, &connection.descriptor)?; + if !loaded && session.is_none() { + break; + } + if session + .as_ref() + .zip(current.as_ref()) + .is_some_and(|(now, old)| now.generation != old.generation) + { + return Err("Godot 卸载期间出现新会话,保留文件".into()); + } + if Instant::now() >= deadline { + return Err("Godot 停机请求已发送,但模块或会话仍存在,卸载未确认".into()); + } + std::thread::sleep(Duration::from_millis(25)); + } + cleanup_confirmed(connection) +} + +fn cleanup_confirmed(connection: &Connection) -> Result<(), String> { + runtime_cache::cleanup( + &connection.cache_root, + &connection.identity, + &connection.descriptor, + )?; + files::remove_descriptor(&connection.identity.project, &connection.descriptor) +} + +fn connection_info( + identity: &ProcessIdentity, + session: Option<&Session>, + connected: bool, +) -> Value { + json!({"adapter":GODOT_EDITOR_ADAPTER_ID,"connected":connected,"pid":identity.pid,"projectPath":identity.project,"version":identity.version, + "startedFileTime":identity.started_file_time,"generation":session.map(|s|s.generation.as_str()),"buildId":session.map(|s|s.build_id.as_str()),"ready":connected}) +} + +pub(crate) fn remaining(deadline: Instant) -> Result { + deadline + .checked_duration_since(Instant::now()) + .filter(|d| !d.is_zero()) + .ok_or("Godot 请求总期限已到".into()) +} + +pub fn validate_code(code: &str) -> Result<(), String> { + if code.trim().is_empty() { + return Err("execute.code 不能为空".into()); + } + if code.contains('\0') { + return Err("execute.code 不能包含 NUL".into()); + } + if code.len() > MAX_EXECUTE_CODE_BYTES { + return Err("execute.code 超过 128 KiB 上限".into()); + } + Ok(()) +} + +fn reconciliation(message: &str) -> Value { + json!({"ok":false,"status":"needs-reconciliation","dispatched":true,"retryAllowed":false,"error":{"code":"execution-uncertain","message":message}}) +} +fn execution_result(result: Result) -> Result { + Ok(result.unwrap_or_else(|message| json!({"ok":false,"status":"failed","dispatched":false,"retryAllowed":false,"error":{"code":"not-dispatched","message":message}}))) +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct RpcParams { + project_path: Option, + process_id: Option, + code: Option, + timeout_ms: Option, +} + +pub struct GodotEditorAdapter { + configuration_error: Option, +} +impl GodotEditorAdapter { + pub fn new(candidates: Vec) -> Self { + Self { + configuration_error: if candidates.is_empty() { + None + } else { + configure_payload_candidates(candidates).err() + }, + } + } +} +impl Default for GodotEditorAdapter { + fn default() -> Self { + Self::new(Vec::new()) + } +} +fn canonical_method(method: &str) -> Result<&str, String> { + match method.strip_prefix("editor.").unwrap_or(method) { + method @ ("detect" | "connect" | "status" | "execute" | "disconnect") => Ok(method), + _ => Err("Godot 适配器不支持该 RPC 方法".into()), + } +} +impl EditorAdapter for GodotEditorAdapter { + fn id(&self) -> &'static str { + GODOT_EDITOR_ADAPTER_ID + } + fn detect(&self, project: &Path) -> Result { + serde_json::from_value(self.rpc("detect", json!({"projectPath":project}))?) + .map_err(|_| "Godot 探测回执无效".into()) + } + fn connect( + &mut self, + pid: u32, + project: &Path, + version: &str, + ) -> Result { + let result: EditorConnectionInfo = serde_json::from_value( + self.rpc("connect", json!({"projectPath":project,"processId":pid}))?, + ) + .map_err(|_| "Godot 连接回执无效")?; + if result.version.as_deref() != Some(version) { + return Err("Godot 目标版本与探测结果不一致".into()); + } + Ok(result) + } + fn disconnect(&mut self) { + let _ = disconnect_godot_editor(); + } + fn translate_rpc(&self, method: &str, params: Value) -> Result { + Ok(json!({"method":canonical_method(method)?,"params":params})) + } + fn rpc(&self, method: &str, params: Value) -> Result { + let method = canonical_method(method)?; + let result = if let Some(error) = &self.configuration_error { + Err(error.clone()) + } else { + serde_json::from_value(params) + .map_err(|_| "Godot RPC 参数无效或含未允许字段".to_string()) + .and_then(|params| service().call(method, params)) + }; + if method == "execute" { + execution_result(result) + } else { + result + } + } +} + +#[cfg(test)] +mod tests; diff --git a/plugins/agc-godot-editor/native/godot-editor-bridge/src/platform.rs b/plugins/agc-godot-editor/native/godot-editor-bridge/src/platform.rs new file mode 100644 index 000000000..a998b824c --- /dev/null +++ b/plugins/agc-godot-editor/native/godot-editor-bridge/src/platform.rs @@ -0,0 +1,437 @@ +use std::path::{Path, PathBuf}; +use std::time::Instant; + +#[derive(Clone, Debug)] +pub struct ProcessIdentity { + pub pid: u32, + pub started_file_time: String, + pub project: PathBuf, + pub version: String, +} + +pub fn supported_version(version: &str) -> Option { + let mut parts = version.split(|c: char| !c.is_ascii_digit()); + let major: u32 = parts.next()?.parse().ok()?; + let minor: u32 = parts.next()?.parse().ok()?; + let patch: u32 = parts.next()?.parse().ok()?; + (major == 4 && minor >= 7).then(|| format!("{major}.{minor}.{patch}")) +} + +#[cfg(windows)] +mod windows { + use super::*; + use serde::Deserialize; + use std::io::Read; + use std::os::windows::ffi::OsStringExt; + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + use std::thread; + use std::time::Duration; + use windows_sys::Win32::Foundation::*; + use windows_sys::Win32::System::ProcessStatus::{ + K32EnumProcessModulesEx, K32GetModuleBaseNameW, K32GetModuleFileNameExW, LIST_MODULES_ALL, + }; + use windows_sys::Win32::System::Threading::*; + use windows_sys::Win32::UI::Shell::CommandLineToArgvW; + use windows_sys::Win32::UI::WindowsAndMessaging::*; + + struct Handle(HANDLE); + impl Drop for Handle { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } + + pub fn started(pid: u32) -> Result, String> { + unsafe { + let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if process.is_null() { + return if GetLastError() == ERROR_INVALID_PARAMETER { + Ok(None) + } else { + Err("Godot 进程身份无法读取".into()) + }; + } + let process = Handle(process); + let mut exit_code = 0; + if GetExitCodeProcess(process.0, &mut exit_code) == 0 { + return Err("Godot 进程状态无法读取".into()); + } + if exit_code != STILL_ACTIVE as u32 { + return Ok(None); + } + let mut creation: FILETIME = std::mem::zeroed(); + let mut exit: FILETIME = std::mem::zeroed(); + let mut kernel: FILETIME = std::mem::zeroed(); + let mut user: FILETIME = std::mem::zeroed(); + if GetProcessTimes(process.0, &mut creation, &mut exit, &mut kernel, &mut user) == 0 { + return Err("Godot 进程启动身份无法读取".into()); + } + Ok(Some( + (((creation.dwHighDateTime as u64) << 32) | creation.dwLowDateTime as u64) + .to_string(), + )) + } + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Candidate { + pid: u32, + command_line: String, + executable: String, + version: String, + } + + pub fn detect( + project: &Path, + requested: Option, + deadline: Instant, + ) -> Result { + crate::remaining(deadline)?; + // 固定查询,不把项目路径或模型参数插入 PowerShell 代码。只启动本服务自己的只读查询子进程。 + let script = "$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $rows=@(Get-CimInstance Win32_Process -Filter \"Name LIKE 'Godot%.exe'\" | ForEach-Object { if ($_.ExecutablePath -and $_.CommandLine) { $v=[System.Diagnostics.FileVersionInfo]::GetVersionInfo($_.ExecutablePath); [pscustomobject]@{pid=$_.ProcessId;commandLine=$_.CommandLine;executable=$_.ExecutablePath;version=$v.ProductVersion} } }); ConvertTo-Json -InputObject $rows -Compress"; + let mut child = Command::new("powershell.exe") + .env_remove("PSModulePath") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map_err(|_| "无法查询已打开的 Godot 编辑器")?; + let stdout = child.stdout.take().ok_or("Godot 进程查询输出不可读")?; + let reader = thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = stdout.take(1024 * 1024 + 1).read_to_end(&mut bytes); + bytes + }); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return Err("Godot 进程发现超时,未发送执行请求".into()); + } + } + }; + let bytes = reader.join().map_err(|_| "Godot 进程查询失败")?; + if !status.success() || bytes.len() > 1024 * 1024 { + return Err("Godot 进程查询失败或超出大小限制".into()); + } + let candidates: Vec = + serde_json::from_slice(&bytes).map_err(|_| "Godot 进程查询返回无效结果")?; + let mut found = Vec::new(); + for candidate in candidates { + let args = parse_arguments(&candidate.command_line)?; + if !args.iter().any(|a| a == "--editor" || a == "-e") { + continue; + } + let Some(target) = command_project(&args) else { + continue; + }; + let Ok(target) = crate::files::canonical(&target) else { + continue; + }; + if target != project { + continue; + } + let executable = Path::new(&candidate.executable) + .file_name() + .ok_or("Godot 编辑器可执行文件路径无效")? + .to_string_lossy() + .to_ascii_lowercase(); + if executable.contains("mono") + || executable.contains("dotnet") + || args.iter().any(|a| a == "--headless") + { + return Err("Godot 桥仅支持标准 GUI 编辑器,不支持 .NET 或 headless 编辑器".into()); + } + let version = supported_version(&candidate.version) + .ok_or("Godot 桥要求 Godot 4.7 及以上的 4.x 编辑器")?; + let Some(started_file_time) = started(candidate.pid)? else { + continue; + }; + found.push(ProcessIdentity { + pid: candidate.pid, + started_file_time, + project: target, + version, + }); + } + if found.len() != 1 { + return Err(if found.is_empty() { + "没有找到唯一匹配项目的已打开 Godot 编辑器(需要 --editor 与明确项目路径)" + } else { + "同一项目存在多个 Godot 编辑器,拒绝选择不明确目标" + } + .into()); + } + let identity = found.remove(0); + if requested.is_some_and(|pid| pid != identity.pid) { + return Err("指定 Godot PID 与项目的编辑器不匹配".into()); + } + Ok(identity) + } + + fn parse_arguments(command: &str) -> Result, String> { + let wide: Vec = command.encode_utf16().chain(Some(0)).collect(); + unsafe { + let mut count = 0; + let args = CommandLineToArgvW(wide.as_ptr(), &mut count); + if args.is_null() { + return Err("Godot 进程命令行不可解析".into()); + } + let result = (0..count) + .map(|i| { + let ptr = *args.add(i as usize); + let mut len = 0; + while *ptr.add(len) != 0 { + len += 1; + } + String::from_utf16_lossy(std::slice::from_raw_parts(ptr, len)) + }) + .collect(); + LocalFree(args.cast()); + Ok(result) + } + } + + fn command_project(args: &[String]) -> Option { + for pair in args.windows(2) { + if pair[0] == "--path" { + return Some(PathBuf::from(&pair[1])); + } + } + args.iter().skip(1).find_map(|arg| { + let path = Path::new(arg); + if path.file_name().is_some_and(|s| s == "project.godot") { + path.parent().map(Path::to_path_buf) + } else { + None + } + }) + } + + pub fn module_loaded( + identity: &ProcessIdentity, + descriptor: &crate::files::Descriptor, + ) -> Result { + if started(identity.pid)?.as_deref() != Some(&identity.started_file_time) { + return Ok(false); + } + unsafe { + // ToolHelp 的模块快照会因长路径返回 ERROR_MORE_DATA;使用动态句柄数组和宽路径读取。 + let process = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, identity.pid); + if process.is_null() { + return Err(format!("无法核验 Godot 模块(Win32 {})", GetLastError())); + } + let process = Handle(process); + let mut modules: Vec = vec![std::ptr::null_mut(); 256]; + let mut last_error = 0; + for attempt in 0..5 { + if started(identity.pid)?.as_deref() != Some(&identity.started_file_time) { + return Ok(false); + } + let mut needed = 0u32; + if K32EnumProcessModulesEx( + process.0, + modules.as_mut_ptr(), + (modules.len() * std::mem::size_of::()) as u32, + &mut needed, + LIST_MODULES_ALL, + ) == 0 + { + last_error = GetLastError(); + } else { + let count = needed as usize / std::mem::size_of::(); + if count > 8192 || needed as usize % std::mem::size_of::() != 0 { + return Err("Godot 模块列表大小无效,保留受管文件".into()); + } + if count > modules.len() { + modules.resize(count, std::ptr::null_mut()); + continue; + } + let mut loaded = false; + let mut name = [0u16; 260]; + let mut path = vec![0u16; 32768]; + last_error = 0; + for module in modules.iter().take(count) { + let len = K32GetModuleBaseNameW( + process.0, + *module, + name.as_mut_ptr(), + name.len() as u32, + ) as usize; + if len == 0 { + last_error = GetLastError(); + break; + } + if len >= name.len() { + return Err("Godot 模块名被截断,不能确认卸载".into()); + } + let module_name = + String::from_utf16_lossy(&name[..len]).to_ascii_lowercase(); + if !matches!( + module_name.as_str(), + "agc_godot_editor.dll" | "~agc_godot_editor.dll" + ) { + continue; + } + let length = K32GetModuleFileNameExW( + process.0, + *module, + path.as_mut_ptr(), + path.len() as u32, + ) as usize; + if length == 0 { + last_error = GetLastError(); + break; + } + if length >= path.len() { + return Err("Godot 模块路径被截断,不能确认卸载".into()); + } + let path = PathBuf::from(std::ffi::OsString::from_wide(&path[..length])); + if crate::files::verified_module_path(&path, descriptor)? { + loaded = true; + } + } + if last_error == 0 { + return if started(identity.pid)?.as_deref() + == Some(&identity.started_file_time) + { + Ok(loaded) + } else { + Ok(false) + }; + } + } + if attempt == 4 + || !matches!( + last_error, + ERROR_PARTIAL_COPY | ERROR_BAD_LENGTH | ERROR_INVALID_HANDLE + ) + { + break; + } + thread::sleep(Duration::from_millis(10)); + } + Err(format!( + "无法完整核对 Godot 模块,保留受管文件(Win32 {last_error})" + )) + } + } + + pub fn focus(identity: &ProcessIdentity, deadline: Instant) -> Result { + crate::remaining(deadline)?; + if started(identity.pid)?.as_deref() != Some(&identity.started_file_time) { + return Err("Godot 编辑器已退出或 PID 被复用".into()); + } + struct Find { + pid: u32, + window: HWND, + } + unsafe extern "system" fn visit(window: HWND, parameter: LPARAM) -> i32 { + let state = &mut *(parameter as *mut Find); + let mut pid = 0; + GetWindowThreadProcessId(window, &mut pid); + if pid == state.pid + && IsWindowVisible(window) != 0 + && GetWindow(window, GW_OWNER).is_null() + { + state.window = window; + return 0; + } + 1 + } + let mut find = Find { + pid: identity.pid, + window: std::ptr::null_mut(), + }; + unsafe { + EnumWindows(Some(visit), &mut find as *mut Find as LPARAM); + if find.window.is_null() { + return Ok(false); + } + let mut window_pid = 0; + GetWindowThreadProcessId(find.window, &mut window_pid); + if window_pid != identity.pid + || started(identity.pid)?.as_deref() != Some(&identity.started_file_time) + { + return Err("Godot 窗口归属在聚焦前变化".into()); + } + let already_foreground = GetForegroundWindow() == find.window; + if already_foreground { + // 同窗 SetForegroundWindow 不会产生 FocusIn。只短暂切换已核验目标窗口, + // 由 Godot 自己的焦点事件启动资源扫描,不切换或发送消息给其它应用窗口。 + let mut placement: WINDOWPLACEMENT = std::mem::zeroed(); + placement.length = std::mem::size_of::() as u32; + if GetWindowPlacement(find.window, &mut placement) == 0 { + return Ok(false); + } + crate::remaining(deadline)?; + if ShowWindowAsync(find.window, SW_MINIMIZE) == 0 { + return Ok(false); + } + let focus_deadline = deadline.min(Instant::now() + Duration::from_millis(500)); + while IsIconic(find.window) == 0 && Instant::now() < focus_deadline { + thread::sleep(Duration::from_millis(10)); + } + GetWindowThreadProcessId(find.window, &mut window_pid); + if window_pid != identity.pid + || started(identity.pid)?.as_deref() != Some(&identity.started_file_time) + { + return Err("Godot 窗口在重新聚焦期间退出或改变归属".into()); + } + // 即使等待预算耗尽也先恢复同一个目标,不能因连接超时把用户窗口留在最小化状态。 + let restore = if placement.showCmd == SW_SHOWMAXIMIZED as u32 { + SW_SHOWMAXIMIZED + } else { + SW_RESTORE + }; + ShowWindowAsync(find.window, restore); + crate::remaining(deadline)?; + } + if IsIconic(find.window) != 0 { + ShowWindowAsync(find.window, SW_RESTORE); + let restore_deadline = deadline.min(Instant::now() + Duration::from_millis(500)); + while IsIconic(find.window) != 0 && Instant::now() < restore_deadline { + thread::sleep(Duration::from_millis(10)); + } + } + crate::remaining(deadline)?; + Ok(SetForegroundWindow(find.window) != 0) + } + } +} + +#[cfg(windows)] +pub use windows::{detect, focus, module_loaded, started}; + +#[cfg(not(windows))] +pub fn detect(_: &Path, _: Option, _: Instant) -> Result { + Err("Godot 原生桥仅支持 Windows x64".into()) +} +#[cfg(not(windows))] +pub fn started(_: u32) -> Result, String> { + Err("Godot 原生桥仅支持 Windows x64".into()) +} +#[cfg(not(windows))] +pub fn module_loaded(_: &ProcessIdentity, _: &crate::files::Descriptor) -> Result { + Err("Godot 原生桥仅支持 Windows x64".into()) +} +#[cfg(not(windows))] +pub fn focus(_: &ProcessIdentity, _: Instant) -> Result { + Err("Godot 原生桥仅支持 Windows x64".into()) +} diff --git a/plugins/agc-godot-editor/native/godot-editor-bridge/src/runtime_cache.rs b/plugins/agc-godot-editor/native/godot-editor-bridge/src/runtime_cache.rs new file mode 100644 index 000000000..163a4cc6f --- /dev/null +++ b/plugins/agc-godot-editor/native/godot-editor-bridge/src/runtime_cache.rs @@ -0,0 +1,330 @@ +//! 安装原件仅作为可信来源。每个编辑器实例使用宿主私有、带归属证明的可写副本。 +use crate::{ + files::{self, Descriptor, Payload, PayloadMetadata}, + platform::ProcessIdentity, + PROTOCOL_VERSION, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +const DLL: &str = "agc_godot_editor.dll"; +const COPY: &str = "~agc_godot_editor.dll"; +const MARKER: &str = "runtime-ownership.json"; + +#[derive(Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CacheOwner { + protocol: String, + source_dll_path: PathBuf, + runtime_dll_path: PathBuf, + pid: u32, + started_file_time: String, + project_path: PathBuf, + version: String, + metadata: PayloadMetadata, +} + +fn cache_directory(root: &Path, source: &Payload, identity: &ProcessIdentity) -> PathBuf { + let mut key = Sha256::new(); + key.update(source.source_path.to_string_lossy().as_bytes()); + key.update([0]); + key.update(source.metadata.build_id.as_bytes()); + key.update([0]); + key.update(source.metadata.sha256.as_bytes()); + root.join(format!("p{}-{}", identity.pid, identity.started_file_time)) + .join(format!("b{:x}", key.finalize())) +} + +fn owner(root: &Path, source: &Payload, identity: &ProcessIdentity) -> Result { + files::validate_payload_metadata(&source.metadata)?; + files::validate_candidate(&source.source_path)?; + if identity.pid == 0 || identity.started_file_time.parse::().is_err() { + return Err("Godot 实例缓存身份无效".into()); + } + Ok(CacheOwner { + protocol: PROTOCOL_VERSION.into(), + source_dll_path: source.source_path.clone(), + runtime_dll_path: cache_directory(root, source, identity).join(DLL), + pid: identity.pid, + started_file_time: identity.started_file_time.clone(), + project_path: identity.project.clone(), + version: identity.version.clone(), + metadata: source.metadata.clone(), + }) +} + +/// 配置只来自宿主;先规范化已存在的私有目录,再用于路径归属比较。 +pub fn configure_root(root: &Path) -> Result { + files::no_links(root)?; + let ancestor = root + .ancestors() + .find(|path| path.exists()) + .ok_or("Godot 缓存路径没有可核验的父目录")?; + if !ancestor.is_dir() { + return Err("Godot 运行缓存的已存在父路径必须是目录".into()); + } + let canonical = files::canonical(ancestor)?; + let suffix = root + .strip_prefix(ancestor) + .map_err(|_| "Godot 缓存路径无法规范化")?; + Ok(canonical.join(suffix)) +} + +pub fn desired( + root: &Path, + source: &Payload, + identity: &ProcessIdentity, + workspace: &Path, +) -> Result { + let root = root_outside_workspace(root, workspace, &identity.project)?; + if source.source_path.starts_with(workspace) { + return Err("Godot 安装原件必须位于受控工作区外".into()); + } + let expected = owner(&root, source, identity)?; + Ok(Descriptor::from_payload(&Payload { + path: expected.runtime_dll_path, + source_path: source.source_path.clone(), + metadata: source.metadata.clone(), + })) +} + +fn root_outside_workspace( + root: &Path, + workspace: &Path, + project: &Path, +) -> Result { + // 未创建的末端也先按现有父目录规范化,防止 Windows 大小写和短路径别名绕过工作区边界。 + let root = configure_root(root)?; + let workspace = files::canonical(workspace)?; + let project = files::canonical(project)?; + if root.starts_with(&workspace) || root.starts_with(&project) { + return Err("Godot 运行副本缓存必须位于整个受控工作区外".into()); + } + Ok(root) +} + +/// AppData 在打包桌面进程中可能发生文件系统虚拟化;创建后以真实落点固定后续身份。 +pub fn prepare_root(root: &Path, workspace: &Path, project: &Path) -> Result { + let root = root_outside_workspace(root, workspace, project)?; + fs::create_dir_all(&root).map_err(|_| "无法创建 Godot 宿主私有运行缓存")?; + root_outside_workspace(&root, workspace, project) +} + +fn verify_contents(directory: &Path, expected: &CacheOwner) -> Result<(), String> { + files::no_links(directory)?; + let actual: CacheOwner = serde_json::from_slice(&files::read_small(&directory.join(MARKER))?) + .map_err(|_| "Godot 运行缓存归属记录无效")?; + if actual != *expected { + return Err("Godot 运行缓存归属与来源/进程/构建身份不匹配".into()); + } + for entry in fs::read_dir(directory).map_err(|_| "Godot 运行缓存不可读取")? { + let entry = entry.map_err(|_| "Godot 运行缓存目录项不可读取")?; + files::no_links(&entry.path())?; + if ![DLL, COPY, MARKER] + .iter() + .any(|name| entry.file_name() == *name) + || !entry + .file_type() + .map_err(|_| "Godot 缓存类型不可读取")? + .is_file() + { + return Err("Godot 运行缓存存在未知文件,保留目录供核对".into()); + } + } + files::verify_dll_hash(&directory.join(DLL), &expected.metadata.sha256)?; + let copy = directory.join(COPY); + if copy.exists() { + files::verify_dll_hash(©, &expected.metadata.sha256)?; + } + Ok(()) +} + +pub fn prepare( + root: &Path, + source: &Payload, + identity: &ProcessIdentity, + workspace: &Path, +) -> Result { + let root = root_outside_workspace(root, workspace, &identity.project)?; + if source.source_path.starts_with(workspace) { + return Err("Godot 安装原件必须位于受控工作区外".into()); + } + let root = prepare_root(&root, workspace, &identity.project)?; + let expected = owner(&root, source, identity)?; + let directory = expected + .runtime_dll_path + .parent() + .ok_or("Godot 运行缓存路径无效")?; + files::no_links(directory)?; + if directory.exists() { + verify_contents(directory, &expected)?; + } else { + let parent = directory.parent().ok_or("Godot 运行缓存父目录无效")?; + fs::create_dir_all(parent).map_err(|_| "无法创建 Godot 实例缓存目录")?; + files::no_links(parent)?; + let staging = tempfile::Builder::new() + .prefix(".prepare-") + .tempdir_in(parent) + .map_err(|_| "无法准备 Godot 缓存副本")?; + fs::copy(&source.source_path, staging.path().join(DLL)) + .map_err(|_| "无法复制已验证的 Godot 安装原件")?; + // Windows copy 保留只读位;临时副本可写,安装原件的权限完全不变。 + #[cfg(windows)] + { + let path = staging.path().join(DLL); + let mut permissions = fs::metadata(&path) + .map_err(|_| "Godot 运行副本属性不可读")? + .permissions(); + permissions.set_readonly(false); + fs::set_permissions(&path, permissions).map_err(|_| "Godot 运行副本不能设置为可写")?; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let path = staging.path().join(DLL); + let mode = fs::metadata(&path) + .map_err(|_| "Godot 运行副本属性不可读")? + .permissions() + .mode(); + fs::set_permissions(&path, fs::Permissions::from_mode(mode | 0o200)) + .map_err(|_| "Godot 运行副本不能设置为可写")?; + } + files::verify_dll_hash(&staging.path().join(DLL), &source.metadata.sha256)?; + let mut marker = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(staging.path().join(MARKER)) + .map_err(|_| "无法创建 Godot 缓存归属记录")?; + marker + .write_all(&serde_json::to_vec(&expected).map_err(|_| "Godot 缓存归属编码失败")?) + .and_then(|_| marker.sync_all()) + .map_err(|_| "无法持久保存 Godot 缓存归属")?; + drop(marker); + // 目标目录不存在才创建;同实例竞争者或未知目录出现时不覆盖。 + if directory.exists() { + return Err("Godot 实例缓存被并发创建,未覆盖任何文件".into()); + } + fs::rename(staging.path(), directory).map_err(|_| "无法提交 Godot 实例缓存")?; + verify_contents(directory, &expected)?; + } + Ok(Payload { + path: expected.runtime_dll_path, + source_path: source.source_path.clone(), + metadata: source.metadata.clone(), + }) +} + +pub fn verify( + root: &Path, + source: &Payload, + identity: &ProcessIdentity, + descriptor: &Descriptor, +) -> Result<(), String> { + let root = files::canonical(root)?; + let expected = owner(&root, source, identity)?; + if descriptor.dll_path != expected.runtime_dll_path + || descriptor.source_dll_path != source.source_path + || descriptor.sha256 != source.metadata.sha256 + || descriptor.build_id != source.metadata.build_id + { + return Err("Godot 描述文件不属于宿主受控运行副本".into()); + } + verify_contents( + expected + .runtime_dll_path + .parent() + .ok_or("Godot 运行缓存路径无效")?, + &expected, + ) +} + +/// 私有缓存中的来源快照允许安装原件更新/移动后仍安全卸载旧实例;它不能用于加载新代码。 +pub fn verify_owned( + root: &Path, + identity: &ProcessIdentity, + descriptor: &Descriptor, +) -> Result<(), String> { + let root = files::canonical(root)?; + files::no_links(&descriptor.dll_path)?; + let directory = descriptor + .dll_path + .parent() + .ok_or("Godot 运行缓存路径无效")?; + if !directory.starts_with(&root) || directory == root { + return Err("Godot 缓存清理越出宿主私有目录".into()); + } + let record: CacheOwner = serde_json::from_slice(&files::read_small(&directory.join(MARKER))?) + .map_err(|_| "Godot 缓存归属记录无效")?; + let source = Payload { + path: record.source_dll_path.clone(), + source_path: record.source_dll_path.clone(), + metadata: record.metadata.clone(), + }; + let expected = owner(&root, &source, identity)?; + if expected.runtime_dll_path != descriptor.dll_path + || expected.source_dll_path != descriptor.source_dll_path + || expected.metadata.sha256 != descriptor.sha256 + || expected.metadata.build_id != descriptor.build_id + { + return Err("Godot 缓存归属不匹配,保留所有文件".into()); + } + verify_contents(directory, &expected) +} + +/// 首次握手前 owner 退出时,缓存仍保有经过进程发现验证的原实例身份。 +pub fn identity(root: &Path, descriptor: &Descriptor) -> Result { + let root = files::canonical(root)?; + files::no_links(&descriptor.dll_path)?; + let directory = descriptor + .dll_path + .parent() + .ok_or("Godot 运行缓存路径无效")?; + if !directory.starts_with(&root) || directory == root { + return Err("Godot 缓存身份越出宿主私有目录".into()); + } + let record: CacheOwner = serde_json::from_slice(&files::read_small(&directory.join(MARKER))?) + .map_err(|_| "Godot 缓存归属记录无效")?; + if crate::platform::supported_version(&record.version).as_deref() + != Some(record.version.as_str()) + { + return Err("Godot 缓存版本身份无效".into()); + } + let identity = ProcessIdentity { + pid: record.pid, + started_file_time: record.started_file_time, + project: record.project_path, + version: record.version, + }; + verify_owned(&root, &identity, descriptor)?; + Ok(identity) +} + +/// 调用方已确认模块和会话均消失;只删除这个实例、内容仍匹配的已知文件。 +pub fn cleanup( + root: &Path, + identity: &ProcessIdentity, + descriptor: &Descriptor, +) -> Result<(), String> { + verify_owned(root, identity, descriptor)?; + let root = files::canonical(root)?; + let directory = descriptor + .dll_path + .parent() + .ok_or("Godot 运行缓存路径无效")?; + for name in [COPY, DLL, MARKER] { + let path = directory.join(name); + if path.exists() { + fs::remove_file(path).map_err(|_| "Godot 模块已卸载但运行缓存无法清理")?; + } + } + fs::remove_dir(directory).map_err(|_| "Godot 缓存目录仍有文件,未递归删除")?; + if let Some(parent) = directory.parent() { + if parent != root { + let _ = fs::remove_dir(parent); + } + } + Ok(()) +} diff --git a/plugins/agc-godot-editor/native/godot-editor-bridge/src/tests.rs b/plugins/agc-godot-editor/native/godot-editor-bridge/src/tests.rs new file mode 100644 index 000000000..9375c5f73 --- /dev/null +++ b/plugins/agc-godot-editor/native/godot-editor-bridge/src/tests.rs @@ -0,0 +1,1117 @@ +use super::*; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread; + +fn project() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("project.godot"), "config_version=5\n").unwrap(); + dir +} + +#[test] +fn configured_missing_cache_keeps_identity_after_first_creation() { + let install = tempfile::tempdir().unwrap(); + let source = trusted_payload_at(install.path()); + let work = project(); + let workspace = files::canonical(work.path()).unwrap(); + let cache_parent = tempfile::tempdir().unwrap(); + let cache = + runtime_cache::configure_root(&cache_parent.path().join("NewCache/Instances")).unwrap(); + let identity = ProcessIdentity { + pid: 7, + started_file_time: "42".into(), + project: workspace.clone(), + version: "4.7.2".into(), + }; + let before = runtime_cache::desired(&cache, &source, &identity, &workspace).unwrap(); + let prepared = runtime_cache::prepare(&cache, &source, &identity, &workspace).unwrap(); + let after = runtime_cache::desired(&cache, &source, &identity, &workspace).unwrap(); + assert_eq!( + before, after, + "cache root: {cache:?}; prepared: {:?}", + prepared.path + ); + runtime_cache::verify_owned(&cache, &identity, &after).unwrap(); +} + +#[test] +#[cfg(windows)] +fn restart_can_clean_prepared_cache_when_editor_exited_before_first_handshake() { + let install = tempfile::tempdir().unwrap(); + let source = trusted_payload_at(install.path()); + let work = project(); + let root = files::canonical(work.path()).unwrap(); + let cache = tempfile::tempdir().unwrap(); + let identity = ProcessIdentity { + pid: std::process::id(), + started_file_time: "1".into(), + project: root.clone(), + version: "4.7.2".into(), + }; + let prepared = runtime_cache::prepare(cache.path(), &source, &identity, &root).unwrap(); + let descriptor = Descriptor::from_payload(&prepared); + files::write_descriptor(&root, None, &descriptor).unwrap(); + // 没有 session/UID 快照;私有缓存仍能证明这是已经退出的原实例。 + let restarted = GodotEditorService::default(); + restarted + .configure_cache(cache.path().to_path_buf()) + .unwrap(); + restarted.configure(vec![source.path.clone()]).unwrap(); + restarted + .disconnect_project(Some(&root), Instant::now() + Duration::from_secs(1), false) + .unwrap(); + assert!(!prepared.path.exists()); + assert!(!files::has_owned_artifacts(&root).unwrap()); + assert!(source.path.exists()); +} + +fn descriptor(root: &Path) -> Descriptor { + Descriptor { + protocol: PROTOCOL_VERSION.into(), + build_id: format!("sha256:{}", "a".repeat(64)), + sha256: "b".repeat(64), + source_dll_path: root + .join("plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll"), + dll_path: root + .join("plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll"), + } +} + +fn session() -> Session { + Session { + protocol: PROTOCOL_VERSION.into(), + build_id: format!("sha256:{}", "a".repeat(64)), + pid: 7, + started_file_time: "42".into(), + generation: "c".repeat(64), + project_path: "C:/fixture".into(), + version: "4.7.2".into(), + port: 1, + token: "d".repeat(64), + } +} + +fn envelope(session: &Session, id: u64, result: Value) -> Value { + json!({"protocol":PROTOCOL_VERSION,"id":id,"generation":session.generation,"pid":session.pid, + "projectPath":session.project_path,"buildId":session.build_id,"result":result}) +} + +fn completed(value: Value) -> Value { + json!({"ok":true,"status":"completed","dispatched":true,"retryAllowed":false,"result":value}) +} + +#[test] +fn project_root_and_unique_child_preserve_workspace_selection() { + let root = project(); + assert_eq!( + files::normalize_project(root.path()).unwrap(), + fs::canonicalize(root.path()).unwrap() + ); + let parent = tempfile::tempdir().unwrap(); + fs::create_dir(parent.path().join("game")).unwrap(); + fs::write(parent.path().join("game/project.godot"), "config_version=5").unwrap(); + assert_eq!( + files::normalize_project(parent.path()).unwrap(), + fs::canonicalize(parent.path().join("game")).unwrap() + ); + fs::create_dir(parent.path().join("another")).unwrap(); + fs::write( + parent.path().join("another/project.godot"), + "config_version=5", + ) + .unwrap(); + assert!(files::normalize_project(parent.path()) + .unwrap_err() + .contains("多个")); + assert!(files::normalize_project(&parent.path().join("game/..")).is_err()); +} + +#[test] +fn descriptor_is_idempotent_upgradable_and_never_overwrites_user_content() { + let dir = project(); + let old = descriptor(dir.path()); + files::write_descriptor(dir.path(), None, &old).unwrap(); + let path = dir.path().join(files::DESCRIPTOR_NAME); + let time = fs::metadata(&path).unwrap().modified().unwrap(); + files::write_descriptor(dir.path(), Some(&old), &old).unwrap(); + assert_eq!(time, fs::metadata(&path).unwrap().modified().unwrap()); + let mut new = old.clone(); + new.build_id = format!("sha256:{}", "e".repeat(64)); + files::write_descriptor(dir.path(), Some(&old), &new).unwrap(); + assert_eq!( + files::read_descriptor(dir.path()).unwrap(), + Some(new.clone()) + ); + assert!(files::remove_descriptor(dir.path(), &old).is_err()); + let content = fs::read_to_string(&path).unwrap() + "; user edit\n"; + fs::write(&path, &content).unwrap(); + assert!(files::remove_descriptor(dir.path(), &new).is_err()); + assert!(files::write_descriptor(dir.path(), Some(&new), &old).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), content); +} + +#[test] +fn unknown_descriptor_and_oversized_files_are_rejected() { + let dir = project(); + let path = dir.path().join(files::DESCRIPTOR_NAME); + fs::write(&path, "[configuration]\nentry_symbol=\"user_extension\"\n").unwrap(); + assert!(files::write_descriptor(dir.path(), None, &descriptor(dir.path())).is_err()); + fs::write(&path, vec![b'a'; 65537]).unwrap(); + assert!(files::read_small(&path).is_err()); +} + +#[test] +fn payload_requires_layout_metadata_and_matching_sha() { + let dir = tempfile::tempdir().unwrap(); + let path = descriptor(dir.path()).dll_path; + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, b"fixture DLL").unwrap(); + let mut metadata = json!({"protocol":PROTOCOL_VERSION,"buildId":format!("sha256:{}", "a".repeat(64)), + "sha256":format!("{:x}",Sha256::digest(b"fixture DLL")),"platform":"windows","arch":"x86_64", + "entrySymbol":"agc_godot_editor_init","minimumGodotVersion":"4.7"}); + let meta_path = path.parent().unwrap().join("metadata.json"); + fs::write(&meta_path, serde_json::to_vec(&metadata).unwrap()).unwrap(); + assert!(files::load_payload(&path).is_ok()); + fs::write(&path, b"changed DLL").unwrap(); + assert!(files::load_payload(&path).unwrap_err().contains("SHA256")); + metadata["arch"] = json!("arm64"); + fs::write(&meta_path, serde_json::to_vec(&metadata).unwrap()).unwrap(); + assert!(files::load_payload(&path).is_err()); + assert!(files::validate_candidate(&dir.path().join("arbitrary.dll")).is_err()); +} + +#[test] +fn session_requires_process_start_project_build_and_secret_identity() { + let dir = project(); + let root = fs::canonicalize(dir.path()).unwrap(); + let identity = ProcessIdentity { + pid: 7, + started_file_time: "42".into(), + project: root.clone(), + version: "4.7.2".into(), + }; + let desc = descriptor(&root); + let path = files::session_path(&root, identity.pid); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let good = json!({"protocol":PROTOCOL_VERSION,"buildId":desc.build_id,"pid":7,"startedFileTime":"42", + "generation":"c".repeat(64),"projectPath":root,"version":"4.7.2","port":12345,"token":"d".repeat(64)}); + fs::write(&path, serde_json::to_vec(&good).unwrap()).unwrap(); + assert!(files::read_session(&root, &identity, &desc) + .unwrap() + .is_some()); + for (key, value) in [ + ("pid", json!(8)), + ("startedFileTime", json!("43")), + ("buildId", json!("other")), + ("version", json!("4.8.0")), + ("token", json!("short")), + ("port", json!(0)), + ("projectPath", json!("missing")), + ] { + let mut bad = good.clone(); + bad[key] = value; + fs::write(&path, serde_json::to_vec(&bad).unwrap()).unwrap(); + assert!( + files::read_session(&root, &identity, &desc).is_err(), + "{key}" + ); + } +} + +#[test] +fn response_identity_and_execution_semantics_are_strict() { + let session = session(); + let good = envelope(&session, 2, completed(Value::Null)); + assert!( + transport::parse_response(&serde_json::to_vec(&good).unwrap(), &session, 2, "execute") + .is_ok() + ); + for (key, value) in [ + ("protocol", json!("wrong")), + ("id", json!(3)), + ("pid", json!(8)), + ("generation", json!("old")), + ("projectPath", json!("other")), + ("buildId", json!("other")), + ] { + let mut bad = good.clone(); + bad[key] = value; + assert!( + transport::parse_response(&serde_json::to_vec(&bad).unwrap(), &session, 2, "execute") + .is_err(), + "{key}" + ); + } + for result in [ + json!({"ok":true,"status":"completed","dispatched":true,"retryAllowed":false}), + json!({"ok":true,"status":"failed","dispatched":true,"retryAllowed":false}), + json!({"ok":false,"status":"needs-reconciliation","dispatched":false,"retryAllowed":false,"error":{"code":"x","message":"x"}}), + json!({"ok":true,"status":"completed","dispatched":true,"retryAllowed":false,"result":42,"error":{"code":"x","message":"x"}}), + json!({"ok":false,"status":"failed","dispatched":true,"retryAllowed":false,"result":42,"error":{"code":"x","message":"x"}}), + ] { + assert!(transport::parse_response( + &serde_json::to_vec(&envelope(&session, 2, result)).unwrap(), + &session, + 2, + "execute" + ) + .is_err()); + } +} + +fn server( + response: impl FnOnce(&Session) -> Vec + Send + 'static, + stall: bool, +) -> (Session, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let mut session = session(); + session.port = listener.local_addr().unwrap().port(); + let peer = session.clone(); + let handle = thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut byte = [0u8; 1]; + loop { + if socket.read(&mut byte).unwrap_or(0) == 0 || byte[0] == b'\n' { + break; + } + } + if stall { + thread::sleep(Duration::from_millis(150)); + } + let _ = socket.write_all(&response(&peer)); + }); + (session, handle) +} + +#[test] +fn loopback_returns_real_receipt_without_retry() { + let (session, handle) = server( + |s| { + let mut b = serde_json::to_vec(&envelope(s, 2, completed(json!(42)))).unwrap(); + b.push(b'\n'); + b + }, + false, + ); + let result = transport::exchange( + &session, + 2, + "execute", + json!({"code":"return 42","timeoutMs":1000}), + Instant::now() + Duration::from_secs(1), + ) + .unwrap_or_else(|e| panic!("{}", e.message)); + assert_eq!(result["result"], 42); + handle.join().unwrap(); +} + +#[test] +fn preconnect_failure_is_not_dispatched_but_timeout_disconnect_and_corruption_are() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let mut s = session(); + s.port = listener.local_addr().unwrap().port(); + drop(listener); + assert!( + !transport::exchange( + &s, + 2, + "execute", + json!({}), + Instant::now() + Duration::from_secs(1) + ) + .err() + .unwrap() + .dispatched + ); + for (bytes, stall) in [ + (Vec::new(), false), + (b"broken\n".to_vec(), false), + (Vec::new(), true), + (vec![b'x'; MAX_MESSAGE_BYTES + 1], false), + ] { + let (s, handle) = server(move |_| bytes, stall); + let deadline = Instant::now() + Duration::from_millis(if stall { 40 } else { 1000 }); + let error = transport::exchange(&s, 2, "execute", json!({}), deadline) + .err() + .unwrap(); + assert!(error.dispatched); + if stall { + assert!(Instant::now() < deadline + Duration::from_millis(90)); + } + handle.join().unwrap(); + } +} + +#[test] +fn uncertainty_is_not_cleared_by_disconnect_or_reconfiguration() { + let service = GodotEditorService::default(); + let value = service + .finish_transport_error(transport::ExchangeError { + message: "lost receipt".into(), + dispatched: true, + }) + .unwrap(); + assert_eq!(value["status"], "needs-reconciliation"); + assert!(service + .disconnect(Instant::now() + Duration::from_secs(1)) + .is_err()); + let dir = tempfile::tempdir().unwrap(); + assert!(service + .configure(vec![descriptor(dir.path()).dll_path]) + .is_err()); + assert!(service + .configure_cache(dir.path().join("runtime-cache")) + .is_err()); + assert!(service.uncertain.load(Ordering::SeqCst)); + let value = service + .call( + "execute", + RpcParams { + code: Some("return 42".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(value["status"], "needs-reconciliation"); +} + +#[test] +fn code_and_rpc_do_not_allow_untrusted_routing() { + for code in [ + "".to_string(), + " \n".into(), + "return '\0'".into(), + "x".repeat(MAX_EXECUTE_CODE_BYTES + 1), + ] { + assert!(validate_code(&code).is_err()); + } + assert!(validate_code("return null").is_ok()); + for key in [ + "dllPath", + "payloadPath", + "sourceDllPath", + "runtimeDllPath", + "runtimeCacheDir", + "cacheRoot", + "port", + "token", + "generation", + ] { + let mut params = json!({"projectPath":"project","code":"return 42"}); + params[key] = json!("untrusted"); + let result = GodotEditorAdapter::default() + .rpc("execute", params) + .unwrap(); + assert_eq!(result["dispatched"], false); + assert_eq!(result["status"], "failed"); + } +} + +#[test] +#[cfg(windows)] +fn concurrent_request_is_rejected_before_process_discovery() { + let dir = project(); + let service = GodotEditorService::default(); + let _held = service.state.lock().unwrap(); + let start = Instant::now(); + let error = service + .call( + "execute", + RpcParams { + project_path: Some(dir.path().to_string_lossy().into_owned()), + code: Some("return 42".into()), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(error.contains("已有请求")); + assert!(start.elapsed() < Duration::from_millis(200)); +} + +#[test] +fn version_gate_is_4_7_or_newer_standard_branch() { + assert_eq!( + platform::supported_version("4.7.2.stable.official"), + Some("4.7.2".into()) + ); + assert!(platform::supported_version("4.6.1").is_none()); + assert!(platform::supported_version("5.0.0").is_none()); +} + +#[test] +fn uid_ownership_survives_restart_and_preserves_changed_or_unknown_files() { + let dir = project(); + let desc = descriptor(dir.path()); + let uid = dir.path().join("agc-editor-bridge.gdextension.uid"); + fs::write(&uid, "uid://abc123\n").unwrap(); + assert!(files::write_descriptor(dir.path(), None, &desc).is_err()); + assert!(!dir.path().join(files::DESCRIPTOR_NAME).exists()); + fs::remove_file(&uid).unwrap(); + files::write_descriptor(dir.path(), None, &desc).unwrap(); + fs::write(&uid, "uid://abc123\n").unwrap(); + // 模拟在写完descriptor但尚未采集UID时重启;不能认领未知内容。 + assert!(files::snapshot_uid(dir.path(), &desc, false).is_err()); + assert!(files::remove_descriptor(dir.path(), &desc).is_err()); + files::snapshot_uid(dir.path(), &desc, true).unwrap(); + // 新进程完全依赖持久快照,不需要旧内存状态。 + files::snapshot_uid(dir.path(), &desc, false).unwrap(); + fs::write(&uid, "uid://userchanged\n").unwrap(); + assert!(files::snapshot_uid(dir.path(), &desc, false).is_err()); + assert!(files::remove_descriptor(dir.path(), &desc).is_err()); + assert!(uid.exists()); + fs::write(&uid, "uid://abc123\n").unwrap(); + files::remove_descriptor(dir.path(), &desc).unwrap(); + assert!(!uid.exists()); + assert!(!dir.path().join(".godot/agc/bridge-ownership.json").exists()); + files::write_descriptor(dir.path(), None, &desc).unwrap(); + fs::remove_file(dir.path().join(".godot/agc/bridge-ownership.json")).unwrap(); + assert!(files::remove_descriptor(dir.path(), &desc).is_err()); + assert!(dir.path().join(files::DESCRIPTOR_NAME).exists()); +} + +fn trusted_payload_at(root: &Path) -> files::Payload { + trusted_payload_version_at(root, b"owned native test payload", &"a".repeat(64)) +} + +fn trusted_payload_version_at(root: &Path, bytes: &[u8], build: &str) -> files::Payload { + let path = descriptor(root).dll_path; + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, bytes).unwrap(); + let metadata = json!({"protocol":PROTOCOL_VERSION,"buildId":format!("sha256:{build}"), + "sha256":format!("{:x}",Sha256::digest(bytes)),"platform":"windows","arch":"x86_64", + "entrySymbol":"agc_godot_editor_init","minimumGodotVersion":"4.7"}); + fs::write( + path.parent().unwrap().join("metadata.json"), + serde_json::to_vec(&metadata).unwrap(), + ) + .unwrap(); + files::load_payload(&path).unwrap() +} + +fn identity_at(project: &Path, pid: u32, started_file_time: &str) -> ProcessIdentity { + ProcessIdentity { + pid, + started_file_time: started_file_time.into(), + project: fs::canonicalize(project).unwrap(), + version: "4.7.2".into(), + } +} + +fn prepare_cached_descriptor( + cache: &Path, + payload: &files::Payload, + identity: &ProcessIdentity, +) -> Descriptor { + let runtime = runtime_cache::prepare(cache, payload, identity, &identity.project).unwrap(); + Descriptor::from_payload(&runtime) +} + +#[test] +fn official_loader_copy_requires_exact_parent_and_trusted_bytes() { + let root = tempfile::tempdir().unwrap(); + let payload = trusted_payload_at(root.path()); + let project = project(); + let cache = tempfile::tempdir().unwrap(); + let identity = identity_at(project.path(), 7, "42"); + let desc = prepare_cached_descriptor(cache.path(), &payload, &identity); + let copy = desc.dll_path.with_file_name("~agc_godot_editor.dll"); + fs::copy(&desc.dll_path, ©).unwrap(); + assert!(files::verified_module_path(&desc.dll_path, &desc).unwrap()); + assert!(files::verified_module_path(©, &desc).unwrap()); + assert!(files::verified_module_path(&payload.path, &desc).is_err()); + fs::write(©, b"different DLL at official derived name").unwrap(); + assert!(files::verified_module_path(©, &desc).is_err()); + let other = tempfile::tempdir().unwrap(); + let unrelated = other.path().join("~agc_godot_editor.dll"); + fs::copy(&payload.path, &unrelated).unwrap(); + assert!(files::verified_module_path(&unrelated, &desc).is_err()); + assert!(!files::verified_module_path(&other.path().join("kernel32.dll"), &desc).unwrap()); +} + +#[test] +#[cfg(windows)] +fn readonly_install_creates_writable_private_copy_without_changing_source() { + let install = tempfile::tempdir().unwrap(); + let payload = trusted_payload_at(install.path()); + let metadata_path = payload.path.with_file_name("metadata.json"); + let original_dll = fs::read(&payload.path).unwrap(); + let original_metadata = fs::read(&metadata_path).unwrap(); + let original_modified = fs::metadata(&payload.path).unwrap().modified().unwrap(); + for path in [&payload.path, &metadata_path] { + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_readonly(true); + fs::set_permissions(path, permissions).unwrap(); + } + let project = project(); + let cache = tempfile::tempdir().unwrap(); + let identity = identity_at(project.path(), 7, "42"); + let desc = prepare_cached_descriptor(cache.path(), &payload, &identity); + assert_eq!(desc.source_dll_path, payload.path); + assert_ne!(desc.dll_path, payload.path); + assert!(desc + .dll_path + .starts_with(fs::canonicalize(cache.path()).unwrap())); + assert!(!fs::metadata(&desc.dll_path) + .unwrap() + .permissions() + .readonly()); + let loader_copy = desc.dll_path.with_file_name("~agc_godot_editor.dll"); + fs::copy(&desc.dll_path, &loader_copy).unwrap(); + runtime_cache::verify(cache.path(), &payload, &identity, &desc).unwrap(); + runtime_cache::cleanup(cache.path(), &identity, &desc).unwrap(); + assert!(!desc.dll_path.exists()); + assert!(!loader_copy.exists()); + assert_eq!(fs::read(&payload.path).unwrap(), original_dll); + assert_eq!(fs::read(&metadata_path).unwrap(), original_metadata); + assert_eq!( + fs::metadata(&payload.path).unwrap().modified().unwrap(), + original_modified + ); + assert!(!payload + .path + .with_file_name("~agc_godot_editor.dll") + .exists()); + assert_eq!( + fs::read_dir(payload.path.parent().unwrap()) + .unwrap() + .count(), + 2 + ); + for path in [&payload.path, &metadata_path] { + let mut permissions = fs::metadata(path).unwrap().permissions(); + assert!(permissions.readonly()); + permissions.set_readonly(false); + fs::set_permissions(path, permissions).unwrap(); + } +} + +#[test] +fn runtime_cache_isolates_pid_start_full_build_and_source_and_cleans_one_instance() { + let project = project(); + let install = tempfile::tempdir().unwrap(); + let other_install = tempfile::tempdir().unwrap(); + let cache = tempfile::tempdir().unwrap(); + let payload = trusted_payload_at(install.path()); + let other_source = trusted_payload_at(other_install.path()); + let first_identity = identity_at(project.path(), 7, "42"); + let first = prepare_cached_descriptor(cache.path(), &payload, &first_identity); + let first_modified = fs::metadata(&first.dll_path).unwrap().modified().unwrap(); + assert_eq!( + prepare_cached_descriptor(cache.path(), &payload, &first_identity), + first + ); + assert_eq!( + fs::metadata(&first.dll_path).unwrap().modified().unwrap(), + first_modified + ); + let mut instances = vec![(first_identity.clone(), first.clone())]; + for identity in [ + identity_at(project.path(), 8, "42"), + identity_at(project.path(), 7, "43"), + ] { + let descriptor = prepare_cached_descriptor(cache.path(), &payload, &identity); + instances.push((identity, descriptor)); + } + instances.push(( + first_identity.clone(), + prepare_cached_descriptor(cache.path(), &other_source, &first_identity), + )); + // 完整构建身份变化必须分离,即使前缀完全相同。 + let next_build = trusted_payload_version_at( + install.path(), + b"new native payload", + &format!("{}b", "a".repeat(63)), + ); + instances.push(( + first_identity.clone(), + prepare_cached_descriptor(cache.path(), &next_build, &first_identity), + )); + let paths: std::collections::HashSet<_> = instances + .iter() + .map(|(_, descriptor)| &descriptor.dll_path) + .collect(); + assert_eq!(paths.len(), instances.len()); + for (identity, descriptor) in &instances { + fs::copy( + &descriptor.dll_path, + descriptor.dll_path.with_file_name("~agc_godot_editor.dll"), + ) + .unwrap(); + runtime_cache::verify_owned(cache.path(), identity, descriptor).unwrap(); + } + assert!(runtime_cache::cleanup(cache.path(), &instances[1].0, &first).is_err()); + assert!(first.dll_path.exists()); + runtime_cache::cleanup(cache.path(), &first_identity, &first).unwrap(); + assert!(!first.dll_path.parent().unwrap().exists()); + for (identity, descriptor) in instances.iter().skip(1) { + runtime_cache::verify_owned(cache.path(), identity, descriptor).unwrap(); + assert!(descriptor + .dll_path + .with_file_name("~agc_godot_editor.dll") + .exists()); + } + assert_eq!(fs::read(&payload.path).unwrap(), b"new native payload"); + for (identity, descriptor) in instances.iter().skip(1) { + runtime_cache::cleanup(cache.path(), identity, descriptor).unwrap(); + } + assert_eq!(fs::read_dir(cache.path()).unwrap().count(), 0); +} + +#[test] +fn changed_install_metadata_blocks_new_payload_but_preserves_old_cache_cleanup() { + let project = project(); + let install = tempfile::tempdir().unwrap(); + let cache = tempfile::tempdir().unwrap(); + let payload = trusted_payload_at(install.path()); + let identity = identity_at(project.path(), 7, "42"); + let descriptor = prepare_cached_descriptor(cache.path(), &payload, &identity); + let service = GodotEditorService::default(); + service.configure(vec![payload.path.clone()]).unwrap(); + service.configure_cache(cache.path().into()).unwrap(); + let metadata_path = payload.path.with_file_name("metadata.json"); + let metadata = fs::read(&metadata_path).unwrap(); + let mut invalid: Value = serde_json::from_slice(&metadata).unwrap(); + invalid["protocol"] = json!("untrusted.protocol"); + fs::write(&metadata_path, serde_json::to_vec(&invalid).unwrap()).unwrap(); + assert!(service.payload().is_err()); + runtime_cache::verify_owned(cache.path(), &identity, &descriptor).unwrap(); + fs::write(&metadata_path, &metadata).unwrap(); + fs::write(&payload.path, b"updated install with stale metadata").unwrap(); + assert!(service.payload().unwrap_err().contains("SHA256")); + runtime_cache::verify_owned(cache.path(), &identity, &descriptor).unwrap(); + let updated = + trusted_payload_version_at(install.path(), b"new trusted payload", &"e".repeat(64)); + assert_eq!(service.payload().unwrap().metadata, updated.metadata); + assert!(runtime_cache::verify(cache.path(), &updated, &identity, &descriptor).is_err()); + runtime_cache::cleanup(cache.path(), &identity, &descriptor).unwrap(); + assert_eq!(fs::read(&updated.path).unwrap(), b"new trusted payload"); +} + +#[test] +fn changed_cache_contents_or_ownership_are_rejected_and_preserved() { + for changed_name in [ + "agc_godot_editor.dll", + "~agc_godot_editor.dll", + "runtime-ownership.json", + "unknown-user-file.txt", + ] { + let project = project(); + let install = tempfile::tempdir().unwrap(); + let cache = tempfile::tempdir().unwrap(); + let payload = trusted_payload_at(install.path()); + let identity = identity_at(project.path(), 7, "42"); + let descriptor = prepare_cached_descriptor(cache.path(), &payload, &identity); + let directory = descriptor.dll_path.parent().unwrap(); + let changed_path = directory.join(changed_name); + fs::write(&changed_path, b"user replacement").unwrap(); + assert!( + runtime_cache::verify(cache.path(), &payload, &identity, &descriptor).is_err(), + "{changed_name}" + ); + assert!( + runtime_cache::verify_owned(cache.path(), &identity, &descriptor).is_err(), + "{changed_name}" + ); + assert!( + runtime_cache::prepare(cache.path(), &payload, &identity, &identity.project).is_err(), + "{changed_name}" + ); + assert!( + runtime_cache::cleanup(cache.path(), &identity, &descriptor).is_err(), + "{changed_name}" + ); + assert_eq!( + fs::read(&changed_path).unwrap(), + b"user replacement", + "{changed_name}" + ); + assert!(descriptor.dll_path.exists(), "{changed_name}"); + assert!( + directory.join("runtime-ownership.json").exists(), + "{changed_name}" + ); + } +} + +#[test] +fn valid_json_cache_marker_with_changed_identity_is_not_owned() { + for (key, value) in [ + ("pid", json!(8)), + ("startedFileTime", json!("43")), + ( + "sourceDllPath", + json!("C:/untrusted/native/gdextension/bin/win-x64/agc_godot_editor.dll"), + ), + ] { + let project = project(); + let install = tempfile::tempdir().unwrap(); + let cache = tempfile::tempdir().unwrap(); + let payload = trusted_payload_at(install.path()); + let identity = identity_at(project.path(), 7, "42"); + let descriptor = prepare_cached_descriptor(cache.path(), &payload, &identity); + let path = descriptor.dll_path.with_file_name("runtime-ownership.json"); + let mut record: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + record[key] = value; + let changed = serde_json::to_vec(&record).unwrap(); + fs::write(&path, &changed).unwrap(); + assert!( + runtime_cache::verify(cache.path(), &payload, &identity, &descriptor).is_err(), + "{key}" + ); + assert!( + runtime_cache::cleanup(cache.path(), &identity, &descriptor).is_err(), + "{key}" + ); + assert_eq!(fs::read(&path).unwrap(), changed); + assert!(descriptor.dll_path.exists()); + } +} + +#[test] +fn cache_is_rejected_anywhere_inside_workspace_including_project_sibling() { + let workspace = tempfile::tempdir().unwrap(); + let project_path = workspace.path().join("game"); + fs::create_dir(&project_path).unwrap(); + fs::write(project_path.join("project.godot"), "config_version=5\n").unwrap(); + let workspace_root = fs::canonicalize(workspace.path()).unwrap(); + let identity = identity_at(&project_path, 7, "42"); + let install = tempfile::tempdir().unwrap(); + let payload = trusted_payload_at(install.path()); + for root in [ + workspace_root.clone(), + workspace_root.join("private-cache"), + identity.project.join(".godot/runtime-cache"), + ] { + assert!(runtime_cache::desired(&root, &payload, &identity, &workspace_root).is_err()); + assert!(runtime_cache::prepare(&root, &payload, &identity, &workspace_root).is_err()); + if root != workspace_root { + assert!(!root.exists()); + } + } + let inside_source = trusted_payload_at(workspace.path()); + let outside_cache = tempfile::tempdir().unwrap(); + assert!(runtime_cache::desired( + outside_cache.path(), + &inside_source, + &identity, + &workspace_root + ) + .is_err()); + assert!(runtime_cache::prepare( + outside_cache.path(), + &inside_source, + &identity, + &workspace_root + ) + .is_err()); + assert_eq!(fs::read_dir(outside_cache.path()).unwrap().count(), 0); +} + +#[test] +#[cfg(windows)] +fn nonexisting_cache_inside_workspace_is_rejected_with_alternate_windows_path_case() { + let workspace = tempfile::tempdir().unwrap(); + let project_path = workspace.path().join("game"); + fs::create_dir(&project_path).unwrap(); + fs::write(project_path.join("project.godot"), "config_version=5\n").unwrap(); + let workspace_root = fs::canonicalize(workspace.path()).unwrap(); + let identity = identity_at(&project_path, 7, "42"); + let install = tempfile::tempdir().unwrap(); + let payload = trusted_payload_at(install.path()); + let alternate_case = PathBuf::from(workspace_root.to_string_lossy().to_ascii_lowercase()) + .join("not-created-runtime-cache"); + assert!(!alternate_case.exists()); + assert!(runtime_cache::desired(&alternate_case, &payload, &identity, &workspace_root).is_err()); + assert!(runtime_cache::prepare(&alternate_case, &payload, &identity, &workspace_root).is_err()); + assert!(!alternate_case.exists()); +} + +#[test] +fn relocated_install_gets_new_reference_while_old_cache_remains_owned() { + let container = tempfile::tempdir().unwrap(); + let install = container.path().join("old-install"); + let payload = trusted_payload_at(&install); + let project = project(); + let cache = tempfile::tempdir().unwrap(); + let identity = identity_at(project.path(), 7, "42"); + let previous = prepare_cached_descriptor(cache.path(), &payload, &identity); + let relocated = container.path().join("new-install"); + fs::rename(&install, &relocated).unwrap(); + assert!(!payload.path.exists()); + let moved_payload = files::load_payload(&descriptor(&relocated).dll_path).unwrap(); + let next = prepare_cached_descriptor(cache.path(), &moved_payload, &identity); + assert_ne!(previous.source_dll_path, next.source_dll_path); + assert_ne!(previous.dll_path, next.dll_path); + assert_eq!(previous.build_id, next.build_id); + assert_eq!(previous.sha256, next.sha256); + runtime_cache::verify_owned(cache.path(), &identity, &previous).unwrap(); + runtime_cache::cleanup(cache.path(), &identity, &previous).unwrap(); + runtime_cache::verify(cache.path(), &moved_payload, &identity, &next).unwrap(); + assert!(moved_payload.path.exists()); +} + +#[test] +#[cfg(windows)] +fn focus_rejects_expired_budget_and_reused_pid_before_touching_windows() { + let identity = ProcessIdentity { + pid: std::process::id(), + started_file_time: "1".into(), + project: std::env::temp_dir(), + version: "4.7.2".into(), + }; + let started = Instant::now(); + assert!( + platform::focus(&identity, started - Duration::from_millis(1)) + .unwrap_err() + .contains("期限") + ); + assert!(platform::focus(&identity, started + Duration::from_secs(1)) + .unwrap_err() + .contains("退出或 PID")); + assert!(started.elapsed() < Duration::from_millis(100)); +} + +#[test] +#[cfg(windows)] +fn restarted_service_restores_confirmed_exited_session_cleanup_without_native_memory() { + let project = project(); + let project_root = fs::canonicalize(project.path()).unwrap(); + let install = tempfile::tempdir().unwrap(); + let payload = trusted_payload_at(install.path()); + let cache = tempfile::tempdir().unwrap(); + let identity = identity_at(&project_root, std::process::id(), "1"); + let descriptor = prepare_cached_descriptor(cache.path(), &payload, &identity); + files::write_descriptor(&project_root, None, &descriptor).unwrap(); + let mut known = session(); + known.pid = std::process::id(); + known.started_file_time = "1".into(); + known.project_path = project_root.to_string_lossy().into_owned(); + known.build_id = descriptor.build_id.clone(); + files::snapshot_session(&project_root, &descriptor, &known).unwrap(); + // 新服务只有安装候选和受控工程,完全没有旧 Connection 对象。 + let restarted = GodotEditorService::default(); + restarted.configure(vec![payload.path.clone()]).unwrap(); + restarted.configure_cache(cache.path().into()).unwrap(); + assert!(restarted + .disconnect(Instant::now() + Duration::from_secs(1)) + .is_err()); + restarted + .disconnect_project( + Some(&project_root), + Instant::now() + Duration::from_secs(1), + false, + ) + .unwrap(); + assert!(!files::has_owned_artifacts(&project_root).unwrap()); + assert!(!descriptor.dll_path.exists()); + assert!(payload.path.exists()); + + // 安装原件已经变化时,旧缓存仍须按持久归属完成卸载;新执行不能跳过安装包验证。 + let descriptor = prepare_cached_descriptor(cache.path(), &payload, &identity); + files::write_descriptor(&project_root, None, &descriptor).unwrap(); + files::snapshot_session(&project_root, &descriptor, &known).unwrap(); + fs::write(&payload.path, b"tampered install").unwrap(); + assert!(restarted.payload().is_err()); + restarted + .disconnect_project( + Some(&project_root), + Instant::now() + Duration::from_secs(1), + false, + ) + .unwrap(); + assert!(!files::has_owned_artifacts(&project_root).unwrap()); + assert!(!descriptor.dll_path.exists()); + assert_eq!(fs::read(&payload.path).unwrap(), b"tampered install"); +} + +#[test] +#[cfg(windows)] +fn explicit_old_project_cleanup_preserves_another_current_connection() { + let current = project(); + let old = project(); + let current_root = fs::canonicalize(current.path()).unwrap(); + let old_root = fs::canonicalize(old.path()).unwrap(); + let install = tempfile::tempdir().unwrap(); + let payload = trusted_payload_at(install.path()); + let cache = tempfile::tempdir().unwrap(); + let current_identity = identity_at(¤t_root, std::process::id(), "2"); + let old_identity = identity_at(&old_root, std::process::id(), "1"); + let current_descriptor = prepare_cached_descriptor(cache.path(), &payload, ¤t_identity); + let descriptor = prepare_cached_descriptor(cache.path(), &payload, &old_identity); + let service = GodotEditorService::default(); + service.configure(vec![payload.path.clone()]).unwrap(); + service.configure_cache(cache.path().into()).unwrap(); + service.state.lock().unwrap().connection = Some(Connection { + identity: current_identity.clone(), + descriptor: current_descriptor.clone(), + session: None, + created_here: true, + cache_root: fs::canonicalize(cache.path()).unwrap(), + }); + // 旧工作区可能只因发送前错误曾被授权,根本没有产生引导文件。 + service + .disconnect_project( + Some(&old_root), + Instant::now() + Duration::from_secs(1), + false, + ) + .unwrap(); + assert_eq!( + service + .state + .lock() + .unwrap() + .connection + .as_ref() + .unwrap() + .identity + .project, + current_root + ); + files::write_descriptor(&old_root, None, &descriptor).unwrap(); + let mut known = session(); + known.pid = std::process::id(); + known.started_file_time = "1".into(); + known.project_path = old_root.to_string_lossy().into_owned(); + known.build_id = descriptor.build_id.clone(); + files::snapshot_session(&old_root, &descriptor, &known).unwrap(); + service + .disconnect_project( + Some(&old_root), + Instant::now() + Duration::from_secs(1), + false, + ) + .unwrap(); + assert!(!files::has_owned_artifacts(&old_root).unwrap()); + assert!(!descriptor.dll_path.exists()); + runtime_cache::verify_owned(cache.path(), ¤t_identity, ¤t_descriptor).unwrap(); + assert_eq!( + service + .state + .lock() + .unwrap() + .connection + .as_ref() + .unwrap() + .identity + .project, + current_root + ); +} + +#[test] +#[cfg(windows)] +fn exited_process_cleanup_requires_matching_owned_descriptor_and_session() { + let dir = project(); + let root = fs::canonicalize(dir.path()).unwrap(); + // 使用本测试进程的 PID 加上明确不同的启动身份,证明旧进程已不复存在;不终止任何进程。 + let identity = ProcessIdentity { + pid: std::process::id(), + started_file_time: "1".into(), + project: root.clone(), + version: "4.7.2".into(), + }; + let install = tempfile::tempdir().unwrap(); + let payload = trusted_payload_at(install.path()); + let cache = tempfile::tempdir().unwrap(); + let descriptor = prepare_cached_descriptor(cache.path(), &payload, &identity); + files::write_descriptor(&root, None, &descriptor).unwrap(); + let path = files::session_path(&root, identity.pid); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let session = json!({"protocol":PROTOCOL_VERSION,"buildId":descriptor.build_id,"pid":identity.pid, + "startedFileTime":"1","generation":"c".repeat(64),"projectPath":root,"version":"4.7.2", + "port":12345,"token":"d".repeat(64)}); + fs::write(&path, serde_json::to_vec(&session).unwrap()).unwrap(); + let connection = Connection { + identity: identity.clone(), + descriptor: descriptor.clone(), + session: None, + created_here: false, + cache_root: fs::canonicalize(cache.path()).unwrap(), + }; + shutdown(&connection, 2, Instant::now() + Duration::from_secs(1)).unwrap(); + assert!(!path.exists()); + assert!(!root.join(files::DESCRIPTOR_NAME).exists()); + assert!(!descriptor.dll_path.exists()); + + assert_eq!( + prepare_cached_descriptor(cache.path(), &payload, &identity), + descriptor + ); + files::write_descriptor(&root, None, &descriptor).unwrap(); + let mut replaced = session; + replaced["startedFileTime"] = json!("2"); + fs::write(&path, serde_json::to_vec(&replaced).unwrap()).unwrap(); + assert!(shutdown(&connection, 4, Instant::now() + Duration::from_secs(1)).is_err()); + assert!(path.exists()); + assert!(root.join(files::DESCRIPTOR_NAME).exists()); + runtime_cache::verify_owned(cache.path(), &identity, &descriptor).unwrap(); +} + +#[test] +#[cfg(windows)] +fn runtime_cache_junction_is_rejected_without_touching_link_target() { + let project = project(); + let install = tempfile::tempdir().unwrap(); + let cache = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + let payload = trusted_payload_at(install.path()); + let identity = identity_at(project.path(), 7, "42"); + let descriptor = prepare_cached_descriptor(cache.path(), &payload, &identity); + let directory = descriptor.dll_path.parent().unwrap(); + let retained = target.path().join("retained-cache"); + fs::rename(directory, &retained).unwrap(); + create_junction(directory, &retained); + let original_dll = fs::read(retained.join("agc_godot_editor.dll")).unwrap(); + let original_marker = fs::read(retained.join("runtime-ownership.json")).unwrap(); + assert!(runtime_cache::prepare(cache.path(), &payload, &identity, &identity.project).is_err()); + assert!(runtime_cache::verify(cache.path(), &payload, &identity, &descriptor).is_err()); + assert!(runtime_cache::verify_owned(cache.path(), &identity, &descriptor).is_err()); + assert!(runtime_cache::cleanup(cache.path(), &identity, &descriptor).is_err()); + assert_eq!( + fs::read(retained.join("agc_godot_editor.dll")).unwrap(), + original_dll + ); + assert_eq!( + fs::read(retained.join("runtime-ownership.json")).unwrap(), + original_marker + ); + fs::remove_dir(directory).unwrap(); + + let linked_root = target.path().join("linked-root"); + create_junction(&linked_root, cache.path()); + assert!(runtime_cache::configure_root(&linked_root).is_err()); + fs::remove_dir(linked_root).unwrap(); +} + +#[cfg(windows)] +fn create_junction(link: &Path, target: &Path) { + use std::os::windows::process::CommandExt; + let output = std::process::Command::new("cmd.exe") + .args(["/c", "mklink", "/J"]) + .arg(link) + .arg(target) + .creation_flags(0x08000000) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +#[cfg(windows)] +fn reparse_point_directory_is_rejected() { + let root = tempfile::tempdir().unwrap(); + let target = project(); + let junction = root.path().join("linked"); + create_junction(&junction, target.path()); + let result = files::normalize_project(&junction); + fs::remove_dir(&junction).unwrap(); + assert!(result.is_err()); +} diff --git a/plugins/agc-godot-editor/native/godot-editor-bridge/src/transport.rs b/plugins/agc-godot-editor/native/godot-editor-bridge/src/transport.rs new file mode 100644 index 000000000..92e9aa5c9 --- /dev/null +++ b/plugins/agc-godot-editor/native/godot-editor-bridge/src/transport.rs @@ -0,0 +1,164 @@ +use crate::{files::Session, remaining, MAX_MESSAGE_BYTES, PROTOCOL_VERSION}; +use serde_json::{json, Value}; +use std::io::{Read, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; +use std::time::Instant; + +pub struct ExchangeError { + pub message: String, + pub dispatched: bool, +} + +pub fn exchange( + session: &Session, + id: u64, + method: &str, + params: Value, + deadline: Instant, +) -> Result { + let mut sent = false; + let mut operation = || -> Result { + let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), session.port); + let mut stream = TcpStream::connect_timeout(&address, remaining(deadline)?) + .map_err(|_| "Godot 桥连接失败")?; + let request = json!({"protocol":PROTOCOL_VERSION,"id":id,"generation":session.generation,"token":session.token,"method":method,"params":params}); + let mut bytes = serde_json::to_vec(&request).map_err(|_| "Godot 请求编码失败")?; + bytes.push(b'\n'); + if bytes.len() > MAX_MESSAGE_BYTES { + return Err("Godot 请求超过 2 MiB".into()); + } + stream + .set_write_timeout(Some(remaining(deadline)?)) + .map_err(|_| "Godot 桥无法设置发送期限")?; + // 第一次 write 可能只发送一部分;此后任何错误都不能证明编辑器没有接收请求。 + let mut offset = 0; + while offset < bytes.len() { + stream + .set_write_timeout(Some(remaining(deadline)?)) + .map_err(|_| "Godot 桥发送期限无效")?; + sent = true; + let n = stream + .write(&bytes[offset..]) + .map_err(|_| "Godot 请求发送中断")?; + if n == 0 { + return Err("Godot 请求发送中断".into()); + } + offset += n; + } + let mut response = Vec::new(); + let mut buf = [0; 8192]; + loop { + stream + .set_read_timeout(Some(remaining(deadline)?)) + .map_err(|_| "Godot 桥读取期限无效")?; + let count = stream + .read(&mut buf) + .map_err(|_| "Godot 执行回执超时或读取中断")?; + if count == 0 { + return Err("Godot 在完整回执前关闭连接".into()); + } + if let Some(end) = buf[..count].iter().position(|&b| b == b'\n') { + response.extend_from_slice(&buf[..end]); + if end + 1 != count { + return Err("Godot 桥返回多余协议数据".into()); + } + break; + } + response.extend_from_slice(&buf[..count]); + if response.len() >= MAX_MESSAGE_BYTES { + return Err("Godot 回执超过 2 MiB".into()); + } + } + parse_response(&response, session, id, method) + }; + operation().map_err(|message| ExchangeError { + message, + dispatched: sent, + }) +} + +pub fn parse_response( + bytes: &[u8], + session: &Session, + id: u64, + method: &str, +) -> Result { + if bytes.len() > MAX_MESSAGE_BYTES { + return Err("Godot 回执超过 2 MiB".into()); + } + let value: Value = serde_json::from_slice(bytes).map_err(|_| "Godot 回执 JSON 损坏")?; + if value.get("protocol").and_then(Value::as_str) != Some(PROTOCOL_VERSION) + || value.get("id").and_then(Value::as_u64) != Some(id) + || value.get("generation").and_then(Value::as_str) != Some(&session.generation) + || value.get("pid").and_then(Value::as_u64) != Some(session.pid.into()) + || value.get("projectPath").and_then(Value::as_str) != Some(&session.project_path) + || value.get("buildId").and_then(Value::as_str) != Some(&session.build_id) + || value.get("error").is_some() + { + return Err("Godot 回执协议、请求或目标身份不匹配".into()); + } + let result = value + .get("result") + .filter(|v| v.is_object()) + .ok_or("Godot 回执缺少结果对象")?; + match method { + "execute" => { + if result.get("retryAllowed").and_then(Value::as_bool) != Some(false) { + return Err("Godot 回执缺少禁止重放标记".into()); + } + let ok = result.get("ok").and_then(Value::as_bool); + let dispatched = result.get("dispatched").and_then(Value::as_bool); + match result.get("status").and_then(Value::as_str) { + Some("completed") + if ok == Some(true) + && dispatched == Some(true) + && result.get("result").is_some() + && result.get("error").is_none() => {} + Some("failed") + if ok == Some(false) + && dispatched.is_some() + && valid_error(result.get("error")) + && result.get("result").is_none() => {} + Some("needs-reconciliation") + if ok == Some(false) + && dispatched == Some(true) + && valid_error(result.get("error")) + && result.get("result").is_none() => {} + _ => return Err("Godot 返回矛盾或不完整的执行状态".into()), + } + } + "status" => { + if result.get("connected").and_then(Value::as_bool) != Some(true) + || result.get("pid").and_then(Value::as_u64) != Some(session.pid.into()) + || result.get("projectPath").and_then(Value::as_str) != Some(&session.project_path) + || result.get("version").and_then(Value::as_str) != Some(&session.version) + || result.get("generation").and_then(Value::as_str) != Some(&session.generation) + || result.get("buildId").and_then(Value::as_str) != Some(&session.build_id) + || result.get("executing").and_then(Value::as_bool).is_none() + { + return Err("Godot status 身份或状态不匹配".into()); + } + } + "shutdown" => { + if !((result.get("accepted").and_then(Value::as_bool) == Some(true) + && result.get("status").and_then(Value::as_str) == Some("shutting-down")) + || (result.get("accepted").and_then(Value::as_bool) == Some(false) + && valid_error(result.get("error")))) + { + return Err("Godot shutdown 回执无效".into()); + } + } + _ => return Err("Godot 协议方法无效".into()), + } + Ok(result.clone()) +} + +fn valid_error(value: Option<&Value>) -> bool { + value.is_some_and(|v| { + ["code", "message"].iter().all(|k| { + v.get(k) + .and_then(Value::as_str) + .is_some_and(|s| !s.trim().is_empty()) + }) + }) +} diff --git a/plugins/agc-godot-editor/package.json b/plugins/agc-godot-editor/package.json new file mode 100644 index 000000000..79232890d --- /dev/null +++ b/plugins/agc-godot-editor/package.json @@ -0,0 +1,13 @@ +{ + "name": "@genarrative/agc-plugin-godot-editor", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "AGC Godot 编辑器插件", + "scripts": { + "test": "node --test src/entry.test.mjs" + }, + "dependencies": { + "@genarrative/agc-plugin-sdk": "0.1.0" + } +} diff --git a/plugins/agc-godot-editor/plugin.json b/plugins/agc-godot-editor/plugin.json new file mode 100644 index 000000000..03f889742 --- /dev/null +++ b/plugins/agc-godot-editor/plugin.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "agc-godot-editor", + "version": "0.1.0", + "description": "在当前已打开的 Godot 项目中执行编辑器操作", + "extensions": { + "com.openai": { "interface": { "displayName": "Godot 编辑器" } }, + "world.genarrative.agc": { + "apiVersion": "v1", + "entry": "./src/entry.mjs", + "adapter": "godot-editor", + "permissions": [ + "events.subscribe", + "editor.rpc", + "ui.register", + "capability.register" + ] + } + } +} diff --git a/plugins/agc-godot-editor/src/entry.mjs b/plugins/agc-godot-editor/src/entry.mjs new file mode 100644 index 000000000..3b4499331 --- /dev/null +++ b/plugins/agc-godot-editor/src/entry.mjs @@ -0,0 +1,301 @@ +/** + * AGC Godot 插件的 agc.plugin.v1 入口。 + * 与内置 Cocos 插件相同,直接运行的 JS 使用 SDK 的宿主协议;全部编辑器副作用 + * 经 host.rpc 交给统一 Runner,入口不查找进程、不准备扩展文件、不持有凭据。 + */ +import { pathToFileURL } from 'node:url'; + +export const GODOT_PLUGIN_PROTOCOL_VERSION = 'agc.plugin.v1'; +export const GODOT_EXECUTE_COMMAND_ID = 'godot.editor.execute'; +export const GODOT_CONNECTION_CAPABILITY_ID = 'godot.editor.connection'; +const MAX_MESSAGE_BYTES = 2 * 1024 * 1024; +const MAX_CODE_BYTES = 128 * 1024; + +export function createGodotEditorPlugin({ send, timeoutMs = 85_000 }) { + let nextId = 1; + let activeProjectPath = null; + let projectEpoch = 0; + let disposed = false; + let executing = false; + let uncertain = false; + const pending = new Map(); + + function request(method, params) { + if (disposed) return Promise.reject(new Error('插件已停止')); + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error('Godot 插件宿主 RPC 超时')); + }, timeoutMs); + pending.set(id, { resolve, reject, timer }); + try { + send({ jsonrpc: '2.0', id, method, params }); + } catch (error) { + clearTimeout(timer); + pending.delete(id); + reject(error); + } + }); + } + + function editorParams(input, allowed) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Godot 参数必须是对象'); + } + for (const key of Object.keys(input)) { + if (!allowed.includes(key)) throw new Error(`Godot 不接受参数:${key}`); + } + if (!activeProjectPath) throw new Error('当前没有受控 Godot 项目'); + if ( + input.timeoutMs !== undefined && + (!Number.isInteger(input.timeoutMs) || + input.timeoutMs < 1 || + input.timeoutMs > 60_000) + ) { + throw new Error('timeoutMs 必须在 1..60000 之间'); + } + return { ...input, projectPath: activeProjectPath }; + } + + function reconcile(error) { + uncertain = true; + return { + ok: false, + status: 'needs-reconciliation', + retryAllowed: false, + dispatched: true, + error: { + code: 'execution-uncertain', + message: typeof error === 'string' ? error : 'Godot 执行结果待核对', + }, + }; + } + + async function execute(input) { + const params = editorParams(input, ['code', 'timeoutMs']); + if ( + typeof params.code !== 'string' || + !params.code.trim() || + params.code.includes('\0') || + Buffer.byteLength(params.code) > MAX_CODE_BYTES + ) { + throw new Error('Godot GDScript 代码必须非空、不含 NUL 且不超过 128 KiB'); + } + if (uncertain) + return reconcile('先前执行结果待核对,请核对 Godot 后重启客户端'); + if (executing) { + return { + ok: false, + status: 'failed', + dispatched: false, + retryAllowed: false, + error: { code: 'editor-busy', message: 'Godot 已有执行正在处理' }, + }; + } + executing = true; + const epoch = projectEpoch; + try { + const result = await request('host.rpc', { + method: 'editor.execute', + params, + }); + if (epoch !== projectEpoch) + return reconcile('Godot 执行期间项目已切换,原项目结果待核对'); + if (result?.status === 'needs-reconciliation') + return reconcile(result.error?.message); + const validError = + typeof result?.error?.code === 'string' && + result.error.code.trim() && + typeof result?.error?.message === 'string' && + result.error.message.trim(); + if ( + !result || + typeof result.ok !== 'boolean' || + typeof result.dispatched !== 'boolean' || + result.retryAllowed !== false || + !( + (result.status === 'completed' && + result.ok && + result.dispatched && + !Object.hasOwn(result, 'error') && + Object.hasOwn(result, 'result')) || + (result.status === 'failed' && + !result.ok && + validError && + !Object.hasOwn(result, 'result')) + ) + ) { + return reconcile('Godot 执行回执无效'); + } + return result; + } catch { + return reconcile('Godot 执行连接中断或超时,结果待核对'); + } finally { + executing = false; + } + } + + async function connection(input = {}) { + const params = editorParams(input, ['operation', 'timeoutMs']); + const operation = params.operation ?? 'detect'; + if (!['detect', 'connect', 'status', 'disconnect'].includes(operation)) { + throw new Error('不支持的 Godot 连接操作'); + } + if (operation === 'disconnect' && (executing || uncertain)) + throw new Error('Godot 执行尚未完成或结果待核对,不能卸载连接桥'); + delete params.operation; + const epoch = projectEpoch; + const result = await request('host.rpc', { + method: `editor.${operation}`, + params, + }); + if (epoch !== projectEpoch) + throw new Error('Godot 连接期间项目已切换,回执不属于当前项目'); + if ( + operation === 'disconnect' && + (result?.connected !== false || + Object.hasOwn(result, 'accepted') || + result?.error || + result?.status === 'needs-reconciliation') + ) { + uncertain = true; + throw new Error('Godot 原生扩展尚未确认卸载,连接状态待核对'); + } + return result; + } + + async function handleMessage(message) { + if (disposed) return; + const value = typeof message === 'string' ? JSON.parse(message) : message; + if (!value || value.jsonrpc !== '2.0') return; + if (value.method === 'host.event') { + if (value.params?.type === 'project.changed') { + const project = value.params.payload?.projectPath; + activeProjectPath = + typeof project === 'string' && project ? project : null; + projectEpoch += 1; + } + return; + } + if (value.method !== undefined) { + if (value.id === undefined) return; + try { + const handler = + value.method === GODOT_EXECUTE_COMMAND_ID + ? execute + : value.method === GODOT_CONNECTION_CAPABILITY_ID + ? connection + : null; + if (!handler) throw new Error('插件未注册该方法'); + const result = await handler(value.params ?? {}); + if (!disposed) send({ jsonrpc: '2.0', id: value.id, result }); + } catch (error) { + if (!disposed) { + if (value.method === GODOT_EXECUTE_COMMAND_ID) { + send({ + jsonrpc: '2.0', + id: value.id, + result: { + ok: false, + status: 'failed', + retryAllowed: false, + dispatched: false, + error: { code: 'invalid-input', message: error.message }, + }, + }); + } else { + send({ + jsonrpc: '2.0', + id: value.id, + error: { code: -32602, message: error.message }, + }); + } + } + } + return; + } + const item = pending.get(value.id); + if (!item) return; + pending.delete(value.id); + clearTimeout(item.timer); + if (value.error) item.reject(new Error('插件宿主拒绝请求')); + else item.resolve(value.result); + } + + async function start() { + await request('host.registerCommand', { + id: GODOT_EXECUTE_COMMAND_ID, + title: '执行 Godot GDScript', + description: '在当前 Godot 项目的编辑器主线程执行 GDScript', + }); + await request('host.registerCapability', { + id: GODOT_CONNECTION_CAPABILITY_ID, + description: '当前 Godot 项目的编辑器连接与状态', + }); + const epoch = projectEpoch; + const result = await request('host.events.subscribe', { + type: 'project.changed', + }); + if (epoch === projectEpoch) activeProjectPath = result?.projectPath ?? null; + } + + function dispose() { + disposed = true; + for (const item of pending.values()) { + clearTimeout(item.timer); + item.reject(new Error('插件已停止')); + } + pending.clear(); + } + return { start, handleMessage, dispose }; +} + +export function startGodotEditorStdioPlugin({ + stdin = process.stdin, + stdout = process.stdout, +} = {}) { + const plugin = createGodotEditorPlugin({ + send(message) { + const line = `${JSON.stringify(message)}\n`; + if (Buffer.byteLength(line) > MAX_MESSAGE_BYTES) + throw new Error('插件消息过大'); + stdout.write(line); + }, + }); + let buffer = ''; + let stopped = false; + const stop = () => { + stopped = true; + buffer = ''; + plugin.dispose(); + stdin.pause(); + }; + stdin.setEncoding('utf8'); + stdin.on('data', (chunk) => { + if (stopped) return; + buffer += chunk; + let boundary; + while ((boundary = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 1); + if (Buffer.byteLength(line) > MAX_MESSAGE_BYTES) { + stop(); + return; + } + if (line.trim()) void plugin.handleMessage(line).catch(stop); + } + if (Buffer.byteLength(buffer) > MAX_MESSAGE_BYTES) stop(); + }); + stdin.on('end', stop); + stdin.on('error', stop); + void plugin.start().catch(stop); + return plugin; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + startGodotEditorStdioPlugin(); +} diff --git a/plugins/agc-godot-editor/src/entry.test.mjs b/plugins/agc-godot-editor/src/entry.test.mjs new file mode 100644 index 000000000..23133ec9c --- /dev/null +++ b/plugins/agc-godot-editor/src/entry.test.mjs @@ -0,0 +1,379 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { + createGodotEditorPlugin, + GODOT_CONNECTION_CAPABILITY_ID, + GODOT_EXECUTE_COMMAND_ID, +} from './entry.mjs'; + +const success = { + ok: true, + status: 'completed', + dispatched: true, + retryAllowed: false, + result: 2, +}; + +async function fixture(t, rpc = () => success, options = {}) { + const requests = []; + const replies = new Map(); + let inbound = 1000; + const plugin = createGodotEditorPlugin({ + ...options, + send(message) { + if (!message.method) { + replies.set(message.id, message); + return; + } + requests.push(message); + Promise.resolve() + .then(async () => { + const result = + message.method === 'host.rpc' + ? await rpc(message.params) + : message.method === 'host.events.subscribe' + ? { projectPath: 'C:/Godot/A', subscriptionId: 'project' } + : {}; + await plugin.handleMessage({ + jsonrpc: '2.0', + id: message.id, + result, + }); + }) + .catch(() => undefined); + }, + }); + t.after(() => plugin.dispose()); + await plugin.start(); + return { + plugin, + requests, + async call(params, method = GODOT_EXECUTE_COMMAND_ID) { + const id = inbound++; + await plugin.handleMessage({ jsonrpc: '2.0', id, method, params }); + return replies.get(id); + }, + async project(projectPath) { + await plugin.handleMessage({ + jsonrpc: '2.0', + method: 'host.event', + params: { type: 'project.changed', payload: { projectPath } }, + }); + }, + }; +} + +test('注册声明的命令与连接能力,并使用宿主提供的当前项目', async (t) => { + const f = await fixture(t); + assert.deepEqual( + f.requests.slice(0, 3).map((item) => item.method), + [ + 'host.registerCommand', + 'host.registerCapability', + 'host.events.subscribe', + ], + ); + assert.deepEqual((await f.call({ code: 'return 1 + 1;' })).result, success); + assert.equal(f.requests.at(-1).params.params.projectPath, 'C:/Godot/A'); + await f.project('C:/Godot/B'); + await f.call({ operation: 'detect' }, GODOT_CONNECTION_CAPABILITY_ID); + assert.equal(f.requests.at(-1).params.params.projectPath, 'C:/Godot/B'); + const manifest = JSON.parse( + await readFile(new URL('../plugin.json', import.meta.url), 'utf8'), + ); + assert.equal( + manifest.extensions['world.genarrative.agc'].adapter, + 'godot-editor', + ); +}); + +test('显式项目、任意payload和非法代码在宿主派发前拒绝', async (t) => { + const f = await fixture(t); + for (const input of [ + { code: 'return 1;', projectPath: 'C:/Other' }, + { code: 'return 1;', payloadPath: 'C:/evil.dll' }, + { code: '' }, + { code: 'x\0y' }, + { code: '中'.repeat(128 * 1024) }, + { code: 'return 1;', timeoutMs: 60_001 }, + ]) + assert.equal((await f.call(input)).result.dispatched, false); + assert.equal( + f.requests.filter((item) => item.method === 'host.rpc').length, + 0, + ); + await f.project(null); + assert.equal((await f.call({ code: 'return 1;' })).result.dispatched, false); +}); + +test('并发写请求立即失败,不会在上一请求结束后补发', async (t) => { + let finish; + const f = await fixture( + t, + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const first = f.call({ code: 'return 1;' }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal((await f.call({ code: 'return 2;' })).result.dispatched, false); + finish(success); + await first; + assert.equal( + f.requests.filter((item) => item.method === 'host.rpc').length, + 1, + ); +}); + +test('未知结果经过断开及项目切换仍阻断写入', async (t) => { + const f = await fixture(t, ({ method }) => + method === 'editor.execute' + ? { + ok: false, + status: 'needs-reconciliation', + retryAllowed: false, + dispatched: true, + error: 'lost', + } + : { connected: false }, + ); + assert.equal( + (await f.call({ code: 'return 1;' })).result.status, + 'needs-reconciliation', + ); + await f.call({ operation: 'disconnect' }, GODOT_CONNECTION_CAPABILITY_ID); + await f.project('C:/Godot/B'); + assert.equal( + (await f.call({ code: 'return 2;' })).result.status, + 'needs-reconciliation', + ); + assert.equal( + f.requests.filter((item) => item.params?.method === 'editor.execute') + .length, + 1, + ); +}); + +test('可信运行失败可以修正代码后再次执行', async (t) => { + let count = 0; + const f = await fixture(t, () => + ++count === 1 + ? { + ok: false, + status: 'failed', + retryAllowed: false, + dispatched: true, + error: { code: 'runtime-error', message: 'division by zero' }, + } + : success, + ); + assert.equal( + (await f.call({ code: 'return 1 / 0;' })).result.status, + 'failed', + ); + assert.equal((await f.call({ code: 'return 2;' })).result.ok, true); + assert.equal(count, 2); +}); + +test('超时回执与不完整终态均保守阻断,不自行重放', async (t) => { + for (const rpc of [ + () => ({ ...success, error: { code: 'conflict', message: 'both' } }), + () => ({ + ...success, + ok: false, + status: 'failed', + error: { code: 'conflict', message: 'both' }, + }), + () => new Promise(() => {}), + () => ({ ok: true }), + () => ({ + ok: true, + status: 'completed', + dispatched: true, + retryAllowed: false, + }), + () => ({ + ok: false, + status: 'failed', + dispatched: true, + retryAllowed: false, + error: 'untrusted', + }), + ]) { + const f = await fixture(t, rpc, { timeoutMs: 10 }); + assert.equal( + (await f.call({ code: 'return 1;' })).result.status, + 'needs-reconciliation', + ); + assert.equal( + (await f.call({ code: 'return 2;' })).result.status, + 'needs-reconciliation', + ); + assert.equal( + f.requests.filter((item) => item.method === 'host.rpc').length, + 1, + ); + } +}); + +test('迟到的订阅快照不能覆盖已收到的新项目事件', async (t) => { + const requests = []; + const plugin = createGodotEditorPlugin({ + send(message) { + requests.push(message); + if (!message.method) return; + queueMicrotask(async () => { + if (message.method === 'host.events.subscribe') { + await plugin.handleMessage({ + jsonrpc: '2.0', + method: 'host.event', + params: { + type: 'project.changed', + payload: { projectPath: 'C:/New' }, + }, + }); + } + await plugin.handleMessage({ + jsonrpc: '2.0', + id: message.id, + result: + message.method === 'host.events.subscribe' + ? { projectPath: 'C:/Old' } + : success, + }); + }); + }, + }); + t.after(() => plugin.dispose()); + await plugin.start(); + await plugin.handleMessage({ + jsonrpc: '2.0', + id: 500, + method: GODOT_EXECUTE_COMMAND_ID, + params: { code: 'return 2;' }, + }); + assert.equal( + requests.find((item) => item.method === 'host.rpc').params.params + .projectPath, + 'C:/New', + ); +}); + +test('连接身份由宿主绑定,不接受进程、端口、令牌或库路径覆盖', async (t) => { + const f = await fixture(t); + for (const forbidden of [ + 'processId', + 'projectPath', + 'port', + 'token', + 'dllPath', + ]) { + const reply = await f.call( + { operation: 'connect', [forbidden]: 123 }, + GODOT_CONNECTION_CAPABILITY_ID, + ); + assert.equal(reply.error.code, -32602); + } + assert.equal( + f.requests.filter((item) => item.method === 'host.rpc').length, + 0, + ); +}); + +test('nil 返回值保留真实完成语义', async (t) => { + const result = { ...success, result: null }; + const f = await fixture(t, () => result); + assert.deepEqual((await f.call({ code: 'return null' })).result, result); +}); + +test('await 未完成时拒绝断开,不卸载在途执行', async (t) => { + let finish; + const f = await fixture( + t, + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const first = f.call({ code: 'await get_tree().process_frame\nreturn 42' }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + (await f.call({ operation: 'disconnect' }, GODOT_CONNECTION_CAPABILITY_ID)) + .error.code, + -32602, + ); + assert.equal( + f.requests.filter((item) => item.method === 'host.rpc').length, + 1, + ); + finish(success); + assert.equal((await first).result.status, 'completed'); +}); + +test('shutdown 受理和不确定回执不能冒充卸载完成,也不能解除执行阻断', async (t) => { + for (const result of [ + { accepted: true, status: 'shutting-down' }, + { accepted: true, connected: false, status: 'shutting-down' }, + { + accepted: false, + connected: false, + error: { code: 'busy', message: 'busy' }, + }, + { connected: false, status: 'needs-reconciliation' }, + ]) { + const f = await fixture(t, () => result); + assert.equal( + ( + await f.call( + { operation: 'disconnect' }, + GODOT_CONNECTION_CAPABILITY_ID, + ) + ).error.code, + -32602, + ); + assert.equal( + (await f.call({ code: 'return 42' })).result.status, + 'needs-reconciliation', + ); + assert.equal( + f.requests.filter((item) => item.method === 'host.rpc').length, + 1, + ); + } + const f = await fixture(t, () => ({ + adapter: 'godot-editor', + connected: false, + })); + assert.equal( + (await f.call({ operation: 'disconnect' }, GODOT_CONNECTION_CAPABILITY_ID)) + .result.connected, + false, + ); +}); + +test('项目切换后旧项目的执行回执不能当成新项目成功', async (t) => { + let finish; + const f = await fixture( + t, + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const first = f.call({ code: 'return 42' }); + await new Promise((resolve) => setImmediate(resolve)); + await f.project('C:/Godot/B'); + finish(success); + assert.equal((await first).result.status, 'needs-reconciliation'); + assert.equal( + (await f.call({ code: 'return 43' })).result.status, + 'needs-reconciliation', + ); + assert.equal( + f.requests.filter((item) => item.method === 'host.rpc').length, + 1, + ); +}); diff --git a/plugins/agc-unity-editor/src/entry.mjs b/plugins/agc-unity-editor/src/entry.mjs index e32535175..66de0f411 100644 --- a/plugins/agc-unity-editor/src/entry.mjs +++ b/plugins/agc-unity-editor/src/entry.mjs @@ -115,8 +115,12 @@ export function createUnityEditorPlugin({ send, timeoutMs = 85_000 }) { (result.status === 'completed' && result.ok && result.dispatched && + !Object.hasOwn(result, 'error') && Object.hasOwn(result, 'result')) || - (result.status === 'failed' && !result.ok && validError) + (result.status === 'failed' && + !result.ok && + validError && + !Object.hasOwn(result, 'result')) ) ) { return reconcile('Unity 执行回执无效'); diff --git a/plugins/agc-unity-editor/src/entry.test.mjs b/plugins/agc-unity-editor/src/entry.test.mjs index 9a08446ae..cadf26ca3 100644 --- a/plugins/agc-unity-editor/src/entry.test.mjs +++ b/plugins/agc-unity-editor/src/entry.test.mjs @@ -180,6 +180,13 @@ test('可信运行失败可以修正代码后再次执行', async (t) => { test('超时回执与不完整终态均保守阻断,不自行重放', async (t) => { for (const rpc of [ + () => ({ ...success, error: { code: 'conflict', message: 'both' } }), + () => ({ + ...success, + ok: false, + status: 'failed', + error: { code: 'conflict', message: 'both' }, + }), () => new Promise(() => {}), () => ({ ok: true }), () => ({ diff --git a/scripts/check-npm-workspaces.mjs b/scripts/check-npm-workspaces.mjs index e5cf1e886..55bc2f5fb 100644 --- a/scripts/check-npm-workspaces.mjs +++ b/scripts/check-npm-workspaces.mjs @@ -16,6 +16,7 @@ export const REQUIRED_WORKSPACES = Object.freeze([ 'packages/shared', 'plugins/agc-cocos-editor', 'plugins/agc-unity-editor', + 'plugins/agc-godot-editor', 'tools/spine-json-export-validator', ]); @@ -31,6 +32,7 @@ const WORKSPACE_NAMES = Object.freeze({ 'packages/shared': '@genarrative/shared', 'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor', 'plugins/agc-unity-editor': '@genarrative/agc-plugin-unity-editor', + 'plugins/agc-godot-editor': '@genarrative/agc-plugin-godot-editor', 'tools/spine-json-export-validator': '@genarrative/spine-json-export-validator', }); @@ -53,6 +55,7 @@ const REQUIRED_LOCAL_DEPENDENCIES = Object.freeze({ ], 'plugins/agc-cocos-editor/package.json': ['@genarrative/agc-plugin-sdk'], 'plugins/agc-unity-editor/package.json': ['@genarrative/agc-plugin-sdk'], + 'plugins/agc-godot-editor/package.json': ['@genarrative/agc-plugin-sdk'], }); const DEPENDENCY_FIELDS = Object.freeze([ diff --git a/scripts/check-npm-workspaces.test.mjs b/scripts/check-npm-workspaces.test.mjs index 39b9fd2e7..0f04973ee 100644 --- a/scripts/check-npm-workspaces.test.mjs +++ b/scripts/check-npm-workspaces.test.mjs @@ -24,6 +24,7 @@ const workspaceNames = { 'packages/shared': '@genarrative/shared', 'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor', 'plugins/agc-unity-editor': '@genarrative/agc-plugin-unity-editor', + 'plugins/agc-godot-editor': '@genarrative/agc-plugin-godot-editor', 'tools/spine-json-export-validator': '@genarrative/spine-json-export-validator', }; @@ -39,6 +40,7 @@ const localDependencies = { 'packages/image-canvas-react': { '@genarrative/image-canvas-core': '0.1.0' }, 'plugins/agc-cocos-editor': { '@genarrative/agc-plugin-sdk': '0.1.0' }, 'plugins/agc-unity-editor': { '@genarrative/agc-plugin-sdk': '0.1.0' }, + 'plugins/agc-godot-editor': { '@genarrative/agc-plugin-sdk': '0.1.0' }, }; afterEach(() => { diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 6c109af8e..03d09e363 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -6,7 +6,7 @@ const checks = [ { file: 'package.json', includes: - '"check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml -- --check"', + '"check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml -- --check"', reason: '仓库必须保留统一、只读的 Rust workspace 格式检查入口。', }, { diff --git a/scripts/lint-staged-rustfmt-workspaces.mjs b/scripts/lint-staged-rustfmt-workspaces.mjs index 7aea8fea2..806e3cdb9 100644 --- a/scripts/lint-staged-rustfmt-workspaces.mjs +++ b/scripts/lint-staged-rustfmt-workspaces.mjs @@ -20,6 +20,11 @@ export const RUSTFMT_WORKSPACES = [ manifestPath: 'plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml', }, + { + prefix: 'plugins/agc-godot-editor/native/godot-editor-bridge/', + manifestPath: + 'plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml', + }, ]; /** diff --git a/scripts/lint-staged-rustfmt.test.ts b/scripts/lint-staged-rustfmt.test.ts index b84a519eb..38449085a 100644 --- a/scripts/lint-staged-rustfmt.test.ts +++ b/scripts/lint-staged-rustfmt.test.ts @@ -43,11 +43,13 @@ describe('lint-staged Rust 格式检查的 workspace 选择', () => { 'server-rs/crates/api-server/src/editor_project.rs', 'apps/ai-game-creator-shell/src-tauri/src/assets.rs', 'plugins/agc-unity-editor/native/unity-editor-bridge/src/lib.rs', + 'plugins/agc-godot-editor/native/godot-editor-bridge/src/lib.rs', ]), ).toEqual([ 'server-rs/Cargo.toml', 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', 'plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml', + 'plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml', ]); }); diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts index 1c0f15423..75188cff0 100644 --- a/scripts/project-ci-workflow.test.ts +++ b/scripts/project-ci-workflow.test.ts @@ -422,6 +422,9 @@ describe('project CI workflow', () => { expect(rootPackageJson.scripts?.['agc:plugins:test']).toContain( 'plugins/agc-unity-editor/src/entry.test.mjs', ); + expect(rootPackageJson.scripts?.['agc:plugins:test']).toContain( + 'plugins/agc-godot-editor/src/entry.test.mjs', + ); // 壳 bin 单测按名单分 4 片,一片一个 job:每个片 job 只跑自己那片,且只预热 AGC 壳 // 自己那份锁定依赖(server-rs 那份归 crate 级 job)。 @@ -470,6 +473,9 @@ describe('project CI workflow', () => { expect(rootPackageJson.scripts?.['agc:plugins:native-test']).toContain( 'cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml', ); + expect(rootPackageJson.scripts?.['agc:plugins:native-test']).toContain( + 'cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml', + ); // 拆开的 web / rust 两段必须还是原 `ai-game-creator-shell:check` 的同一条命令序列, // rust 段再拆成 crate 级与壳分片两段后在聚合脚本里保持同序。 @@ -543,6 +549,14 @@ describe('project CI workflow', () => { expect(unityStep).toContain( 'plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml', ); + const godotStep = stepSection( + 'ai-game-creator-shell-rust-crates', + 'Prepare Godot plugin Rust dependencies', + ); + expect(godotStep).toContain('cargo fetch --locked'); + expect(godotStep).toContain( + 'plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml', + ); const cratesJob = jobSection('ai-game-creator-shell-rust-crates'); expect( diff --git a/server-rs/crates/editor-adapter-api/src/lib.rs b/server-rs/crates/editor-adapter-api/src/lib.rs index 71e308a3e..b35c577df 100644 --- a/server-rs/crates/editor-adapter-api/src/lib.rs +++ b/server-rs/crates/editor-adapter-api/src/lib.rs @@ -44,7 +44,7 @@ pub trait EditorAdapter: Send + Sync { fn id(&self) -> &'static str; /// 按项目路径探测当前编辑器实例,不注入、不修改项目文件。 fn detect(&self, project_path: &Path) -> Result; - /// 绑定目标 PID / 项目 / 版本并验证协议握手;适配器可安装受控进程桥,但不修改项目文件。 + /// 绑定目标 PID / 项目 / 版本并验证协议握手;受管引导文件仅按对应编辑器已授权合同维护。 fn connect( &mut self, pid: u32, diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 4b70f31b2..d5ed46dd4 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -28,7 +28,7 @@ pub struct GameCreationAppCommandDescriptor { pub permission: GameCreationAppPermission, } -pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 66] = [ +pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 67] = [ command("help.show", GameCreationAppPermission::Auto), command("project.create", GameCreationAppPermission::Confirm), command("project.rename", GameCreationAppPermission::Confirm), @@ -85,6 +85,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 66] = [ command("command.stdin", GameCreationAppPermission::Confirm), command("command.terminate", GameCreationAppPermission::Confirm), command("cocos.editor.execute", GameCreationAppPermission::Confirm), + command("godot.editor.execute", GameCreationAppPermission::Confirm), command("canvas.project_open", GameCreationAppPermission::Confirm), command("canvas.project_sync", GameCreationAppPermission::Confirm), command("canvas.asset_import", GameCreationAppPermission::Confirm), @@ -1263,7 +1264,15 @@ mod tests { #[test] fn command_contract_keeps_expected_permissions() { - assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 66); + assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 67); + assert_eq!( + GAME_CREATION_APP_COMMANDS + .iter() + .find(|command| command.id == "godot.editor.execute") + .unwrap() + .permission, + GameCreationAppPermission::Confirm + ); let command_ids = GAME_CREATION_APP_COMMANDS .iter() From b96473836dec5f2828287043d22bc02f69f5a5e6 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:26:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Unity=20=E4=B8=8E=20Go?= =?UTF-8?q?dot=20=E5=B8=B8=E7=94=A8=E6=93=8D=E4=BD=9C=E6=8C=87=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增两种编辑器的内置 Skill 和场景、资源、UI、保存撤销示例 接入 DirectProject 按需读取与 Runtime 同源操作参考 补齐指南原文实机测试、安装投影及工具说明完整性检查 记录 Godot 图形补验结果并保留尚未验收的边界 --- .../scripts/skill-pack-manifest.mjs | 2 + .../agc-skills/agc-godot-editor/SKILL.md | 14 + ...操作指南】Godot编辑器常用操作-2026-09-20.md | 257 ++++++++++++ .../agc-skills/agc-unity-editor/SKILL.md | 10 + ...操作指南】Unity编辑器常用操作-2026-09-20.md | 225 +++++++++++ .../resources/agc-skills/manifest.json | 34 +- .../src/agent/codex_app_server/mod.rs | 4 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 22 + .../src-tauri/src/agent/direct_tools_mcp.rs | 16 + .../src-tauri/src/agent/skill_pack.rs | 51 ++- .../src-tauri/src/agent_native_tools.rs | 83 +++- .../shared-memory/decision-log.md | 4 + ...方案】AGC Godot编辑器插件接入-2026-09-20.md | 6 + ...方案】AGC Unity编辑器插件接入-2026-09-18.md | 6 +- ...案】AGC通用插件宿主与编辑器适配-2026-09-09.md | 21 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- .../gdextension/tests/guide-examples.test.mjs | 380 ++++++++++++++++++ .../tests/guide_examples.rs | 142 +++++++ 18 files changed, 1266 insertions(+), 13 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-godot-editor/SKILL.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-unity-editor/SKILL.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-unity-editor/references/【操作指南】Unity编辑器常用操作-2026-09-20.md create mode 100644 plugins/agc-godot-editor/native/gdextension/tests/guide-examples.test.mjs create mode 100644 plugins/agc-unity-editor/native/unity-editor-bridge/tests/guide_examples.rs diff --git a/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs b/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs index 5f89ad3ae..88c717208 100644 --- a/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs +++ b/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs @@ -9,7 +9,9 @@ export const EXPECTED_SKILL_NAMES = Object.freeze([ 'agc-browser-playtest', 'agc-client-projection', 'agc-game-production-workflow', + 'agc-godot-editor', 'agc-project-structure', + 'agc-unity-editor', 'agc-web-game-development', 'taonier-art-assets', ]); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-godot-editor/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-godot-editor/SKILL.md new file mode 100644 index 000000000..31a4edd78 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-godot-editor/SKILL.md @@ -0,0 +1,14 @@ +--- +name: agc-godot-editor +description: 在 AGC 中通过已连接的 Godot 编辑器读取、修改和保存场景、节点、资源与 UI,运行项目并诊断 GDScript 执行结果。 +--- + +# Godot 编辑器操作 + +使用当前环境实际提供的 Godot 执行工具:DirectProject 为 `agc_godot_execute`,Runtime 使用 `godot.editor.execute` 对应的已发现工具。执行载荷只含 GDScript **函数体** `code`;Direct 传 `{code:...}`,Runtime 按实际 schema 包装为 `{reason:"...",input:{code:...}}`。项目、编辑器和连接身份由 AGC 管理。 + +开始操作前读取 [Godot 编辑器常用操作](references/【操作指南】Godot编辑器常用操作-2026-09-20.md),按当前任务选取查询、节点、撤销、资源、UI、保存或运行示例。先查询真实编辑场景与目标节点,再做有限修改并回读结果。 + +DLL 随 AGC 分发,首次连接需要 Godot 扫描时重新聚焦编辑器即可;无需手动复制 DLL、配置端口或运行引导脚本。不要读取或返回连接凭据。 + +明确失败也可能已经修改场景;先检查日志和真实状态再修复。超时、断线或 `needs-reconciliation` 表示结果待核对,不自动重放,不通过重连绕过执行阻断。保存、运行和删除范围以用户任务为准;局部 `UndoRedo` 不等于编辑器撤销历史。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md new file mode 100644 index 000000000..ed00f7074 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md @@ -0,0 +1,257 @@ +# Godot 编辑器常用操作 + +面向 AGC 内置 Godot 工具,仅支持 Windows x64 标准编辑器;不推断 .NET 或其他平台支持。缺少执行工具时报告不可用。目录:执行、查询、节点、撤销、资源、UI、保存、运行、诊断。 + +## 执行合同 + +- Direct 的 `agc_godot_execute` 传 `{code:...}`;Runtime 先发现 `godot.editor.execute`,按实际 schema 传 `{reason:"操作原因",input:{code:...}}`。执行载荷只含 `code`,不增加项目路径等字段。以下是函数体,不增加 `extends`、`@tool` 或 `func run()`,保留内部缩进。 +- 上下文是临时 `RefCounted.run()`;`self` 不是场景 Node,不能直接 `get_tree()`。用 `EditorInterface.get_edited_scene_root()` 取得编辑场景根;`EditorInterface.get_base_control().get_tree().root` 是编辑器根,不是用户场景。 +- 各次调用不共享局部变量。返回 `null`、布尔、整数、有限浮点、字符串、数组、字符串键字典。Node、Resource、Vector2、Color 等需投影为路径、数值数组或字典;不要直接返回 Godot 对象。用 `return` 返回结果,`print` 只写有界日志。 +- 可 `await EditorInterface.get_base_control().get_tree().process_frame` 或短计时器;不要死循环、长阻塞,也不要派发未等待的后台修改。一次只执行一段有界操作。 +- DLL 原件由 AGC 安装资源提供,私有缓存按编辑器实例隔离;首次发现扩展时重新聚焦 Godot 即可。不手改 `.gdextension`、DLL、端口、令牌或 `.godot/agc`。 + +## 读取当前场景、选中节点和树 + +先核对 `scene`、类型和相对路径。无打开场景时返回空结果。遍历最多 256 节点,`truncated` 为 true 时按目标子树继续查。 + + +```gdscript +var root := EditorInterface.get_edited_scene_root() +if root == null: + return {"scene": null, "nodes": [], "selected": []} +var selected: Array = [] +for node in EditorInterface.get_selection().get_selected_nodes(): + if node == root or root.is_ancestor_of(node): + selected.append(str(root.get_path_to(node))) +var nodes: Array = [] +var pending: Array[Node] = [root] +while not pending.is_empty() and nodes.size() < 256: + var node: Node = pending.pop_back() + nodes.append({"path": str(root.get_path_to(node)), "type": node.get_class()}) + for child in node.get_children(): + pending.append(child) +return {"scene": root.scene_file_path, "root": str(root.name), "nodes": nodes, + "selected": selected, "truncated": not pending.is_empty()} +``` + +`get_node_or_null("Player/Sprite2D")` 相对于场景根。选择用 `EditorInterface.get_selection().clear()` / `add_node(node)`;检查器用 `EditorInterface.edit_node(node)`,均不保存场景。 + +## 创建、改属性、删除节点 + +将 `AGCGuideMarker` 替换为任务指定且不冲突的名称。示例直接修改,不自动加入编辑器撤销历史。`add_child` 后设 `owner = root` 才随当前场景保存;新子树逐个设置 owner,不重写实例场景内部 owner。 + + +```gdscript +var root := EditorInterface.get_edited_scene_root() +assert(root != null and root.get_node_or_null("AGCGuideMarker") == null) +var marker := Node2D.new() +marker.name = "AGCGuideMarker" +root.add_child(marker) +marker.owner = root +marker.position = Vector2(12, 24) +EditorInterface.mark_scene_as_unsaved() +return {"path": str(root.get_path_to(marker)), "position": [marker.position.x, marker.position.y], + "owned": marker.owner == root} +``` + + +```gdscript +var root := EditorInterface.get_edited_scene_root() +assert(root != null) +var marker := root.get_node_or_null("AGCGuideMarker") as Node2D +assert(marker != null) +marker.position = Vector2(24, 48) +EditorInterface.mark_scene_as_unsaved() +return {"path": str(root.get_path_to(marker)), "position": [marker.position.x, marker.position.y]} +``` + +删除前核对目标及后代;`queue_free()` 连同后代删除,下一帧完成后对象失效。不要删除场景根。 + + +```gdscript +var root := EditorInterface.get_edited_scene_root() +assert(root != null) +var marker := root.get_node_or_null("AGCGuideMarker") +assert(marker != null and marker != root) +root.remove_child(marker) +marker.queue_free() +EditorInterface.mark_scene_as_unsaved() +await EditorInterface.get_base_control().get_tree().process_frame +return {"removed": root.get_node_or_null("AGCGuideMarker") == null} +``` + +其它属性如 `Sprite2D.texture`、`Node3D.position`、`Label.text`,先确认实际类型。向量和颜色返回数值数组。 + +## 撤销:局部事务与编辑器历史 + +局部 `UndoRedo.new()` 不进入 Ctrl+Z 菜单,调用结束即失去历史。下例同一次调用改位置为 `(80, 90)`,随后撤销并回读。 + + +```gdscript +var root := EditorInterface.get_edited_scene_root() +assert(root != null) +var marker := root.get_node_or_null("AGCGuideMarker") as Node2D +assert(marker != null) +var previous := marker.position +var undo := UndoRedo.new() +undo.create_action("验证位置撤销") +undo.add_do_property(marker, "position", Vector2(80, 90)) +undo.add_undo_property(marker, "position", previous) +undo.commit_action() +var changed := marker.position +assert(undo.undo()) +return {"changed": [changed.x, changed.y], "restored": [marker.position.x, marker.position.y], + "matches": marker.position == previous} +``` + +Ctrl+Z 需复用已有 `EditorPlugin.get_undo_redo()` 的 `EditorUndoRedoManager`,`create_action(..., UndoRedo.MERGE_DISABLE, root)` 指定场景历史。局部 UndoRedo 方法操作用 Callable;manager 用对象、方法名、参数。不要为取得 manager 擅自安装 addon。 + +创建历史需登记 `add_child`、`owner`、逆向 `remove_child` 和 `add_do_reference`;删除记录父节点、顺序、owner,用 `add_undo_reference` 保活,禁止 `free/queue_free` 后再承诺恢复。属性成对登记新旧值。无持久 EditorPlugin 时只能承诺直接修改,不能承诺 Ctrl+Z。 + +保存重开后旧 Node 引用和局部历史不能复用。需重新查询,确认无后续用户改动,再执行逆操作并重新保存;内存 undo 不会恢复磁盘文件。 + +## PackedScene 与资源 + +将 `res://agc_guide_piece.tscn` 改为任务指定新路径,确认不存在并检查 `pack`、`ResourceSaver.save` 返回值。`owner` 决定子节点能否打包;不照例覆盖已有资源。 + + +```gdscript +var target := "res://agc_guide_piece.tscn" +assert(not FileAccess.file_exists(target)) +var source := Node2D.new() +source.name = "GuidePiece" +var child := Marker2D.new() +child.name = "Anchor" +source.add_child(child) +child.owner = source +var packed := PackedScene.new() +var packed_error := packed.pack(source) +source.free() +assert(packed_error == OK) +var save_error := ResourceSaver.save(packed, target) +assert(save_error == OK) +EditorInterface.get_resource_filesystem().scan() +return {"path": target, "saved": FileAccess.file_exists(target)} +``` + +实例化时检查 PackedScene 类型,只把实例根归属于当前根,保留内部所有权。实例局部覆盖不会改写源 `.tscn`。 + + +```gdscript +var root := EditorInterface.get_edited_scene_root() +assert(root != null and root.get_node_or_null("AGCGuidePiece") == null) +var packed := ResourceLoader.load("res://agc_guide_piece.tscn", "PackedScene", ResourceLoader.CACHE_MODE_IGNORE) as PackedScene +assert(packed != null) +var instance := packed.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE) +instance.name = "AGCGuidePiece" +root.add_child(instance) +instance.owner = root +EditorInterface.mark_scene_as_unsaved() +return {"path": str(root.get_path_to(instance)), "source": instance.scene_file_path, + "has_anchor": instance.get_node_or_null("Anchor") != null} +``` + +ResourceLoader 默认缓存可能返回旧对象;外部刚写文件可用 `CACHE_MODE_IGNORE`。共享 Resource 的修改影响所有引用;局部变化先 `duplicate()` 再赋回。图片/音频须等扫描和导入完成,文件存在不代表已导入。 + +## 基础 Control / Container UI + +Container 管理直属子 Control 布局,使用 `custom_minimum_size`、size flags、theme 常量,避免手写子控件 position/size。新节点逐个设置 owner。 + + +```gdscript +var root := EditorInterface.get_edited_scene_root() +assert(root != null and root.get_node_or_null("AGCGuideHUD") == null) +var layer := CanvasLayer.new() +layer.name = "AGCGuideHUD" +root.add_child(layer) +layer.owner = root +var center := CenterContainer.new() +center.name = "Center" +layer.add_child(center) +center.owner = root +center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) +var column := VBoxContainer.new() +column.name = "Column" +center.add_child(column) +column.owner = root +column.custom_minimum_size = Vector2(240, 96) +column.add_theme_constant_override("separation", 8) +var label := Label.new() +label.name = "Title" +label.text = "关卡目标" +column.add_child(label) +label.owner = root +var button := Button.new() +button.name = "Start" +button.text = "开始" +button.custom_minimum_size = Vector2(200, 40) +column.add_child(button) +button.owner = root +EditorInterface.mark_scene_as_unsaved() +return {"path": str(root.get_path_to(layer)), "title": label.text, "button": button.text, + "anchors": [center.anchor_left, center.anchor_top, center.anchor_right, center.anchor_bottom], + "owned": [layer.owner == root, center.owner == root, column.owner == root, label.owner == root, button.owner == root]} +``` + +持久信号应连接游戏脚本的方法,不把临时执行器 Callable 当运行时回调。此例只建布局;尺寸、层级、输入仍需实际试玩验收。 + +## 保存、重新打开与新场景 + +`mark_scene_as_unsaved()` 不写盘。仅在获准保存全部当前改动时执行。`save_scene_as(path,false)` 跳过缩略图但返回 void;旧文件可加载不代表本次保存成功。下例依赖前文三个分支,先核验磁盘节点和位置再重开;实际任务须覆盖所有待保存变更,无法证明时只保存、不 reload。 + + +```gdscript +var root := EditorInterface.get_edited_scene_root() +assert(root != null and not root.scene_file_path.is_empty()) +var scene_path := root.scene_file_path +var expected: Vector2 = root.get_node("AGCGuideMarker").position +EditorInterface.save_scene_as(scene_path, false) +var saved := ResourceLoader.load(scene_path, "PackedScene", ResourceLoader.CACHE_MODE_IGNORE) as PackedScene +assert(saved != null) +var probe := saved.instantiate() +var marker := probe.get_node_or_null("AGCGuideMarker") as Node2D +var matches := marker != null and marker.position == expected and probe.has_node("AGCGuidePiece/Anchor") and probe.has_node("AGCGuideHUD/Center/Column/Title") +probe.free() +if not matches: + return {"reloaded": false, "reason": "磁盘内容未验证,保留当前编辑场景"} +EditorInterface.reload_scene_from_path(scene_path) +await EditorInterface.get_base_control().get_tree().process_frame +var reopened := EditorInterface.get_edited_scene_root() +assert(reopened != null and reopened.scene_file_path == scene_path) +return {"scene": reopened.scene_file_path, "saved": true, "reloaded": true, + "has_piece": reopened.get_node_or_null("AGCGuidePiece/Anchor") != null, + "has_ui": reopened.get_node_or_null("AGCGuideHUD/Center/Column/Title") != null} +``` + +打开场景用 `open_scene_from_path("res://...")`,等一帧重新取根核对路径;`get_open_scenes()` 查已打开路径,均属 EditorInterface。未命名场景用 `save_scene_as(path)`;常规 GUI 用 `save_scene()` 检查 `OK`,headless 缩略图可能报错。不要覆盖未知未保存工作。 + +## 运行与停止 + +EditorInterface 的 `play_current_scene()` 运行当前场景,`play_main_scene()` 运行主场景,`play_custom_scene("res://...")` 运行指定场景。仅需试玩时调用,先核对路径、主场景与未保存改动。`is_playing_scene()` / `get_playing_scene()` 只报告启动状态,不证明玩法正确;编辑根不是游戏 Remote SceneTree。 + + +```gdscript +var was_playing := EditorInterface.is_playing_scene() +if was_playing: + EditorInterface.stop_playing_scene() + await EditorInterface.get_base_control().get_tree().process_frame +return {"was_playing": was_playing, "playing": EditorInterface.is_playing_scene()} +``` + +## 错误诊断与回执 + +- 读取执行回执的 `ok/status/result/error/logs`。编译错误先检查函数体包装、类型推断和真实 API;确定运行失败也可能已经执行前半段修改,先读回节点/资源,再修复剩余步骤。 +- `godot_result_not_serializable` 可能只是返回了对象,不能据此认定修改未发生;改用只读查询返回路径和标量。`assert` 失败不会替你回滚此前副作用。 +- 超时、断线、`needs-reconciliation` 或发送后的身份不明不能自动重放;先核对编辑器真实状态,按 AGC 现有恢复流程处理阻断。重新连接、启停插件或重启 Runner 都不是“确认没有执行”。 +- 捕获日志只覆盖这次编辑器执行且有长度上限;成功启动游戏不等于运行时无错误。结合 Godot Output/Debugger、游戏日志与实际试玩核验,不将空日志当作无故障。 + + +```gdscript +var root := EditorInterface.get_edited_scene_root() +return {"version": Engine.get_version_info().string, + "editor": Engine.is_editor_hint(), "scene": root.scene_file_path if root != null else null, + "open_scenes": Array(EditorInterface.get_open_scenes()), "playing": EditorInterface.is_playing_scene(), + "playing_scene": EditorInterface.get_playing_scene()} +``` + +示例已在 Godot 4.7.2 标准版 headless 验证;停止仅验证已停止状态。GUI 缩略图保存、Ctrl+Z 历史、运行中停止及 UI 视觉效果未在此指南测试中验收。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-unity-editor/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-unity-editor/SKILL.md new file mode 100644 index 000000000..a70108174 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-unity-editor/SKILL.md @@ -0,0 +1,10 @@ +--- +name: agc-unity-editor +description: 通过 AGC 的 Unity 编辑器执行工具读取和修改当前项目的场景、对象、组件、Prefab、Canvas 与资源,并保存、撤销和检查播放状态。 +--- + +# Unity 编辑器操作 + +使用当前会话提供的 Unity 执行工具,提交 C# 方法正文。开始操作前读取[常用操作指南](references/【操作指南】Unity编辑器常用操作-2026-09-20.md),按任务选择其中的示例。指南包含调用格式、目标定位、返回值投影和可执行代码。 + +先查询目标与编辑状态,修改后回读;写操作显式登记 Undo,保存操作检查返回值。执行失败可能留下部分修改,结果未知时不得重放。插件不会自动把任意代码变成可撤销事务。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-unity-editor/references/【操作指南】Unity编辑器常用操作-2026-09-20.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-unity-editor/references/【操作指南】Unity编辑器常用操作-2026-09-20.md new file mode 100644 index 000000000..06b8bdf98 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-unity-editor/references/【操作指南】Unity编辑器常用操作-2026-09-20.md @@ -0,0 +1,225 @@ +# Unity 编辑器常用操作 + +## 调用与结果 + +当前接入支持 Windows x64 的 Mono 编辑器。工具缺失时报告不可用,不推断 .NET/CoreCLR 或其他平台已支持。 + +连接当前项目的 Unity 后提交仅含 `code` 的执行载荷。DirectProject 工具 `agc_unity_execute` 传 `{"code":"return 42;"}`;Runtime 的 `unity.editor.execute` 按实际 schema 传 `{"reason":"读取编辑器状态","input":{"code":"return 42;"}}`。以会话工具清单为准。 + +`code` 是主线程执行的方法正文,直接 `return`,不加 `using`、类或 `Main`。使用完整 API 名称。Unity 对象先投影为普通数据;返回集合最多保留 32 项,嵌套深度达到 4 会转字符串,采用浅层投影、每批 30 项及显式截断标记。跨调用保留路径/GUID,实例 ID 仅当前 Editor 生命周期内有效。 + +先确认场景、选择、编辑模式和待修改资源。遍历 `GetRootGameObjects()` 和 `GetComponentsInChildren(..., true)` 可包含未激活对象;`GameObject.Find` 会漏掉它们。结合场景路径、层级路径和实例 ID 回读目标,重名时不要任取首个。 + +`completed` 只证明代码返回,仍要回读。`failed` 可能已部分修改,检查 `dispatched` 与现场后修复;编译失败且 `dispatched=false` 表示未执行。`needs-reconciliation`、超时或断线后结果未知时不重放,保留执行 ID 并核对现场,重连不等于允许重试。工具不自动撤销/回滚,不能中断死循环;保持调用短小,不在主线程等待编译/播放切换。 + +## 当前场景、选择和层级 + +返回当前场景及最多 30 个节点。其他场景用 `SceneManager.sceneCount/GetSceneAt` 枚举。 + + +```csharp +var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); +var rows = new System.Collections.Generic.List(); +var queue = new System.Collections.Generic.Queue(); +foreach (var root in scene.GetRootGameObjects()) queue.Enqueue(root.transform); +while (queue.Count > 0 && rows.Count < 30) { + var t = queue.Dequeue(); + var path = t.name; + for (var p = t.parent; p != null; p = p.parent) path = p.name + "/" + path; + rows.Add(new { id = t.gameObject.GetInstanceID(), path, active = t.gameObject.activeSelf, + x = t.localPosition.x, y = t.localPosition.y, z = t.localPosition.z }); + for (int i = 0; i < t.childCount; i++) queue.Enqueue(t.GetChild(i)); +} +var selected = UnityEditor.Selection.activeGameObject; +return new { scene = scene.path, dirty = scene.isDirty, nodes = rows.ToArray(), truncated = queue.Count > 0, + selectedId = selected == null ? 0 : selected.GetInstanceID(), + playing = UnityEditor.EditorApplication.isPlaying, compiling = UnityEditor.EditorApplication.isCompiling }; +``` + +## 创建、修改、删除与 Undo + +示例对象 `AGC_Guide_Object` 应替换成任务目标。编辑先退出播放模式。属性写入前 `Undo.RecordObject`;创建用 `RegisterCreatedObjectUndo`,加组件用 `Undo.AddComponent`,删除用 `Undo.DestroyObjectImmediate`,改父级用 `Undo.SetTransformParent`。磁盘写入、外部副作用及未登记修改不会自动撤销。 + +创建对象和组件并选中它;检查重复名是防误建措施,不是结果未知后重试的许可。 + + +```csharp +if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("请先停止播放"); +var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); +foreach (var root in scene.GetRootGameObjects()) + if (root.name == "AGC_Guide_Object") throw new System.Exception("目标已存在,请先核对"); +UnityEditor.Undo.IncrementCurrentGroup(); +int group = UnityEditor.Undo.GetCurrentGroup(); +UnityEditor.Undo.SetCurrentGroupName("AGC 创建对象"); +var go = new UnityEngine.GameObject("AGC_Guide_Object"); +UnityEditor.Undo.RegisterCreatedObjectUndo(go, "AGC 创建对象"); +UnityEditor.Undo.AddComponent(go); +UnityEditor.Selection.activeGameObject = go; +UnityEditor.Undo.CollapseUndoOperations(group); +return new { id = go.GetInstanceID(), name = go.name, collider = go.GetComponent() != null }; +``` + +确认选择是目标后修改。Prefab 实例属性写入后记录 override。改 Prefab 资产用 `LoadPrefabContents/SaveAsPrefabAsset/UnloadPrefabContents` 并在 `finally` 释放,不能当场景对象保存。 + + +```csharp +var go = UnityEditor.Selection.activeGameObject; +if (go == null || !go.scene.IsValid() || UnityEditor.EditorUtility.IsPersistent(go)) throw new System.Exception("请选中场景对象"); +if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("请先停止播放"); +UnityEditor.Undo.IncrementCurrentGroup(); +int group = UnityEditor.Undo.GetCurrentGroup(); +UnityEditor.Undo.SetCurrentGroupName("AGC 修改对象"); +UnityEditor.Undo.RecordObject(go.transform, "AGC 移动对象"); +go.transform.localPosition = new UnityEngine.Vector3(1, 2, 3); +var collider = go.GetComponent(); +if (collider == null) collider = UnityEditor.Undo.AddComponent(go); +UnityEditor.Undo.RecordObject(collider, "AGC 修改碰撞体"); +collider.size = new UnityEngine.Vector3(2, 3, 4); +if (UnityEditor.PrefabUtility.IsPartOfPrefabInstance(go)) { + UnityEditor.PrefabUtility.RecordPrefabInstancePropertyModifications(go.transform); + UnityEditor.PrefabUtility.RecordPrefabInstancePropertyModifications(collider); +} +UnityEditor.Undo.FlushUndoRecordObjects(); +UnityEditor.Undo.CollapseUndoOperations(group); +UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(go.scene); +return new { id = go.GetInstanceID(), x = go.transform.localPosition.x, colliderX = collider.size.x }; +``` + +删除选择对象上的碰撞体;删除整个已核对对象时把 `collider` 替换为 `go`,并提前回读待删除子树。 + + +```csharp +var go = UnityEditor.Selection.activeGameObject; +if (go == null || !go.scene.IsValid() || UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("需要编辑模式中的场景对象"); +var collider = go.GetComponent(); +if (collider == null) throw new System.Exception("没有 BoxCollider"); +UnityEditor.Undo.IncrementCurrentGroup(); +UnityEditor.Undo.SetCurrentGroupName("AGC 删除碰撞体"); +UnityEditor.Undo.DestroyObjectImmediate(collider); +UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(go.scene); +return new { removed = go.GetComponent() == null }; +``` + +只在确认最后一条 Undo 就是本次操作时执行撤销,避免撤销用户插入的编辑。撤销后重新运行查询检查对象/属性。 + + +```csharp +UnityEditor.Undo.PerformUndo(); +var go = UnityEditor.Selection.activeGameObject; +return new { selectedId = go == null ? 0 : go.GetInstanceID(), collider = go != null && go.GetComponent() != null }; +``` + +## 资源查找与 Prefab 实例化 + +按类型和目录查询,拿到 GUID/路径后加载。下例返回前 30 个 Prefab;过滤器可换成 `t:Material`、`t:Texture2D` 等。 + + +```csharp +var ids = UnityEditor.AssetDatabase.FindAssets("t:Prefab", new[] { "Assets" }); +var rows = new System.Collections.Generic.List(); +for (int i = 0; i < ids.Length && i < 30; i++) + rows.Add(new { guid = ids[i], path = UnityEditor.AssetDatabase.GUIDToAssetPath(ids[i]) }); +return new { assets = rows.ToArray(), total = ids.Length, truncated = ids.Length > 30 }; +``` + +路径替换为已查到的 Prefab;`InstantiatePrefab` 保持 Prefab 联系,后续修改登记 override。 + + +```csharp +if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("请先停止播放"); +var path = "Assets/AGCGuide/Guide.prefab"; +var asset = UnityEditor.AssetDatabase.LoadAssetAtPath(path); +if (asset == null || UnityEditor.PrefabUtility.GetPrefabAssetType(asset) == UnityEditor.PrefabAssetType.NotAPrefab) throw new System.Exception("未找到 Prefab"); +var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); +UnityEditor.Undo.IncrementCurrentGroup(); +var instance = (UnityEngine.GameObject)UnityEditor.PrefabUtility.InstantiatePrefab(asset, scene); +UnityEditor.Undo.RegisterCreatedObjectUndo(instance, "AGC 实例化 Prefab"); +UnityEditor.Selection.activeGameObject = instance; +return new { id = instance.GetInstanceID(), source = UnityEditor.PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(instance) }; +``` + +## 基础 Canvas 与布局 + +先查询并复用现有 UI。下例创建 Canvas 与居中布局容器,不依赖 uGUI/TMP,容器无可见图形。添加 `Image`、`Button`、文本或 `EventSystem` 前确认项目 UI 体系和包,避免重复事件系统。 + + +```csharp +if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("请先停止播放"); +var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); +foreach (var root in scene.GetRootGameObjects()) + if (root.name == "AGC_Guide_Canvas") throw new System.Exception("示例 Canvas 已存在"); +UnityEditor.Undo.IncrementCurrentGroup(); +int group = UnityEditor.Undo.GetCurrentGroup(); +var canvasObject = new UnityEngine.GameObject("AGC_Guide_Canvas", typeof(UnityEngine.RectTransform), typeof(UnityEngine.Canvas)); +UnityEditor.Undo.RegisterCreatedObjectUndo(canvasObject, "AGC 创建 Canvas"); +canvasObject.GetComponent().renderMode = UnityEngine.RenderMode.ScreenSpaceOverlay; +var panel = new UnityEngine.GameObject("Content", typeof(UnityEngine.RectTransform)); +UnityEditor.Undo.RegisterCreatedObjectUndo(panel, "AGC 创建布局"); +UnityEditor.Undo.SetTransformParent(panel.transform, canvasObject.transform, "AGC 设置 UI 父级"); +var rect = (UnityEngine.RectTransform)panel.transform; +rect.anchorMin = rect.anchorMax = rect.pivot = new UnityEngine.Vector2(0.5f, 0.5f); +rect.anchoredPosition = UnityEngine.Vector2.zero; +rect.sizeDelta = new UnityEngine.Vector2(320, 180); +UnityEditor.Undo.CollapseUndoOperations(group); +UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(scene); +return new { canvasId = canvasObject.GetInstanceID(), panelId = panel.GetInstanceID(), width = rect.sizeDelta.x, height = rect.sizeDelta.y }; +``` + +## 保存与打开场景 + +确认目标路径及对象所属场景,多场景时用 `go.scene` 而非默认 active scene;已有场景通常沿用 `scene.path`。`MarkSceneDirty` 不是保存;独立资源用 `SetDirty` 和 `AssetDatabase.SaveAssetIfDirty` 保存。磁盘保存不由 Undo 回滚。 + + +```csharp +if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("请先停止播放"); +var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); +var path = "Assets/AGCGuide/Guide.unity"; +if (!UnityEditor.AssetDatabase.IsValidFolder("Assets/AGCGuide")) UnityEditor.AssetDatabase.CreateFolder("Assets", "AGCGuide"); +if (!UnityEditor.SceneManagement.EditorSceneManager.SaveScene(scene, path)) throw new System.Exception("场景保存失败"); +return new { path = scene.path, dirty = scene.isDirty }; +``` + +Single 会关闭当前场景;存在未保存修改时先停下处理,不默默丢弃。要保留场景则用 `OpenSceneMode.Additive`,并明确后续目标场景。 + + +```csharp +if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) throw new System.Exception("请先停止播放"); +for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.sceneCount; i++) + if (UnityEngine.SceneManagement.SceneManager.GetSceneAt(i).isDirty) throw new System.Exception("存在未保存场景,请先处理"); +var path = "Assets/AGCGuide/Guide.unity"; +if (UnityEditor.AssetDatabase.LoadAssetAtPath(path) == null) throw new System.Exception("场景文件不存在"); +var scene = UnityEditor.SceneManagement.EditorSceneManager.OpenScene(path, UnityEditor.SceneManagement.OpenSceneMode.Single); +return new { path = scene.path, loaded = scene.isLoaded, roots = scene.rootCount }; +``` + +## 播放、停止与编译诊断 + +播放/停止在下一次 Editor update 调度,`requested` 不代表已切换,稍后查询。播放和修改脚本可能触发编译/Domain Reload 使连接失效,稳定后重连核对,不重发操作。退出播放通常不保留运行期改动。 + + +```csharp +if (UnityEditor.EditorApplication.isCompiling || UnityEditor.EditorApplication.isUpdating) throw new System.Exception("编辑器正在编译或导入"); +UnityEditor.EditorApplication.delayCall += () => { UnityEditor.EditorApplication.isPlaying = true; }; +return new { requested = "play" }; +``` + + +```csharp +UnityEditor.EditorApplication.delayCall += () => { UnityEditor.EditorApplication.isPlaying = false; }; +return new { requested = "stop" }; +``` + +状态查询不能证明编译成功。代码编译错误由工具回执返回;项目编译详情查看 Console/Editor 日志,回执不含全量 Console。不要依赖未公开的 `LogEntries` API。 + + +```csharp +return new { compiling = UnityEditor.EditorApplication.isCompiling, + importing = UnityEditor.EditorApplication.isUpdating, + playing = UnityEditor.EditorApplication.isPlaying, + changingPlayMode = UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode, + version = UnityEngine.Application.unityVersion }; +``` + +## 验证范围 + +以上 13 个代码块已从本文提取,在 Windows x64 Unity 6000.3.7f1 Mono 的独立无包依赖项目中经 AGC Attach 实测,包含修改回读、Undo、Prefab override 保存重开及播放/停止。采用 batchmode/nographics;未验收 UI 视觉、第三方包或其他 Unity 版本。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 0b9f67ba9..7a2ef73d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,7 +1,39 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.25", + "version": "2026-08-26.27", "skills": [ + { + "name": "agc-unity-editor", + "purpose": "通过 AGC 内置 Unity 插件查询和修改场景、对象、资源与 UI,正确处理撤销、保存和回执", + "triggers": [ + "操作已打开的 Unity 工程", + "编写 Unity 编辑器执行代码" + ], + "requiredTools": [ + "agc_tools.agc_unity_execute" + ], + "files": [ + "SKILL.md", + "references/【操作指南】Unity编辑器常用操作-2026-09-20.md" + ], + "sha256": "9599fa1884db9c4f3eeab20d18871d4dafc845f5ecfe0f9ac9ba7417e65062fc" + }, + { + "name": "agc-godot-editor", + "purpose": "通过 AGC 内置 Godot 插件查询和修改场景、节点、资源与 UI,正确处理 owner、撤销和回执", + "triggers": [ + "操作已打开的 Godot 工程", + "编写 Godot 编辑器执行代码" + ], + "requiredTools": [ + "agc_tools.agc_godot_execute" + ], + "files": [ + "SKILL.md", + "references/【操作指南】Godot编辑器常用操作-2026-09-20.md" + ], + "sha256": "b5d76c8685c49e0cd1b0a243a2f46f00daa1a7c8a37c5137b6f31917df9a0af0" + }, { "name": "agc-game-production-workflow", "purpose": "把完整游戏从策划案按阶段推进到真实素材接入、构建、试玩和交付", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index b33ebcf21..175e8f454 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -6092,7 +6092,7 @@ case "$extra_roots" in *'"method":"skills/extraRoots/set"'*) ;; *) exit 87 ;; es printf '%s\n' '{"id":2,"result":{}}' IFS= read -r skills_list case "$skills_list" in *'"method":"skills/list"'*) ;; *) exit 88 ;; esac -printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}' +printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}' while IFS= read -r line; do :; done "#, ) @@ -6833,7 +6833,7 @@ while IFS= read -r line; do case "$line" in *'"method":"initialize"'*) printf '{"id":%s,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}\n' "$id" ;; *'"method":"skills/extraRoots/set"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; - *'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}\n' "$id" ;; + *'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}\n' "$id" ;; *'"method":"thread/start"'*) printf '{"id":%s,"result":{"thread":{"id":"thread-echo"}}}\n' "$id" ;; *'"method":"thread/inject_items"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; *'"method":"turn/start"'*) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 79256bcd0..7b989047e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -18,6 +18,7 @@ const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“ const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。Phaser 画布居中责任唯一:使用 Phaser Scale.FIT 与 autoCenter CENTER_BOTH 时,canvas 的直接父容器用普通 block 按需要的宽高确定尺寸,不得在同一个 canvas 父容器上叠加 grid/flex 的 place-items、justify-content、align-items 居中或 margin:auto、translate 居中;若选择用 CSS 居中,则必须把 Phaser autoCenter 设为 NO_CENTER。外围布局仍可用 flex/grid,但同一个 canvas 的定位责任只能有一处。预览偏移先查项目自身的 CSS 与 Phaser 配置,不得用修改 AGC iframe 偏移来掩盖。改完布局后必须在桌面与移动视口以及 resize 后实测 canvas 相对游戏父容器的中心误差不超过 1 CSS px、无溢出,并按项目 scripts 构建 dist 后复验。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite;二维游戏 Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;用户要做三维游戏时不受 Phaser 约束,由你自选三维技术栈(例如 Three.js / Babylon.js),不要用等轴伪 3D 冒充三维。两种情况都可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_UNITY_BUILTIN_PLUGIN_GUIDANCE: &str = "Unity 编辑器能力来自客户端内置插件 agc-unity-editor,工具为 agc_unity_execute(Runtime 为 unity.editor.execute)。当前工程是 Unity 时使用该工具执行 C#,先读取实际场景与对象再修改;不安装 UPM 或项目内 MCP,不改写为 Phaser。只支持 Windows x64 Mono Editor;缺少工具时报告客户端内置插件不可用。仅提交 code;主线程同步代码无法硬中止。needs-reconciliation 表示结果待人工核对,禁止自动重发、重启插件或切换项目以绕过阻断。只有真实 completed 回执才可报告成功。"; const DIRECT_GODOT_BUILTIN_PLUGIN_GUIDANCE: &str = "Godot 编辑器能力来自客户端内置插件 agc-godot-editor,工具为 agc_godot_execute(Runtime 为 godot.editor.execute)。当前工程是 Godot 时使用该工具执行支持 return/await 的 GDScript 函数体,先读取真实场景再修改;不改写为 Phaser。DLL 随 AGC 安装目录分发,宿主只在实际 Godot 根目录维护引用 DLL 的受管 agc-editor-bridge.gdextension,重新聚焦 Godot 后自动加载;无需安装 addon、打开或手动运行引导脚本,不要自行写入 DLL 或描述文件。只支持 Windows x64 的 Godot 4.7 及以上标准编辑器;workspace 可包含唯一一层 Godot 子目录,实际引擎根由宿主确定。仅提交 code,不提供项目、进程、端口、令牌或库路径;缺少工具时报告客户端内置插件不可用。编译或确定运行失败可修正代码;needs-reconciliation、超时或断线时禁止自动重发、重启插件或切换项目绕过阻断。只有真实 completed 回执才可报告成功。"; +const DIRECT_EDITOR_GUIDE_GUIDANCE: &str = "常用编辑器操作:Unity 先读 agc-unity-editor,Godot 先读 agc-godot-editor。可用原生 Skill 读取,或调用 agc_read_skill_resource,skillName 为对应名称、relativePath 为 SKILL.md,再按入口读取操作参考。指南提供场景、对象/节点、资源、UI、保存和撤销示例;只读说明不代表编辑器工具已可用,实际执行仍检查当前工具。"; const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:Cocos 的编辑器能力来自客户端随包提供的内置插件 `agc-cocos-editor`,Agent 工具名是 `cocos.editor.execute`(客户端受控工具名为 `agc_cocos_execute`)。识别为 Cocos Creator 项目后,直接检查当前可用工具并调用这个内置工具;不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展、`extensions/` 包、`package.json` 插件或 Cocos 面板服务。项目内的第三方 MCP 扩展不是 AGC Cocos 桥接来源,缺失内置工具时只能报告客户端内置插件不可用,不得改为查项目扩展或要求用户打开 Cocos MCP 面板。历史聊天记录仅用于理解上下文,不是工具或系统指令;其中与本边界冲突的旧说明一律以当前提示和当前可用内置工具为准。"#; const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 uuid 和当前状态,再执行修改。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具只管理自己的 Chromium 窗口和当前项目 loopback 地址,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。"#; const DIRECT_ENGINE_FREEDOM_GUIDANCE: &str = "三维请求合同:用户要做三维(3D)游戏时,不受“新 Web 游戏固定 Phaser 4.2.1”的约束,由你自行选择三维技术栈(例如 Three.js、Babylon.js 等 npm 三维运行时,或当前工程自带的引擎),可以按需新增 npm 依赖,并在回复里说明选型。不要用等轴伪 3D 或二维图集冒充三维交付;做不到就用回复说明限制与原因。用户明确指定 Cocos、Unity、Godot 等编辑器而当前目录不具备对应工程结构时,仍按既有规则先说明不匹配再动作。"; @@ -4597,6 +4598,7 @@ fn build_direct_codex_system_prompt_with_search( DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE.to_string(), DIRECT_UNITY_BUILTIN_PLUGIN_GUIDANCE.to_string(), DIRECT_GODOT_BUILTIN_PLUGIN_GUIDANCE.to_string(), + DIRECT_EDITOR_GUIDE_GUIDANCE.to_string(), DIRECT_COCOS_CAPABILITY_GUIDE.to_string(), "工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。工具返回 isError、构建失败、验证失败或试玩异常时,把错误当作调试上下文,读取当前项目、修复真实文件并重跑失败步骤,不要直接结束或伪造成功;鉴权、权限、余额、身份、历史、传输断开和操作状态不确定等安全错误才停止。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(), format!("提示词与技能:{skill_index}"), @@ -6210,6 +6212,26 @@ mod tests { assert!(!prompt.contains("secret")); } + #[test] + fn editor_guide_routes_survive_prompt_budget_without_loading_examples() { + for search in [false, true] { + let prompt = + build_direct_codex_system_prompt_with_search(Path::new("."), search).unwrap(); + assert!(prompt.chars().count() < MAX_DIRECT_SYSTEM_PROMPT_CHARS); + assert!(prompt.contains(DIRECT_EDITOR_GUIDE_GUIDANCE)); + assert!(prompt.contains(DIRECT_UNITY_BUILTIN_PLUGIN_GUIDANCE)); + assert!(prompt.contains(DIRECT_GODOT_BUILTIN_PLUGIN_GUIDANCE)); + for (skill, engine) in [("agc-unity-editor", "Unity"), ("agc-godot-editor", "Godot")] { + assert!(prompt.contains(skill)); + let reference = read_agc_skill_resource(&format!( + "{skill}/references/【操作指南】{engine}编辑器常用操作-2026-09-20.md" + )) + .unwrap(); + assert!(!prompt.contains(reference.trim())); + } + } + } + #[test] fn system_prompt_does_not_preload_current_game_files() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index dd05660f4..cbc5e90a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -3009,6 +3009,22 @@ mod tests { assert_eq!(denied_windows_absolute["isError"], true); } + #[test] + fn editor_guides_are_available_through_the_existing_skill_resource_tool() { + for (skill, engine) in [("agc-unity-editor", "Unity"), ("agc-godot-editor", "Godot")] { + let relative = format!("references/【操作指南】{engine}编辑器常用操作-2026-09-20.md"); + let expected = read_agc_skill_resource(&format!("{skill}/{relative}")).unwrap(); + let response = + call_agc_read_skill_resource(&json!({"skillName":skill,"relativePath":relative})); + assert_eq!(response["isError"], false); + assert_eq!(response["content"][0]["text"], expected); + let denied = call_agc_read_skill_resource( + &json!({"skillName":skill,"relativePath":"references/not-in-manifest.md"}), + ); + assert_eq!(denied["isError"], true); + } + } + #[test] fn external_codex_response_redacts_sensitive_lines_and_keeps_safe_text() { let response = redact_external_mcp_response( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index 5c5f9e602..e0d474324 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -6,16 +6,34 @@ use std::path::{Component, Path}; const AGC_SKILL_PACK_MANIFEST: &[u8] = include_bytes!("../../resources/agc-skills/manifest.json"); const AGC_SKILL_PACK_SCHEMA_VERSION: &str = "agc-skill-pack.v1"; -pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 6] = [ +pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 8] = [ "agc-browser-playtest", "agc-client-projection", "agc-game-production-workflow", + "agc-godot-editor", "agc-project-structure", + "agc-unity-editor", "agc-web-game-development", "taonier-art-assets", ]; -const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 18] = [ +const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 22] = [ + ( + "agc-unity-editor/SKILL.md", + include_bytes!("../../resources/agc-skills/agc-unity-editor/SKILL.md"), + ), + ( + "agc-unity-editor/references/【操作指南】Unity编辑器常用操作-2026-09-20.md", + include_bytes!("../../resources/agc-skills/agc-unity-editor/references/【操作指南】Unity编辑器常用操作-2026-09-20.md"), + ), + ( + "agc-godot-editor/SKILL.md", + include_bytes!("../../resources/agc-skills/agc-godot-editor/SKILL.md"), + ), + ( + "agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md", + include_bytes!("../../resources/agc-skills/agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md"), + ), ( "agc-browser-playtest/SKILL.md", include_bytes!("../../resources/agc-skills/agc-browser-playtest/SKILL.md"), @@ -306,10 +324,10 @@ mod tests { use super::*; #[test] - fn bundled_skill_pack_is_exactly_the_six_reviewed_skills() { + fn bundled_skill_pack_matches_the_reviewed_allowlist() { let manifest = validated_skill_pack_manifest().expect("validated manifest"); assert_eq!(manifest.schema_version, "agc-skill-pack.v1"); - assert_eq!(manifest.skills.len(), 6); + assert_eq!(manifest.skills.len(), AGC_SKILL_PACK_EXPECTED_NAMES.len()); assert!(manifest.skills.iter().all(|entry| entry.sha256.len() == 64)); let serialized = serde_json::to_string( &manifest @@ -391,4 +409,29 @@ mod tests { assert!(!is_safe_skill_relative_path(r"\\server\share\SKILL.md")); assert!(!is_safe_skill_relative_path(r"references\contract.md")); } + + #[test] + fn editor_guides_are_complete_in_installed_and_readable_skill_resources() { + let home = tempfile::tempdir().unwrap(); + install_agc_skill_pack(home.path()).unwrap(); + for (skill, engine) in [("agc-unity-editor", "Unity"), ("agc-godot-editor", "Godot")] { + let resource = + format!("{skill}/references/【操作指南】{engine}编辑器常用操作-2026-09-20.md"); + let guide = read_agc_skill_resource(&resource).unwrap(); + assert!(!guide.is_empty()); + assert!( + guide.len() <= 14 * 1024, + "{engine} reference exceeds UTF-8 budget" + ); + let installed = + std::fs::read_to_string(home.path().join(".agents/skills").join(&resource)) + .unwrap(); + assert_eq!(installed, guide); + let entry = read_agc_skill_resource(&format!("{skill}/SKILL.md")).unwrap(); + assert!(entry.contains(resource.split_once('/').unwrap().1)); + assert!( + read_agc_skill_resource(&format!("{skill}/references/../../auth.json")).is_err() + ); + } + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 9d25349d5..36e30fbe1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -294,10 +294,22 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent( if !names.insert(name.clone()) { return Err(format!("Runtime 原生函数名重复:{name}")); } + let description = if let Some(reference) = editor_operation_reference(definition.id()) { + let reference = reference.replace("\r\n", "\n"); + if reference.len() > 14 * 1024 { + return Err(format!( + "Runtime 编辑器操作参考超过随包预算:{}", + definition.id() + )); + } + reference + } else { + definition.description().to_owned() + }; functions.push( LlmFunctionTool::new( name, - definition.description(), + description, action_function_parameters(definition.input_schema().clone()), ) .with_strict(true), @@ -1004,6 +1016,14 @@ fn string_array_schema(max_items: usize) -> Value { }) } +fn editor_operation_reference(tool: &str) -> Option<&'static str> { + match tool { + "unity.editor.execute" => Some(include_str!("../resources/agc-skills/agc-unity-editor/references/【操作指南】Unity编辑器常用操作-2026-09-20.md")), + "godot.editor.execute" => Some(include_str!("../resources/agc-skills/agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md")), + _ => None, + } +} + fn runtime_tool_description(tool: &str) -> &'static str { match tool { "user.input_request" => "向用户提出一至三个结构化问题,并暂停当前 run 等待回答。", @@ -1056,8 +1076,8 @@ fn runtime_tool_description(tool: &str) -> &'static str { "cocos.editor.execute" => { "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。" } - "unity.editor.execute" => "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。", - "godot.editor.execute" => "在当前 Godot 项目已打开的 Windows x64 标准编辑器中执行支持 return/await 的 GDScript 函数体。DLL 原件保留在安装目录,宿主在私有缓存准备每实例加载副本,受管描述文件引用该副本,重新聚焦后自动加载;仅提交 code,结果待核对时禁止自动重发。", + "unity.editor.execute" => "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。执行载荷仅有 code,宿主绑定项目身份;结果待核对时禁止自动重发。", + "godot.editor.execute" => "在当前 Godot 项目已打开的 Windows x64 标准编辑器中执行支持 return/await 的 GDScript 函数体。执行载荷仅有 code,重新聚焦可触发首次加载;结果待核对时禁止自动重发。", "blackboard.write" => "向项目级共享黑板追加稳定结论。", "agent.message" => "向一个目标 Agent 写入定向上下文消息。", "agent.delegate" => { @@ -1927,6 +1947,63 @@ mod tests { assert_eq!(schema["properties"].as_object().unwrap().len(), 1); } + #[test] + fn editor_guides_reach_native_tool_definitions_without_truncation() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + for id in [ + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID, + crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID, + ] { + crate::builtin_plugins::set_enabled(id, true).unwrap(); + } + let functions = build_agent_runtime_native_function_tools().unwrap(); + for (engine, skill, tool) in [ + ("Unity", "agc-unity-editor", "unity.editor.execute"), + ("Godot", "agc-godot-editor", "godot.editor.execute"), + ] { + let reference = crate::agent::read_agc_skill_resource(&format!( + "{skill}/references/【操作指南】{engine}编辑器常用操作-2026-09-20.md" + )) + .unwrap(); + assert_eq!( + editor_operation_reference(tool) + .unwrap() + .replace("\r\n", "\n"), + reference + ); + let emitted = functions + .iter() + .find(|function| function.name == native_runtime_function_name_for_tool(tool)); + let expected = match engine { + "Unity" => cfg!(all( + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" + )), + "Godot" => cfg!(all( + windows, + target_arch = "x86_64", + feature = "godot-editor-execute" + )), + _ => false, + }; + assert_eq!(emitted.is_some(), expected); + if let Some(function) = emitted { + let wire = serde_json::to_value(function).unwrap(); + assert_eq!( + wire["description"].as_str().unwrap().replace("\r\n", "\n"), + reference + ); + } else { + assert!(!functions + .iter() + .any(|function| function.description.contains(&reference))); + } + } + } + #[test] fn strict_native_function_schemas_match_openai_subset() { let functions = diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e6dd1189f..6c9caa3ea 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,9 @@ # 决策记录 +## Unity 与 Godot 常用操作指导 + +两种编辑器的操作指导复用客户端审核 Skill pack:DirectProject 通过原生 Skill 或既有审核资源读取入口按需取得,Agent Runtime 的对应执行工具说明嵌入同源参考。指南不改变插件可用性、执行授权或 Runner 回执;只读说明不能证明编辑器已连接。常用示例与执行失败/部分修改、保存、撤销边界在同一参考中维护,避免提示词和文档各存一份代码。 + ## 2026-09-20 Godot 编辑器执行接入 Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和不确定执行回执合同,编辑器实现留在 `plugins/agc-godot-editor`。用户选择 DLL 原件随 AGC 安装资源分发,并确认按编辑器实例在 AGC 私有缓存准备临时加载副本,以满足 Godot Windows 加载器的同目录 `~DLL` 写入要求;项目内不复制 DLL,只用受管 `.gdextension` 引导。Godot 自动 UID 伴生文件必须记录归属并在确认卸载后按内容匹配清理。工作区根不迁移到 Godot 子目录,原始项目配置与场景只通过明确编辑操作修改。完整合同及验证范围见 [Godot 编辑器插件接入](<../../technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>)。 diff --git a/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md b/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md index 31022fd09..c2becfe13 100644 --- a/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md +++ b/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md @@ -7,6 +7,12 @@ ## 目标与边界 +常用操作指导随客户端审核 Skill pack 提供,入口为 `agc-godot-editor`。DirectProject 按需读取 [Skill 入口](../../apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-godot-editor/SKILL.md) 和其常用操作参考;Runtime 的 `godot.editor.execute` 工具说明包含同一参考正文。指导覆盖场景/节点、owner、PackedScene、资源、UI、保存与撤销,不新增专用操作工具,不改变执行授权。 + +原文示例在 Godot 4.7.2 标准版 headless 工程验证了节点回读、局部撤销、PackedScene 存读、居中 UI 结构、无缩略图保存重开及只读旧文件写失败时保留内存修改。`save_scene_as` 不返回错误码,指南在重开前核验磁盘包含本次预期变更,不能以旧文件可加载作为保存成功证据。复验时设置 `AGC_GODOT_TEST_EXECUTABLE`,运行 `node --test plugins/agc-godot-editor/native/gdextension/tests/guide-examples.test.mjs`。正式 Ctrl+Z 历史、运行中停止、GUI 缩略图保存及 UI 视觉仍未由该测试验收。 + +独立图形环境补验已确认该示例在 800×600、480×800、1280×720 三种实际渲染尺寸下中文和按钮正常显示、容器居中且无裁切;此结果只覆盖示例布局,不代表按钮已接入游戏逻辑或其他 UI 已验收。 + 将 Godot 编辑器操控接入现有 AGC PluginHost、EditorAdapter、Runner、内置插件开关、权限审计和 Agent 工具链。Windows x64 的 Godot 4.7 及以上标准编辑器是首个实现目标,实机验收使用 4.7.2;其他平台和 .NET 编辑器不得从该结果推断支持。 初版工程路径支持 Windows 本地盘符目录;UNC/网络共享路径在准备描述文件前明确拒绝。链接/reparse point 继续按同一文件边界失败关闭。 diff --git a/docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md b/docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md index 3cb49fcbe..67cdb2938 100644 --- a/docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md +++ b/docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md @@ -3,10 +3,14 @@ > 文档状态:`current` > 规范关系:承接 AGC 通用插件宿主与编辑器适配主规范 -更新时间:`2026-09-18` +更新时间:`2026-09-20` ## 目标与非目标 +常用操作指导随客户端审核 Skill pack 提供,入口为 `agc-unity-editor`。DirectProject 按需读取 [Skill 入口](../../apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-unity-editor/SKILL.md) 和其常用操作参考;Runtime 的 `unity.editor.execute` 工具说明包含同一参考正文。指导覆盖查询、对象/组件、Prefab、资源、UI、保存与撤销,不新增专用操作工具,不改变执行授权。 + +指南示例在独立 Unity 6000.3.7f1 Mono 工程经真实 Attach 验证,覆盖查询、创建/修改、Undo、Prefab override 保存重开、Canvas 及播放切换;不据此推断 UI 视觉、第三方包或其他版本已验收。复验入口为 `native/unity-editor-bridge/tests/guide_examples.rs`:按文件头显式设置 fixture 的 helper、项目与 PID 环境变量后,运行 `cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml --test guide_examples -- --ignored --nocapture`;它直接提取随包指南代码,而非维护示例副本。 + 将 DotCraft.Unity 0.4.3 对应的 Attach 执行核心接入 AGC 现有插件系统,使当前 Unity 项目能够探测编辑器、建立连接、执行 C# 并获得真实结果。复用既有扩展列表、内置插件开关、权限、审计、EditorAdapter 和 Agent 工具通路。 首期只支持 Windows x64 的 Unity Mono Editor。连接不修改项目文件、不安装 UPM 包、不启动或关闭用户编辑器。不引入 DotCraft.Harness、另一套 Agent Runtime、MCP 服务或聊天界面;截图、热重载专用工具、macOS、Linux 和 Unity CoreCLR 不属于本次交付。 diff --git a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md index 0f5cd9bd8..ade9f8249 100644 --- a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md +++ b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md @@ -4,7 +4,7 @@ > 规范关系:AGC 插件与编辑器适配主规范 > 验收范围:插件 manifest、宿主生命周期、RPC、Capability/权限审计、UI 挂载和编辑器适配器边界 -更新时间:`2026-09-18` +更新时间:`2026-09-20` ## 目标与边界 @@ -126,8 +126,27 @@ Windows x64 的 Attach helper 来源、构建工具链和执行回执合同见 Godot 使用同一 Runner 执行与回执确认层,按引擎分别保存 pending/uncertain 状态,不能相互确认或清除。`godot-editor` 的受控连接允许按用户已选方案维护项目内 `.gdextension` 引用及 Godot 自动生成的 UID;DLL 随安装资源分发,探测仍只读,原始项目配置和场景不改。具体文件归属、GDScript 错误/async、升级卸载与分发合同见 [Godot 插件接入](<./【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>)。 +## 编辑器常用操作指导 + +Unity 与 Godot 的常用操作指导由客户端既有审核 Skill pack 随包提供;每个引擎有独立入口和常用操作参考,不新增执行工具或任意文件读取入口。DirectProject 在隔离 Skill 目录发现指导,也可通过已有 `agc_read_skill_resource` 按审核名称和相对路径读取。Agent Runtime 的对应执行工具说明包含同一份常用操作参考,避免只覆盖 Codex 原生 Skill 路径。正文只有一个源码来源,清单指纹与安装投影必须一致。 + +指导覆盖场景/层级查询、对象或节点的创建/修改/删除、组件或属性、Prefab/PackedScene、资源引用、基础 UI、打开/保存场景、运行/停止及诊断。示例是提交给现有 execute 的代码正文,说明前置状态、预期回读和保存/撤销语义,不写宿主路径、PID、令牌或桥接安装动作。读说明不连接编辑器、不授权修改;执行仍受原插件开关、平台、项目和权限门禁控制。 + +每个引擎的操作参考不超过 14 KiB UTF-8,并独立包含执行参数和失败边界;Direct 常驻提示只给读取路由,16K 字符预算内必须保留两个引擎的指南入口。Runtime 的最终工具定义须完整包含对应参考,不能因截断丢失末尾内容,也不能在该执行工具不可用时额外注入正文。 + +通用 CapabilityRegistry 保留原有短描述和 4000 字符约束;完整参考只在已注册能力转换为 Provider 函数工具时附加,按 UTF-8/LF 规范化并校验 14 KiB 上限。不扩大 core 的任务、能力或摘要长度合同。 + +执行载荷仅有 `code`;Direct 工具的顶层参数为 `{code}`,Runtime 原生函数沿用 `{reason,input:{code}}` 外层,指南必须按实际工具 schema 区分这两种调用格式。 + +确定失败也可能已经产生部分修改;未知结果继续禁止自动重放。Unity 使用实际场景与对象身份,区分 Undo、Prefab override、保存和 Domain Reload。Godot 使用真实编辑场景根、为需保存的新节点设置 owner,区分独立 UndoRedo 回滚与编辑器历史,避免承诺未验证的 Ctrl+Z。主线程同步死循环不可硬中止。 + +验收要求:两个入口均能取得审核正文,源码与安装后的字节一致;非法路径及未登记资源继续拒绝;系统提示能够发现指南但不塞入全部示例;从指南原文提取代码做真实临时工程验证,至少覆盖查询、修改与回读、局部撤销、场景持久化、资源实例化和 UI。未实测操作和平台须在交付记录中明确,不把 API 示例当作已有独立工具。 + +当前证据覆盖本地指南读取/安装、最终工具描述、真实编辑器执行示例及 Godot 示例图形渲染。Unity GUI 视觉和真实 Provider 读取指南后调用编辑器的端到端链路尚未验收,不能由定向测试或前置状态检查推断通过。 + ## Tauri 命令 + `list_agc_extensions` 返回统一的 Plugin/Skill/MCP catalog;`list_agc_plugins`、`refresh_agc_plugins`、`start_agc_plugin`、`stop_agc_plugin`、`reload_agc_plugin`、`call_agc_plugin` 和 `read_agc_plugin_panel` 提供 Runtime Plugin 管理入口;`set_agc_plugin_project_path` 设置当前项目的受控上下文。编辑器适配器通过宿主 registry 和 Plugin RPC 使用,不增加编辑器专属 Tauri 命令。 编辑器操作统一走 `host.rpc`:插件用 `extensions.world.genarrative.agc.adapter` 或显式 `adapter` 参数选择适配器,宿主校验 `editor.rpc` 权限后调用 `EditorAdapter::rpc`。项目上下文通过 `host.events.subscribe` 的响应和 `project.changed` 事件 payload 下发,插件不需要自己扫描目录。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8f08efafe..7261b3ee3 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1377,7 +1377,7 @@ game-project/ - 普通项目对话只由一个 project-bound Codex app-server thread 执行。客户端系统提示词只放最小工程合同、当前游戏源码有界快照、项目 prompts 和审核 Skill 索引;不再批量读取项目 `.codex/.agents` Skill 正文,也不恢复 Supervisor、专业 Agent 或 harness。 - 首页恢复“做游戏 / 做素材 / 做方案”三个创作类型,默认“做游戏”。该选择与设置页的 Agent Runtime 模式无关;每次首页提交仍只自动创建一个新项目并进入项目工作台。用户正文原样进入项目对话,`game|art|doc` 仅作为受限结构化首轮上下文传给同一 Codex thread,不拼接“初始意图”文案、不产生首页对话、不切换 Provider 或恢复旧 Runtime 编排。 -- `agc-skill-pack.v1` 只包含项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影五项 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;审核文本按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免混合换行造成 Windows / Linux 构建结果漂移,语义内容变化时必须同步重算对应清单指纹并提升版本。同步统一运行 `npm run agc:skill-pack:sync`,只读校验由 AGC `typecheck` 和 release build 自动执行,发现漂移时直接列出 Skill 与实际摘要,不让失配内容进入构建产物。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。 +- `agc-skill-pack.v1` 包含完整游戏交付流程、项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影,以及 Unity/Godot 编辑器常用操作八项审核 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;审核文本按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免混合换行造成 Windows / Linux 构建结果漂移,语义内容变化时必须同步重算对应清单指纹并提升版本。同步统一运行 `npm run agc:skill-pack:sync`,只读校验由 AGC `typecheck` 和 release build 自动执行,发现漂移时直接列出 Skill 与实际摘要,不让失配内容进入构建产物。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。 - DirectProject 始终连接客户端内置的 `agc_tools` STDIO MCP;2026-08-31 起还会在启动时接入客户端扩展仓库中用户已启用的独立第三方 STDIO/HTTP MCP 配置,但不读取用户全局 Codex MCP、不开启完整 Plugin Runtime。内置工具固定为审核引用读取、标准陶泥儿美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive 语义生成、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`。内置 MCP 进程只做协议;真实浏览器、付费 External v1 调用与受控搜索通过随机 loopback 地址回到客户端主进程,因此不复制 GUI 登录态、开发者 Key、项目路径、revision、operation 或幂等键到模型上下文。内置与用户启用的第三方 MCP 工具都沿用 DirectProject 自动批准方式,但付费资源工具仍由客户端绑定稳定回合身份、限制单回合请求数、串行执行并优先恢复匹配账本;通用 shell、Codex 原生 webSearch、任意原生命令网络、多 Agent 和完整插件能力继续关闭。`llm.webSearchEnabled` 只控制 DirectProject 的 AGC 受控搜索工具暴露与执行,Codex 原生 `web_search` 始终保持 disabled;Provider、ToolHost、DirectHome 不纳入本次联网主链路。 - 陶泥儿生成继续复用持久幂等账本、operation 恢复、来源/下载/PNG 解码和 manifest 登记;普通客户端优先使用当前 AGC 登录会话及账号路由,只有受控的 ExternalDeveloper 发布模式才在客户端内部使用按服务器 origin 隔离的私有 Key。用户和模型都不需要提供或配置 API Key;凭据失效、来源不明或结果未知时失败关闭,不能自动换 Key 或重新扣费。 - 自定义 LLM API Key 路由只在 DirectHome/DirectProject 经 loopback `/responses` 流式代理转发。代理不注入 Key,只要求请求自带 Bearer,并剥离开发网关错误携带的 `X-Codex-*` ChatGPT 账户额度头,防止隔离 app-server 把 API Provider 误判为余额 0;旧 ToolHost 保持原 Provider 行为。 diff --git a/plugins/agc-godot-editor/native/gdextension/tests/guide-examples.test.mjs b/plugins/agc-godot-editor/native/gdextension/tests/guide-examples.test.mjs new file mode 100644 index 000000000..f405c07a8 --- /dev/null +++ b/plugins/agc-godot-editor/native/gdextension/tests/guide-examples.test.mjs @@ -0,0 +1,380 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const nativeRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const repoRoot = path.resolve(nativeRoot, '../../../..'); +const guidePath = path.join( + repoRoot, + 'apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-godot-editor/references/【操作指南】Godot编辑器常用操作-2026-09-20.md', +); +const guide = fs.readFileSync(guidePath, 'utf8'); +const examples = new Map( + [ + ...guide.matchAll( + /\r?\n```gdscript\r?\n([\s\S]*?)\r?\n```/g, + ), + ].map((match) => [match[1], match[2]]), +); +const executable = process.env.AGC_GODOT_TEST_EXECUTABLE; +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function until(predicate, duration = 15000) { + const end = Date.now() + duration; + while (Date.now() < end) { + const value = predicate(); + if (value) return value; + await pause(25); + } + throw Error('Godot guide fixture did not become ready'); +} + +function request(session, method, params = {}) { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ + host: '127.0.0.1', + port: session.port, + }); + let data = ''; + socket.setTimeout(10000, () => + socket.destroy(Error('Guide execution receipt timed out')), + ); + socket.once('error', reject); + socket.once('connect', () => + socket.write( + `${JSON.stringify({ + protocol: session.protocol, + id: 1, + generation: session.generation, + token: session.token, + method, + params, + })}\n`, + ), + ); + socket.on('data', (chunk) => { + data += chunk; + const newline = data.indexOf('\n'); + if (newline < 0) return; + try { + const reply = JSON.parse(data.slice(0, newline)); + assert.equal(reply.protocol, session.protocol); + assert.equal(reply.generation, session.generation); + assert.equal(reply.pid, session.pid); + assert.equal(reply.buildId, session.buildId); + assert.equal( + path.resolve(reply.projectPath).toLowerCase(), + path.resolve(session.projectPath).toLowerCase(), + ); + resolve(reply.result); + } catch (error) { + reject(error); + } + socket.end(); + }); + socket.once('end', () => { + if (!data.includes('\n')) reject(Error('Godot exited without a receipt')); + }); + }); +} + +test('Godot guide examples are unique, extractable, and within the runtime read budget', () => { + assert.ok(Buffer.byteLength(guide, 'utf8') <= 14 * 1024); + assert.equal([...guide.matchAll(/\n```csharp\n"); + let (_, after) = guide.split_once(&marker).expect("指南缺少示例"); + after + .split_once("\n```") + .expect("示例代码未闭合") + .0 + .to_string() +} + +fn required(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| panic!("显式设置 {name} 后才能运行实机示例")) +} + +struct Disconnect; +impl Drop for Disconnect { + fn drop(&mut self) { + disconnect_unity_editor(); + } +} + +#[test] +#[ignore = "需要指定独立临时 Unity fixture;修改演示场景、资源并验证 Undo 和保存"] +fn execute_documented_unity_examples_in_owned_fixture() { + let project = required("AGC_UNITY_SMOKE_PROJECT"); + let project_path = PathBuf::from(&project); + assert!(project_path.join(".agc-guide-fixture").is_file()); + let pid = required("AGC_UNITY_SMOKE_PID").parse::().unwrap(); + let adapter = UnityEditorAdapter::new(vec![PathBuf::from(required("AGC_UNITY_SMOKE_HELPER"))]); + let _disconnect = Disconnect; + let connected = adapter + .rpc("connect", json!({"projectPath":project,"processId":pid})) + .unwrap(); + assert_eq!(connected["connected"], true, "{connected}"); + + let execute = |label: &str, code: &str| -> Value { + let reply = adapter + .rpc( + "execute", + json!({"projectPath":project,"processId":pid,"code":code}), + ) + .unwrap(); + println!("{}", json!({"example":label,"reply":reply})); + assert_eq!(reply["status"], "completed", "{label}: {reply}"); + reply["result"].clone() + }; + let run = |name: &str| execute(name, &example(name)); + execute("reset_owned_fixture_scene", "UnityEditor.SceneManagement.EditorSceneManager.NewScene(UnityEditor.SceneManagement.NewSceneSetup.EmptyScene, UnityEditor.SceneManagement.NewSceneMode.Single); return true;"); + run("inspect"); + assert_eq!(run("create")["collider"], true); + assert_eq!(run("modify")["colliderX"].as_f64(), Some(2.0)); + let read = run("inspect"); + assert!(read["nodes"] + .as_array() + .unwrap() + .iter() + .any(|node| node["path"] == "AGC_Guide_Object" && node["x"].as_f64() == Some(1.0))); + run("undo"); + assert_eq!( + execute( + "read_undo", + "return UnityEditor.Selection.activeGameObject.transform.localPosition.x;" + ) + .as_f64(), + Some(0.0) + ); + assert_eq!(run("remove_component")["removed"], true); + assert_eq!(run("undo")["collider"], true); + assert_eq!(run("save")["dirty"], false); + assert_eq!(run("open")["loaded"], true); + assert_eq!(run("inspect")["scene"], "Assets/AGCGuide/Guide.unity"); + + execute("prepare_prefab_fixture", "var go = new UnityEngine.GameObject(\"GuidePrefab\"); try { var saved = UnityEditor.PrefabUtility.SaveAsPrefabAsset(go, \"Assets/AGCGuide/Guide.prefab\"); return saved != null; } finally { UnityEngine.Object.DestroyImmediate(go); }"); + assert!(run("assets")["assets"] + .as_array() + .unwrap() + .iter() + .any(|asset| asset["path"] == "Assets/AGCGuide/Guide.prefab")); + assert_eq!(run("prefab")["source"], "Assets/AGCGuide/Guide.prefab"); + run("modify"); + assert_eq!(execute("read_prefab_override", "return UnityEditor.PrefabUtility.HasPrefabInstanceAnyOverrides(UnityEditor.Selection.activeGameObject, false);"), true); + let canvas = run("canvas"); + assert_eq!(canvas["width"].as_f64(), Some(320.0)); + assert_eq!(canvas["height"].as_f64(), Some(180.0)); + let ui = execute("read_canvas", "var go = UnityEngine.GameObject.Find(\"AGC_Guide_Canvas/Content\"); var rect = go.GetComponent(); return new { width = rect.sizeDelta.x, height = rect.sizeDelta.y, parent = rect.parent.name }; "); + assert_eq!(ui["width"].as_f64(), Some(320.0)); + assert_eq!(ui["height"].as_f64(), Some(180.0)); + assert_eq!(ui["parent"], "AGC_Guide_Canvas"); + run("undo"); + assert_eq!( + execute( + "read_canvas_undo", + "return UnityEngine.GameObject.Find(\"AGC_Guide_Canvas\") == null;" + ), + true + ); + run("canvas"); + run("save"); + run("open"); + let reopened = run("inspect"); + assert!(reopened["nodes"] + .as_array() + .unwrap() + .iter() + .any(|node| node["path"] == "AGC_Guide_Canvas/Content")); + let persisted = execute("read_prefab_after_reopen", "foreach (var go in UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects()) { if (UnityEditor.PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(go) == \"Assets/AGCGuide/Guide.prefab\") return new { overrideExists = UnityEditor.PrefabUtility.HasPrefabInstanceAnyOverrides(go, false), x = go.transform.localPosition.x, colliderX = go.GetComponent().size.x }; } throw new System.Exception(\"Prefab instance missing\");"); + assert_eq!(persisted["colliderX"].as_f64(), Some(2.0)); + assert_eq!(persisted["x"].as_f64(), Some(1.0)); + assert_eq!(persisted["overrideExists"], true); + assert_eq!(run("diagnostics")["playing"], false); + assert_eq!(run("play")["requested"], "play"); + std::thread::sleep(std::time::Duration::from_secs(3)); + let reconnected = adapter + .rpc("connect", json!({"projectPath":project,"processId":pid})) + .unwrap(); + assert_eq!(reconnected["connected"], true, "{reconnected}"); + assert_eq!(run("diagnostics")["playing"], true); + assert_eq!(run("stop")["requested"], "stop"); + std::thread::sleep(std::time::Duration::from_secs(2)); + let reconnected = adapter + .rpc("connect", json!({"projectPath":project,"processId":pid})) + .unwrap(); + assert_eq!(reconnected["connected"], true, "{reconnected}"); + assert_eq!(run("diagnostics")["playing"], false); + println!("Unity 指南 13 个原文示例:查询、创建、修改、组件删除与撤销、资源查找、Prefab override、Canvas 撤销、保存重开、播放/停止及状态诊断通过。"); +}