合并 Godot 编辑器插件与常用操作指导到主分支

接入 Godot 原生桥、受控执行、Runner 回执与编辑器操作指南
保留主分支 Cocos 和 Unity 跨工程能力及外置提示词结构
解决插件生命周期、工具目录、前端启动和文档合并冲突
This commit is contained in:
kdletters
2026-09-20 17:36:31 +08:00
93 changed files with 12873 additions and 543 deletions
+9
View File
@@ -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>)。
@@ -0,0 +1,2 @@
/bin/
/.build/
@@ -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
}
@@ -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)
@@ -0,0 +1,257 @@
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
#include <string.h>
#include <wchar.h>
#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;
}
@@ -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(
/<!-- example:([a-z-]+) -->\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(/<!-- example:/g)].length, examples.size);
assert.equal([...guide.matchAll(/```gdscript/g)].length, examples.size);
assert.deepEqual(
[...examples.keys()],
[
'query',
'create-node',
'update-node',
'delete-node',
'local-undo',
'pack-resource',
'instance-resource',
'create-ui',
'save-reopen',
'stop-play',
'diagnostics',
],
);
});
test(
'real headless Godot executes unchanged examples from the shipped guide',
{
skip: !executable,
timeout: 90000,
},
async (t) => {
assert.equal(process.platform, 'win32');
const fixture = path.join(nativeRoot, '.build', `guide-${Date.now()}`);
const project = path.join(fixture, 'project');
const cache = path.join(fixture, 'native-cache');
fs.mkdirSync(project, { recursive: true });
fs.mkdirSync(cache);
const projectText =
'config_version=5\n[application]\nconfig/name="AGC Godot Guide Fixture"\nrun/main_scene="res://main.tscn"\n[rendering]\nrenderer/rendering_method="gl_compatibility"\n';
fs.writeFileSync(path.join(project, 'project.godot'), projectText);
fs.writeFileSync(
path.join(project, 'main.tscn'),
'[gd_scene format=3]\n\n[node name="GuideRoot" type="Node2D"]\n\n[node name="Existing" type="Node2D" parent="."]\n',
);
const dll = path.join(cache, 'agc_godot_editor.dll');
fs.copyFileSync(
path.join(nativeRoot, 'bin/win-x64/agc_godot_editor.dll'),
dll,
);
const metadata = JSON.parse(
fs.readFileSync(
path.join(nativeRoot, 'bin/win-x64/metadata.json'),
'utf8',
),
);
fs.writeFileSync(
path.join(project, '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 args = [
'--headless',
'--quiet',
'--editor',
'--path',
project,
'--log-file',
path.join(fixture, 'editor.log'),
'res://main.tscn',
];
const child = spawn(executable, args, {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
const ownedPid = child.pid;
const exit = once(child, 'exit');
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk;
});
child.stderr.on('data', (chunk) => {
output += chunk;
});
fs.writeFileSync(
path.join(fixture, 'launch.json'),
JSON.stringify({ executable, args, pid: ownedPid }, null, 2),
);
const receipts = [];
let session;
try {
const sessionPath = path.join(
project,
'.godot/agc',
`editor-bridge-${ownedPid}.json`,
);
session = await until(() => {
if (child.exitCode !== null)
throw Error(`Owned Godot fixture exited: ${output}`);
try {
return JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
} catch {
return false;
}
});
assert.equal(session.pid, ownedPid);
assert.equal(session.buildId, metadata.buildId);
const execute = (code) =>
request(session, 'execute', { code, timeoutMs: 5000 });
const run = async (name) => {
assert.ok(examples.has(name), `Missing example ${name}`);
const receipt = await execute(examples.get(name));
receipts.push({ example: name, receipt });
assert.equal(
receipt.ok,
true,
JSON.stringify({ example: name, receipt }),
);
return receipt.result;
};
const selection = await execute(
'var root := EditorInterface.get_edited_scene_root()\nassert(root != null)\nEditorInterface.get_selection().clear()\nEditorInterface.get_selection().add_node(root.get_node("Existing"))\nreturn true',
);
assert.equal(selection.ok, true, JSON.stringify(selection));
await t.test(
'query returns the edited scene, real hierarchy and selected relative path',
async () => {
const result = await run('query');
assert.equal(result.scene, 'res://main.tscn');
assert.deepEqual(result.selected, ['Existing']);
assert.deepEqual(result.nodes, [
{ path: '.', type: 'Node2D' },
{ path: 'Existing', type: 'Node2D' },
]);
},
);
await t.test(
'create and property changes return actual node values',
async () => {
assert.deepEqual(await run('create-node'), {
path: 'AGCGuideMarker',
position: [12, 24],
owned: true,
});
assert.deepEqual(await run('update-node'), {
path: 'AGCGuideMarker',
position: [24, 48],
});
},
);
await t.test(
'local UndoRedo performs and reverts the property change',
async () => {
assert.deepEqual(await run('local-undo'), {
changed: [80, 90],
restored: [24, 48],
matches: true,
});
},
);
await t.test(
'PackedScene writes owned children and instantiates the resource',
async () => {
assert.deepEqual(await run('pack-resource'), {
path: 'res://agc_guide_piece.tscn',
saved: true,
});
assert.match(
fs.readFileSync(path.join(project, 'agc_guide_piece.tscn'), 'utf8'),
/name="Anchor"/,
);
assert.deepEqual(await run('instance-resource'), {
path: 'AGCGuidePiece',
source: 'res://agc_guide_piece.tscn',
has_anchor: true,
});
},
);
await t.test(
'Control and Container UI is owned by the saved scene',
async () => {
assert.deepEqual(await run('create-ui'), {
path: 'AGCGuideHUD',
title: '关卡目标',
button: '开始',
anchors: [0, 0, 1, 1],
owned: [true, true, true, true, true],
});
},
);
await t.test(
'read-only old scene cannot discard unsaved edits by reloading stale disk content',
async () => {
const sceneFile = path.join(project, 'main.tscn');
const oldDisk = fs.readFileSync(sceneFile, 'utf8');
const identityCode =
'var root := EditorInterface.get_edited_scene_root()\nvar marker := root.get_node("AGCGuideMarker") as Node2D\nreturn {"id": str(root.get_instance_id()), "position": [marker.position.x, marker.position.y], "has_ui": root.has_node("AGCGuideHUD/Center/Column/Title")}';
const before = await execute(identityCode);
assert.equal(before.ok, true);
fs.chmodSync(sceneFile, 0o444);
try {
const receipt = await execute(examples.get('save-reopen'));
receipts.push({ example: 'save-reopen-read-only', receipt });
if (receipt.ok) {
assert.equal(receipt.result.reloaded, false);
} else {
assert.equal(receipt.error.code, 'godot_runtime_error');
}
const after = await execute(identityCode);
receipts.push({
example: 'read-only-memory-verification',
receipt: after,
});
assert.equal(after.ok, true, JSON.stringify(after));
assert.deepEqual(after.result, before.result);
assert.equal(fs.readFileSync(sceneFile, 'utf8'), oldDisk);
} finally {
fs.chmodSync(sceneFile, 0o666);
}
},
);
await t.test(
'save and reload retain the instance and UI hierarchy on disk and in editor',
async () => {
assert.deepEqual(await run('save-reopen'), {
scene: 'res://main.tscn',
saved: true,
reloaded: true,
has_piece: true,
has_ui: true,
});
const disk = fs.readFileSync(path.join(project, 'main.tscn'), 'utf8');
assert.match(disk, /agc_guide_piece\.tscn/);
assert.match(disk, /name="Title"/);
assert.match(disk, /position = Vector2\(24, 48\)/);
},
);
await t.test(
'diagnostics and stop when already stopped have faithful results',
async () => {
const diagnostics = await run('diagnostics');
assert.equal(diagnostics.editor, true);
assert.equal(diagnostics.scene, 'res://main.tscn');
assert.equal(diagnostics.playing, false);
assert.ok(diagnostics.open_scenes.includes('res://main.tscn'));
assert.deepEqual(await run('stop-play'), {
was_playing: false,
playing: false,
});
},
);
await t.test('delete removes only the selected guide node', async () => {
assert.deepEqual(await run('delete-node'), { removed: true });
const after = await run('query');
assert.ok(!after.nodes.some((node) => node.path === 'AGCGuideMarker'));
assert.ok(after.nodes.some((node) => node.path === 'Existing'));
assert.ok(
after.nodes.some(
(node) => node.path === 'AGCGuideHUD/Center/Column/Title',
),
);
});
assert.equal(
fs.readFileSync(path.join(project, 'project.godot'), 'utf8'),
projectText,
);
assert.equal((await request(session, 'shutdown')).accepted, true);
await until(() => !fs.existsSync(sessionPath));
} finally {
// This retained ChildProcess is the editor launched above, never a discovered user process.
assert.equal(child.pid, ownedPid);
if (child.exitCode === null) child.kill();
await Promise.race([exit, pause(5000)]);
fs.writeFileSync(path.join(fixture, 'editor-output.log'), output);
fs.writeFileSync(
path.join(fixture, 'receipts.json'),
JSON.stringify(receipts, null, 2),
);
fs.writeFileSync(
path.join(fixture, 'cleanup.json'),
JSON.stringify(
{
pid: ownedPid,
exited: child.exitCode !== null || child.signalCode !== null,
},
null,
2,
),
);
assert.ok(
child.exitCode !== null || child.signalCode !== null,
'Owned fixture editor must exit',
);
}
},
);
@@ -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);
}
},
);
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -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."
}
@@ -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"
@@ -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]
@@ -0,0 +1,461 @@
//! 仅操作调用者拥有、已打开且可丢弃的 Godot fixture,不启动或关闭编辑器。
//! 用法:install_location_smoke <workspace> <pid> <old-dll> <new-dll> <outside-cache> --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<PathBuf, String> {
no_links(path)?;
fs::canonicalize(path).map_err(|error| format!("fixture 路径不可访问:{error}"))
}
fn canonical_cache(path: &Path) -> Result<PathBuf, String> {
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<String, String> {
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<PathBuf, String> {
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<String, String>,
) -> 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<BTreeMap<String, String>, 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<SourceSnapshot, String> {
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<ObservedDescriptor, String> {
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<Value, String> {
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<String> = 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(())
}
@@ -0,0 +1,85 @@
//! 只连接调用者明确指定的、已经打开的可丢弃 fixture,不启动或关闭 Godot。
//! 首次引导或重连时,已处于前台的目标窗口会短暂最小化并恢复,以触发 Godot 的 FocusIn 扫描。
//! 用法:cargo run --example live_smoke -- <fixture-workspace> <pid> <packaged-dll> <private-cache-dir> --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<String> = 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::<u64>()
.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(())
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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<String> {
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<Option<String>, 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<u32>,
deadline: Instant,
) -> Result<ProcessIdentity, String> {
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<Candidate> =
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<Vec<String>, String> {
let wide: Vec<u16> = 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<PathBuf> {
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<bool, String> {
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<HMODULE> = 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::<HMODULE>()) as u32,
&mut needed,
LIST_MODULES_ALL,
) == 0
{
last_error = GetLastError();
} else {
let count = needed as usize / std::mem::size_of::<HMODULE>();
if count > 8192 || needed as usize % std::mem::size_of::<HMODULE>() != 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<bool, String> {
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::<WINDOWPLACEMENT>() 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<u32>, _: Instant) -> Result<ProcessIdentity, String> {
Err("Godot 原生桥仅支持 Windows x64".into())
}
#[cfg(not(windows))]
pub fn started(_: u32) -> Result<Option<String>, String> {
Err("Godot 原生桥仅支持 Windows x64".into())
}
#[cfg(not(windows))]
pub fn module_loaded(_: &ProcessIdentity, _: &crate::files::Descriptor) -> Result<bool, String> {
Err("Godot 原生桥仅支持 Windows x64".into())
}
#[cfg(not(windows))]
pub fn focus(_: &ProcessIdentity, _: Instant) -> Result<bool, String> {
Err("Godot 原生桥仅支持 Windows x64".into())
}
@@ -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<CacheOwner, String> {
files::validate_payload_metadata(&source.metadata)?;
files::validate_candidate(&source.source_path)?;
if identity.pid == 0 || identity.started_file_time.parse::<u64>().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<PathBuf, String> {
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<Descriptor, String> {
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<PathBuf, String> {
// 未创建的末端也先按现有父目录规范化,防止 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<PathBuf, String> {
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(&copy, &expected.metadata.sha256)?;
}
Ok(())
}
pub fn prepare(
root: &Path,
source: &Payload,
identity: &ProcessIdentity,
workspace: &Path,
) -> Result<Payload, String> {
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<ProcessIdentity, 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 缓存归属记录无效")?;
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(())
}
File diff suppressed because it is too large Load Diff
@@ -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<Value, ExchangeError> {
let mut sent = false;
let mut operation = || -> Result<Value, String> {
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<Value, String> {
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())
})
})
}
+13
View File
@@ -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"
}
}
+20
View File
@@ -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"
]
}
}
}
+301
View File
@@ -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();
}
+379
View File
@@ -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,
);
});
@@ -0,0 +1,142 @@
//! 从随包操作指南提取代码,在显式授权的独立 Unity fixture 中执行。
//! fixture 根目录必须包含 `.agc-guide-fixture`;设置 AGC_UNITY_SMOKE_HELPER、
//! AGC_UNITY_SMOKE_PROJECT、AGC_UNITY_SMOKE_PID,再运行本 ignored 测试。
#![cfg(all(windows, target_arch = "x86_64"))]
use editor_adapter_api::EditorAdapter;
use serde_json::{json, Value};
use std::path::PathBuf;
use unity_editor_bridge::{disconnect_unity_editor, UnityEditorAdapter};
const GUIDE: &str = include_str!(concat!(
"../../../../../apps/ai-game-creator-shell/src-tauri/resources/agc-skills/",
"agc-unity-editor/references/【操作指南】Unity编辑器常用操作-2026-09-20.md"
));
fn example(name: &str) -> String {
let guide = GUIDE.replace("\r\n", "\n");
let marker = format!("<!-- example:{name} -->\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::<u32>().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<UnityEngine.RectTransform>(); 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<UnityEngine.BoxCollider>().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 撤销、保存重开、播放/停止及状态诊断通过。");
}

Some files were not shown because too many files have changed in this diff Show More