Files
Genarrative/plugins/agc-unity-editor/src/entry.mjs
T
kdletters 33f5ad68bf
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 10m54s
Project CI / Backend tests (push) Successful in 12m42s
Project CI / Frontend tests (push) Successful in 13m4s
Project CI / AI game creator shell web tests (push) Successful in 5m1s
Project CI / Repository checks (push) Successful in 12m48s
接入 DotCraft Unity 编辑器插件与受控执行链路 (#423)
AGC 原有插件系统无法直接操作已打开的 Unity Editor。本变更增加内置 `agc-unity-editor`,在 Windows x64 / Unity Mono 上支持当前项目探测、连接与 C# 执行,不向 Unity 工程安装 UPM 桥接包。

## 主要变更

- 固定复用 DotCraft.Unity 0.4.3 的 Attach 核心,提供自包含 .NET helper,保留上游许可证、来源及修改记录。
- GUI、Runtime、DirectProject 共用 Runner 执行服务;补齐项目身份、并发、总期限、回执确认与持久不确定状态阻断。
- 现有打开项目入口支持 Unity,按项目类型及开关暴露插件和 Agent 工具。
- Windows 构建准备 helper 并随包分发;插件 JS/Rust 测试接入现有 CI 组,Jenkins 增加 .NET 10 工具链预检。

## 验证

- .NET helper 27 项测试、自包含发布及最小环境协议 smoke 通过。
- Unity 6000.3.7f1 实机验证通过:连接、C# 执行、编译错误修复、断连重连、Domain Reload 后重新握手;真实 Runner 的 ACK、并发拒绝和跨重启阻断通过。
- 宿主 Unity、PluginHost、Cocos、MCP、工具目录与引擎识别定向回归通过;前端类型检查、插件 JS/Rust、CI 配置、格式、编码和文档门禁通过。

Linux CI 不代替 Windows helper/实机验证;发行安装包 UI smoke、其它 Unity 版本和 Unity CoreCLR 未验证。Unity 演示工程中的场景和组件已撤销,不在此 PR 范围内。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/423
2026-09-19 12:29:27 +08:00

282 lines
8.6 KiB
JavaScript

/**
* AGC Unity 插件的 agc.plugin.v1 入口。
* 与内置 Cocos 插件相同,直接运行的 JS 使用 SDK 的宿主协议;全部编辑器副作用
* 经 host.rpc 交给统一原生服务,入口不查找进程、不启动 helper、不持有凭据。
*/
import { pathToFileURL } from 'node:url';
export const UNITY_PLUGIN_PROTOCOL_VERSION = 'agc.plugin.v1';
export const UNITY_EXECUTE_COMMAND_ID = 'unity.editor.execute';
export const UNITY_CONNECTION_CAPABILITY_ID = 'unity.editor.connection';
const MAX_MESSAGE_BYTES = 2 * 1024 * 1024;
const MAX_CODE_BYTES = 128 * 1024;
export function createUnityEditorPlugin({ 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('Unity 插件宿主 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('Unity 参数必须是对象');
}
for (const key of Object.keys(input)) {
if (!allowed.includes(key)) throw new Error(`Unity 不接受参数:${key}`);
}
if (!activeProjectPath) throw new Error('当前没有受控 Unity 项目');
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 : 'Unity 执行结果待核对',
},
};
}
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('Unity C# 代码必须非空、不含 NUL 且不超过 128 KiB');
}
if (uncertain)
return reconcile('先前执行结果待核对,请核对 Unity 后重启客户端');
if (executing) {
return {
ok: false,
status: 'failed',
dispatched: false,
retryAllowed: false,
error: { code: 'editor-busy', message: 'Unity 已有执行正在处理' },
};
}
executing = true;
try {
const result = await request('host.rpc', {
method: 'editor.execute',
params,
});
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, 'result')) ||
(result.status === 'failed' && !result.ok && validError)
)
) {
return reconcile('Unity 执行回执无效');
}
return result;
} catch {
return reconcile('Unity 执行连接中断或超时,结果待核对');
} finally {
executing = false;
}
}
async function connection(input = {}) {
const params = editorParams(input, ['operation', 'processId', 'timeoutMs']);
const operation = params.operation ?? 'detect';
if (!['detect', 'connect', 'status', 'disconnect'].includes(operation)) {
throw new Error('不支持的 Unity 连接操作');
}
if (
params.processId !== undefined &&
(!Number.isInteger(params.processId) || params.processId <= 0)
) {
throw new Error('processId 必须是正整数');
}
delete params.operation;
return request('host.rpc', { method: `editor.${operation}`, params });
}
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 === UNITY_EXECUTE_COMMAND_ID
? execute
: value.method === UNITY_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 === UNITY_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: UNITY_EXECUTE_COMMAND_ID,
title: '执行 Unity C#',
description: '在当前 Unity 项目的编辑器主线程执行 C#',
});
await request('host.registerCapability', {
id: UNITY_CONNECTION_CAPABILITY_ID,
description: '当前 Unity 项目的编辑器连接与状态',
});
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 startUnityEditorStdioPlugin({
stdin = process.stdin,
stdout = process.stdout,
} = {}) {
const plugin = createUnityEditorPlugin({
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
) {
startUnityEditorStdioPlugin();
}