冻结游戏图集私有完成合同

持久化主图与四切片的私有摘要回执
绑定切片清单与当前Canvas资源身份
完成门拒绝素材与公开清单的协同篡改
将私有回执纳入原子回滚与崩溃恢复
补齐图集合同回归测试与技术文档
This commit is contained in:
2026-08-03 23:12:16 +08:00
parent 602723ea0d
commit 3ac0a6fafc
7 changed files with 267 additions and 7 deletions
@@ -1549,7 +1549,9 @@ impl PlatformArtSliceContractRollback {
fn capture(root: &Path, suffix: &str) -> Result<Self, String> {
let paths = [
".agent/manifest.json",
".agent/runtime/art-spritesheet-contract.json",
"assets/manifest.art.json",
"assets/art-spritesheet.png",
"assets/art-spritesheet-slices/player.png",
"assets/art-spritesheet-slices/blocks-and-targets.png",
"assets/art-spritesheet-slices/obstacles-and-scene.png",
@@ -1720,6 +1722,45 @@ fn strict_game_art_manifest_bytes() -> Vec<u8> {
game_chat_fast_path_art_manifest_content().into_bytes()
}
fn strict_game_art_contract_receipt_bytes(
source_resource_id: &str,
source_task_id: &str,
source_canvas_project_id: &str,
reference_resource_ids: &[String],
main_content_sha256: &str,
slices: &[GeneratedPlatformArtAssetSlice],
) -> Result<Vec<u8>, String> {
let usages = [
"player",
"blocks-and-targets",
"obstacles-and-scene",
"feedback-effects",
];
let receipt = serde_json::json!({
"schemaVersion": "game-art-spritesheet-contract.v1",
"source": "assets/art-spritesheet.png",
"sourceResourceId": source_resource_id,
"sourceTaskId": source_task_id,
"sourceCanvasProjectId": source_canvas_project_id,
"sourceReferenceResourceIds": reference_resource_ids,
"mainContentSha256": main_content_sha256,
"sliceManifest": "assets/art-spritesheet-slices/manifest.json",
"slices": slices.iter().enumerate().map(|(index, slice)| serde_json::json!({
"name": slice.name,
"usage": usages[index],
"path": slice.local_path,
"width": slice.width,
"height": slice.height,
"resourceId": slice.resource_id,
"assetObjectId": slice.asset_object_id,
"contentSha256": slice.content_sha256,
"pixelSha256": slice.pixel_sha256,
})).collect::<Vec<_>>(),
});
serde_json::to_vec_pretty(&receipt)
.map_err(|error| format!("序列化平台图集私有合同回执失败:{error}"))
}
fn commit_strict_platform_art_slices_at(
root: &Path,
slices: Vec<PreparedPlatformArtAssetSlice>,
@@ -1759,6 +1800,8 @@ fn commit_strict_platform_art_slices_at(
local_path,
resource_id: slice.resource_id,
asset_object_id: slice.asset_object_id,
content_sha256: slice.content_sha256,
pixel_sha256: slice.pixel_sha256,
});
debug_assert_eq!(index + 1, generated.len());
}
@@ -1842,6 +1885,8 @@ fn commit_prepared_platform_art_slices_at(
local_path,
resource_id: slice.resource_id,
asset_object_id: slice.asset_object_id,
content_sha256: slice.content_sha256,
pixel_sha256: slice.pixel_sha256,
});
}
let usages = [
@@ -2141,6 +2186,47 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
return Err(format!("替换平台生成素材失败:{error}{restore_detail}"));
}
}
if require_complete_core_slices {
let receipt_path =
resolve_local_project_path(root, ".agent/runtime/art-spritesheet-contract.json")?;
if let Some(parent) = receipt_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"创建平台图集私有合同回执目录失败:{}: {error}",
parent.display()
)
})?;
}
let receipt_bytes = strict_game_art_contract_receipt_bytes(
resource_id
.as_deref()
.expect("strict spritesheet resourceId was validated"),
task_id
.as_deref()
.expect("strict spritesheet taskId was validated"),
canvas_project_id
.as_deref()
.expect("strict spritesheet canvas project was validated"),
&reference_resource_ids,
&format!("{:x}", Sha256::digest(&download.bytes)),
&strict_generated_slices,
)?;
if let Err(error) =
replace_platform_art_slice_file(&receipt_path, &receipt_bytes, &replacement_suffix)
{
if let Some(backup_path) = replacement_backup_path.as_ref() {
let _ = fs::remove_file(&absolute_path);
if let Err(restore_error) =
move_platform_art_asset_without_replacing(backup_path, &absolute_path)
{
return Err(format!("{error};恢复旧素材失败:{restore_error}"));
}
} else {
let _ = fs::remove_file(&absolute_path);
}
return Err(error);
}
}
let registered = match register_local_asset_entry(
root,
&local_path,
@@ -3670,6 +3756,7 @@ mod canvas_generation_tests {
init_local_game_project_at(root, "canvas-slices", "图集切片测试")
.expect("init sliced spritesheet project");
let main_download = rgba_test_png(0);
let main_sha256 = format!("{:x}", Sha256::digest(&main_download.bytes));
let slices = ["玩家主体", "目标物", "场景障碍", "反馈特效"]
.into_iter()
.enumerate()
@@ -3760,6 +3847,15 @@ mod canvas_generation_tests {
assert!(manifest["slices"][0]["contentSha256"]
.as_str()
.is_some_and(|value| value.len() == 64));
let receipt: serde_json::Value = serde_json::from_slice(
&fs::read(root.join(".agent/runtime/art-spritesheet-contract.json"))
.expect("read private spritesheet contract receipt"),
)
.expect("parse private spritesheet contract receipt");
assert_eq!(receipt["schemaVersion"], "game-art-spritesheet-contract.v1");
assert_eq!(receipt["sourceResourceId"], "spritesheet-resource");
assert_eq!(receipt["slices"], manifest["slices"]);
assert_eq!(receipt["mainContentSha256"], main_sha256);
let art_manifest: serde_json::Value = serde_json::from_slice(
&fs::read(root.join("assets/manifest.art.json")).expect("read art manifest"),
)
@@ -341,11 +341,15 @@ pub(in crate::agent) fn game_chat_fast_path_validated_art_slices(
return Err("game-chat 图集切片清单 schema 或 source 无效".to_string());
}
let project_manifest = read_manifest_for_project(root)?;
let current_resource_id = project_manifest
let current_asset = project_manifest
.assets
.iter()
.find(|asset| asset.local_path == "assets/art-spritesheet.png")
.and_then(|asset| asset.source.resource_id.as_deref())
.ok_or_else(|| "game-chat 当前图集缺少 Canvas 资产登记".to_string())?;
let current_resource_id = current_asset
.source
.resource_id
.as_deref()
.ok_or_else(|| "game-chat 当前图集缺少 Canvas resourceId".to_string())?;
if manifest
.get("sourceResourceId")
@@ -354,6 +358,78 @@ pub(in crate::agent) fn game_chat_fast_path_validated_art_slices(
{
return Err("game-chat 图集切片清单不属于当前 art-spritesheet resource".to_string());
}
let receipt_path =
resolve_local_project_path(root, ".agent/runtime/art-spritesheet-contract.json")?;
let receipt_size = usize::try_from(
fs::metadata(&receipt_path)
.map_err(|error| format!("game-chat 图集私有合同回执无法读取:{error}"))?
.len(),
)
.map_err(|_| "game-chat 图集私有合同回执大小溢出".to_string())?;
if receipt_size > 256 * 1024 {
return Err("game-chat 图集私有合同回执超过 256 KiB".to_string());
}
let receipt_bytes = fs::read(&receipt_path)
.map_err(|error| format!("game-chat 图集私有合同回执无法读取:{error}"))?;
if receipt_bytes.len() != receipt_size {
return Err("game-chat 图集私有合同回执在读取期间发生变化".to_string());
}
let receipt: serde_json::Value = serde_json::from_slice(&receipt_bytes)
.map_err(|error| format!("game-chat 图集私有合同回执不是有效 JSON:{error}"))?;
if receipt
.get("schemaVersion")
.and_then(serde_json::Value::as_str)
!= Some("game-art-spritesheet-contract.v1")
|| receipt.get("source").and_then(serde_json::Value::as_str)
!= Some("assets/art-spritesheet.png")
|| receipt
.get("sliceManifest")
.and_then(serde_json::Value::as_str)
!= Some("assets/art-spritesheet-slices/manifest.json")
|| receipt
.get("sourceResourceId")
.and_then(serde_json::Value::as_str)
!= Some(current_resource_id)
|| receipt
.get("sourceTaskId")
.and_then(serde_json::Value::as_str)
!= current_asset.source.task_id.as_deref()
|| receipt
.get("sourceCanvasProjectId")
.and_then(serde_json::Value::as_str)
!= current_asset.source.canvas_project_id.as_deref()
|| receipt.get("sourceReferenceResourceIds")
!= Some(&serde_json::json!(
current_asset.source.reference_resource_ids
))
{
return Err("game-chat 图集私有合同回执与当前 Canvas 登记身份不一致".to_string());
}
let main_path = resolve_local_project_path(root, "assets/art-spritesheet.png")?;
let main_size = usize::try_from(
fs::metadata(&main_path)
.map_err(|error| format!("game-chat 当前图集无法读取元数据:{error}"))?
.len(),
)
.map_err(|_| "game-chat 当前图集大小溢出".to_string())?;
if main_size > 20 * 1024 * 1024 {
return Err("game-chat 当前图集超过 20 MiB 校验上限".to_string());
}
let main_bytes =
fs::read(&main_path).map_err(|error| format!("game-chat 当前图集无法读取:{error}"))?;
if main_bytes.len() != main_size
|| receipt
.get("mainContentSha256")
.and_then(serde_json::Value::as_str)
!= Some(format!("{:x}", Sha256::digest(&main_bytes)).as_str())
{
return Err("game-chat 当前图集内容与私有合同回执不一致".to_string());
}
let receipt_slices = receipt
.get("slices")
.and_then(serde_json::Value::as_array)
.filter(|slices| slices.len() == 4)
.ok_or_else(|| "game-chat 图集私有合同回执必须恰好包含四个切片".to_string())?;
let slices = manifest
.get("slices")
.and_then(serde_json::Value::as_array)
@@ -372,6 +448,10 @@ pub(in crate::agent) fn game_chat_fast_path_validated_art_slices(
.iter()
.find(|slice| slice.get("usage").and_then(serde_json::Value::as_str) == Some(usage))
.ok_or_else(|| format!("game-chat 图集切片清单缺少 {usage} 素材"))?;
let receipt_slice = receipt_slices
.iter()
.find(|slice| slice.get("usage").and_then(serde_json::Value::as_str) == Some(usage))
.ok_or_else(|| format!("game-chat 图集私有合同回执缺少 {usage} 素材"))?;
let expected_path = format!("assets/art-spritesheet-slices/{usage}.png");
let path = slice
.get("path")
@@ -414,6 +494,22 @@ pub(in crate::agent) fn game_chat_fast_path_validated_art_slices(
if expected_width != Some(validated.width) || expected_height != Some(validated.height) {
return Err(format!("game-chat {usage} 切片尺寸与清单不一致"));
}
for field in [
"usage",
"path",
"width",
"height",
"resourceId",
"assetObjectId",
"contentSha256",
"pixelSha256",
] {
if slice.get(field) != receipt_slice.get(field) {
return Err(format!(
"game-chat {usage} 切片清单字段 {field} 与私有合同回执不一致"
));
}
}
if slice
.get("contentSha256")
.and_then(serde_json::Value::as_str)
@@ -1342,6 +1438,8 @@ mod tests {
"width": 32,
"height": 32,
"usage": usage,
"resourceId": format!("fast-path-slice-resource-{index}"),
"assetObjectId": format!("fast-path-slice-object-{index}"),
"contentSha256": validated.content_sha256,
"pixelSha256": validated.pixel_sha256,
})
@@ -1358,6 +1456,26 @@ mod tests {
.expect("serialize fast path slice fixture"),
)
.expect("write fast path slice manifest");
let receipt_directory = root.join(".agent/runtime");
fs::create_dir_all(&receipt_directory).expect("create private receipt directory");
let main_bytes = fs::read(root.join("assets/art-spritesheet.png"))
.expect("read fast path spritesheet fixture");
fs::write(
receipt_directory.join("art-spritesheet-contract.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"schemaVersion": "game-art-spritesheet-contract.v1",
"source": "assets/art-spritesheet.png",
"sourceResourceId": "fast-path-art-spritesheet-resource",
"sourceTaskId": "fast-path-art-spritesheet-task",
"sourceCanvasProjectId": "fast-path-canvas",
"sourceReferenceResourceIds": ["fast-path-icon-spec-resource"],
"mainContentSha256": format!("{:x}", Sha256::digest(main_bytes)),
"sliceManifest": "assets/art-spritesheet-slices/manifest.json",
"slices": slices,
}))
.expect("serialize private slice receipt"),
)
.expect("write private slice receipt");
}
#[test]
@@ -1380,7 +1498,7 @@ mod tests {
root,
"assets/art-spritesheet.png",
"art-spritesheet",
vec!["fast-path-art-spec-resource".to_string()],
vec!["fast-path-icon-spec-resource".to_string()],
);
write_fast_path_art_slice_fixture(root);
assert_eq!(
@@ -1418,8 +1536,8 @@ mod tests {
.expect("write tampered manifest");
let error = game_chat_fast_path_validated_art_slices(root)
.expect_err("updating the manifest cannot legitimize duplicate visual content");
assert!(error.contains("相同规范像素"));
.expect_err("updating the public manifest cannot change the private contract receipt");
assert!(error.contains("私有合同回执"));
}
#[test]
@@ -103,6 +103,8 @@ fn register_game_chat_art_spritesheet_fixture(root: &Path) {
"width": 32,
"height": 32,
"usage": usage,
"resourceId": format!("game-chat-slice-resource-{index}"),
"assetObjectId": format!("game-chat-slice-object-{index}"),
"contentSha256": validated.content_sha256,
"pixelSha256": validated.pixel_sha256,
})
@@ -119,6 +121,26 @@ fn register_game_chat_art_spritesheet_fixture(root: &Path) {
.expect("serialize game-chat slice fixture"),
)
.expect("write game-chat slice manifest");
let receipt_directory = root.join(".agent/runtime");
fs::create_dir_all(&receipt_directory).expect("create game-chat receipt directory");
let main_bytes = fs::read(root.join("assets/art-spritesheet.png"))
.expect("read game-chat spritesheet fixture");
fs::write(
receipt_directory.join("art-spritesheet-contract.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"schemaVersion": "game-art-spritesheet-contract.v1",
"source": "assets/art-spritesheet.png",
"sourceResourceId": "game-chat-art-spritesheet-resource",
"sourceTaskId": "art-asset-plan",
"sourceCanvasProjectId": "game-chat-canvas",
"sourceReferenceResourceIds": ["game-chat-art-spec-resource"],
"mainContentSha256": format!("{:x}", Sha256::digest(main_bytes)),
"sliceManifest": "assets/art-spritesheet-slices/manifest.json",
"slices": slices,
}))
.expect("serialize game-chat private slice receipt"),
)
.expect("write game-chat private slice receipt");
}
fn queue_game_chat_fast_path_child(
@@ -329,6 +329,8 @@ fn prepare_completed_autonomous_manifest_fixture(root: &Path) {
"width": 32,
"height": 32,
"usage": usage,
"resourceId": format!("autonomous-slice-resource-{index}"),
"assetObjectId": format!("autonomous-slice-object-{index}"),
"contentSha256": validated.content_sha256,
"pixelSha256": validated.pixel_sha256,
})
@@ -345,6 +347,26 @@ fn prepare_completed_autonomous_manifest_fixture(root: &Path) {
.expect("serialize autonomous slice fixture"),
)
.expect("write autonomous slice manifest");
let receipt_directory = root.join(".agent/runtime");
fs::create_dir_all(&receipt_directory).expect("create autonomous receipt directory");
let main_bytes = fs::read(root.join("assets/art-spritesheet.png"))
.expect("read autonomous spritesheet fixture");
fs::write(
receipt_directory.join("art-spritesheet-contract.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"schemaVersion": "game-art-spritesheet-contract.v1",
"source": "assets/art-spritesheet.png",
"sourceResourceId": "resource-art-spritesheet",
"sourceTaskId": "task-art-spritesheet",
"sourceCanvasProjectId": "autonomous-canvas-project",
"sourceReferenceResourceIds": ["resource-icon-spec"],
"mainContentSha256": format!("{:x}", Sha256::digest(main_bytes)),
"sliceManifest": "assets/art-spritesheet-slices/manifest.json",
"slices": slices,
}))
.expect("serialize autonomous private slice receipt"),
)
.expect("write autonomous private slice receipt");
for task in new_game_creation_app_seed_tasks() {
update_manifest_task_status_at(root, &task.id, GameCreationAppTaskStatus::Completed)
.expect("complete autonomous seed task fixture");
@@ -889,6 +889,8 @@ struct GeneratedPlatformArtAssetSlice {
local_path: String,
resource_id: Option<String>,
asset_object_id: Option<String>,
content_sha256: String,
pixel_sha256: String,
}
#[derive(Debug, Eq, PartialEq)]
@@ -5924,7 +5924,7 @@
- 事故事实:`game01` 的首个根 run 已交付并验证水晶下落方块原型,但父 run 在上下文压缩后因 token 上限失败;用户随后输入“继续”时,客户端创建了 task 仅为“继续”的新 game-chat root,完成合同重置 seed manifest,五分钟 fallback 再把该短语当主题整文件覆盖 `game/index.html`。这不是模型随机换题,而是 root 目标未继承、每新 run 无条件 reset 和 fallback 无条件 `file.write` 叠加形成的确定性缺陷。
- 续跑决策:同一 Supervisor Session、同一持久 source 的最近失败根 run 后,严格继续短语创建 successor root,并继承前序原始任务、baseline revision / artifact 身份;纯继续识别统一由一个精确函数负责,覆盖“继续 / 接着 / continue / go on”等无新约束短语。真正新需求、跨 Session、跨 GUI / CLI / game-chat source、前序正常完成或含具体新约束的输入继续创建独立新任务并重置本轮 manifest。successor 不复用旧网络请求、pending、action、Provider lifecycle 或 sidecar,只继承业务目标和已提交项目事实。
- 覆盖决策:game-chat fallback 只允许初始化缺失/占位入口的首次落盘。非占位 `game/index.html` 必须保留,并由当前 `code-prototype` 先读取和实际 patch,取得本人 `mutationRevision` 后才能运行 `game.static_smoke` 与交付;只读 smoke 不得冒充续作。确定性 fallback 只允许已实现真实语义的显式玩法模板:俄罗斯方块模板必须具备 10×20 棋盘、下落、移动、旋转、锁定、消行和触顶失败,收集模板只用于明确收集类目标,未知玩法失败关闭。纯继续目标未恢复时同样失败关闭。完成门新增 baseline 玩法连续性和 action-driven state 检查,generic Canvas 非空、三个按钮存在或静态 smoke 通过都不能单独证明任务没有换题。
- 美术决策:`art-spec.png` 回归为规范图和下游派生 reference,不能铺作完整场景,也不能裁剪成玩家/目标。首版必须继续由 `art-asset-plan` 通过 icon-spritesheet 生成透明 `art-spritesheet.png`;桌面 Runtime 同时下载服务端 `iconImageSrcs`,按当前图集 resourceId 写入本地切片清单。game-chat 在任何本地落盘前要求稳定、非空的图集 resourceId,并在正式图集登记前要求切片严格等于四、每片 `sourceResourceId` 精确绑定整图,全部切片累计下载最多 `32 MiB`;四类差异按尺寸加规范 RGBA 像素摘要判定,PNG 编码字节不同不代表视觉内容不同。主图、四张 canonical 切片与切片清单作为一个提交合同,主图安装或资产登记失败时必须恢复整组旧合同generation 账本恢复允许对摘要一致的已落盘主图幂等补齐合同,摘要冲突不得覆盖。完成门重新读取切片时继续有界解码并复核清单内容摘要、规范像素摘要、可见 alpha四类唯一性。`code-prototype` 在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景和反馈四类不同切片。纯代码核心画面、猜测 atlas 等分坐标、单个裁切冒充全部类别、整图展示与路径诱饵失败关闭;编辑器仍允许仅有 `sliceWarning` 的完整透明图集完成,但 game-chat 必须等到真实切片可用。
- 美术决策:`art-spec.png` 回归为规范图和下游派生 reference,不能铺作完整场景,也不能裁剪成玩家/目标。首版必须继续由 `art-asset-plan` 通过 icon-spritesheet 生成透明 `art-spritesheet.png`;桌面 Runtime 同时下载服务端 `iconImageSrcs`,按当前图集 resourceId 写入本地切片清单。game-chat 在任何本地落盘前要求稳定、非空的图集 resourceId,并在正式图集登记前要求切片严格等于四、每片 `sourceResourceId` 精确绑定整图,全部切片累计下载最多 `32 MiB`;四类差异按尺寸加规范 RGBA 像素摘要判定,PNG 编码字节不同不代表视觉内容不同。主图、四张 canonical 切片与切片清单作为一个提交合同,并把主图/切片摘要及 Canvas 身份冻结到 `.agent/runtime/art-spritesheet-contract.json` 私有回执;主图安装、回执或资产登记失败时必须恢复整组旧合同generation 账本恢复允许对摘要一致的已落盘主图幂等补齐合同,摘要冲突不得覆盖。完成门重新读取切片时继续有界解码,并要求实际素材、公开清单、当前 Canvas 登记与私有回执在内容摘要、规范像素摘要、可见 alpha四类唯一性和资源身份上全部一致`code-prototype` 在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景和反馈四类不同切片。纯代码核心画面、猜测 atlas 等分坐标、单个裁切冒充全部类别、整图展示与路径诱饵失败关闭;编辑器仍允许仅有 `sliceWarning` 的完整透明图集完成,但 game-chat 必须等到真实切片可用。
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs``start-dev-stack.mjs``src-tauri/src/agent/runtime_protocol/autonomous_completion.rs``response_stream.rs``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-03 托管 MCP 未鉴权响应提供安全接入引导
@@ -64,7 +64,7 @@
- 时间预算:从 game-chat 父 Run 接受用户请求开始,素材完整首版使用 `4200` 秒软预算;父 Run 与其全部 child Run、等待和回收阶段共享从 root `bound_at` 计算的 `4500` 秒绝对硬上限。该值来自现有两次串行生成各自最长 35 分钟的客户端等待合同,并为代码兜底、静态检查和双视口试玩保留 5 分钟。整个 Runtime pass 受同一 `timeout_at` 约束;硬上限内未通过完成门必须失败关闭,不得为了守时跳过图集、换普通生图或回退纯代码核心画面。即使完成证据恰好在上限后到齐,单轮确定性收束也必须再次检查累计预算并拒绝写入 `single_round_converged`
- Provider 次数:首波 `design-director / code-director` 的规划请求与 `art-director` 的确定性规范图生成并行;规范图和设计方向就绪后,Runtime 确定性派发 `art-asset-plan` 的 icon-spritesheet 生成。图集登记后 `code-prototype` 最多执行一次 Provider 首版写入请求;软预算耗尽时只允许生成真实加载并裁切图集的确定性本地兜底。Provider 成功返回后由 Runtime 依次执行确定性的 `game.static_smoke``preview.validate`
- 可玩兜底:软预算或首版 Provider 无法及时完成时,只能为已显式实现真实语义的玩法生成完整、自包含、无远程运行依赖的中文 HTML 模板;未知玩法失败关闭,不能只替换标题后套用固定收集游戏。俄罗斯方块模板必须包含 10×20 棋盘、下落、移动、旋转、锁定、消行和触顶失败;收集模板只匹配明确收集类目标。模板必须从 `ready` 开始,包含真实 Canvas 绘制、`requestAnimationFrame`、键盘 / 触控主要操作、唯一可见且启用的 start / primary-action / restart 控件,状态 JSON 只随真实输入、状态迁移或模拟状态变化推进,并能在 primary-action 后保持 `playing`、在 restart 后稳定回到 `ready | playing`;不得在开始前固定进入 `lost`,不得通过固定失败冒充试玩通过,也不得由纯渲染帧空转 `sequence`。兜底只允许写入缺失或精确初始化占位的 `game/index.html`;存在非占位入口时,当前 `code-prototype` 必须读取并实际 patch,取得本人 `mutationRevision` 后再静态检查和试玩,不得反复用只读 smoke 冒充续作。
- 平台图片:`art-spec.png` 只用于约束色板、材质、形状与后续派生,不是运行时背景、角色、目标或图集。`art-asset-plan` 必须以其稳定资源 ID 走专用 icon-spritesheet 路由,透明像素、generation route/kind、同画布归属和 reference resource ID 继续由 Runtime 验证。完整透明图集在编辑器合同中即使产生 `sliceWarning` 仍可登记,但 game-chat 不能据此猜测 atlas 坐标;图集 resourceId 缺失或空白时必须在任何本地写入前失败,四个切片均须保留与整图完全一致的 `sourceResourceId`,切片数量也必须在正式图集登记前严格等于四,多或少都失败关闭。四类唯一性按尺寸与解码后的规范 RGBA 像素摘要判断,不能用不同 PNG 压缩或 ancillary chunk 冒充不同素材。主图、四张 canonical 切片和切片清单必须共同提交,后续主图安装或登记失败时恢复旧切片合同,避免出现旧图集绑定新切片进程在固定主图落盘后崩溃时,保留的 generation 账本只允许按远端预期摘要幂等补齐同一组合同,路径内容冲突继续失败关闭。全部切片下载累计最多 `32 MiB`。完成门重新读取本地素材时仍执行相同文件大小、解码内存、内容摘要、规范像素摘要、可见 alpha 与四类唯一性校验。`postprocess-failed-source-preserved`、无真实 alpha、缺文件或缺登记同样失败关闭。首版只在 `preview-playtest` 后单轮收束,不进入发布节点。
- 平台图片:`art-spec.png` 只用于约束色板、材质、形状与后续派生,不是运行时背景、角色、目标或图集。`art-asset-plan` 必须以其稳定资源 ID 走专用 icon-spritesheet 路由,透明像素、generation route/kind、同画布归属和 reference resource ID 继续由 Runtime 验证。完整透明图集在编辑器合同中即使产生 `sliceWarning` 仍可登记,但 game-chat 不能据此猜测 atlas 坐标;图集 resourceId 缺失或空白时必须在任何本地写入前失败,四个切片均须保留与整图完全一致的 `sourceResourceId`,切片数量也必须在正式图集登记前严格等于四,多或少都失败关闭。四类唯一性按尺寸与解码后的规范 RGBA 像素摘要判断,不能用不同 PNG 压缩或 ancillary chunk 冒充不同素材。主图、四张 canonical 切片和切片清单必须共同提交,并把主图/切片摘要及 Canvas 身份冻结到 `.agent/runtime/art-spritesheet-contract.json` 私有回执;后续主图安装、回执或登记失败时恢复旧合同,避免出现旧图集绑定新切片进程在固定主图落盘后崩溃时,保留的 generation 账本只允许按远端预期摘要幂等补齐同一组合同,路径内容冲突继续失败关闭。全部切片下载累计最多 `32 MiB`。完成门重新读取本地素材时仍执行相同文件大小、解码内存、内容摘要、规范像素摘要、可见 alpha 与四类唯一性校验,并要求公开切片清单、当前 Canvas 登记与私有回执完全一致,后续代码 Agent 不能通过同时改写素材和公开清单重定义合同`postprocess-failed-source-preserved`、无真实 alpha、缺文件或缺登记同样失败关闭。首版只在 `preview-playtest` 后单轮收束,不进入发布节点。
- 关联验收:快车道必须分别验证三 Director 首波并行、`art-asset-plan` 在代码前完成、`x/7` 投影、七个专业 Agent 安全 final-reply、4200 / 4500 秒累计预算、规范图到 icon-spritesheet 的真实引用、`iconImageSrcs` 本地持久化与资源 ID 绑定、失败续跑目标继承、非占位入口禁止整文件覆盖、纯代码核心画面、猜测单个 atlas 裁切与整图展示失败、四类独立切片可见使用通过、action-driven `sequence`,以及当前 revision 的静态 smoke 与浏览器试玩。
## 2026-08-03 game-chat 开发态同源与持久输出修复