diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs index 3cf87fe77..917071ea2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -50,6 +50,12 @@ pub(crate) struct ProjectResourceConnectionIndex { pub(crate) struct ProjectResourceProducerAssignment { pub resource_id: String, pub task_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceDependencyDepth { + pub resource_id: String, pub dependency_depth: u32, } @@ -61,6 +67,7 @@ pub(crate) struct ProjectResourceGraphReadModel { pub task_flows: Vec, pub connection_index: Vec, pub producer_assignments: Vec, + pub dependency_depths: Vec, pub unresolved_reference_resource_ids: Vec, pub cyclic_resource_ids: Vec, pub cyclic_task_ids: Vec, @@ -194,6 +201,7 @@ fn analyze_directed_cycles<'a>( fn dependency_depth_by_node( analysis: &CycleAnalysis, edges: &[DirectedEdge], + minimum_depth_by_node: &BTreeMap, ) -> BTreeMap { let component_count = analysis .component_by_node @@ -223,6 +231,12 @@ fn dependency_depth_by_node( .filter_map(|(component, degree)| (*degree == 0).then_some(component)) .collect::>(); let mut depth_by_component = vec![0u32; component_count]; + for (node_id, minimum_depth) in minimum_depth_by_node { + let Some(component) = analysis.component_by_node.get(node_id) else { + continue; + }; + depth_by_component[*component] = depth_by_component[*component].max(*minimum_depth); + } while let Some(component) = ready.pop_first() { for &target in &outgoing[component] { depth_by_component[target] = @@ -464,7 +478,22 @@ pub(crate) fn build_project_resource_graph( }) .collect::>(); let task_cycles = analyze_directed_cycles(task_by_id.keys(), &task_dependency_edges); - let task_dependency_depths = dependency_depth_by_node(&task_cycles, &task_dependency_edges); + let task_dependency_depths = + dependency_depth_by_node(&task_cycles, &task_dependency_edges, &BTreeMap::new()); + let minimum_resource_dependency_depths = producer_by_resource_id + .iter() + .filter_map(|(resource_id, task_id)| { + task_dependency_depths + .get(task_id) + .copied() + .map(|depth| (resource_id.clone(), depth)) + }) + .collect::>(); + let resource_dependency_depths = dependency_depth_by_node( + &reference_cycles, + &reference_directed_edges, + &minimum_resource_dependency_depths, + ); let task_flows = task_dependency_edges .iter() .filter_map(|edge| { @@ -538,13 +567,18 @@ pub(crate) fn build_project_resource_graph( .into_iter() .map(|(resource_id, task_id)| ProjectResourceProducerAssignment { resource_id, - dependency_depth: task_dependency_depths - .get(&task_id) - .copied() - .unwrap_or_default(), task_id, }) .collect(), + dependency_depths: resource_dependency_depths + .into_iter() + .map( + |(resource_id, dependency_depth)| ProjectResourceDependencyDepth { + resource_id, + dependency_depth, + }, + ) + .collect(), unresolved_reference_resource_ids: unresolved_reference_resource_ids.into_iter().collect(), cyclic_resource_ids: reference_cycles.cyclic_node_ids.into_iter().collect(), cyclic_task_ids: task_cycles.cyclic_node_ids.into_iter().collect(), @@ -680,7 +714,18 @@ mod tests { graph .producer_assignments .iter() - .map(|assignment| (assignment.resource_id.as_str(), assignment.dependency_depth,)) + .map(|assignment| (assignment.resource_id.as_str(), assignment.task_id.as_str())) + .collect::>(), + BTreeMap::from([ + ("asset:spec", "art-director"), + ("asset:ui", "design-foundation"), + ]), + ); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) .collect::>(), BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), ); @@ -720,6 +765,14 @@ mod tests { assert_eq!(graph.reference_edges.len(), 1); assert!(graph.task_flows.is_empty()); assert!(graph.producer_assignments.is_empty()); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); } #[test] @@ -810,10 +863,10 @@ mod tests { .all(|index| index.task_flow_ids.len() <= 2)); assert_eq!( graph - .producer_assignments + .dependency_depths .iter() - .find(|assignment| assignment.resource_id == "resource:4095") - .map(|assignment| assignment.dependency_depth), + .find(|depth| depth.resource_id == "resource:4095") + .map(|depth| depth.dependency_depth), Some(4095), ); } @@ -841,13 +894,72 @@ mod tests { ); let depths = graph - .producer_assignments + .dependency_depths .iter() - .map(|assignment| (assignment.resource_id.as_str(), assignment.dependency_depth)) + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) .collect::>(); assert_eq!(depths["source-resource"], 0); assert_eq!(depths["cycle-a-resource"], 1); assert_eq!(depths["cycle-b-resource"], 1); assert_eq!(depths["target-resource"], 2); } + + #[test] + fn dependency_depth_uses_reference_sccs_after_task_depth_floors() { + let graph = build_project_resource_graph( + &manifest( + vec![ + task("source-task", &[]), + task("late-task", &["source-task"]), + ], + vec![ + asset("base", Some("external-base"), &[], None), + asset( + "cycle-a", + Some("external-cycle-a"), + &["external-base", "external-cycle-b"], + None, + ), + asset( + "cycle-b", + Some("external-cycle-b"), + &["external-cycle-a"], + None, + ), + asset( + "target", + Some("external-target"), + &["external-cycle-b"], + None, + ), + ], + ), + vec![ + resource("asset:base", Some("base"), None), + resource("asset:cycle-a", Some("cycle-a"), None), + resource("asset:cycle-b", Some("cycle-b"), None), + resource("asset:target", Some("target"), None), + ], + &[ + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "base", "agentId": "source-task"}), + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "cycle-a", "agentId": "late-task"}), + ], + false, + ); + + let depths = graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(); + assert_eq!(depths["asset:base"], 0); + assert_eq!(depths["asset:cycle-a"], 1); + assert_eq!(depths["asset:cycle-b"], 1); + assert_eq!(depths["asset:target"], 2); + assert_eq!( + graph.cyclic_resource_ids, + vec!["asset:cycle-a", "asset:cycle-b"] + ); + assert_eq!(graph.task_flows.len(), 1); + } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts index 57d56662f..648c88700 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts @@ -33,6 +33,10 @@ export type ProjectResourceConnectionIndexDto = { export type ProjectResourceProducerAssignment = { resourceId: string; taskId: string; +}; + +export type ProjectResourceDependencyDepth = { + resourceId: string; dependencyDepth: number; }; @@ -42,6 +46,7 @@ export type ProjectResourceGraphReadModel = { taskFlows: ProjectResourceTaskFlow[]; connectionIndex: ProjectResourceConnectionIndexDto[]; producerAssignments: ProjectResourceProducerAssignment[]; + dependencyDepths: ProjectResourceDependencyDepth[]; unresolvedReferenceResourceIds: string[]; cyclicResourceIds: string[]; cyclicTaskIds: string[]; @@ -185,13 +190,19 @@ export function normalizeProjectResourceGraph( continue; } producerTaskIdByResourceId.set(assignment.resourceId, assignment.taskId); + } + for (const depth of readModel.dependencyDepths) { if ( - Number.isSafeInteger(assignment.dependencyDepth) && - assignment.dependencyDepth >= 0 + resourceIds.has(depth.resourceId) && + Number.isSafeInteger(depth.dependencyDepth) && + depth.dependencyDepth >= 0 ) { dependencyDepthByResourceId.set( - assignment.resourceId, - assignment.dependencyDepth, + depth.resourceId, + Math.max( + dependencyDepthByResourceId.get(depth.resourceId) ?? 0, + depth.dependencyDepth, + ), ); } } diff --git a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts index 6348de9bf..a81ab0602 100644 --- a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -93,6 +93,7 @@ function graphFixture() { taskFlowIds: resourceId === 'unrelated' ? [] : [flowId], })), producerAssignments: [], + dependencyDepths: [], unresolvedReferenceResourceIds: [], cyclicResourceIds: ['unrelated'], cyclicTaskIds: [], @@ -373,6 +374,7 @@ describe('ResourceDependencyOverlay', () => { ], })), producerAssignments: [], + dependencyDepths: [], unresolvedReferenceResourceIds: [], cyclicResourceIds: [], cyclicTaskIds: [], diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index f1b9ec5b5..7e4795549 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -78,11 +78,14 @@ function resourceGraphForInputs(args?: Record) { { resourceId: resource.resourceId, taskId: resource.producerTaskId, - dependencyDepth: 0, }, ] : [], ), + dependencyDepths: resources.map((resource) => ({ + resourceId: resource.resourceId, + dependencyDepth: 0, + })), unresolvedReferenceResourceIds: [], cyclicResourceIds: [], cyclicTaskIds: [], @@ -1103,13 +1106,25 @@ export function registerProjectWorkbenchFoundationTests() { { resourceId: 'asset:dependency-spec', taskId: 'art-director', - dependencyDepth: 0, }, { resourceId: 'asset:dependency-ui', taskId: 'design-foundation', + }, + ], + dependencyDepths: [ + { + resourceId: 'asset:dependency-spec', + dependencyDepth: 0, + }, + { + resourceId: 'asset:dependency-ui', dependencyDepth: 1, }, + { + resourceId: 'asset:unrelated-cycle', + dependencyDepth: 0, + }, ], unresolvedReferenceResourceIds: [], cyclicResourceIds: ['asset:unrelated-cycle'], diff --git a/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts index 0ed293914..92bc6da08 100644 --- a/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts @@ -15,6 +15,7 @@ function readModel( taskFlows: [], connectionIndex: [], producerAssignments: [], + dependencyDepths: [], unresolvedReferenceResourceIds: [], cyclicResourceIds: [], cyclicTaskIds: [], @@ -130,23 +131,42 @@ describe('resource dependency graph model', () => { it('keeps real producer assignments and audit truncation metadata', () => { const graph = normalizeProjectResourceGraph( readModel({ - resourceIds: ['asset:spec', 'asset:ui'], + resourceIds: ['asset:spec', 'asset:ui', 'asset:derived'], producerAssignments: [ { resourceId: 'asset:spec', taskId: 'art-director', - dependencyDepth: 0, }, { resourceId: 'asset:ui', taskId: 'design-foundation', - dependencyDepth: 1, }, { resourceId: 'asset:deleted', taskId: 'task-1', + }, + ], + dependencyDepths: [ + { + resourceId: 'asset:spec', + dependencyDepth: 0, + }, + { + resourceId: 'asset:ui', + dependencyDepth: 1, + }, + { + resourceId: 'asset:deleted', dependencyDepth: 99, }, + { + resourceId: 'asset:ui', + dependencyDepth: 0, + }, + { + resourceId: 'asset:derived', + dependencyDepth: 2, + }, ], producerMappingTruncated: true, }), @@ -162,6 +182,7 @@ describe('resource dependency graph model', () => { new Map([ ['asset:spec', 0], ['asset:ui', 1], + ['asset:derived', 2], ]), ); expect(graph.producerMappingTruncated).toBe(true); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 3886475cf..bb8020892 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -260,6 +260,7 @@ type UpdateProjectResourceCanvasLayoutResult = #### 5.2.6 资源依赖关系图层 +- 阶段五实现状态(2026-08-03):dependency 自动排列同时消费任务 DAG 与精确资源引用。Rust 把可信 producer 的任务深度作为资源深度下限,再对 `asset-reference` 图做迭代式 SCC 压缩与确定性层级传播;被引用资源位于引用资源之前,同一引用环共享稳定深度,环后资源继续递增,没有引用关系的资源保持默认不重叠位置。布局深度通过独立 `dependencyDepths` 返回,不能把 producer assignment 冒充全部资源的布局结果。 - 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。 - 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击。 - `asset-reference` 表示精确资源引用,使用橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。 @@ -271,6 +272,7 @@ type UpdateProjectResourceCanvasLayoutResult = - 选中资源后,高亮其直接上游、直接下游卡片和关联边,弱化其余边;不做跨多层递归高亮。选中 ID 已失效时按未选中处理。 - 资源卡 Pointer Move 不改变基础 positions 或 SVG 几何。连线只随布局读取、资源自动协调、搜索、选择、项目切换或 section origin 变化而更新。 - `ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。 +- 阶段五不改变手动位置边界:已有 `manuallyPlaced=true` 坐标原样保留,资源引用新增或变化只允许重新派生 `manuallyPlaced=false` 的自动坐标;任务流继续按任务对聚合,禁止为了计算深度或绘线生成资源笛卡尔积。 ### 5.3 资源类型与替换兼容性(P1) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 89510f57e..10b6e04ff 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-08-03 资源依赖阶段五以引用 SCC 深度驱动自动排列 + +- 背景:资源关系图已经能展示精确引用与聚合任务流,但 dependency 自动布局只消费可信 producer 对应的任务 DAG 深度;同一任务生成的派生资源、没有 producer 审计的 manifest 资源和资源引用环均无法稳定体现“被引用资源在前、引用资源在后”的顺序。 +- 决策:`read_local_project_resource_graph` 把 producer assignment 与布局深度拆成两个只读字段。Rust 先压缩完整任务图并把可信 producer 的任务深度作为资源下限,再对精确资源引用图做迭代式 SCC 压缩和确定性最长层级传播;同一引用环共享深度,环后资源递增一层,没有引用关系的资源保持默认深度 `0`。前端只校验并消费 `dependencyDepths`,沿用现有自动位置协调和 SVG 几何,不自行推导关系。 +- 边界:不修改 manifest、layout sidecar、External Editor API、api-server 或 SpacetimeDB;不恢复资源卡拖动。已有 `manuallyPlaced=true` 坐标继续保留,只有可派生自动坐标会按新深度重算;任务流继续按任务对聚合,不展开资源笛卡尔积。 +- 验证方式:Rust 定向测试覆盖无 producer 的精确引用、任务深度下限、资源引用环 SCC、环后资源与 4096 任务链;前端 DTO、布局 Hook、纯布局和 SVG 测试覆盖独立深度字段、自动重排与手动坐标保留,并运行 shell typecheck、编码检查和 `git diff --check`。 +- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-08-03 资源聚焦阶段四采用只读受控文档与媒体链路 - 背景:阶段一至三已经完成资源卡禁拖、固定四类资源投影与中央聚焦容器,但只有 PNG / JPEG / WEBP 和合法 Agent 文本回执具备真实主体预览;本地文档、SVG / 视频与音频仍只有路径和元数据。飞书需求同时把“编辑并生成新资源”写为条件项,而当前仓库尚未具备从画板返回后的血缘登记与自动选中闭环。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 745ee19a0..cda56e3c0 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -397,7 +397,7 @@ game-project/ 2026-07-31 起,项目工作台使用“Tauri Rust 只读拓扑 + 前端原生 SVG 几何”的资源依赖图层;不修改 layout sidecar、既有布局模型、api-server 或 SpacetimeDB: -- `read_local_project_resource_graph` 读取当前 manifest、前端资源卡身份列表和最多 `32 MiB` 的安全 Agent DB 尾部,通过 Rust 构建稳定 read model;读取使用既有 Agent DB 普通文件 / 链接 / 追加锁边界,不新增数据库或 sidecar。返回资源 ID、引用边、聚合任务流、producer assignment、循环集合、unresolved 外部 ID、局部连接索引和 `producerMappingTruncated`。 +- `read_local_project_resource_graph` 读取当前 manifest、前端资源卡身份列表和最多 `32 MiB` 的安全 Agent DB 尾部,通过 Rust 构建稳定 read model;读取使用既有 Agent DB 普通文件 / 链接 / 追加锁边界,不新增数据库或 sidecar。返回资源 ID、引用边、聚合任务流、producer assignment、独立 `dependencyDepths`、循环集合、unresolved 外部 ID、局部连接索引和 `producerMappingTruncated`。 - 精确引用把 manifest 资产 `source.referenceResourceIds` 唯一匹配到另一资产的 `source.resourceId`,再转换为本次资源卡 ID;无匹配、多匹配、重复卡片或已删除资源只记录为 unresolved / 忽略,不生成边。引用边按 `sourceResourceId + targetResourceId` 稳定去重;前端 `resourceDependencyGraphModel.ts` 再做一次 DTO 端点防御过滤,避免异步切项目时出现幽灵线。 - task flow 只读取存在于当前 manifest 的任务依赖。画布资产 producer 仅接受 `agent.runtime.canvas.asset_generate` 中经 manifest 校验的 `assetId -> agentId`;External Editor 返回并保存在 `source.taskId` 的 `task-1` 等身份属于平台生成任务,禁止复用为 manifest task。多个有效 Agent 对同一资产形成冲突或证据缺失时,不生成该资产对应 task flow。任务产物 / Agent 回执继续使用资源投影中已有的 manifest task 身份。 - 资源按可信 producer 分组,每个 `sourceTaskId -> targetTaskId` 只生成一个聚合 flow;SVG 侧绘制 source 分支、唯一主线和 target 分支,路径数量为 `O(S+T)`,禁止资源笛卡尔积。局部连接索引保存 resource 关联的 reference edge ID / task flow ID,不预先展开 `S×T` 邻接矩阵。 @@ -408,7 +408,7 @@ game-project/ - 基础 positions 保持稳定;卡片 Pointer Move 不进入 SVG preview,只有搜索、选择、项目切换、真实 positions 或 section origin 变化才重新协调图层。SVG 几何从不持久化。 - Rust、前端 DTO/SVG 和工作台 AppSurface 回归覆盖生产 `task-1` 数据形状、真实 producer、证据缺失、精确引用、去重、无效 ID、完整任务环、4096 链式拓扑、聚合复杂度、搜索过滤、选择高亮、稳定 Observer、type 模式卸载与项目切换销毁。局部拖动更新与真实 Chromium 拖动性能目标暂缓。 -2026-08-03 评审加固:Rust read model 在现有 producer assignment 上同时返回经完整任务图 SCC 压缩计算的确定性 `dependencyDepth`,前端不再递归推导正式依赖层级。dependency 模式以 scope 化 `idle / loading / ready / failed` 状态阻断布局 Hook;图终态前不创建 fallback、不读取或写入 sidecar,图失败只以空图初始化一次。读取已有 dependency 布局时保留全部 `manuallyPlaced=true` 坐标,把 `manuallyPlaced=false` 作为可派生自动位置按最终深度重新协调;结果未变化时不写入。 +2026-08-03 阶段五加固:Rust read model 把 producer assignment 与布局深度分离。完整任务图先经 SCC 压缩形成可信 producer 的任务深度下限,精确资源引用图再经迭代式 SCC 压缩和确定性最长层级传播形成所有可见资源的 `dependencyDepths`;因此同一任务的派生资源、缺少 producer 审计的 manifest 资源和引用环都能稳定满足“被引用资源在前、引用资源在后”,没有引用关系的资源保持深度 `0`。前端不递归推导正式依赖层级。dependency 模式以 scope 化 `idle / loading / ready / failed` 状态阻断布局 Hook;图终态前不创建 fallback、不读取或写入 sidecar,图失败只以空图初始化一次。读取已有 dependency 布局时保留全部 `manuallyPlaced=true` 坐标,把 `manuallyPlaced=false` 作为可派生自动位置按最终深度重新协调;结果未变化时不写入。任务流仍按任务对聚合,不为布局计算或 SVG 绘制生成资源笛卡尔积。 历史命令式 drag preview 句柄与局部连接索引可以保留,但项目工作台不再向资源卡传入该入口。拖动热路径、4096 张真实卡片拖动重渲染和 Chromium p95 门槛统一暂缓;当前回归只要求 Pointer Move 不改变卡片坐标、SVG path 或布局 revision。`ResizeObserver` 仍保持单图层单实例,任何实时 DOM 几何都不得通过 Tauri IPC 往返 Rust。