From 6785b0c91ef8012dcad77e4a52549fe4c8537f18 Mon Sep 17 00:00:00 2001 From: vincentadamnemessis Date: Wed, 12 Aug 2026 12:30:28 +0800 Subject: [PATCH] feat(mh3g): split compatibility repair by data domain --- README.md | 86 ++- README.zh-CN.md | 50 +- apps/mh3g-save-converter-macos/README.md | 42 +- .../mh3g-save-converter-macos/README.zh-CN.md | 31 +- .../ConversionTypes.swift | 112 ++- .../ConversionWorkflow.swift | 587 ++++++++++++++- .../ConverterPresentation/Localization.swift | 28 +- .../WorkflowStatusPresentation.swift | 12 +- .../ComponentSelectionView.swift | 94 ++- .../ExperimentalCECView.swift | 54 +- .../Resources/Localizable.xcstrings | 4 +- .../WriteRollbackView.swift | 247 +++++-- .../ConversionWorkflowTests.swift | 182 ++++- .../WorkflowStatusPresentationTests.swift | 15 +- .../Infrastructure/ConverterCopy.cs | 65 +- .../MainWindow.xaml | 138 +++- .../MainWindow.xaml.cs | 124 ++++ .../Models/ConverterModels.cs | 32 + apps/mh3g-save-converter-windows/README.md | 28 +- .../README.zh-CN.md | 21 +- .../ViewModels/MainViewModel.cs | 649 +++++++++++++++-- crates/mh3g-save-convert/src/cec.rs | 233 +++++- crates/mh3g-save-convert/src/main.rs | 678 +++++++++++++++++- crates/mh3g-save-convert/tests/cli.rs | 281 ++++++++ docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md | 163 +++-- docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md | 75 +- docs/adr/0017-mh3g-domain-scoped-repair.md | 145 ++++ ...rify-mh3g-save-converter-windows-source.py | 89 ++- 28 files changed, 3817 insertions(+), 448 deletions(-) create mode 100644 docs/adr/0017-mh3g-domain-scoped-repair.md diff --git a/README.md b/README.md index e43a32d..856d9b4 100644 --- a/README.md +++ b/README.md @@ -391,12 +391,12 @@ If a target existed, `--write` creates `.user2.mh3g-install-history-.json`. Keep the manifest until manual Cemu validation succeeds. -#### `repair-converted` — repair a played save created by an older converter +#### `repair-converted` — independently repair the core slot ```text mh3g-save-convert repair-converted --current \ [--output ] \ - [--source-extdata-dir ] [--from-version <0.0.3|0.0.4|0.0.5|0.0.6>] \ + [--source-extdata-dir ] [--from-version <0.0.3|0.0.4|0.0.5|0.0.6>] \ [--dry-run | --write --expected-source-set-sha256 \ --expected-current-set-sha256 --expected-output-set-sha256 \ --expected-preview-sha256 ] @@ -419,25 +419,20 @@ paths. Omitting `--output` retains the CLI's legacy in-place behavior and uses `--current` as the destination; the native macOS and Windows workbenches never hide this coupling and always display/pass a separate output selection. -The native macOS and Windows workbenches may accept a file or its direct parent -directory, but they still resolve and pass one exact file to the CLI. To repair -guild cards, `--source-extdata-dir` must be the complete 3DS -`.../00000481/user` directory with all eight source files, and the current -`user#` parent must contain all eight same-named Cemu files. A separate output -directory must already be an initialized Cemu save directory containing -`card1`, `card2`, `card3`, and `cardbox`; this keeps the multi-file transaction -fail-closed. The current -converter field-repairs `user#`, `card1`, `card2`, `card3`, and `cardbox`; -`quest1` through `quest4` participate in set validation but remain -byte-identical. `system`, `cec`, `phrase1` through `phrase3`, and unknown files -are not read or written by this command. +The native macOS and Windows workbenches may accept a core file or its direct +parent directory, but they still resolve and pass one exact file to the CLI. +They use `repair-converted` for the core slot only. The old +`--source-extdata-dir` option remains available to existing scripts, but it is +no longer used by either native UI; new repairs use the independent domain +commands below. `system`, `cec`, `phrase1` through `phrase3`, and unknown files +are not read or written by a core-only run. Start with a read-only preview: ```bash # SOURCE, CURRENT, and OUTPUT are three distinct same-slot user# paths. REPAIR_JSON=$("${CLI[@]}" repair-converted "$SOURCE" --current "$CURRENT" --output "$OUTPUT" \ - --source-extdata-dir "$EXTRAS_SOURCE" --dry-run) + --dry-run) ``` If the top-level `detection.confidence` is `ambiguous`, do not write. Confirm @@ -455,7 +450,6 @@ OUTPUT_SET_SHA256=$(jq -er '.output_set_sha256' <<<"$REPAIR_JSON") PREVIEW_SHA256=$(jq -er '.preview_sha256' <<<"$REPAIR_JSON") "${CLI[@]}" repair-converted "$SOURCE" --current "$CURRENT" --output "$OUTPUT" \ - --source-extdata-dir "$EXTRAS_SOURCE" \ --expected-source-set-sha256 "$SOURCE_SET_SHA256" \ --expected-current-set-sha256 "$CURRENT_SET_SHA256" \ --expected-output-set-sha256 "$OUTPUT_SET_SHA256" \ @@ -465,16 +459,55 @@ PREVIEW_SHA256=$(jq -er '.preview_sha256' <<<"$REPAIR_JSON") If Dry Run used `--from-version`, pass the same value to the write. Any source, current reference, output state, or preview change between the two steps fails closed. A -successful write returns a coordinator manifest named -`.mh3g-compatibility-repair-.json`, covering the core slot and optional -guild-card subtransactions. Roll it back with: +successful core write returns a coordinator manifest named +`.mh3g-compatibility-repair-.json`. Roll it back with: ```bash "${CLI[@]}" rollback-repair --manifest "$COMPATIBILITY_MANIFEST" ``` -Omit `--source-extdata-dir` for a core-only repair. A `no-changes` report means -the selected scope needed no write, so no empty coordinator manifest is made. +The deprecated `--source-extdata-dir` path is documented only for older +automation. A `no-changes` report means the core needed no write, so no empty +coordinator manifest is made. + +#### Independent repair domains + +Repair mode exposes five separate transactions. Original 3DS, current +Wii U/Cemu, and output are distinct path roles; selecting one never fills or +changes another. Every row has its own Dry Run, write authorization, manifest, +and rollback: + +| Domain | Command | Required source/current/output | +| --- | --- | --- | +| Core | `repair-converted` | Three same-named `user#` paths | +| Guild cards | `repair-extras --group guild-cards` | Three ExtData directories; output already contains `card1`, `card2`, `card3`, `cardbox` | +| Quests | `repair-extras --group quests` | Three ExtData directories; output already contains `quest1` through `quest4` | +| Gallery/movie `system` | `repair-system` | Exact 3DS `system`, exact current Cemu `system`, exact output `system` | +| Experimental CEC | `repair-cec` | Exact 3DS CEC mailbox directory, exact current Cemu `cec`, exact output `cec` | + +Cards and quests physically share one ExtData directory picker triplet in the +native UIs, but each group is still authorized and rolled back separately. +Quest repair validates the original group and preserves the current Wii U +bytes exactly. `system` unions only the verified gallery/movie flags into the +current authority. CEC remains opt-in experimental. An incomplete optional +domain never blocks core repair or another complete domain. + +```text +mh3g-save-convert repair-extras --group \ + --source-dir <3DS-ExtData-user> --current-dir \ + --output-dir [--dry-run | --write ...] + +mh3g-save-convert repair-system <3DS-system> --current \ + --output [--dry-run | --write ...] + +mh3g-save-convert repair-cec --source-dir <3DS-CEC-mailbox> \ + --current --output \ + [--dry-run | --write --experimental ...] +``` + +Use `rollback-extras`, `rollback`, and `rollback-cec` with the manifest returned +by the matching domain. See the exact file contract for all expected hash +arguments and fail-closed completeness rules. #### `convert-system` — safely merge shared gallery/movie flags @@ -555,13 +588,10 @@ directory containing the selected named components. A write creates a manifest-bound recovery transaction and retains the previous target bytes; it never installs one `card#` or `quest#` file by itself. -> **Windows limitation:** Windows supports `convert-extras` staging and -> `install-extras --dry-run` review, but intentionally refuses -> `install-extras --write` and `rollback-extras` before changing any ExtData -> files. A safe multi-file install requires this converter's durable -> directory-metadata protocol and a two-name atomic exchange; use a supported platform for that guarded -> install/rollback step. Core-slot and shared-`system` conversion remain -> available on Windows. +On Windows, complete-group writes use `ReplaceFileW`, manifest-bound backups, +and a durable recovery journal. The same complete-group, Dry Run hash, and +rollback requirements apply; individual `card#` or `quest#` installation is +still refused. Run the installation Dry Run immediately before writing, and bind both reported set hashes to the write: diff --git a/README.zh-CN.md b/README.zh-CN.md index 7df3b6f..c969960 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -275,12 +275,12 @@ NEW_SOURCE_SHA256=$(jq -er '.hashes.source' <<<"$NEW_DRY_RUN_JSON") 如果目标原本存在,`--write` 会在同目录创建 `.user2.mh3g-backup-` 和 `.user2.mh3g-install.json`;重复安装还可能生成 `.user2.mh3g-install-history-.json`。在 Cemu 中手动验证成功前,请保留 manifest。 -#### `repair-converted`:修复旧版转换后继续游玩的存档 +#### `repair-converted`:独立修复核心槽位 ```text mh3g-save-convert repair-converted <原始-3DS-user#> --current <当前-Cemu-user#> \ [--output <修复后-Cemu-user#>] \ - [--source-extdata-dir <原始-3DS-ExtData-user>] [--from-version <0.0.3|0.0.4|0.0.5|0.0.6>] \ + [--source-extdata-dir <旧式耦合-3DS-ExtData-user>] [--from-version <0.0.3|0.0.4|0.0.5|0.0.6>] \ [--dry-run | --write --expected-source-set-sha256 \ --expected-current-set-sha256 --expected-output-set-sha256 \ --expected-preview-sha256 ] @@ -290,14 +290,14 @@ mh3g-save-convert repair-converted <原始-3DS-user#> --current <当前-Cemu-use 三个路径的职责刻意分开:原始 3DS 槽位是只读转换源,`--current` 是保存玩家后续 Wii U/Cemu 进度的只读引用,`--output` 才是唯一允许写入的核心槽位。三者必须是编号和文件名相同的准确 `user1`、`user2` 或 `user3`。CLI 省略 `--output` 时仅为兼容旧脚本而继续原地写回 `--current`;原生 macOS/Windows 工作台不会隐藏这种复用,始终独立显示并传入“当前 Wii U 引用”和“修复后输出”。 -原生工作台可以让用户选择文件或其直接父目录,但最终仍只把解析出的准确文件传给 CLI。若启用公会名片修复,`--source-extdata-dir` 必须是含全部八个源文件的 3DS `.../00000481/user` 目录,并且当前 `user#` 的父目录必须含全部八个同名 Cemu 文件。若输出目录与当前引用目录不同,输出目录必须是已经初始化的 Cemu 存档目录,并至少含 `card1`、`card2`、`card3`、`cardbox`,否则多文件事务会失败关闭。当前转换器会字段级修复 `user#`、`card1`、`card2`、`card3`、`cardbox`;`quest1` 至 `quest4` 只参与集合校验并逐字节保留。`system`、`cec`、`phrase1` 至 `phrase3` 和未知文件不会被该命令读取或写入。 +原生工作台可以让用户选择核心文件或其直接父目录,但最终仍只把解析出的准确文件传给 CLI。macOS/Windows 原生界面只用 `repair-converted` 修复核心槽位。旧 `--source-extdata-dir` 参数继续兼容已有脚本,但原生界面不再使用;新的公会名片、任务、`system` 与 CEC 修复均走下文独立域命令。核心单独运行时不会读取或写入 `system`、`cec`、`phrase1` 至 `phrase3` 或未知文件。 先运行只读预览: ```bash # SOURCE、CURRENT、OUTPUT 是三个独立但同槽位的 user# 路径。 REPAIR_JSON=$("${CLI[@]}" repair-converted "$SOURCE" --current "$CURRENT" --output "$OUTPUT" \ - --source-extdata-dir "$EXTRAS_SOURCE" --dry-run) + --dry-run) ``` 如果 JSON 顶层的 `detection.confidence` 为 `ambiguous`,不要直接写入;从其 `candidates` 中确认当时使用的版本,并用 `--from-version` 重新运行 Dry Run。所有选中组件始终共用这一份版本判断,不会把 `user#` 和 `card*` 分别按不同历史版本修复。自动检测不是读取存档内嵌版本号,因为旧版本没有写入可靠标记。写入必须复用同一次最终 Dry Run 的四个授权哈希: @@ -309,7 +309,6 @@ OUTPUT_SET_SHA256=$(jq -er '.output_set_sha256' <<<"$REPAIR_JSON") PREVIEW_SHA256=$(jq -er '.preview_sha256' <<<"$REPAIR_JSON") "${CLI[@]}" repair-converted "$SOURCE" --current "$CURRENT" --output "$OUTPUT" \ - --source-extdata-dir "$EXTRAS_SOURCE" \ --expected-source-set-sha256 "$SOURCE_SET_SHA256" \ --expected-current-set-sha256 "$CURRENT_SET_SHA256" \ --expected-output-set-sha256 "$OUTPUT_SET_SHA256" \ @@ -317,13 +316,42 @@ PREVIEW_SHA256=$(jq -er '.preview_sha256' <<<"$REPAIR_JSON") --write ``` -若 Dry Run 使用了 `--from-version`,写入必须传入同一个值。任一原始源、当前 Wii U 引用、输出状态或预览在两步之间变化都会失败关闭。成功写入会返回总 manifest `.mh3g-compatibility-repair-.json`;它协调核心槽位与可选公会名片子事务。完整回滚使用: +若 Dry Run 使用了 `--from-version`,写入必须传入同一个值。任一原始源、当前 Wii U 引用、输出状态或预览在两步之间变化都会失败关闭。成功核心写入会返回 manifest `.mh3g-compatibility-repair-.json`。回滚使用: ```bash "${CLI[@]}" rollback-repair --manifest "$COMPATIBILITY_MANIFEST" ``` -只修核心 `user#` 时省略 `--source-extdata-dir`。若报告状态为 `no-changes`,说明所选范围无需修改,不会生成无意义的总 manifest。 +旧 `--source-extdata-dir` 只为历史自动化保留。若报告状态为 `no-changes`,说明核心无需修改,不会生成无意义的 manifest。 + +#### 独立修复域 + +修复模式提供五条彼此独立的事务。原始 3DS、当前 Wii U/Cemu、输出是三种不同路径职责;选择其中一个绝不会自动填充或改变另一个。每个域都有自己的 Dry Run、写入授权、manifest 和回滚: + +| 修复域 | 命令 | 必需的原始/当前/输出 | +| --- | --- | --- | +| 核心槽位 | `repair-converted` | 三个同名 `user#` 路径 | +| 公会名片 | `repair-extras --group guild-cards` | 三个 ExtData 目录;输出必须已有 `card1`、`card2`、`card3`、`cardbox` | +| 任务 | `repair-extras --group quests` | 三个 ExtData 目录;输出必须已有 `quest1` 至 `quest4` | +| 画廊/动画 `system` | `repair-system` | 准确 3DS `system`、准确当前 Cemu `system`、准确输出 `system` | +| 实验性 CEC | `repair-cec` | 准确 3DS CEC 邮箱目录、准确当前 Cemu `cec`、准确输出 `cec` | + +公会名片和任务在物理上共用同一组 ExtData 目录选择器,但两个组件组仍分别授权、写入和回滚。任务修复会校验原始组,并逐字节保留当前 Wii U 数据;`system` 只把已确认的画廊/动画标记合并到当前权威数据;CEC 仍需单独启用实验确认。某个可选域配置不完整时,不会阻塞核心或其他已经完整的域。 + +```text +mh3g-save-convert repair-extras --group \ + --source-dir <3DS-ExtData-user> --current-dir <当前-Cemu-目录> \ + --output-dir <已初始化-输出-Cemu-目录> [--dry-run | --write ...] + +mh3g-save-convert repair-system <3DS-system> --current <当前-Cemu-system> \ + --output <输出-Cemu-system> [--dry-run | --write ...] + +mh3g-save-convert repair-cec --source-dir <3DS-CEC-邮箱目录> \ + --current <当前-Cemu-cec> --output <输出-Cemu-cec> \ + [--dry-run | --write --experimental ...] +``` + +使用对应域返回的 manifest 调用 `rollback-extras`、`rollback` 或 `rollback-cec`。完整哈希参数和失败关闭规则见准确文件契约。 #### `convert-system`:安全合并共享画廊/动画标记 @@ -384,11 +412,9 @@ mh3g-save-convert install-extras [--dry-run | --write] \ 同名组件。写入会创建绑定 manifest 的恢复事务并保留目标原始字节;不会单独安装某一个 `card#` 或 `quest#` 文件。 -> **Windows 限制:**Windows 支持 `convert-extras` 生成暂存文件和 -> `install-extras --dry-run` 预览,但会在尚未改动任何 ExtData 文件前,主动拒绝 -> `install-extras --write` 与 `rollback-extras`。安全的多文件安装需要当前转换器完整的持久目录元数据协议和 -> 双名称原子交换;请在受支持的平台完成该受保护的安装/回滚步骤。核心 `user#` 与共享 `system` -> 转换仍可在 Windows 使用。 +Windows 上的完整组件组写入使用 `ReplaceFileW`、manifest 绑定备份与持久恢复日志。 +同样要求完整组件组、Dry Run 哈希和可回滚事务;仍会拒绝单独安装某一个 `card#` 或 +`quest#`。 安装前应紧接着执行 Dry Run,并把两组报告哈希绑定到写入: diff --git a/apps/mh3g-save-converter-macos/README.md b/apps/mh3g-save-converter-macos/README.md index 0ee39f4..4adc284 100644 --- a/apps/mh3g-save-converter-macos/README.md +++ b/apps/mh3g-save-converter-macos/README.md @@ -15,30 +15,38 @@ no save conversion is reimplemented in Swift. result to an independently selected same-slot output. Only fields that still retain a 0.0.3 through 0.0.6 conversion result are repaired. -Repair mode therefore displays three independent selectors: original 3DS, -read-only current Wii U/Cemu reference, and repaired output. It may also select -the complete 3DS ExtData `user` directory for guild-card repair. Current Cemu `card1`, `card2`, `card3`, `cardbox`, and -`quest1` through `quest4` are resolved beside the selected current `user#`; -quest files are validated and preserved, not rewritten by compatibility -repair. A different output directory must already contain initialized -`card1`, `card2`, `card3`, and `cardbox` files. Ambiguous detection requires an -explicit historical version followed by another Dry Run. +Repair mode is domain-scoped. Core, guild cards, quests, shared `system`, and +experimental CEC each have their own Dry Run, write authorization, manifest, +and rollback. A failed or incomplete optional domain never blocks or revokes an +independently authorized domain. + +Each domain displays separate original 3DS, read-only current Wii U/Cemu, and +output controls. Path values never cascade between those controls. Core accepts +an exact `user#` file or its direct parent. `system` and CEC use exact files. +Cards and quests share one ExtData source/current/output directory triplet +because the files are siblings, but they retain separate actions and manifests. +The selected output group must already be initialized and complete: all four +`card*` files or all four `quest*` files. Quest repair preserves the current +Wii U bytes exactly. Ambiguous historical detection requires an explicit +0.0.3-0.0.6 revision followed by another Dry Run; that explicit selection is +reused for core, cards, and quests from the same historical conversion. The core picker accepts an exact `user1`, `user2`, or `user3` file or its direct parent. It does not recursively scan an SD card or MLC and does not open ZIP, 7z, or RAR archives. A directory resolves only to the directly contained same-named selected slot. -Every write is bound to the immediately preceding Dry Run's SHA-256 values. -Normal conversion uses a single-file manifest; compatibility repair uses -`.mh3g-compatibility-repair-.json` and `rollback-repair`. Quit Nemessix, -Azahar, and Cemu before any write or rollback. +Every write is bound to its domain's immediately preceding Dry Run SHA-256 +values. Core uses `.mh3g-compatibility-repair-.json` and +`rollback-repair`; cards/quests use `rollback-extras`; system uses `rollback`; +CEC uses `rollback-cec`. Quit Nemessix, Azahar, and Cemu before any write or +rollback. -The optional housekeeper gallery/movie migration never creates or replaces a -shared `system` from the 3DS file alone. Select both the 3DS source `system` and -an existing initialized Cemu `system`; the converter unions only the known -gallery/movie flags and preserves every other Cemu byte, including data shared -by the other character slots. +The optional housekeeper gallery/movie repair never replaces shared `system` +from the 3DS file alone. Select the 3DS source, current initialized Cemu +authority, and an independent output. The converter unions only the known +gallery/movie flags and preserves every other current Cemu byte, including data +shared by the other character slots. ## Updates diff --git a/apps/mh3g-save-converter-macos/README.zh-CN.md b/apps/mh3g-save-converter-macos/README.zh-CN.md index 4f6be58..38f2718 100644 --- a/apps/mh3g-save-converter-macos/README.zh-CN.md +++ b/apps/mh3g-save-converter-macos/README.zh-CN.md @@ -12,24 +12,29 @@ sidecar 并展示 JSON 报告,不在 Swift 中重复实现存档转换。 `user#` 作为两个独立只读输入,并把结果写到独立选择的同槽位输出;只修复仍 保持 0.0.3 至 0.0.6 旧转换结果的字段。 -因此修复模式会明确显示三个选择器:原始 3DS、只读当前 Wii U/Cemu 引用、修复后 -输出。它还可以额外选择完整 3DS ExtData `user` 目录以修复公会名片。当前 Cemu -`card1`、`card2`、`card3`、`cardbox` 和 `quest1` 至 `quest4` 从所选当前 -`user#` 的父目录解析;任务文件只校验并保留,不会被兼容修复改写。若输出目录与 -当前引用目录不同,输出目录必须已有初始化的 `card1`、`card2`、`card3`、 -`cardbox`。自动检测结果为歧义时,必须明确选择历史版本并重新运行 Dry Run。 +修复模式按数据域拆分:核心槽位、公会名片、任务、共享 `system`、实验性 CEC +各自执行 Dry Run、写入授权、记录 manifest 和回滚。某个可选域失败或配置不完整, +不会阻塞或撤销另一个已经独立授权的域。 + +每个域都明确显示原始 3DS、只读当前 Wii U/Cemu、输出三种控件,路径值不会在控件 +之间自动级联。核心可选准确 `user#` 文件或其直接父目录;`system` 与 CEC 使用 +准确文件。公会名片和任务在物理上属于同一个目录,因此共用一组 ExtData +原始/当前/输出目录控件,但操作和 manifest 仍完全分开。输出必须已初始化并包含 +完整四个 `card*` 或完整四个 `quest*`;任务修复逐字节保留当前 Wii U 数据。 +历史检测为歧义时,必须明确选择 0.0.3-0.0.6 并重新 Dry Run;来自同一次旧转换的 +核心、名片和任务会复用这个显式版本。 核心选择器接受准确 `user1`、`user2`、`user3` 文件或其直接父目录。它不会递归 扫描 SD 卡/MLC,不接受 ZIP、7z、RAR。目录只解析成当前选中槽位的同名直接子文件。 -每次写入都绑定紧邻 Dry Run 的 SHA-256。普通转换使用单文件 manifest;兼容修复 -使用 `.mh3g-compatibility-repair-.json` 并通过 `rollback-repair` 总体回滚。 -执行写入或回滚前必须退出 Nemessix、Azahar 和 Cemu。 +每次写入都绑定本域紧邻 Dry Run 的 SHA-256。核心使用 +`.mh3g-compatibility-repair-.json` 与 `rollback-repair`;名片/任务使用 +`rollback-extras`;system 使用 `rollback`;CEC 使用 `rollback-cec`。执行写入或 +回滚前必须退出 Nemessix、Azahar 和 Cemu。 -可选的管家画廊/动画迁移不会只凭 3DS `system` 新建或整文件覆盖共享 -`system`。必须同时选择 3DS 源 `system` 和已存在、已初始化的 Cemu `system`; -转换器只合并已知画廊/动画标记,其他 Cemu 字节(包括其他角色槽位共享数据) -全部保留。 +可选的管家画廊/动画修复不会只凭 3DS `system` 整文件覆盖共享 `system`。必须 +分别选择 3DS 源、当前已初始化 Cemu 权威文件和独立输出;转换器只合并已知 +画廊/动画标记,其他当前 Cemu 字节(包括其他角色槽位共享数据)全部保留。 ## 更新检查 diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionTypes.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionTypes.swift index df11047..50d04af 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionTypes.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionTypes.swift @@ -7,6 +7,9 @@ public enum ConverterOperation: String, CaseIterable, Codable, Sendable { case inspect case convert case repairConverted = "repair-converted" + case repairExtras = "repair-extras" + case repairSystem = "repair-system" + case repairCEC = "repair-cec" case rollbackRepair = "rollback-repair" case convertSystem = "convert-system" case convertExtras = "convert-extras" @@ -184,11 +187,14 @@ public struct ComponentSelection: Equatable, Sendable { public var includeGuildCards: Bool public var includeQuests: Bool public var systemSource: URL? + public var systemCurrent: URL? public var systemTarget: URL? public var extraSourceDirectory: URL? + public var extraCurrentDirectory: URL? public var extraStagingDirectory: URL? public var extraTargetDirectory: URL? public var cecSourceDirectory: URL? + public var cecCurrent: URL? public var cecTarget: URL? public var acknowledgeExperimentalCEC: Bool @@ -197,11 +203,14 @@ public struct ComponentSelection: Equatable, Sendable { includeGuildCards: Bool = false, includeQuests: Bool = false, systemSource: URL? = nil, + systemCurrent: URL? = nil, systemTarget: URL? = nil, extraSourceDirectory: URL? = nil, + extraCurrentDirectory: URL? = nil, extraStagingDirectory: URL? = nil, extraTargetDirectory: URL? = nil, cecSourceDirectory: URL? = nil, + cecCurrent: URL? = nil, cecTarget: URL? = nil, acknowledgeExperimentalCEC: Bool = false ) { @@ -209,11 +218,14 @@ public struct ComponentSelection: Equatable, Sendable { self.includeGuildCards = includeGuildCards self.includeQuests = includeQuests self.systemSource = systemSource?.standardizedFileURL + self.systemCurrent = systemCurrent?.standardizedFileURL self.systemTarget = systemTarget?.standardizedFileURL self.extraSourceDirectory = extraSourceDirectory?.standardizedFileURL + self.extraCurrentDirectory = extraCurrentDirectory?.standardizedFileURL self.extraStagingDirectory = extraStagingDirectory?.standardizedFileURL self.extraTargetDirectory = extraTargetDirectory?.standardizedFileURL self.cecSourceDirectory = cecSourceDirectory?.standardizedFileURL + self.cecCurrent = cecCurrent?.standardizedFileURL self.cecTarget = cecTarget?.standardizedFileURL self.acknowledgeExperimentalCEC = acknowledgeExperimentalCEC } @@ -226,7 +238,7 @@ public struct ComponentSelection: Equatable, Sendable { } public var includesCEC: Bool { - cecSourceDirectory != nil || cecTarget != nil + cecSourceDirectory != nil || cecCurrent != nil || cecTarget != nil } } @@ -305,6 +317,71 @@ public struct RepairComponentFingerprint: Equatable, Sendable { public let modified: Bool } +public struct RepairExtrasDryRunFingerprint: Equatable, Sendable { + public let group: ExtraGroup + public let sourceDirectory: URL + public let currentDirectory: URL + public let outputDirectory: URL + public let fromVersion: HistoricalConverterRevision? + public let sourceSetSHA256: String + public let currentSetSHA256: String + public let outputSetSHA256: String + public let previewSHA256: String + public let components: [RepairComponentFingerprint] + + public init( + group: ExtraGroup, + sourceDirectory: URL, + currentDirectory: URL, + outputDirectory: URL, + fromVersion: HistoricalConverterRevision?, + sourceSetSHA256: String, + currentSetSHA256: String, + outputSetSHA256: String, + previewSHA256: String, + components: [RepairComponentFingerprint] + ) { + self.group = group + self.sourceDirectory = sourceDirectory.standardizedFileURL + self.currentDirectory = currentDirectory.standardizedFileURL + self.outputDirectory = outputDirectory.standardizedFileURL + self.fromVersion = fromVersion + self.sourceSetSHA256 = sourceSetSHA256 + self.currentSetSHA256 = currentSetSHA256 + self.outputSetSHA256 = outputSetSHA256 + self.previewSHA256 = previewSHA256 + self.components = components + } +} + +public struct RepairSystemDryRunFingerprint: Equatable, Sendable { + public let source: URL + public let current: URL + public let output: URL + public let sourceSetSHA256: String + public let currentSetSHA256: String + public let outputSetSHA256: String + public let previewSHA256: String + + public init( + source: URL, + current: URL, + output: URL, + sourceSetSHA256: String, + currentSetSHA256: String, + outputSetSHA256: String, + previewSHA256: String + ) { + self.source = source.standardizedFileURL + self.current = current.standardizedFileURL + self.output = output.standardizedFileURL + self.sourceSetSHA256 = sourceSetSHA256 + self.currentSetSHA256 = currentSetSHA256 + self.outputSetSHA256 = outputSetSHA256 + self.previewSHA256 = previewSHA256 + } +} + /// `system` is a distinct 3DS/Wii U file pair, so it must retain its own /// authorization instead of borrowing the selected `user#` slot fingerprint. public struct SystemDryRunFingerprint: Equatable, Sendable { @@ -432,6 +509,34 @@ public struct CECDryRunFingerprint: Equatable, Sendable { } } +public struct RepairCECDryRunFingerprint: Equatable, Sendable { + public let sourceDirectory: URL + public let current: URL + public let output: URL + public let sourceRecordSetSHA256: String + public let currentSetSHA256: String + public let outputSetSHA256: String + public let previewSHA256: String + + public init( + sourceDirectory: URL, + current: URL, + output: URL, + sourceRecordSetSHA256: String, + currentSetSHA256: String, + outputSetSHA256: String, + previewSHA256: String + ) { + self.sourceDirectory = sourceDirectory.standardizedFileURL + self.current = current.standardizedFileURL + self.output = output.standardizedFileURL + self.sourceRecordSetSHA256 = sourceRecordSetSHA256 + self.currentSetSHA256 = currentSetSHA256 + self.outputSetSHA256 = outputSetSHA256 + self.previewSHA256 = previewSHA256 + } +} + public struct PlannedConverterCommand: Equatable, Sendable { public let operation: ConverterOperation public let arguments: [String] @@ -510,6 +615,7 @@ public struct ConverterReport: Decodable, Sendable { public let sourceRecordSetSHA256: String? public let components: [ConverterExtraComponent]? public let groups: [ExtraGroup]? + public let group: ExtraGroup? public let stagingSetSHA256: String? public let targetSetSHA256Before: String? public let sourceSetSHA256: String? @@ -525,6 +631,7 @@ public struct ConverterReport: Decodable, Sendable { public let targetDirectory: String? public let source: String? public let current: String? + public let currentDirectory: String? public let target: String? public let entries: [ConverterExtraInstallEntry]? public let backupPaths: [String]? @@ -534,11 +641,12 @@ public struct ConverterReport: Decodable, Sendable { public let stderr: String? enum CodingKeys: String, CodingKey { - case operation, status, profile, size, hashes, output, backup, manifest, stderr, components, groups, manifests, detection + case operation, status, profile, size, hashes, output, backup, manifest, stderr, components, groups, group, manifests, detection case source, current, target, entries case compatibilityManifest = "compatibility_manifest" case sourceDirectory = "source_dir" case outputDirectory = "output_dir" + case currentDirectory = "current_dir" case stagingDirectory = "staging_dir" case targetDirectory = "target_dir" case backupPaths = "backup_paths" diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionWorkflow.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionWorkflow.swift index fe1b467..6da0f4d 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionWorkflow.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionWorkflow.swift @@ -16,13 +16,18 @@ public final class ConversionWorkflow { public private(set) var repairFromVersion: HistoricalConverterRevision? public private(set) var repairRevisionCandidates = [HistoricalConverterRevision]() public private(set) var repairRevisionSelectionRequired = false + public private(set) var repairExtraRevisionSelectionRequired = Set() public private(set) var systemDryRunFingerprint: SystemDryRunFingerprint? + public private(set) var repairSystemDryRunFingerprint: RepairSystemDryRunFingerprint? public private(set) var extrasStageDryRunFingerprint: ExtrasStageDryRunFingerprint? public private(set) var extrasInstallDryRunFingerprint: ExtrasInstallDryRunFingerprint? + public private(set) var repairExtrasDryRunFingerprints = [ExtraGroup: RepairExtrasDryRunFingerprint]() public private(set) var cecDryRunFingerprint: CECDryRunFingerprint? + public private(set) var repairCECDryRunFingerprint: RepairCECDryRunFingerprint? public private(set) var coreWriteCompleted = false public private(set) var systemWriteCompleted = false public private(set) var extrasInstallCompleted = false + public private(set) var repairedExtraGroups = Set() public private(set) var failure: WorkflowFailure? public private(set) var latestReport: ConverterReport? public private(set) var activeOperation: ConverterOperation? @@ -39,7 +44,6 @@ public final class ConversionWorkflow { public var canStartDryRun: Bool { input != nil && coreInspectionComplete - && selectedOptionalDataIsConfigured && activeOperation == nil } @@ -63,7 +67,15 @@ public final class ConversionWorkflow { /// argv construction. public var selectedOptionalDataIsConfigured: Bool { if mode == .repairConverted { - return !components.includeGuildCards || components.extraSourceDirectory != nil + let systemConfigured = !components.includeSystem + || (components.systemSource != nil + && components.systemCurrent != nil + && components.systemTarget != nil) + let extrasConfigured = components.selectedGroups.isEmpty + || (components.extraSourceDirectory != nil + && components.extraCurrentDirectory != nil + && components.extraTargetDirectory != nil) + return systemConfigured && extrasConfigured } let systemConfigured = !components.includeSystem || (components.systemSource != nil && components.systemTarget != nil) @@ -80,7 +92,8 @@ public final class ConversionWorkflow { /// and does not block the normal conversion route. public var hasPendingSelectedOptionalWork: Bool { if mode == .repairConverted { - return false + return (components.includeSystem && !systemWriteCompleted) + || !components.selectedGroups.isSubset(of: repairedExtraGroups) } return (components.includeSystem && !systemWriteCompleted) || (!components.selectedGroups.isEmpty && !extrasInstallCompleted) @@ -102,12 +115,11 @@ public final class ConversionWorkflow { return authorized.source == input.source.standardizedFileURL && authorized.current == input.current?.standardizedFileURL && authorized.output == input.target.standardizedFileURL - && authorized.extDataSource == components.extraSourceDirectory?.standardizedFileURL + && authorized.extDataSource == nil && authorized.fromVersion == repairFromVersion && !repairRevisionSelectionRequired } guard activeOperation == nil, - selectedOptionalDataIsConfigured, let authorized = dryRunFingerprint, let current = currentFingerprint() else { return false } @@ -116,6 +128,16 @@ public final class ConversionWorkflow { } public var canWriteCEC: Bool { + if mode == .repairConverted { + guard activeOperation == nil, + components.acknowledgeExperimentalCEC, + let authorized = repairCECDryRunFingerprint, + let paths = try? repairCECPaths() + else { return false } + return authorized.sourceDirectory == paths.source.standardizedFileURL + && authorized.current == paths.current.standardizedFileURL + && authorized.output == paths.output.standardizedFileURL + } guard activeOperation == nil, components.includesCEC, components.acknowledgeExperimentalCEC, @@ -128,6 +150,15 @@ public final class ConversionWorkflow { } public var canWriteSystem: Bool { + if mode == .repairConverted { + guard activeOperation == nil, + let authorized = repairSystemDryRunFingerprint, + let paths = try? repairSystemPaths() + else { return false } + return authorized.source == paths.source.standardizedFileURL + && authorized.current == paths.current.standardizedFileURL + && authorized.output == paths.output.standardizedFileURL + } guard activeOperation == nil, components.includeSystem, let source = components.systemSource, @@ -158,6 +189,20 @@ public final class ConversionWorkflow { && authorized.groups == components.selectedGroups } + public func canWriteRepairExtraGroup(_ group: ExtraGroup) -> Bool { + guard mode == .repairConverted, + activeOperation == nil, + components.selectedGroups.contains(group), + let authorized = repairExtrasDryRunFingerprints[group], + !repairExtraRevisionSelectionRequired.contains(group), + let paths = try? repairExtraPaths() + else { return false } + return authorized.sourceDirectory == paths.source.standardizedFileURL + && authorized.currentDirectory == paths.current.standardizedFileURL + && authorized.outputDirectory == paths.output.standardizedFileURL + && authorized.fromVersion == repairFromVersion + } + public func configure(input: ConversionInput) { guard activeOperation == nil else { return } guard self.input != input else { return } @@ -175,8 +220,11 @@ public final class ConversionWorkflow { repairFromVersion = nil repairRevisionCandidates = [] repairRevisionSelectionRequired = false - components.includeSystem = false - components.includeQuests = false + repairExtraRevisionSelectionRequired = [] + repairSystemDryRunFingerprint = nil + repairExtrasDryRunFingerprints = [:] + repairCECDryRunFingerprint = nil + repairedExtraGroups = [] sourceInspection = nil currentInspection = nil targetInspection = nil @@ -188,7 +236,9 @@ public final class ConversionWorkflow { guard activeOperation == nil, repairFromVersion != revision else { return } repairFromVersion = revision repairRevisionSelectionRequired = false + repairExtraRevisionSelectionRequired = [] repairDryRunFingerprint = nil + repairExtrasDryRunFingerprints = [:] if state == .dryRun { state = .componentSelection } @@ -212,27 +262,35 @@ public final class ConversionWorkflow { guard components != selection else { return } let systemChanged = components.includeSystem != selection.includeSystem || components.systemSource != selection.systemSource + || components.systemCurrent != selection.systemCurrent || components.systemTarget != selection.systemTarget let extrasChanged = components.includeGuildCards != selection.includeGuildCards || components.includeQuests != selection.includeQuests || components.extraSourceDirectory != selection.extraSourceDirectory + || components.extraCurrentDirectory != selection.extraCurrentDirectory || components.extraStagingDirectory != selection.extraStagingDirectory || components.extraTargetDirectory != selection.extraTargetDirectory let cecChanged = components.cecSourceDirectory != selection.cecSourceDirectory + || components.cecCurrent != selection.cecCurrent || components.cecTarget != selection.cecTarget || components.acknowledgeExperimentalCEC != selection.acknowledgeExperimentalCEC components = selection if systemChanged { systemDryRunFingerprint = nil + repairSystemDryRunFingerprint = nil systemWriteCompleted = false } if extrasChanged { extrasStageDryRunFingerprint = nil extrasInstallDryRunFingerprint = nil + repairExtrasDryRunFingerprints = [:] + repairExtraRevisionSelectionRequired = [] extrasInstallCompleted = false + repairedExtraGroups = [] } if cecChanged { cecDryRunFingerprint = nil + repairCECDryRunFingerprint = nil } failure = nil state = input == nil ? .input : .componentSelection @@ -309,7 +367,6 @@ public final class ConversionWorkflow { try requireIdleForIndependentOperation() guard let input else { throw ConversionWorkflowError.inputNotInspected } guard sourceInspection != nil else { throw ConversionWorkflowError.inputNotInspected } - try requireSelectedOptionalDataConfiguration() let operation: ConverterOperation = mode == .repairConverted ? .repairConverted : .convert try await withOperation(operation) { lease in self.dryRunFingerprint = nil @@ -326,10 +383,6 @@ public final class ConversionWorkflow { "--current", current.path, "--output", input.target.path, ] - if self.components.includeGuildCards, - let extData = self.components.extraSourceDirectory { - arguments += ["--source-extdata-dir", extData.path] - } if let revision = self.repairFromVersion { arguments += ["--from-version", revision.rawValue] } @@ -372,7 +425,7 @@ public final class ConversionWorkflow { source: input.source, current: current, output: input.target, - extDataSource: self.components.includeGuildCards ? self.components.extraSourceDirectory : nil, + extDataSource: nil, fromVersion: self.repairFromVersion, sourceSetSHA256: sourceSetSHA256, currentSetSHA256: currentSetSHA256, @@ -458,7 +511,6 @@ public final class ConversionWorkflow { public func writeCore() async throws { try requireIdleForIndependentOperation() - try requireSelectedOptionalDataConfiguration() guard let input else { throw ConversionWorkflowError.inputNotInspected } if mode == .repairConverted { guard let fingerprint = repairDryRunFingerprint, @@ -466,7 +518,7 @@ public final class ConversionWorkflow { fingerprint.source == input.source.standardizedFileURL, fingerprint.current == current.standardizedFileURL, fingerprint.output == input.target.standardizedFileURL, - fingerprint.extDataSource == components.extraSourceDirectory?.standardizedFileURL, + fingerprint.extDataSource == nil, fingerprint.fromVersion == repairFromVersion, !repairRevisionSelectionRequired else { throw ConversionWorkflowError.dryRunRequired } @@ -477,10 +529,6 @@ public final class ConversionWorkflow { "--current", current.path, "--output", input.target.path, ] - if self.components.includeGuildCards, - let extData = self.components.extraSourceDirectory { - arguments += ["--source-extdata-dir", extData.path] - } if let revision = fingerprint.fromVersion { arguments += ["--from-version", revision.rawValue] } @@ -516,6 +564,55 @@ public final class ConversionWorkflow { /// subsequent write independently. public func runSystemDryRun() async throws { try requireIdleForIndependentOperation() + if mode == .repairConverted { + let paths = try repairSystemPaths() + try await withOperation(.repairSystem) { lease in + self.repairSystemDryRunFingerprint = nil + let report = try await self.execute( + .repairSystem, + arguments: [ + ConverterOperation.repairSystem.rawValue, + paths.source.path, + "--current", paths.current.path, + "--output", paths.output.path, + "--dry-run", + ], + lease: lease + ) + guard report.status == "dry-run", + report.operation == ConverterOperation.repairSystem.rawValue, + ConverterEvidence.path(report.source, equals: paths.source), + ConverterEvidence.path(report.current, equals: paths.current), + ConverterEvidence.path(report.output, equals: paths.output), + let sourceSetSHA256 = report.sourceSetSHA256, + ConverterEvidence.isValidSHA256(sourceSetSHA256), + let currentSetSHA256 = report.currentSetSHA256, + ConverterEvidence.isValidSHA256(currentSetSHA256), + let outputSetSHA256 = report.outputSetSHA256, + ConverterEvidence.isValidSHA256(outputSetSHA256), + let previewSHA256 = report.previewSHA256, + ConverterEvidence.isValidSHA256(previewSHA256) + else { + throw self.failureAndRethrow( + .repairSystem, + ConversionWorkflowError.invalidReport("repair-system Dry Run requires exact paths and set hashes"), + stderr: report.stderr ?? "" + ) + } + self.repairSystemDryRunFingerprint = RepairSystemDryRunFingerprint( + source: paths.source, + current: paths.current, + output: paths.output, + sourceSetSHA256: sourceSetSHA256, + currentSetSHA256: currentSetSHA256, + outputSetSHA256: outputSetSHA256, + previewSHA256: previewSHA256 + ) + self.latestReport = report + self.state = .dryRun + } + return + } guard components.includeSystem, let source = components.systemSource, let target = components.systemTarget @@ -560,6 +657,56 @@ public final class ConversionWorkflow { public func writeSystem() async throws { try requireIdleForIndependentOperation() + if mode == .repairConverted { + let paths = try repairSystemPaths() + guard let fingerprint = repairSystemDryRunFingerprint, + fingerprint.source == paths.source.standardizedFileURL, + fingerprint.current == paths.current.standardizedFileURL, + fingerprint.output == paths.output.standardizedFileURL + else { throw ConversionWorkflowError.dryRunRequired } + try await withOperation(.repairSystem) { lease in + let report = try await self.execute( + .repairSystem, + arguments: [ + ConverterOperation.repairSystem.rawValue, + paths.source.path, + "--current", paths.current.path, + "--output", paths.output.path, + "--write", + "--expected-source-set-sha256", fingerprint.sourceSetSHA256, + "--expected-current-set-sha256", fingerprint.currentSetSHA256, + "--expected-output-set-sha256", fingerprint.outputSetSHA256, + "--expected-preview-sha256", fingerprint.previewSHA256, + ], + lease: lease + ) + guard report.status == "written" || report.status == "no-changes", + report.operation == ConverterOperation.repairSystem.rawValue, + ConverterEvidence.path(report.source, equals: paths.source), + ConverterEvidence.path(report.current, equals: paths.current), + ConverterEvidence.path(report.output, equals: paths.output), + report.sourceSetSHA256 == fingerprint.sourceSetSHA256, + report.currentSetSHA256 == fingerprint.currentSetSHA256, + report.outputSetSHA256 == fingerprint.outputSetSHA256, + report.previewSHA256 == fingerprint.previewSHA256, + (report.status == "written" + ? ConverterEvidence.hasPath(report.manifest) + : !ConverterEvidence.hasPath(report.manifest)) + else { + throw self.failureAndRethrow( + .repairSystem, + ConversionWorkflowError.invalidReport("repair-system write report does not match its Dry Run"), + stderr: report.stderr ?? "", + scope: .system + ) + } + self.latestReport = report + self.state = .success + self.systemWriteCompleted = true + self.repairSystemDryRunFingerprint = nil + } + return + } guard components.includeSystem, let source = components.systemSource, let target = components.systemTarget @@ -582,6 +729,149 @@ public final class ConversionWorkflow { } } + public func runRepairExtraDryRun(group: ExtraGroup) async throws { + try requireIdleForIndependentOperation() + guard mode == .repairConverted, + components.selectedGroups.contains(group) + else { throw ConversionWorkflowError.missingExtraDirectories } + let paths = try repairExtraPaths() + try await withOperation(.repairExtras) { lease in + self.repairExtrasDryRunFingerprints[group] = nil + self.repairExtraRevisionSelectionRequired.remove(group) + var arguments = [ + ConverterOperation.repairExtras.rawValue, + "--source-dir", paths.source.path, + "--current-dir", paths.current.path, + "--output-dir", paths.output.path, + "--group", group.rawValue, + ] + if let revision = self.repairFromVersion { + arguments += ["--from-version", revision.rawValue] + } + arguments.append("--dry-run") + let report = try await self.execute( + .repairExtras, + arguments: arguments, + lease: lease + ) + guard report.status == "dry-run", + report.operation == ConverterOperation.repairExtras.rawValue, + report.group == group, + ConverterEvidence.path(report.sourceDirectory, equals: paths.source), + ConverterEvidence.path(report.currentDirectory, equals: paths.current), + ConverterEvidence.path(report.outputDirectory, equals: paths.output), + let sourceSetSHA256 = report.sourceSetSHA256, + ConverterEvidence.isValidSHA256(sourceSetSHA256), + let currentSetSHA256 = report.currentSetSHA256, + ConverterEvidence.isValidSHA256(currentSetSHA256), + let outputSetSHA256 = report.outputSetSHA256, + ConverterEvidence.isValidSHA256(outputSetSHA256), + let previewSHA256 = report.previewSHA256, + ConverterEvidence.isValidSHA256(previewSHA256), + let detection = report.detection, + let components = report.components, + Set(components.map(\.component)) == Set(group.componentNames), + components.allSatisfy({ $0.repairFingerprint() != nil }) + else { + throw self.failureAndRethrow( + .repairExtras, + ConversionWorkflowError.invalidReport("repair-extras Dry Run requires one exact group, paths, hashes, and components"), + stderr: report.stderr ?? "" + ) + } + if detection.confidence == "ambiguous", self.repairFromVersion == nil { + self.repairExtraRevisionSelectionRequired.insert(group) + self.repairRevisionCandidates = detection.candidates + self.latestReport = report + self.state = .dryRun + return + } + self.repairExtrasDryRunFingerprints[group] = RepairExtrasDryRunFingerprint( + group: group, + sourceDirectory: paths.source, + currentDirectory: paths.current, + outputDirectory: paths.output, + fromVersion: self.repairFromVersion, + sourceSetSHA256: sourceSetSHA256, + currentSetSHA256: currentSetSHA256, + outputSetSHA256: outputSetSHA256, + previewSHA256: previewSHA256, + components: components.compactMap { $0.repairFingerprint() } + ) + self.latestReport = report + self.state = .dryRun + } + } + + public func writeRepairExtraGroup(_ group: ExtraGroup) async throws { + try requireIdleForIndependentOperation() + guard mode == .repairConverted, + components.selectedGroups.contains(group), + !repairExtraRevisionSelectionRequired.contains(group), + let fingerprint = repairExtrasDryRunFingerprints[group] + else { throw ConversionWorkflowError.dryRunRequired } + let paths = try repairExtraPaths() + guard fingerprint.sourceDirectory == paths.source.standardizedFileURL, + fingerprint.currentDirectory == paths.current.standardizedFileURL, + fingerprint.outputDirectory == paths.output.standardizedFileURL, + fingerprint.fromVersion == repairFromVersion + else { throw ConversionWorkflowError.staleDryRun } + // Consume only this group's authorization before invoking the CLI. + // A failed card repair must not leave a stale card write enabled, and + // must not revoke an independently authorized quest repair. + repairExtrasDryRunFingerprints[group] = nil + try await withOperation(.repairExtras) { lease in + var arguments = [ + ConverterOperation.repairExtras.rawValue, + "--source-dir", paths.source.path, + "--current-dir", paths.current.path, + "--output-dir", paths.output.path, + "--group", group.rawValue, + ] + if let revision = fingerprint.fromVersion { + arguments += ["--from-version", revision.rawValue] + } + arguments += [ + "--write", + "--expected-source-set-sha256", fingerprint.sourceSetSHA256, + "--expected-current-set-sha256", fingerprint.currentSetSHA256, + "--expected-output-set-sha256", fingerprint.outputSetSHA256, + "--expected-preview-sha256", fingerprint.previewSHA256, + ] + let report = try await self.execute( + .repairExtras, + arguments: arguments, + lease: lease + ) + guard report.status == "written" || report.status == "no-changes", + report.operation == ConverterOperation.repairExtras.rawValue, + report.group == group, + ConverterEvidence.path(report.sourceDirectory, equals: paths.source), + ConverterEvidence.path(report.currentDirectory, equals: paths.current), + ConverterEvidence.path(report.outputDirectory, equals: paths.output), + report.sourceSetSHA256 == fingerprint.sourceSetSHA256, + report.currentSetSHA256 == fingerprint.currentSetSHA256, + report.outputSetSHA256 == fingerprint.outputSetSHA256, + report.previewSHA256 == fingerprint.previewSHA256, + report.components?.compactMap({ $0.repairFingerprint() }) == fingerprint.components, + (report.status == "written" + ? ConverterEvidence.hasPath(report.manifest) + : !ConverterEvidence.hasPath(report.manifest)) + else { + throw self.failureAndRethrow( + .repairExtras, + ConversionWorkflowError.invalidReport("repair-extras write report does not match its Dry Run"), + stderr: report.stderr ?? "", + scope: .extras + ) + } + self.latestReport = report + self.repairedExtraGroups.insert(group) + self.repairExtrasDryRunFingerprints[group] = nil + self.state = .success + } + } + /// Preview the full ExtData staging set. This does not authorize a Cemu /// write; it only authorizes creating the explicitly selected staging /// directory after the same read-only plan still matches. @@ -707,6 +997,56 @@ public final class ConversionWorkflow { try requireIdleForIndependentOperation() guard components.includesCEC else { throw ConversionWorkflowError.missingCECDirectories } guard components.acknowledgeExperimentalCEC else { throw ConversionWorkflowError.experimentalCECAcknowledgementRequired } + if mode == .repairConverted { + let paths = try repairCECPaths() + guard let fingerprint = repairCECDryRunFingerprint, + fingerprint.sourceDirectory == paths.source.standardizedFileURL, + fingerprint.current == paths.current.standardizedFileURL, + fingerprint.output == paths.output.standardizedFileURL + else { throw ConversionWorkflowError.dryRunRequired } + try await withOperation(.repairCEC) { lease in + let report = try await self.execute( + .repairCEC, + arguments: [ + ConverterOperation.repairCEC.rawValue, + "--source-dir", paths.source.path, + "--current", paths.current.path, + "--output", paths.output.path, + "--write", + "--experimental", + "--expected-source-record-set-sha256", fingerprint.sourceRecordSetSHA256, + "--expected-current-set-sha256", fingerprint.currentSetSHA256, + "--expected-output-set-sha256", fingerprint.outputSetSHA256, + "--expected-preview-sha256", fingerprint.previewSHA256, + ], + lease: lease + ) + guard report.status == "written" || report.status == "no-changes", + report.operation == ConverterOperation.repairCEC.rawValue, + ConverterEvidence.path(report.sourceDirectory, equals: paths.source), + ConverterEvidence.path(report.current, equals: paths.current), + ConverterEvidence.path(report.output, equals: paths.output), + report.sourceRecordSetSHA256 == fingerprint.sourceRecordSetSHA256, + report.currentSetSHA256 == fingerprint.currentSetSHA256, + report.outputSetSHA256 == fingerprint.outputSetSHA256, + report.previewSHA256 == fingerprint.previewSHA256, + (report.status == "written" + ? ConverterEvidence.hasPath(report.manifest) + : !ConverterEvidence.hasPath(report.manifest)) + else { + throw self.failureAndRethrow( + .repairCEC, + ConversionWorkflowError.invalidReport("repair-cec write report does not match its Dry Run"), + stderr: report.stderr ?? "", + scope: .cec + ) + } + self.latestReport = report + self.repairCECDryRunFingerprint = nil + self.state = .success + } + return + } guard let source = components.cecSourceDirectory, let target = components.cecTarget else { throw ConversionWorkflowError.missingCECDirectories } @@ -755,6 +1095,55 @@ public final class ConversionWorkflow { public func runCECDryRun() async throws { try requireIdleForIndependentOperation() + if mode == .repairConverted { + let paths = try repairCECPaths() + try await withOperation(.repairCEC) { lease in + self.repairCECDryRunFingerprint = nil + let report = try await self.execute( + .repairCEC, + arguments: [ + ConverterOperation.repairCEC.rawValue, + "--source-dir", paths.source.path, + "--current", paths.current.path, + "--output", paths.output.path, + "--dry-run", + ], + lease: lease + ) + guard report.status == "dry-run", + report.operation == ConverterOperation.repairCEC.rawValue, + ConverterEvidence.path(report.sourceDirectory, equals: paths.source), + ConverterEvidence.path(report.current, equals: paths.current), + ConverterEvidence.path(report.output, equals: paths.output), + let sourceRecordSetSHA256 = report.sourceRecordSetSHA256, + ConverterEvidence.isValidSHA256(sourceRecordSetSHA256), + let currentSetSHA256 = report.currentSetSHA256, + ConverterEvidence.isValidSHA256(currentSetSHA256), + let outputSetSHA256 = report.outputSetSHA256, + ConverterEvidence.isValidSHA256(outputSetSHA256), + let previewSHA256 = report.previewSHA256, + ConverterEvidence.isValidSHA256(previewSHA256) + else { + throw self.failureAndRethrow( + .repairCEC, + ConversionWorkflowError.invalidReport("repair-cec Dry Run requires exact paths and set hashes"), + stderr: report.stderr ?? "" + ) + } + self.repairCECDryRunFingerprint = RepairCECDryRunFingerprint( + sourceDirectory: paths.source, + current: paths.current, + output: paths.output, + sourceRecordSetSHA256: sourceRecordSetSHA256, + currentSetSHA256: currentSetSHA256, + outputSetSHA256: outputSetSHA256, + previewSHA256: previewSHA256 + ) + self.latestReport = report + self.state = .dryRun + } + return + } guard components.includesCEC, let source = components.cecSourceDirectory, let target = components.cecTarget @@ -841,6 +1230,104 @@ public final class ConversionWorkflow { public func writePlan() throws -> [PlannedConverterCommand] { try requireIdleForIndependentOperation() var plan = [PlannedConverterCommand]() + if mode == .repairConverted { + if let input, let fingerprint = repairDryRunFingerprint { + guard let current = input.current, + fingerprint.source == input.source.standardizedFileURL, + fingerprint.current == current.standardizedFileURL, + fingerprint.output == input.target.standardizedFileURL, + fingerprint.fromVersion == repairFromVersion, + !repairRevisionSelectionRequired + else { throw ConversionWorkflowError.staleDryRun } + var arguments = [ + ConverterOperation.repairConverted.rawValue, + input.source.path, + "--current", current.path, + "--output", input.target.path, + ] + if let revision = fingerprint.fromVersion { + arguments += ["--from-version", revision.rawValue] + } + arguments += [ + "--write", + "--expected-source-set-sha256", fingerprint.sourceSetSHA256, + "--expected-current-set-sha256", fingerprint.currentSetSHA256, + "--expected-output-set-sha256", fingerprint.outputSetSHA256, + "--expected-preview-sha256", fingerprint.previewSHA256, + ] + plan.append(PlannedConverterCommand(operation: .repairConverted, arguments: arguments)) + } + if components.includeSystem { + let paths = try repairSystemPaths() + guard let fingerprint = repairSystemDryRunFingerprint, + canWriteSystem + else { throw ConversionWorkflowError.dryRunRequired } + plan.append( + PlannedConverterCommand( + operation: .repairSystem, + arguments: [ + ConverterOperation.repairSystem.rawValue, + paths.source.path, + "--current", paths.current.path, + "--output", paths.output.path, + "--write", + "--expected-source-set-sha256", fingerprint.sourceSetSHA256, + "--expected-current-set-sha256", fingerprint.currentSetSHA256, + "--expected-output-set-sha256", fingerprint.outputSetSHA256, + "--expected-preview-sha256", fingerprint.previewSHA256, + ] + ) + ) + } + for group in components.selectedGroups.sorted(by: { $0.rawValue < $1.rawValue }) { + guard canWriteRepairExtraGroup(group), + let fingerprint = repairExtrasDryRunFingerprints[group] + else { throw ConversionWorkflowError.dryRunRequired } + var arguments = [ + ConverterOperation.repairExtras.rawValue, + "--source-dir", fingerprint.sourceDirectory.path, + "--current-dir", fingerprint.currentDirectory.path, + "--output-dir", fingerprint.outputDirectory.path, + "--group", group.rawValue, + ] + if let revision = fingerprint.fromVersion { + arguments += ["--from-version", revision.rawValue] + } + arguments += [ + "--write", + "--expected-source-set-sha256", fingerprint.sourceSetSHA256, + "--expected-current-set-sha256", fingerprint.currentSetSHA256, + "--expected-output-set-sha256", fingerprint.outputSetSHA256, + "--expected-preview-sha256", fingerprint.previewSHA256, + ] + plan.append(PlannedConverterCommand(operation: .repairExtras, arguments: arguments)) + } + if components.includesCEC { + guard components.acknowledgeExperimentalCEC, + let fingerprint = repairCECDryRunFingerprint, + canWriteCEC + else { throw ConversionWorkflowError.dryRunRequired } + plan.append( + PlannedConverterCommand( + operation: .repairCEC, + arguments: [ + ConverterOperation.repairCEC.rawValue, + "--source-dir", fingerprint.sourceDirectory.path, + "--current", fingerprint.current.path, + "--output", fingerprint.output.path, + "--write", + "--experimental", + "--expected-source-record-set-sha256", fingerprint.sourceRecordSetSHA256, + "--expected-current-set-sha256", fingerprint.currentSetSHA256, + "--expected-output-set-sha256", fingerprint.outputSetSHA256, + "--expected-preview-sha256", fingerprint.previewSHA256, + ] + ) + ) + } + guard !plan.isEmpty else { throw ConversionWorkflowError.dryRunRequired } + return plan + } if let input, dryRunFingerprint != nil { let fingerprint = try currentAuthorizedFingerprint() plan.append( @@ -1028,13 +1515,44 @@ public final class ConversionWorkflow { return (source, staging, target) } - private func requireSelectedOptionalDataConfiguration() throws { - guard !components.includeSystem - || (components.systemSource != nil && components.systemTarget != nil) + private func repairExtraPaths() throws -> (source: URL, current: URL, output: URL) { + guard mode == .repairConverted, + let source = components.extraSourceDirectory, + let current = components.extraCurrentDirectory, + let output = components.extraTargetDirectory + else { throw ConversionWorkflowError.missingExtraDirectories } + return ( + source.standardizedFileURL, + current.standardizedFileURL, + output.standardizedFileURL + ) + } + + private func repairSystemPaths() throws -> (source: URL, current: URL, output: URL) { + guard mode == .repairConverted, + components.includeSystem, + let source = components.systemSource, + let current = components.systemCurrent, + let output = components.systemTarget else { throw ConversionWorkflowError.missingSystemPaths } - guard selectedOptionalDataIsConfigured else { - throw ConversionWorkflowError.missingExtraDirectories - } + return ( + source.standardizedFileURL, + current.standardizedFileURL, + output.standardizedFileURL + ) + } + + private func repairCECPaths() throws -> (source: URL, current: URL, output: URL) { + guard mode == .repairConverted, + let source = components.cecSourceDirectory, + let current = components.cecCurrent, + let output = components.cecTarget + else { throw ConversionWorkflowError.missingCECDirectories } + return ( + source.standardizedFileURL, + current.standardizedFileURL, + output.standardizedFileURL + ) } private func extrasStageArguments( @@ -1302,7 +1820,7 @@ public final class ConversionWorkflow { try validateCECWrite(report) case .rollback, .rollbackRepair, .rollbackExtras, .rollbackCEC: try validateRollbackReport(report, operation: operation, manifest: rollbackManifest) - case .inspect, .repairConverted: + case .inspect, .repairConverted, .repairExtras, .repairSystem, .repairCEC: throw ConversionWorkflowError.invalidReport("unsupported completion operation") } } @@ -1489,7 +2007,8 @@ public final class ConversionWorkflow { } case .rollback, .rollbackCEC: break - case .inspect, .convert, .repairConverted, .convertSystem, .convertExtras, .installExtras, .convertCEC: + case .inspect, .convert, .repairConverted, .repairExtras, .repairSystem, .repairCEC, + .convertSystem, .convertExtras, .installExtras, .convertCEC: throw ConversionWorkflowError.invalidReport("unsupported rollback operation") } } @@ -1545,11 +2064,13 @@ public final class ConversionWorkflow { repairDryRunFingerprint = nil case .system: systemDryRunFingerprint = nil + repairSystemDryRunFingerprint = nil case .extras: extrasStageDryRunFingerprint = nil extrasInstallDryRunFingerprint = nil case .cec: cecDryRunFingerprint = nil + repairCECDryRunFingerprint = nil } } @@ -1563,13 +2084,13 @@ public final class ConversionWorkflow { switch operation { case .convert, .repairConverted: coreWriteCompleted = true - case .convertSystem: + case .convertSystem, .repairSystem: systemWriteCompleted = true case .convertExtras: break - case .installExtras: + case .installExtras, .repairExtras: extrasInstallCompleted = true - case .convertCEC: + case .convertCEC, .repairCEC: break case .inspect, .rollback, .rollbackRepair, .rollbackExtras, .rollbackCEC: break @@ -1594,11 +2115,11 @@ public final class ConversionWorkflow { switch operation { case .inspect, .convert, .repairConverted, .rollback, .rollbackRepair: .core - case .convertSystem: + case .convertSystem, .repairSystem: .system - case .convertExtras, .installExtras, .rollbackExtras: + case .convertExtras, .installExtras, .repairExtras, .rollbackExtras: .extras - case .convertCEC, .rollbackCEC: + case .convertCEC, .repairCEC, .rollbackCEC: .cec } } diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift index e375f63..9014712 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift @@ -128,7 +128,7 @@ public enum ConverterCopy { "Repair.Version.Hint": "不知道版本时先自动检测;若多个历史版本都匹配,Dry Run 会要求你明确选择后再运行。", "Repair.Version.Required": "检测到多个可能版本。请选择当时使用的转换器版本,然后重新运行 Dry Run。", "Repair.PreviewSHA256": "修复预览 SHA-256", - "Components.RepairExtData": "可选:添加原始 3DS ExtData,修复已收到的公会名片;当前 card*/quest* 从 Wii U 存档目录自动读取。", + "Components.RepairExtData": "修复模式会明确分开原始 3DS ExtData、当前只读 Wii U/Cemu 目录和输出目录;公会名片与任务分别进行 Dry Run、写入、manifest 和回滚。", "Input.Slot": "存档槽位", "Input.FinalOutput": "最终输出", "Input.NewOutput": "新输出文件;写入时必须保持不存在", @@ -169,7 +169,7 @@ public enum ConverterCopy { "Status.Detail.NeedsInspection": "运行只读检查以确认存档类型、大小与 SHA-256;此步骤不会写入文件。", "Status.Detail.ReadyForDryRun": "输入检查已通过。Dry Run 会把写入授权绑定到当前文件指纹。", "Status.Detail.RevisionRequired": "多个历史转换版本同时匹配;明确选择版本并重新运行 Dry Run 前不会写入。", - "Status.Detail.OptionalDataBlocked": "已启用的 system 或 ExtData 缺少明确路径;补齐前所有写入保持禁用。", + "Status.Detail.OptionalDataBlocked": "已启用的 system 或 ExtData 缺少明确路径;只禁用该数据域,核心角色及其他已授权数据域保持独立。", "Status.Detail.SelectedWorkPending": "核心角色已完成,但已选 system 或 ExtData 仍需各自 Dry Run 与事务写入。", "Status.Detail.Authorized": "写入只对当前 SHA-256 与明确目标有效;任何变化都会撤销授权。", "Status.Detail.Running": "事务正在执行;界面保持状态可见,完成前不会接受第二个写入。", @@ -187,7 +187,7 @@ public enum ConverterCopy { "Input.InvalidSlot": "请选择 user1、user2 或 user3 文件,或一个直接包含所选槽位的目录。", "Guide.InputComplete": "检查已完成。下一步可按需添加名片、任务或其他可选数据;也可以直接继续 Dry Run。", "Guide.ComponentsReady": "可选数据已确认。接下来运行只读 Dry Run,确认本次转换会写入什么。", - "Guide.OptionalDataNeedsConfiguration": "已启用的可选数据尚未配齐。请先在本页选择所需的 system 或 ExtData 目录,再继续 Dry Run。", + "Guide.OptionalDataNeedsConfiguration": "已启用的可选数据尚未配齐;这只会禁用该数据域的操作,不会阻止核心角色 Dry Run 或其他独立数据域。", "Guide.OptionalDataReadyForTransaction": "已选可选数据已配齐。核心角色无需重复转换;前往写入页分别进行可选组件的 Dry Run 和写入。", "Guide.DryRunComplete": "Dry Run 已通过。确认写入前不会修改任何存档。", "Guide.CoreDryRunCompleteWithOptionals": "核心角色 Dry Run 已通过。接下来进入写入页;已选 system 或 ExtData 仍须分别 Dry Run 和写入。", @@ -204,14 +204,14 @@ public enum ConverterCopy { "Components.QuestsDetail": "迁移任务数据。", "Components.SystemSource": "3DS system", "Components.SystemTarget": "Cemu system", - "Components.SystemWarning": "system 由三个角色槽位共享。必须同时提供 3DS 源文件和已初始化的 Cemu 目标;转换器只合并管家画廊/动画标记,其他 Cemu 设置与跨槽位数据保持不变。", - "Components.SystemFooter": "目标必须是已存在且格式有效的 Cemu system。若不提供这一对文件,则不会迁移管家画廊/动画记录。", + "Components.SystemWarning": "system 由三个角色槽位共享。修复时当前 Wii U/Cemu system 只读、输出独立;转换器只合并管家画廊/动画标记,其他当前设置与跨槽位数据保持不变。", + "Components.SystemFooter": "全新转换需要 3DS 源和已初始化的 Cemu 目标;修复需要原始 3DS、当前只读 Cemu 引用和输出,且拥有独立 Dry Run 与 manifest。", "Components.SystemSourceMessage": "请选择一个 3DS system 文件。", "Components.SystemTargetMessage": "请选择一个 Cemu system 文件。", - "Components.ExtrasFooter": "选择 00000481 或其直接 user 子目录即可。应用只处理已选的名片/任务组件,不会扫描 SD 卡或覆盖整个 Cemu 存档目录。", + "Components.ExtrasFooter": "3DS 来源可选择 00000481 或其直接 user 子目录;当前引用和输出选择准确的 Wii U 存档目录。应用只处理本次单独授权的完整名片组或任务组。", "Components.ExtDataSourceMessage": "请选择准确的 3DS .../extdata/00000000/00000481/user 目录。", "Components.StagingMessage": "请选择一个新的空目录,用于暂存转换后的 ExtData 组件。", - "Components.TargetMessage": "请选择准确的 Cemu MH3G 存档目录,其中应包含 card1/card2/card3/cardbox 与 quest1 至 quest4。", + "Components.TargetMessage": "请选择准确的 Cemu MH3G 存档目录。所选组件组必须完整:名片为 card1/card2/card3/cardbox,任务为 quest1 至 quest4。", "Components.GroupScope": "完整事务组", "DryRun.BackupManifest": "备份与 manifest", "DryRun.BackupPending": "只会在事务写入时创建", @@ -341,7 +341,7 @@ public enum ConverterCopy { "Repair.Version.Hint": "Use automatic detection if the version is unknown. If several historical versions match, Dry Run will require an explicit selection before it can authorize a write.", "Repair.Version.Required": "Several versions match this save. Select the converter version originally used, then run Dry Run again.", "Repair.PreviewSHA256": "Repair preview SHA-256", - "Components.RepairExtData": "Optional: add the original 3DS ExtData to repair received guild cards. Current card*/quest* files are read from the Wii U save directory.", + "Components.RepairExtData": "Repair explicitly separates original 3DS ExtData, the current read-only Wii U/Cemu directory, and output. Guild cards and quests each have their own Dry Run, write, manifest, and rollback.", "Input.Slot": "Save slot", "Input.FinalOutput": "Final output", "Input.NewOutput": "New output file; it must remain absent before writing", @@ -382,7 +382,7 @@ public enum ConverterCopy { "Status.Detail.NeedsInspection": "Run the read-only inspection to verify profile, size, and SHA-256. No file is written.", "Status.Detail.ReadyForDryRun": "Inspection passed. Dry Run will bind write authorization to the current file fingerprints.", "Status.Detail.RevisionRequired": "Several historical revisions match. Choose one and repeat Dry Run before any write is allowed.", - "Status.Detail.OptionalDataBlocked": "A selected system or ExtData domain lacks an explicit path. All writes remain disabled until it is complete.", + "Status.Detail.OptionalDataBlocked": "A selected system or ExtData domain lacks an explicit path. Only that domain is disabled; core and other independently authorized domains remain available.", "Status.Detail.SelectedWorkPending": "The core slot completed, but selected system or ExtData still needs its own Dry Run and transaction.", "Status.Detail.Authorized": "Authorization applies only to the current SHA-256 values and explicit target. Any change revokes it.", "Status.Detail.Running": "The transaction is running. Its state remains visible and a second write is refused until completion.", @@ -400,7 +400,7 @@ public enum ConverterCopy { "Input.InvalidSlot": "Choose user1, user2, or user3, or a directory that directly contains the selected slot.", "Guide.InputComplete": "Inspection is complete. Add guild cards, quests, or other optional data if needed, or continue directly to Dry Run.", "Guide.ComponentsReady": "Optional data is set. Next, run a read-only Dry Run to review what this conversion will write.", - "Guide.OptionalDataNeedsConfiguration": "Selected optional data is not fully configured. Choose the required system or ExtData directories here before continuing to Dry Run.", + "Guide.OptionalDataNeedsConfiguration": "A selected optional domain is incomplete. This disables only that domain; it does not block the core Dry Run or another independent domain.", "Guide.OptionalDataReadyForTransaction": "Selected optional data is configured. The core slot does not need another conversion; continue to the write page for each optional component's Dry Run and write.", "Guide.DryRunComplete": "Dry Run passed. No save has been modified; continue when you are ready to confirm the write.", "Guide.CoreDryRunCompleteWithOptionals": "The core-slot Dry Run passed. Continue to the write page; selected system or ExtData data still needs its own Dry Run and write.", @@ -417,14 +417,14 @@ public enum ConverterCopy { "Components.QuestsDetail": "Migrate quest data.", "Components.SystemSource": "3DS system", "Components.SystemTarget": "Cemu system", - "Components.SystemWarning": "System is shared by all three character slots. Supply both the 3DS source and an initialized Cemu target; only housekeeper gallery/movie flags are merged while every other Cemu setting and shared-slot byte is retained.", - "Components.SystemFooter": "The target must be an existing valid Cemu system. If this pair is omitted, housekeeper gallery/movie history is not migrated.", + "Components.SystemWarning": "System is shared by all three character slots. During repair, the current Wii U/Cemu system is read-only and output is independent; only housekeeper gallery/movie flags are merged while every other current setting and shared-slot byte is retained.", + "Components.SystemFooter": "New conversion needs a 3DS source and initialized Cemu target. Repair needs original 3DS, current read-only Cemu reference, and output, with its own Dry Run and manifest.", "Components.SystemSourceMessage": "Choose exactly one 3DS system file.", "Components.SystemTargetMessage": "Choose exactly one Cemu system file.", - "Components.ExtrasFooter": "Choose 00000481 or its direct user child. The app only handles selected card/quest components; it does not scan an SD card or replace an entire Cemu save directory.", + "Components.ExtrasFooter": "For the 3DS source choose 00000481 or its direct user child; current and output are exact Wii U save directories. The app handles only the independently authorized complete card or quest group.", "Components.ExtDataSourceMessage": "Choose the exact 3DS .../extdata/00000000/00000481/user directory.", "Components.StagingMessage": "Choose a new empty staging directory for converted ExtData components.", - "Components.TargetMessage": "Choose the exact Cemu MH3G save directory that contains card1/card2/card3/cardbox and quest1 through quest4.", + "Components.TargetMessage": "Choose the exact Cemu MH3G save directory. Each selected group must be complete: card1/card2/card3/cardbox for cards, or quest1 through quest4 for quests.", "Components.GroupScope": "Complete transaction group", "DryRun.BackupManifest": "Backup + manifest", "DryRun.BackupPending": "Will be created only by the transactional write", diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/WorkflowStatusPresentation.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/WorkflowStatusPresentation.swift index 66ac72c..6f4b7bd 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/WorkflowStatusPresentation.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/WorkflowStatusPresentation.swift @@ -35,8 +35,8 @@ public struct WorkflowStatusPresentation: Equatable, Sendable { public extension ConversionWorkflow { /// The single presentation source for the toolbar and the always-visible /// safety card. Ordering is deliberate: an in-flight operation or failure - /// always wins over stale authorizations, and incomplete optional data is - /// shown as blocked instead of looking like a ready core write. + /// always wins over stale authorizations. Optional domains deliberately do + /// not block or downgrade an independently authorized core transaction. var statusPresentation: WorkflowStatusPresentation { if activeOperation != nil || state == .writing { return .init( @@ -62,14 +62,6 @@ public extension ConversionWorkflow { isBlocking: true ) } - if !selectedOptionalDataIsConfigured { - return .init( - kind: .blocked, - titleKey: "Status.OptionalDataBlocked", - detailKey: "Status.Detail.OptionalDataBlocked", - isBlocking: true - ) - } if canWrite || canWriteSystem || canStageExtras || canInstallExtras || canWriteCEC { return .init( kind: .authorized, diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift index d9dee84..afc8aea 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift @@ -14,8 +14,7 @@ struct ComponentSelectionView: View { subtitle: ConverterCopy.text("Components.Subtitle", language: language) ) { Form { - if workflow.mode == .newConversion { - Section { + Section { Toggle(ConverterCopy.text("Components.System", language: language), isOn: binding(\.includeSystem)) if workflow.components.includeSystem { SelectedPathRow( @@ -25,12 +24,29 @@ struct ComponentSelectionView: View { ) { chooseSystemSource() } - SelectedPathRow( - title: ConverterCopy.text("Components.SystemTarget", language: language), - value: workflow.components.systemTarget, - chooseTitle: ConverterCopy.text("Input.Select", language: language) - ) { - chooseSystemTarget() + if workflow.mode == .newConversion { + SelectedPathRow( + title: ConverterCopy.text("Components.SystemTarget", language: language), + value: workflow.components.systemTarget, + chooseTitle: ConverterCopy.text("Input.Select", language: language) + ) { + chooseSystemTarget() + } + } else { + SelectedPathRow( + title: ConverterCopy.text("Input.Current", language: language), + value: workflow.components.systemCurrent, + chooseTitle: ConverterCopy.text("Input.Select", language: language) + ) { + chooseSystemCurrent() + } + SelectedPathRow( + title: ConverterCopy.text("Input.RepairOutput", language: language), + value: workflow.components.systemTarget, + chooseTitle: ConverterCopy.text("Input.Select", language: language) + ) { + chooseSystemTarget() + } } Label( ConverterCopy.text("Components.SystemWarning", language: language), @@ -39,9 +55,8 @@ struct ComponentSelectionView: View { .font(.caption) .foregroundStyle(.orange) } - } footer: { - Text(ConverterCopy.text("Components.SystemFooter", language: language)) - } + } footer: { + Text(ConverterCopy.text("Components.SystemFooter", language: language)) } Section { @@ -56,19 +71,18 @@ struct ComponentSelectionView: View { enabled: workflow.components.includeGuildCards, language: language ) - if workflow.mode == .newConversion { - Toggle(ConverterCopy.text("Components.Quests", language: language), isOn: binding(\.includeQuests)) - Text(ConverterCopy.text("Components.QuestsDetail", language: language)) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 6) - GroupScopeCaption( - image: "scroll", - names: ExtraGroup.quests.componentNames.joined(separator: " · "), - enabled: workflow.components.includeQuests, - language: language - ) - } else { + Toggle(ConverterCopy.text("Components.Quests", language: language), isOn: binding(\.includeQuests)) + Text(ConverterCopy.text("Components.QuestsDetail", language: language)) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 6) + GroupScopeCaption( + image: "scroll", + names: ExtraGroup.quests.componentNames.joined(separator: " · "), + enabled: workflow.components.includeQuests, + language: language + ) + if workflow.mode == .repairConverted { Text(ConverterCopy.text("Components.RepairExtData", language: language)) .font(.caption) .foregroundStyle(.secondary) @@ -96,6 +110,21 @@ struct ComponentSelectionView: View { ) { chooseExtraTarget() } + } else { + SelectedPathRow( + title: ConverterCopy.text("Input.Current", language: language), + value: workflow.components.extraCurrentDirectory, + chooseTitle: ConverterCopy.text("Input.Select", language: language) + ) { + chooseExtraCurrent() + } + SelectedPathRow( + title: ConverterCopy.text("Input.RepairOutput", language: language), + value: workflow.components.extraTargetDirectory, + chooseTitle: ConverterCopy.text("Input.Select", language: language) + ) { + chooseExtraTarget() + } } } } footer: { @@ -184,6 +213,14 @@ struct ComponentSelectionView: View { update { $0.systemTarget = url } } + private func chooseSystemCurrent() { + guard let url = OpenPanel.selectFile( + title: ConverterCopy.text("Input.Current", language: language), + message: ConverterCopy.text("Components.SystemTargetMessage", language: language) + ) else { return } + update { $0.systemCurrent = url } + } + private func chooseExtraSource() { guard let url = OpenPanel.selectDirectory( title: ConverterCopy.text("Components.ExtDataSource", language: language), @@ -206,6 +243,14 @@ struct ComponentSelectionView: View { update { $0.extraStagingDirectory = url } } + private func chooseExtraCurrent() { + guard let url = OpenPanel.selectDirectory( + title: ConverterCopy.text("Input.Current", language: language), + message: ConverterCopy.text("Components.TargetMessage", language: language) + ) else { return } + update { $0.extraCurrentDirectory = url } + } + private func chooseExtraTarget() { guard let url = OpenPanel.selectDirectory( title: ConverterCopy.text("Components.Target", language: language), @@ -219,6 +264,7 @@ struct ComponentSelectionView: View { change(&next) workflow.setComponents(next) } + } private struct GroupScopeCaption: View { diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ExperimentalCECView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ExperimentalCECView.swift index cdc96e2..45d508a 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ExperimentalCECView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ExperimentalCECView.swift @@ -26,8 +26,20 @@ struct ExperimentalCECView: View { ) { chooseSource() } + if workflow.mode == .repairConverted { + SelectedPathRow( + title: ConverterCopy.text("Input.Current", language: language), + value: workflow.components.cecCurrent, + chooseTitle: ConverterCopy.text("Input.Select", language: language) + ) { + chooseCurrent() + } + } SelectedPathRow( - title: ConverterCopy.text("CEC.Target", language: language), + title: ConverterCopy.text( + workflow.mode == .repairConverted ? "Input.RepairOutput" : "CEC.Target", + language: language + ), value: workflow.components.cecTarget, chooseTitle: ConverterCopy.text("Input.Select", language: language) ) { @@ -37,7 +49,7 @@ struct ExperimentalCECView: View { ConverterCopy.text("CEC.Acknowledge", language: language), isOn: acknowledgementBinding ) - .disabled(workflow.components.cecSourceDirectory == nil || workflow.components.cecTarget == nil) + .disabled(!hasPaths) Label(ConverterCopy.text("CEC.Warning", language: language), systemImage: "exclamationmark.triangle") .foregroundStyle(.orange) .font(.caption) @@ -56,10 +68,10 @@ struct ExperimentalCECView: View { } .disabled(!hasPaths || running) - if workflow.canWriteCEC, let fingerprint = workflow.cecDryRunFingerprint { + if workflow.canWriteCEC { Label(ConverterCopy.text("CEC.Authorized", language: language), systemImage: "checkmark.shield.fill") .foregroundStyle(.green) - Text(fingerprint.targetSHA256Before) + Text(authorizedCECBeforeHash ?? "—") .font(.caption.monospaced()) .textSelection(.enabled) Button(ConverterCopy.text("CEC.Write", language: language)) { @@ -86,7 +98,10 @@ struct ExperimentalCECView: View { .disabled(manifest == nil || running) } - if let failure = workflow.failure, failure.operation == .convertCEC || failure.operation == .rollbackCEC { + if let failure = workflow.failure, + failure.operation == .convertCEC + || failure.operation == .repairCEC + || failure.operation == .rollbackCEC { Section { FailureDetails(failure: failure, language: language) } @@ -111,7 +126,9 @@ struct ExperimentalCECView: View { } private var hasPaths: Bool { - workflow.components.cecSourceDirectory != nil && workflow.components.cecTarget != nil + workflow.components.cecSourceDirectory != nil + && workflow.components.cecTarget != nil + && (workflow.mode == .newConversion || workflow.components.cecCurrent != nil) } private var acknowledgementBinding: Binding { @@ -126,6 +143,18 @@ struct ExperimentalCECView: View { } private var cecConfirmationDetails: [TransactionConfirmationDetail] { + if let fingerprint = workflow.repairCECDryRunFingerprint { + return [ + TransactionConfirmationDetail( + label: ConverterCopy.text("CEC.SourceRecordSetSHA256", language: language), + value: fingerprint.sourceRecordSetSHA256 + ), + TransactionConfirmationDetail( + label: ConverterCopy.text("CEC.TargetSHA256", language: language), + value: fingerprint.outputSetSHA256 + ), + ] + } guard let fingerprint = workflow.cecDryRunFingerprint else { return [] } return [ TransactionConfirmationDetail( @@ -139,6 +168,11 @@ struct ExperimentalCECView: View { ] } + private var authorizedCECBeforeHash: String? { + workflow.repairCECDryRunFingerprint?.outputSetSHA256 + ?? workflow.cecDryRunFingerprint?.targetSHA256Before + } + private func chooseSource() { guard let source = OpenPanel.selectDirectory( title: ConverterCopy.text("CEC.Source", language: language), @@ -155,6 +189,14 @@ struct ExperimentalCECView: View { update { $0.cecTarget = target } } + private func chooseCurrent() { + guard let current = OpenPanel.selectFile( + title: ConverterCopy.text("Input.Current", language: language), + message: ConverterCopy.text("CEC.TargetMessage", language: language) + ) else { return } + update { $0.cecCurrent = current } + } + private func update(_ change: (inout ComponentSelection) -> Void) { var next = workflow.components change(&next) diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings index b7d35a3..599c1aa 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings @@ -17,13 +17,13 @@ "Repair.Version.Hint" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Use automatic detection if the version is unknown. If several historical versions match, Dry Run will require an explicit selection before it can authorize a write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "不知道版本时先自动检测;若多个历史版本都匹配,Dry Run 会要求你明确选择后再运行。" } } } }, "Repair.Version.Required" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Several versions match this save. Select the converter version originally used, then run Dry Run again." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "检测到多个可能版本。请选择当时使用的转换器版本,然后重新运行 Dry Run。" } } } }, "Repair.PreviewSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Repair preview SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "修复预览 SHA-256" } } } }, - "Components.SystemWarning" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "System is shared by all three character slots. Supply both the 3DS source and an initialized Cemu target; only housekeeper gallery/movie flags are merged while every other Cemu setting and shared-slot byte is retained." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "system 由三个角色槽位共享。必须同时提供 3DS 源文件和已初始化的 Cemu 目标;转换器只合并管家画廊/动画标记,其他 Cemu 设置与跨槽位数据保持不变。" } } } }, + "Components.SystemWarning" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "System is shared by all three character slots. During repair, the current Wii U/Cemu system is read-only and output is independent; only housekeeper gallery/movie flags are merged while every other current setting and shared-slot byte is retained." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "system 由三个角色槽位共享。修复时当前 Wii U/Cemu system 只读、输出独立;转换器只合并管家画廊/动画标记,其他当前设置与跨槽位数据保持不变。" } } } }, "Guide.ComponentsReady" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Optional data is set. Next, run a read-only Dry Run to review what this conversion will write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "可选数据已确认。接下来运行只读 Dry Run,确认本次转换会写入什么。" } } } }, "Guide.CoreDryRunCompleteWithOptionals" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The core-slot Dry Run passed. Continue to the write page; selected system or ExtData data still needs its own Dry Run and write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "核心角色 Dry Run 已通过。接下来进入写入页;已选 system 或 ExtData 仍须分别 Dry Run 和写入。" } } } }, "Guide.DryRunComplete" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run passed. No save has been modified; continue when you are ready to confirm the write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run 已通过。确认写入前不会修改任何存档。" } } } }, "Guide.InputComplete" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Inspection is complete. Add guild cards, quests, or other optional data if needed, or continue directly to Dry Run." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "检查已完成。下一步可按需添加名片、任务或其他可选数据;也可以直接继续 Dry Run。" } } } }, "Guide.NextStep" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Next step" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "下一步" } } } }, - "Guide.OptionalDataNeedsConfiguration" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Selected optional data is not fully configured. Choose the required system or ExtData directories here before continuing to Dry Run." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已启用的可选数据尚未配齐。请先在本页选择所需的 system 或 ExtData 目录,再继续 Dry Run。" } } } }, + "Guide.OptionalDataNeedsConfiguration" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "A selected optional domain is incomplete. This disables only that domain; it does not block the core Dry Run or another independent domain." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已启用的可选数据尚未配齐;这只会禁用该数据域的操作,不会阻止核心角色 Dry Run 或其他独立数据域。" } } } }, "Guide.OptionalDataReadyForTransaction" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Selected optional data is configured. The core slot does not need another conversion; continue to the write page for each optional component's Dry Run and write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已选可选数据已配齐。核心角色无需重复转换;前往写入页分别进行可选组件的 Dry Run 和写入。" } } } }, "Guide.SelectedWorkPending" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Selected data is not all complete. Finish each Dry Run and write on this page before the final report becomes available." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "本次已选数据尚未全部完成。请在本页完成每项的 Dry Run 和写入;完成前不会显示最终报告入口。" } } } }, "Guide.ToComponents" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Continue: configure optional data" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "继续:配置可选数据" } } } }, diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/WriteRollbackView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/WriteRollbackView.swift index bd2378a..6a8897d 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/WriteRollbackView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/WriteRollbackView.swift @@ -11,6 +11,8 @@ struct WriteRollbackView: View { @State private var coreManifest: URL? @State private var systemManifest: URL? @State private var extrasManifest: URL? + @State private var repairExtrasManifests = [ExtraGroup: URL]() + @State private var selectedRepairExtraGroup: ExtraGroup? @State private var isRunning = false var body: some View { @@ -73,9 +75,7 @@ struct WriteRollbackView: View { language: language ), target: workflow.input?.target, - files: workflow.mode == .repairConverted && workflow.components.includeGuildCards - ? ConverterCopy.text("Write.RepairTargets", language: language) - : ConverterCopy.text("Write.OneTarget", language: language), + files: ConverterCopy.text("Write.OneTarget", language: language), language: language, verificationDetails: coreConfirmationDetails, onConfirm: writeCore, @@ -86,7 +86,7 @@ struct WriteRollbackView: View { TransactionConfirmationSheet( title: ConverterCopy.text("Write.WriteSystem", language: language), targetLabel: ConverterCopy.text("Write.Target", language: language), - target: workflow.components.systemTarget, + target: systemTargetForDisplay, files: ConverterCopy.text("Write.OneTarget", language: language), language: language, verificationDetails: systemConfirmationDetails, @@ -98,11 +98,11 @@ struct WriteRollbackView: View { TransactionConfirmationSheet( title: ConverterCopy.text("Write.ExtrasInstall", language: language), targetLabel: ConverterCopy.text("Write.TargetDirectory", language: language), - target: workflow.components.extraTargetDirectory, - files: selectedExtraGroups, + target: extrasTargetForDisplay, + files: selectedRepairExtraGroup.map(extraGroupLabel) ?? selectedExtraGroups, language: language, verificationDetails: extrasConfirmationDetails, - onConfirm: installExtras, + onConfirm: confirmExtrasWrite, onCancel: { showExtrasConfirmation = false } ) } @@ -178,43 +178,85 @@ struct WriteRollbackView: View { Text(selectedExtraGroups) .foregroundStyle(.secondary) } - LabeledContent(ConverterCopy.text("Write.Authorization", language: language)) { - authorizationLabel(workflow.canStageExtras, readyKey: "Write.StageReady") - } - Button(ConverterCopy.text("Write.ExtrasStageDryRun", language: language)) { - runExtrasStageDryRun() - } - .disabled(!hasExtraPaths || isRunning) - Button(ConverterCopy.text("Write.ExtrasStage", language: language)) { - stageExtras() - } - .disabled(!workflow.canStageExtras || isRunning) + if workflow.mode == .repairConverted { + ForEach( + workflow.components.selectedGroups.sorted { $0.rawValue < $1.rawValue }, + id: \.self + ) { group in + VStack(alignment: .leading, spacing: 8) { + Text(extraGroupLabel(group)) + .font(.headline) + LabeledContent(ConverterCopy.text("Write.Authorization", language: language)) { + authorizationLabel(workflow.canWriteRepairExtraGroup(group)) + } + HStack { + Button(ConverterCopy.text("Write.ExtrasInstallDryRun", language: language)) { + runRepairExtraDryRun(group) + } + .disabled(!hasExtraPaths || isRunning) + Button(ConverterCopy.text("Write.ExtrasInstall", language: language)) { + selectedRepairExtraGroup = group + showExtrasConfirmation = true + } + .disabled(!workflow.canWriteRepairExtraGroup(group) || isRunning) + } + SelectedPathRow( + title: ConverterCopy.text("Write.ExtrasManifest", language: language), + value: repairExtrasManifests[group], + chooseTitle: ConverterCopy.text("Input.Select", language: language) + ) { + repairExtrasManifests[group] = selectManifest( + title: "\(extraGroupLabel(group)) · \(ConverterCopy.text("Write.ExtrasManifest", language: language))" + ) + } + Button(ConverterCopy.text("Write.Rollback", language: language)) { + rollbackRepairExtra(group) + } + .disabled(repairExtrasManifests[group] == nil || isRunning) + } + } + } else { + LabeledContent(ConverterCopy.text("Write.Authorization", language: language)) { + authorizationLabel(workflow.canStageExtras, readyKey: "Write.StageReady") + } + Button(ConverterCopy.text("Write.ExtrasStageDryRun", language: language)) { + runExtrasStageDryRun() + } + .disabled(!hasExtraPaths || isRunning) + Button(ConverterCopy.text("Write.ExtrasStage", language: language)) { + stageExtras() + } + .disabled(!workflow.canStageExtras || isRunning) - LabeledContent(ConverterCopy.text("Write.Authorization", language: language)) { - authorizationLabel(workflow.canInstallExtras, readyKey: "Write.InstallReady") - } - Button(ConverterCopy.text("Write.ExtrasInstallDryRun", language: language)) { - runExtrasInstallDryRun() - } - .disabled(!hasExtraPaths || isRunning) - Button(ConverterCopy.text("Write.ExtrasInstall", language: language)) { - showExtrasConfirmation = true + LabeledContent(ConverterCopy.text("Write.Authorization", language: language)) { + authorizationLabel(workflow.canInstallExtras, readyKey: "Write.InstallReady") + } + Button(ConverterCopy.text("Write.ExtrasInstallDryRun", language: language)) { + runExtrasInstallDryRun() + } + .disabled(!hasExtraPaths || isRunning) + Button(ConverterCopy.text("Write.ExtrasInstall", language: language)) { + selectedRepairExtraGroup = nil + showExtrasConfirmation = true + } + .disabled(!workflow.canInstallExtras || isRunning) } - .disabled(!workflow.canInstallExtras || isRunning) - SelectedPathRow( - title: ConverterCopy.text("Write.ExtrasManifest", language: language), - value: extrasManifest, - chooseTitle: ConverterCopy.text("Input.Select", language: language) - ) { - extrasManifest = selectManifest( - title: ConverterCopy.text("Write.ExtrasManifest", language: language) - ) - } - Button(ConverterCopy.text("Write.Rollback", language: language)) { - rollbackExtras() + if workflow.mode == .newConversion { + SelectedPathRow( + title: ConverterCopy.text("Write.ExtrasManifest", language: language), + value: extrasManifest, + chooseTitle: ConverterCopy.text("Input.Select", language: language) + ) { + extrasManifest = selectManifest( + title: ConverterCopy.text("Write.ExtrasManifest", language: language) + ) + } + Button(ConverterCopy.text("Write.Rollback", language: language)) { + rollbackExtras() + } + .disabled(extrasManifest == nil || isRunning) } - .disabled(extrasManifest == nil || isRunning) } header: { Text(ConverterCopy.text("Write.Extras", language: language)) } footer: { @@ -223,30 +265,48 @@ struct WriteRollbackView: View { } private var hasSystemPaths: Bool { - workflow.components.systemSource != nil && workflow.components.systemTarget != nil + guard workflow.components.systemSource != nil, + workflow.components.systemTarget != nil + else { return false } + return workflow.mode == .newConversion || workflow.components.systemCurrent != nil } private var hasExtraPaths: Bool { - !workflow.components.selectedGroups.isEmpty - && workflow.components.extraSourceDirectory != nil - && workflow.components.extraStagingDirectory != nil + guard !workflow.components.selectedGroups.isEmpty, + workflow.components.extraSourceDirectory != nil + else { return false } + if workflow.mode == .repairConverted { + return workflow.components.extraCurrentDirectory != nil + && workflow.components.extraTargetDirectory != nil + } + return workflow.components.extraStagingDirectory != nil && workflow.components.extraTargetDirectory != nil } + private var systemTargetForDisplay: URL? { + return workflow.components.systemTarget + } + + private var extrasTargetForDisplay: URL? { + return workflow.components.extraTargetDirectory + } + private var selectedExtraGroups: String { workflow.components.selectedGroups .sorted { $0.rawValue < $1.rawValue } - .map { group in - switch group { - case .guildCards: - ConverterCopy.text("Components.GuildCards", language: language) - case .quests: - ConverterCopy.text("Components.Quests", language: language) - } - } + .map(extraGroupLabel) .joined(separator: " · ") } + private func extraGroupLabel(_ group: ExtraGroup) -> String { + switch group { + case .guildCards: + ConverterCopy.text("Components.GuildCards", language: language) + case .quests: + ConverterCopy.text("Components.Quests", language: language) + } + } + private var coreConfirmationDetails: [TransactionConfirmationDetail] { if let fingerprint = workflow.repairDryRunFingerprint { return [ @@ -284,6 +344,22 @@ struct WriteRollbackView: View { } private var systemConfirmationDetails: [TransactionConfirmationDetail] { + if let fingerprint = workflow.repairSystemDryRunFingerprint { + return [ + TransactionConfirmationDetail( + label: ConverterCopy.text("Write.SourceSHA256", language: language), + value: fingerprint.sourceSetSHA256 + ), + TransactionConfirmationDetail( + label: ConverterCopy.text("Write.CurrentSetSHA256", language: language), + value: fingerprint.currentSetSHA256 + ), + TransactionConfirmationDetail( + label: ConverterCopy.text("Write.OutputSetSHA256", language: language), + value: fingerprint.outputSetSHA256 + ), + ] + } guard let fingerprint = workflow.systemDryRunFingerprint else { return [] } return [ TransactionConfirmationDetail( @@ -298,6 +374,23 @@ struct WriteRollbackView: View { } private var extrasConfirmationDetails: [TransactionConfirmationDetail] { + if let group = selectedRepairExtraGroup, + let fingerprint = workflow.repairExtrasDryRunFingerprints[group] { + return [ + TransactionConfirmationDetail( + label: ConverterCopy.text("Write.CurrentSetSHA256", language: language), + value: fingerprint.currentSetSHA256 + ), + TransactionConfirmationDetail( + label: ConverterCopy.text("Write.OutputSetSHA256", language: language), + value: fingerprint.outputSetSHA256 + ), + TransactionConfirmationDetail( + label: ConverterCopy.text("Repair.PreviewSHA256", language: language), + value: fingerprint.previewSHA256 + ), + ] + } guard let fingerprint = workflow.extrasInstallDryRunFingerprint else { return [] } return [ TransactionConfirmationDetail( @@ -385,6 +478,14 @@ struct WriteRollbackView: View { } } + private func runRepairExtraDryRun(_ group: ExtraGroup) { + isRunning = true + Task { + defer { isRunning = false } + try? await workflow.runRepairExtraDryRun(group: group) + } + } + private func stageExtras() { isRunning = true Task { @@ -413,6 +514,40 @@ struct WriteRollbackView: View { } } + private func confirmExtrasWrite() { + if let group = selectedRepairExtraGroup { + writeRepairExtra(group) + } else { + installExtras() + } + } + + private func writeRepairExtra(_ group: ExtraGroup) { + showExtrasConfirmation = false + isRunning = true + Task { + defer { isRunning = false } + do { + try await workflow.writeRepairExtraGroup(group) + if let manifest = capturedManifest() { + repairExtrasManifests[group] = manifest + } + } catch {} + } + } + + private func rollbackRepairExtra(_ group: ExtraGroup) { + guard let manifest = repairExtrasManifests[group] else { return } + isRunning = true + Task { + defer { isRunning = false } + do { + try await workflow.rollback(manifest: manifest, extraGroup: true) + repairExtrasManifests[group] = nil + } catch {} + } + } + private func rollbackExtras() { guard let extrasManifest else { return } isRunning = true @@ -423,10 +558,14 @@ struct WriteRollbackView: View { } private func captureManifest(into destination: inout URL?) { + destination = capturedManifest() + } + + private func capturedManifest() -> URL? { guard let report = workflow.latestReport, let path = report.compatibilityManifest ?? report.manifest - else { return } - destination = URL(fileURLWithPath: path) + else { return nil } + return URL(fileURLWithPath: path) } } diff --git a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/ConversionWorkflowTests.swift b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/ConversionWorkflowTests.swift index 83ad1f7..187eaf8 100644 --- a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/ConversionWorkflowTests.swift +++ b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/ConversionWorkflowTests.swift @@ -114,6 +114,118 @@ final class ConversionWorkflowTests: XCTestCase { XCTAssertNil(workflow.repairDryRunFingerprint) } + func testRepairSystemUsesItsOwnSourceCurrentAndOutputPaths() async throws { + let selection = ComponentSelection( + includeSystem: true, + systemSource: URL(fileURLWithPath: "/tmp/3ds-shared/system"), + systemCurrent: URL(fileURLWithPath: "/tmp/current-shared/system"), + systemTarget: URL(fileURLWithPath: "/tmp/output-shared/system") + ) + let executor = FakeConverterCommandExecutor(results: [ + .success(repairSystemResult(selection: selection, status: "dry-run")), + .success(repairSystemResult(selection: selection, status: "no-changes")), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setMode(.repairConverted) + workflow.setComponents(selection) + + try await workflow.runSystemDryRun() + XCTAssertTrue(workflow.canWriteSystem) + try await workflow.writeSystem() + + let commands = await executor.recordedCommands() + XCTAssertTrue(commands[0].arguments.containsAdjacent("--current", selection.systemCurrent!.path)) + XCTAssertTrue(commands[0].arguments.containsAdjacent("--output", selection.systemTarget!.path)) + XCTAssertTrue(commands[1].arguments.contains("--expected-preview-sha256")) + } + + func testRepairGuildCardsIsAnIndependentExtDataTransaction() async throws { + let selection = ComponentSelection( + includeGuildCards: true, + extraSourceDirectory: URL(fileURLWithPath: "/tmp/3ds-extdata/user"), + extraCurrentDirectory: URL(fileURLWithPath: "/tmp/current-cemu"), + extraTargetDirectory: URL(fileURLWithPath: "/tmp/output-cemu") + ) + let executor = FakeConverterCommandExecutor(results: [ + .success(repairExtrasResult(selection: selection, status: "dry-run")), + .success(repairExtrasResult(selection: selection, status: "no-changes")), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setMode(.repairConverted) + workflow.setRepairFromVersion(.v0_0_5) + workflow.setComponents(selection) + + try await workflow.runRepairExtraDryRun(group: .guildCards) + XCTAssertTrue(workflow.canWriteRepairExtraGroup(.guildCards)) + try await workflow.writeRepairExtraGroup(.guildCards) + + let commands = await executor.recordedCommands() + XCTAssertTrue(commands[0].arguments.containsAdjacent("--group", "guild-cards")) + XCTAssertTrue(commands[0].arguments.containsAdjacent("--current-dir", selection.extraCurrentDirectory!.path)) + XCTAssertTrue(commands[0].arguments.containsAdjacent("--output-dir", selection.extraTargetDirectory!.path)) + XCTAssertFalse(commands[0].arguments.contains("repair-converted")) + } + + func testFailedGuildCardWriteDoesNotRevokeQuestRepairAuthorization() async throws { + let selection = ComponentSelection( + includeGuildCards: true, + includeQuests: true, + extraSourceDirectory: URL(fileURLWithPath: "/tmp/3ds-extdata/user"), + extraCurrentDirectory: URL(fileURLWithPath: "/tmp/current-cemu"), + extraTargetDirectory: URL(fileURLWithPath: "/tmp/output-cemu") + ) + let executor = FakeConverterCommandExecutor(results: [ + .success(repairExtrasResult(selection: selection, status: "dry-run", group: .guildCards)), + .success(repairExtrasResult(selection: selection, status: "dry-run", group: .quests)), + .success(ConverterCommandResult(exitCode: 2, stdout: Data(), stderr: Data("card output changed".utf8))), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setMode(.repairConverted) + workflow.setRepairFromVersion(.v0_0_5) + workflow.setComponents(selection) + + try await workflow.runRepairExtraDryRun(group: .guildCards) + try await workflow.runRepairExtraDryRun(group: .quests) + XCTAssertTrue(workflow.canWriteRepairExtraGroup(.guildCards)) + XCTAssertTrue(workflow.canWriteRepairExtraGroup(.quests)) + + do { + try await workflow.writeRepairExtraGroup(.guildCards) + XCTFail("the failed card write must surface its CLI failure") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertFalse(workflow.canWriteRepairExtraGroup(.guildCards)) + XCTAssertTrue(workflow.canWriteRepairExtraGroup(.quests)) + } + + func testRepairCECUsesSeparateCurrentAndOutputCaches() async throws { + let selection = ComponentSelection( + cecSourceDirectory: URL(fileURLWithPath: "/tmp/CEC/00048100"), + cecCurrent: URL(fileURLWithPath: "/tmp/current-cemu/cec"), + cecTarget: URL(fileURLWithPath: "/tmp/output-cemu/cec"), + acknowledgeExperimentalCEC: true + ) + let executor = FakeConverterCommandExecutor(results: [ + .success(repairCECResult(selection: selection, status: "dry-run")), + .success(repairCECResult(selection: selection, status: "no-changes")), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setMode(.repairConverted) + workflow.setComponents(selection) + + try await workflow.runCECDryRun() + XCTAssertTrue(workflow.canWriteCEC) + try await workflow.writeCEC() + + let commands = await executor.recordedCommands() + XCTAssertEqual(commands[0].arguments.first, "repair-cec") + XCTAssertTrue(commands[0].arguments.containsAdjacent("--current", selection.cecCurrent!.path)) + XCTAssertTrue(commands[0].arguments.containsAdjacent("--output", selection.cecTarget!.path)) + XCTAssertTrue(commands[1].arguments.contains("--experimental")) + } + func testCompatibilityManifestUsesRollbackRepair() async throws { let executor = FakeConverterCommandExecutor(results: [ .success(rolledBackResult(operation: ConverterOperation.rollbackRepair.rawValue)), @@ -185,7 +297,7 @@ final class ConversionWorkflowTests: XCTestCase { XCTAssertTrue(workflow.selectedOptionalDataIsConfigured) } - func testIncompleteSelectedOptionalDataBlocksCoreDryRunAcrossNavigation() async throws { + func testIncompleteSelectedOptionalDataDoesNotBlockIndependentCoreDryRun() async throws { let executor = FakeConverterCommandExecutor(results: [.success(dryRunResult())]) let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) workflow.configure(input: fixtureInput) @@ -193,18 +305,16 @@ final class ConversionWorkflowTests: XCTestCase { workflow.setComponents(ComponentSelection(includeGuildCards: true)) XCTAssertFalse(workflow.selectedOptionalDataIsConfigured) - XCTAssertFalse(workflow.canStartDryRun) - - do { - try await workflow.runCoreDryRun() - XCTFail("selected optional data must be configured before the core Dry Run") - } catch { - XCTAssertEqual(error as? ConversionWorkflowError, .missingExtraDirectories) - } + XCTAssertTrue(workflow.canStartDryRun) + try await workflow.runCoreDryRun() + XCTAssertTrue(workflow.canWrite) } - func testIncompleteSelectedOptionalDataBlocksAnExistingCoreWriteAuthorization() async throws { - let executor = FakeConverterCommandExecutor(results: [.success(dryRunResult())]) + func testIncompleteSelectedOptionalDataDoesNotRevokeCoreWriteAuthorization() async throws { + let executor = FakeConverterCommandExecutor(results: [ + .success(dryRunResult()), + .success(writtenResult(operation: ConverterOperation.convert.rawValue)), + ]) let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) workflow.configure(input: fixtureInput) workflow.applyInspections(source: fixtureSourceInspection, target: fixtureTargetInspection) @@ -213,14 +323,9 @@ final class ConversionWorkflowTests: XCTestCase { workflow.setComponents(ComponentSelection(includeGuildCards: true)) XCTAssertFalse(workflow.selectedOptionalDataIsConfigured) - XCTAssertFalse(workflow.canWrite) - - do { - try await workflow.writeCore() - XCTFail("an incomplete optional selection must block an existing core write authorization") - } catch { - XCTAssertEqual(error as? ConversionWorkflowError, .missingExtraDirectories) - } + XCTAssertTrue(workflow.canWrite) + try await workflow.writeCore() + XCTAssertTrue(workflow.coreWriteCompleted) } func testCoreWriteDoesNotMarkSelectedOptionalDataAsComplete() async throws { @@ -1251,6 +1356,45 @@ private func repairNoChangesResult() -> ConverterCommandResult { return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) } +private func repairSystemResult( + selection: ComponentSelection, + status: String +) -> ConverterCommandResult { + let manifest = status == "written" ? "\"/tmp/output-shared/.system.manifest.json\"" : "null" + let json = """ + {"operation":"repair-system","status":"\(status)","source":"\(selection.systemSource!.path)","current":"\(selection.systemCurrent!.path)","output":"\(selection.systemTarget!.path)","source_set_sha256":"\(validSHA("1"))","current_set_sha256":"\(validSHA("2"))","output_set_sha256":"\(validSHA("3"))","preview_sha256":"\(validSHA("4"))","manifest":\(manifest)} + """ + return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) +} + +private func repairExtrasResult( + selection: ComponentSelection, + status: String, + group: ExtraGroup = .guildCards +) -> ConverterCommandResult { + let components = group.componentNames.map { component in + """ + {"component":"\(component)","target":"\(selection.extraTargetDirectory!.path)/\(component)","modified":false,"detection":{"confidence":"selected","candidates":["0.0.5"]},"merge":{"component":"\(component)","source_sha256":"\(validSHA("5"))","current_sha256":"\(validSHA("6"))","merged_sha256":"\(validSHA("6"))"}} + """ + }.joined(separator: ",") + let manifest = status == "written" ? "\"/tmp/output-cemu/.extras.manifest.json\"" : "null" + let json = """ + {"operation":"repair-extras","status":"\(status)","group":"\(group.rawValue)","source_dir":"\(selection.extraSourceDirectory!.path)","current_dir":"\(selection.extraCurrentDirectory!.path)","output_dir":"\(selection.extraTargetDirectory!.path)","source_set_sha256":"\(validSHA("1"))","current_set_sha256":"\(validSHA("2"))","output_set_sha256":"\(validSHA("3"))","preview_sha256":"\(validSHA("4"))","detection":{"confidence":"selected","candidates":["0.0.5"]},"components":[\(components)],"manifest":\(manifest)} + """ + return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) +} + +private func repairCECResult( + selection: ComponentSelection, + status: String +) -> ConverterCommandResult { + let manifest = status == "written" ? "\"/tmp/output-cemu/.cec.manifest.json\"" : "null" + let json = """ + {"operation":"repair-cec","status":"\(status)","source_dir":"\(selection.cecSourceDirectory!.path)","current":"\(selection.cecCurrent!.path)","output":"\(selection.cecTarget!.path)","source_record_set_sha256":"\(validSHA("a"))","current_set_sha256":"\(validSHA("b"))","output_set_sha256":"\(validSHA("c"))","preview_sha256":"\(validSHA("d"))","manifest":\(manifest)} + """ + return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) +} + private func rolledBackResult(operation: String) -> ConverterCommandResult { let manifest = operation == ConverterOperation.rollbackRepair.rawValue ? "/tmp/.mh3g-compatibility-repair-test.json" diff --git a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/WorkflowStatusPresentationTests.swift b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/WorkflowStatusPresentationTests.swift index f96cbf5..389e465 100644 --- a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/WorkflowStatusPresentationTests.swift +++ b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/WorkflowStatusPresentationTests.swift @@ -71,21 +71,20 @@ final class WorkflowStatusPresentationTests: XCTestCase { XCTAssertTrue(WorkflowStageRailLayoutContract.adaptive.preservesAccessibilityStateLabels) } - func testIncompleteOptionalSelectionOverridesAStaleCoreAuthorization() throws { + func testIncompleteOptionalSelectionDoesNotOverrideCoreAuthorization() throws { let workflow = configuredWorkflow() try workflow.authorizeDryRunForTesting() XCTAssertEqual(workflow.statusPresentation.kind, .authorized) workflow.setComponents(ComponentSelection(includeGuildCards: true)) - XCTAssertEqual(workflow.statusPresentation.kind, .blocked) - XCTAssertEqual(workflow.statusPresentation.titleKey, "Status.OptionalDataBlocked") - XCTAssertTrue(workflow.statusPresentation.isBlocking) - XCTAssertFalse(workflow.canWrite) + XCTAssertEqual(workflow.statusPresentation.kind, .authorized) + XCTAssertFalse(workflow.statusPresentation.isBlocking) + XCTAssertTrue(workflow.canWrite) let dryRunStep = workflow.stageRailPresentation.first { $0.route == .dryRun } - XCTAssertEqual(dryRunStep?.tone, .blocked) - XCTAssertEqual(dryRunStep?.iconName, "exclamationmark.triangle.fill") - XCTAssertEqual(dryRunStep?.accessibilityStateKey, "Status.Blocked") + XCTAssertEqual(dryRunStep?.tone, .complete) + XCTAssertEqual(dryRunStep?.iconName, "checkmark.circle.fill") + XCTAssertEqual(dryRunStep?.accessibilityStateKey, "Status.Succeeded") } private func configuredWorkflow() -> ConversionWorkflow { diff --git a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs index e3605d7..a9618d4 100644 --- a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs +++ b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs @@ -154,14 +154,19 @@ public void SetLanguage(AppLanguageOverride language) public string CecDisabled => Text(nameof(CecDisabled)); public string CecAcknowledgementRequired => Text(nameof(CecAcknowledgementRequired)); public string CecPathsRequired => Text(nameof(CecPathsRequired)); + public string RepairCecPathsRequired => Text(nameof(RepairCecPathsRequired)); public string CecFingerprintMissing => Text(nameof(CecFingerprintMissing)); public string SystemPathsRequired => Text(nameof(SystemPathsRequired)); + public string RepairSystemPathsRequired => Text(nameof(RepairSystemPathsRequired)); public string ExtrasPathsRequired => Text(nameof(ExtrasPathsRequired)); + public string RepairExtrasPathsRequired => Text(nameof(RepairExtrasPathsRequired)); public string ExtrasFingerprintMissing => Text(nameof(ExtrasFingerprintMissing)); public string OptionalDataNeedsConfiguration => Text(nameof(OptionalDataNeedsConfiguration)); public string SharedSystem => Text(nameof(SharedSystem)); public string SystemSource => Text(nameof(SystemSource)); + public string SystemCurrent => Text(nameof(SystemCurrent)); public string SystemTarget => Text(nameof(SystemTarget)); + public string SystemRepairOutput => Text(nameof(SystemRepairOutput)); public string SystemMergeWarning => Text(nameof(SystemMergeWarning)); public string RunSystemDryRun => Text(nameof(RunSystemDryRun)); public string WriteSystem => Text(nameof(WriteSystem)); @@ -171,6 +176,14 @@ public void SetLanguage(AppLanguageOverride language) public string Quests => Text(nameof(Quests)); public string ExtDataSource => Text(nameof(ExtDataSource)); public string ExtDataSourceHint => Text(nameof(ExtDataSourceHint)); + public string ExtDataCurrent => Text(nameof(ExtDataCurrent)); + public string ExtDataRepairOutput => Text(nameof(ExtDataRepairOutput)); + public string ExtDataRepairPathHint => Text(nameof(ExtDataRepairPathHint)); + public string ExtDataRepairOutputWarning => Text(nameof(ExtDataRepairOutputWarning)); + public string RepairGroupDryRun => Text(nameof(RepairGroupDryRun)); + public string RepairGroupWrite => Text(nameof(RepairGroupWrite)); + public string GuildCardsManifest => Text(nameof(GuildCardsManifest)); + public string QuestsManifest => Text(nameof(QuestsManifest)); public string StagingDirectory => Text(nameof(StagingDirectory)); public string ExtDataTarget => Text(nameof(ExtDataTarget)); public string PreviewStaging => Text(nameof(PreviewStaging)); @@ -180,6 +193,8 @@ public void SetLanguage(AppLanguageOverride language) public string ExtDataManifest => Text(nameof(ExtDataManifest)); public string CecSourceDirectoryHint => Text(nameof(CecSourceDirectoryHint)); public string CecTargetCacheHint => Text(nameof(CecTargetCacheHint)); + public string CecCurrentCache => Text(nameof(CecCurrentCache)); + public string CecRepairOutput => Text(nameof(CecRepairOutput)); public string DescribeResolvedSource(string path) => string.Format(Text("ResolvedSource"), path); public string DescribeResolvedCurrent(string path) => string.Format(Text("ResolvedCurrent"), path); @@ -247,7 +262,7 @@ private string Text(string key) [nameof(ConversionModeNew)] = "New conversion", [nameof(ConversionModeRepair)] = "Repair an already converted save", [nameof(ConversionModeNewDescription)] = "Create a Wii U / Cemu slot from an original Japanese 3DS user# file.", - [nameof(ConversionModeRepairDescription)] = "Repair uses three explicit paths: the original 3DS user#, the latest matching Wii U / Cemu user# that you continued playing, and a separate output. The current Wii U input is read-only; only the output is written. To repair received guild cards and offline partners too, enable Guild cards below and select the original 3DS ExtData user directory.", + [nameof(ConversionModeRepairDescription)] = "Repair is domain-scoped. Core user#, shared system, guild cards, quests, and experimental CEC each use their own Dry Run, write authorization, manifest, and rollback. Every enabled domain explicitly separates original 3DS source, current read-only Wii U / Cemu reference, and output.", [nameof(RepairVersionTitle)] = "Original converter version", [nameof(RepairVersionAutomatic)] = "Detect automatically", [nameof(RepairVersionHint)] = "Keep automatic detection unless Dry Run reports multiple incompatible candidates. Choosing a version invalidates the previous preview and requires another Dry Run.", @@ -292,19 +307,21 @@ private string Text(string key) [nameof(ManifestPath)] = "Transaction manifest", [nameof(RollbackFromManifest)] = "Rollback from manifest", [nameof(OptionalSectionTitle)] = "Optional data", - [nameof(OptionalSectionDescription)] = "Shared system carries housekeeper gallery/movie unlocks; ExtData carries cards and quests. They are separate, explicit transactions. CEC stays isolated and disabled until you enable it.", + [nameof(OptionalSectionDescription)] = "Shared system carries housekeeper gallery/movie unlocks; ExtData carries cards and quests. In repair mode, cards and quests are separate component-group transactions. CEC stays isolated and disabled until you enable it.", [nameof(ExperimentalCec)] = "Experimental CEC", [nameof(ExperimentalCecHint)] = "CEC is a separate StreetPass / Hunter Search cache. Guild-card and offline-partner migration do not require it.", [nameof(CecSourceDirectory)] = "3DS CEC mailbox directory", [nameof(CecTargetCache)] = "Cemu cec cache", + [nameof(CecCurrentCache)] = "Current Wii U / Cemu cec reference", + [nameof(CecRepairOutput)] = "Repaired cec output", ["CecSourceDirectoryHint"] = "Choose the exact CEC/00048100 mailbox folder. It is separate from the ExtData user directory and is never searched recursively.", - ["CecTargetCacheHint"] = "Choose the exact Cemu cec file, usually inside the selected Wii U save directory.", + ["CecTargetCacheHint"] = "Choose the exact Cemu cec file, usually inside the selected Wii U save directory. Repair mode adds a separate read-only current cec reference and writes only the explicit output.", [nameof(InspectCec)] = "Inspect CEC", [nameof(RunCecDryRun)] = "Run CEC Dry Run", [nameof(CecAcknowledge)] = "I understand that CEC import remains experimental", [nameof(WriteCec)] = "Write experimental CEC", [nameof(RollbackCec)] = "Rollback CEC manifest", - [nameof(ExtDataNotice)] = "ExtData is optional. Choose its exact user directory (or only its direct 00000481 parent). Staging always converts the complete eight-file set; the group checkboxes do not filter that output and select groups for the read-only target preview.", + [nameof(ExtDataNotice)] = "ExtData is optional. New conversion uses complete staging plus selected-group install. Repair mode skips staging and repairs guild cards or quests independently from explicit source/current/output directories.", [nameof(ExtDataInstallUnavailable)] = "Windows installs complete ExtData groups through the Rust sidecar's recovery journal and ReplaceFileW-backed transaction. The emulator must remain closed.", [nameof(ResultTitle)] = "Structured report", [nameof(ResultEmpty)] = "Run an inspection or Dry Run to see the CLI JSON report here.", @@ -331,15 +348,20 @@ private string Text(string key) [nameof(CecDisabled)] = "Enable Experimental CEC to use its isolated commands.", [nameof(CecAcknowledgementRequired)] = "Acknowledge the experimental CEC warning before its write command is available.", [nameof(CecPathsRequired)] = "Choose the exact CEC mailbox directory and Cemu cec target path.", + [nameof(RepairCecPathsRequired)] = "Choose the original 3DS CEC mailbox, current read-only Cemu cec reference, and repaired cec output.", [nameof(CecFingerprintMissing)] = "CEC Dry Run did not provide the aggregate record-set and target JSON fingerprints.", [nameof(SystemPathsRequired)] = "Choose the exact 3DS source system and an existing initialized Cemu system before running this separate merge.", + [nameof(RepairSystemPathsRequired)] = "Choose the exact original 3DS system, current read-only Wii U / Cemu system, and repaired system output.", [nameof(ExtrasPathsRequired)] = "Choose at least one ExtData group plus explicit source and staging directories.", + [nameof(RepairExtrasPathsRequired)] = "Choose at least one ExtData group plus explicit original 3DS source, current Wii U / Cemu reference, and initialized output directories.", [nameof(ExtrasFingerprintMissing)] = "The ExtData Dry Run did not provide the expected component or set fingerprints.", [nameof(OptionalDataNeedsConfiguration)] = "Finish configuring the selected system or ExtData paths before the core Dry Run.", [nameof(SharedSystem)] = "Shared system", [nameof(SystemSource)] = "3DS system file", + [nameof(SystemCurrent)] = "Current Wii U / Cemu system reference", [nameof(SystemTarget)] = "Existing initialized Cemu system file", - [nameof(SystemMergeWarning)] = "System is shared by all three character slots and may contain settings or data for the other slots. Supply both the 3DS source and the existing Cemu target. The converter merges only housekeeper gallery/movie flags and preserves every other Cemu byte.", + [nameof(SystemRepairOutput)] = "Repaired system output", + [nameof(SystemMergeWarning)] = "System is shared by all three character slots and may contain settings or data for the other slots. In repair mode, current is read-only and output is independent. The converter merges only housekeeper gallery/movie flags into current Wii U data and preserves every other current byte.", [nameof(RunSystemDryRun)] = "Run system Dry Run", [nameof(WriteSystem)] = "Merge gallery/movie flags", [nameof(SystemManifest)] = "System manifest", @@ -348,6 +370,14 @@ private string Text(string key) [nameof(Quests)] = "Quests", [nameof(ExtDataSource)] = "3DS ExtData user directory", ["ExtDataSourceHint"] = "Choose .../extdata/00000000/00000481/user, or only its direct 00000481 parent. Do not choose an SDMC or MLC root.", + [nameof(ExtDataCurrent)] = "Current Wii U / Cemu ExtData directory", + [nameof(ExtDataRepairOutput)] = "Repaired ExtData output directory", + [nameof(ExtDataRepairPathHint)] = "Repair uses three independent directories. Source is the original 3DS ExtData user directory; current is the latest Wii U / Cemu save directory you continued playing; output is the only directory written.", + [nameof(ExtDataRepairOutputWarning)] = "The output must already be an initialized MH3G Wii U save directory containing the complete selected group. Guild cards require card1/card2/card3/cardbox; quests require quest1/quest2/quest3/quest4.", + [nameof(RepairGroupDryRun)] = "Run independent Dry Run", + [nameof(RepairGroupWrite)] = "Write this group", + [nameof(GuildCardsManifest)] = "Guild-card repair manifest", + [nameof(QuestsManifest)] = "Quest repair manifest", [nameof(StagingDirectory)] = "Staging directory", [nameof(ExtDataTarget)] = "Cemu ExtData target directory", [nameof(PreviewStaging)] = "Preview complete staging conversion", @@ -426,7 +456,7 @@ private string Text(string key) [nameof(ConversionModeNew)] = "全新转换", [nameof(ConversionModeRepair)] = "修复已转换存档", [nameof(ConversionModeNewDescription)] = "使用原始日版 3DS user# 创建新的 Wii U / Cemu 角色存档。", - [nameof(ConversionModeRepairDescription)] = "修复模式使用三个明确路径:原始 3DS user#、继续游玩后的最新同槽位 Wii U / Cemu user#,以及独立输出位置。当前 Wii U 存档只读引用,只有输出位置会被写入。还要修复收到的公会名片和离线伙伴时,请在下方启用“公会名片与离线伙伴”,并选择原始 3DS ExtData user 目录。", + [nameof(ConversionModeRepairDescription)] = "修复按数据域独立执行:核心 user#、共享 system、公会名片、任务和实验性 CEC 都有自己的 Dry Run、写入授权、manifest 与回滚。每个启用的数据域都明确分开原始 3DS 来源、当前只读 Wii U / Cemu 引用和输出。", [nameof(RepairVersionTitle)] = "原转换器版本", [nameof(RepairVersionAutomatic)] = "自动检测", [nameof(RepairVersionHint)] = "默认保持自动检测。只有 Dry Run 报告多个互不兼容的候选版本时才需要手动选择;选择后旧预览会失效,必须重新 Dry Run。", @@ -471,19 +501,21 @@ private string Text(string key) [nameof(ManifestPath)] = "事务 manifest", [nameof(RollbackFromManifest)] = "从 manifest 回滚", [nameof(OptionalSectionTitle)] = "可选数据", - [nameof(OptionalSectionDescription)] = "共享 system 包含管家画廊/动画解锁记录,ExtData 包含名片与任务;两者都是独立、明确的事务。CEC 完全隔离,默认关闭。", + [nameof(OptionalSectionDescription)] = "共享 system 包含管家画廊/动画解锁记录,ExtData 包含名片与任务;修复模式下名片和任务也是两个独立组件组事务。CEC 完全隔离,默认关闭。", [nameof(ExperimentalCec)] = "实验性 CEC", [nameof(ExperimentalCecHint)] = "CEC 是独立的擦身通信 / 猎人搜索缓存。公会名片和离线伙伴迁移不依赖它。", [nameof(CecSourceDirectory)] = "3DS CEC 邮箱目录", [nameof(CecTargetCache)] = "Cemu cec 缓存", + [nameof(CecCurrentCache)] = "当前 Wii U / Cemu cec 只读引用", + [nameof(CecRepairOutput)] = "修复后的 cec 输出", ["CecSourceDirectoryHint"] = "请选择准确的 CEC/00048100 邮箱目录。它与 ExtData user 目录独立,应用不会递归搜索。", - ["CecTargetCacheHint"] = "请选择准确的 Cemu cec 文件,通常位于所选 Wii U 存档目录中。", + ["CecTargetCacheHint"] = "请选择准确的 Cemu cec 文件,通常位于所选 Wii U 存档目录中。修复模式会额外要求一个只读当前 cec 引用,并且只写入明确选择的输出。", [nameof(InspectCec)] = "检查 CEC", [nameof(RunCecDryRun)] = "运行 CEC Dry Run", [nameof(CecAcknowledge)] = "我了解 CEC 导入仍是实验性功能", [nameof(WriteCec)] = "写入实验性 CEC", [nameof(RollbackCec)] = "回滚 CEC manifest", - [nameof(ExtDataNotice)] = "ExtData 是可选项。请选择准确的 user 目录(或它的直接 00000481 父目录)。暂存转换始终生成完整八个文件;组复选框不会筛选这些输出,只决定只读目标预览的组件组。", + [nameof(ExtDataNotice)] = "ExtData 是可选项。全新转换使用完整暂存目录再安装所选组件组;修复模式不复用暂存流程,而是从明确的来源/当前引用/输出目录分别独立修复公会名片或任务。", [nameof(ExtDataInstallUnavailable)] = "Windows 会通过 Rust sidecar 的恢复日志和 ReplaceFileW 事务安装完整 ExtData 组件组;整个过程必须保持模拟器关闭。", [nameof(ResultTitle)] = "结构化报告", [nameof(ResultEmpty)] = "运行检查或 Dry Run 后,会在这里显示 CLI JSON 报告。", @@ -510,15 +542,20 @@ private string Text(string key) [nameof(CecDisabled)] = "启用实验性 CEC 后才能使用它的独立命令。", [nameof(CecAcknowledgementRequired)] = "使用 CEC 写入命令前请确认实验性警告。", [nameof(CecPathsRequired)] = "请选择准确的 CEC 邮箱目录和 Cemu cec 目标路径。", + [nameof(RepairCecPathsRequired)] = "请选择原始 3DS CEC 邮箱、当前只读 Cemu cec 引用,以及修复后的 cec 输出。", [nameof(CecFingerprintMissing)] = "CEC Dry Run 没有提供聚合记录集和目标 JSON 指纹。", [nameof(SystemPathsRequired)] = "运行这个独立合并前,请选择准确的 3DS 源 system 和已存在、已初始化的 Cemu system。", + [nameof(RepairSystemPathsRequired)] = "请选择准确的原始 3DS system、当前只读 Wii U / Cemu system,以及修复后的 system 输出。", [nameof(ExtrasPathsRequired)] = "请至少选择一个 ExtData 组,以及准确的源目录和临时目录。", + [nameof(RepairExtrasPathsRequired)] = "请至少选择一个 ExtData 组件组,并分别指定原始 3DS 来源、当前 Wii U / Cemu 只读引用,以及已初始化的输出目录。", [nameof(ExtrasFingerprintMissing)] = "ExtData Dry Run 没有提供预期的组件或集合指纹。", [nameof(OptionalDataNeedsConfiguration)] = "请先完成已选择的 system 或 ExtData 路径配置,再运行核心 Dry Run。", [nameof(SharedSystem)] = "共享 system", [nameof(SystemSource)] = "3DS system 文件", + [nameof(SystemCurrent)] = "当前 Wii U / Cemu system 只读引用", [nameof(SystemTarget)] = "已存在且已初始化的 Cemu system 文件", - [nameof(SystemMergeWarning)] = "system 由三个角色槽位共享,可能同时保存其他槽位数据或设置。必须同时提供 3DS 源 system 和现有 Cemu 目标 system;转换器只合并管家画廊/动画标记,其他 Cemu 字节全部保持不变。", + [nameof(SystemRepairOutput)] = "修复后的 system 输出", + [nameof(SystemMergeWarning)] = "system 由三个角色槽位共享,可能同时保存其他槽位数据或设置。修复模式下“当前引用”只读、“输出”独立;转换器只把管家画廊/动画标记合入当前 Wii U 数据,其他当前字节全部保持不变。", [nameof(RunSystemDryRun)] = "运行 system Dry Run", [nameof(WriteSystem)] = "合并画廊/动画标记", [nameof(SystemManifest)] = "system manifest", @@ -527,6 +564,14 @@ private string Text(string key) [nameof(Quests)] = "任务", [nameof(ExtDataSource)] = "3DS ExtData user 目录", ["ExtDataSourceHint"] = "请选择 .../extdata/00000000/00000481/user,或它的直接 00000481 父目录。不要选择 SDMC 或 MLC 根目录。", + [nameof(ExtDataCurrent)] = "当前 Wii U / Cemu ExtData 目录", + [nameof(ExtDataRepairOutput)] = "修复后的 ExtData 输出目录", + [nameof(ExtDataRepairPathHint)] = "修复使用三个相互独立的目录:来源是原始 3DS ExtData user 目录;当前引用是继续游玩后的最新 Wii U / Cemu 存档目录;只有输出目录会被写入。", + [nameof(ExtDataRepairOutputWarning)] = "输出必须是已经初始化的 MH3G Wii U 存档目录,并完整包含所选组件组。公会名片需要 card1/card2/card3/cardbox;任务需要 quest1/quest2/quest3/quest4。", + [nameof(RepairGroupDryRun)] = "独立运行 Dry Run", + [nameof(RepairGroupWrite)] = "写入此组件组", + [nameof(GuildCardsManifest)] = "公会名片修复 manifest", + [nameof(QuestsManifest)] = "任务修复 manifest", [nameof(StagingDirectory)] = "临时转换目录", [nameof(ExtDataTarget)] = "Cemu ExtData 目标目录", [nameof(PreviewStaging)] = "预览完整临时转换", diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml b/apps/mh3g-save-converter-windows/MainWindow.xaml index 288add3..3172006 100644 --- a/apps/mh3g-save-converter-windows/MainWindow.xaml +++ b/apps/mh3g-save-converter-windows/MainWindow.xaml @@ -344,7 +344,16 @@ - + + + + + + + + + + @@ -367,42 +376,98 @@ - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - @@ -423,7 +488,16 @@ - + + + + + + + + + + diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml.cs b/apps/mh3g-save-converter-windows/MainWindow.xaml.cs index d6042cb..94dbf83 100644 --- a/apps/mh3g-save-converter-windows/MainWindow.xaml.cs +++ b/apps/mh3g-save-converter-windows/MainWindow.xaml.cs @@ -378,6 +378,16 @@ private async void ChooseSystemTarget_Click(object sender, RoutedEventArgs e) } } + private async void ChooseSystemCurrent_Click(object sender, RoutedEventArgs e) + { + var path = await PickFileAsync("*"); + if (path is not null) + { + SystemCurrentBox.Text = path; + ViewModel.SystemCurrentPath = path; + } + } + private async void ChooseSystemRollbackManifest_Click(object sender, RoutedEventArgs e) { var path = await PickFileAsync(".json"); @@ -398,6 +408,36 @@ private async void ChooseExtrasSource_Click(object sender, RoutedEventArgs e) } } + private async void ChooseRepairExtrasSource_Click(object sender, RoutedEventArgs e) + { + var path = await PickFolderAsync(); + if (path is not null) + { + ExtrasRepairSourceBox.Text = path; + ViewModel.ExtrasSourceDirectory = path; + } + } + + private async void ChooseExtrasCurrent_Click(object sender, RoutedEventArgs e) + { + var path = await PickFolderAsync(); + if (path is not null) + { + ExtrasCurrentBox.Text = path; + ViewModel.ExtrasCurrentDirectory = path; + } + } + + private async void ChooseRepairExtrasTarget_Click(object sender, RoutedEventArgs e) + { + var path = await PickFolderAsync(); + if (path is not null) + { + ExtrasRepairTargetBox.Text = path; + ViewModel.ExtrasTargetDirectory = path; + } + } + private async void ChooseExtrasStaging_Click(object sender, RoutedEventArgs e) { var path = await PickFolderAsync(); @@ -428,6 +468,26 @@ private async void ChooseExtrasRollbackManifest_Click(object sender, RoutedEvent } } + private async void ChooseRepairGuildCardsManifest_Click(object sender, RoutedEventArgs e) + { + var path = await PickFileAsync(".json"); + if (path is not null) + { + RepairGuildCardsManifestBox.Text = path; + ViewModel.RepairGuildCardsRollbackManifestPath = path; + } + } + + private async void ChooseRepairQuestsManifest_Click(object sender, RoutedEventArgs e) + { + var path = await PickFileAsync(".json"); + if (path is not null) + { + RepairQuestsManifestBox.Text = path; + ViewModel.RepairQuestsRollbackManifestPath = path; + } + } + private async void ChooseCecDirectory_Click(object sender, RoutedEventArgs e) { var path = await PickFolderAsync(); @@ -448,6 +508,16 @@ private async void ChooseCecTarget_Click(object sender, RoutedEventArgs e) } } + private async void ChooseCecCurrent_Click(object sender, RoutedEventArgs e) + { + var path = await PickFileAsync("*"); + if (path is not null) + { + CecCurrentBox.Text = path; + ViewModel.CecCurrentPath = path; + } + } + private async void ChooseCecRollbackManifest_Click(object sender, RoutedEventArgs e) { var path = await PickFileAsync(".json"); @@ -522,6 +592,10 @@ private void CecPath_TextChanged(object sender, TextChangedEventArgs e) { ViewModel.CecTargetPath = CecTargetBox.Text; } + else if (ReferenceEquals(sender, CecCurrentBox)) + { + ViewModel.CecCurrentPath = CecCurrentBox.Text; + } } private void SystemPath_TextChanged(object sender, TextChangedEventArgs e) @@ -534,6 +608,10 @@ private void SystemPath_TextChanged(object sender, TextChangedEventArgs e) { ViewModel.SystemTargetPath = SystemTargetBox.Text; } + else if (ReferenceEquals(sender, SystemCurrentBox)) + { + ViewModel.SystemCurrentPath = SystemCurrentBox.Text; + } } private void ExtrasPath_TextChanged(object sender, TextChangedEventArgs e) @@ -542,6 +620,10 @@ private void ExtrasPath_TextChanged(object sender, TextChangedEventArgs e) { ViewModel.ExtrasSourceDirectory = ExtrasSourceBox.Text; } + else if (ReferenceEquals(sender, ExtrasRepairSourceBox)) + { + ViewModel.ExtrasSourceDirectory = ExtrasRepairSourceBox.Text; + } else if (ReferenceEquals(sender, ExtrasStagingBox)) { ViewModel.ExtrasStagingDirectory = ExtrasStagingBox.Text; @@ -550,6 +632,14 @@ private void ExtrasPath_TextChanged(object sender, TextChangedEventArgs e) { ViewModel.ExtrasTargetDirectory = ExtrasTargetBox.Text; } + else if (ReferenceEquals(sender, ExtrasCurrentBox)) + { + ViewModel.ExtrasCurrentDirectory = ExtrasCurrentBox.Text; + } + else if (ReferenceEquals(sender, ExtrasRepairTargetBox)) + { + ViewModel.ExtrasTargetDirectory = ExtrasRepairTargetBox.Text; + } } private async void Inspect_Click(object sender, RoutedEventArgs e) => await RunSafelyAsync(ViewModel.InspectCoreAsync); @@ -559,6 +649,8 @@ private void ExtrasPath_TextChanged(object sender, TextChangedEventArgs e) private async void SystemDryRun_Click(object sender, RoutedEventArgs e) => await RunSafelyAsync(ViewModel.RunSystemDryRunAsync); private async void ExtrasStageDryRun_Click(object sender, RoutedEventArgs e) => await RunSafelyAsync(ViewModel.RunExtrasStageDryRunAsync); private async void ExtrasInstallDryRun_Click(object sender, RoutedEventArgs e) => await RunSafelyAsync(ViewModel.RunExtrasInstallDryRunAsync); + private async void RepairGuildCardsDryRun_Click(object sender, RoutedEventArgs e) => await RunSafelyAsync(ViewModel.RunRepairGuildCardsDryRunAsync); + private async void RepairQuestsDryRun_Click(object sender, RoutedEventArgs e) => await RunSafelyAsync(ViewModel.RunRepairQuestsDryRunAsync); private async void InspectCec_Click(object sender, RoutedEventArgs e) => await RunSafelyAsync(ViewModel.InspectCecAsync); private async void CecDryRun_Click(object sender, RoutedEventArgs e) => await RunSafelyAsync(ViewModel.RunCecDryRunAsync); @@ -615,6 +707,38 @@ private async void RollbackExtras_Click(object sender, RoutedEventArgs e) } } + private async void WriteRepairGuildCards_Click(object sender, RoutedEventArgs e) + { + if (await ConfirmAsync(ViewModel.Copy.ConfirmWriteTitle, ViewModel.Copy.ConfirmWriteBody)) + { + await RunSafelyAsync(ViewModel.WriteRepairGuildCardsAsync); + } + } + + private async void RollbackRepairGuildCards_Click(object sender, RoutedEventArgs e) + { + if (await ConfirmAsync(ViewModel.Copy.ConfirmRollbackTitle, ViewModel.Copy.ConfirmRollbackBody)) + { + await RunSafelyAsync(ViewModel.RollbackRepairGuildCardsAsync); + } + } + + private async void WriteRepairQuests_Click(object sender, RoutedEventArgs e) + { + if (await ConfirmAsync(ViewModel.Copy.ConfirmWriteTitle, ViewModel.Copy.ConfirmWriteBody)) + { + await RunSafelyAsync(ViewModel.WriteRepairQuestsAsync); + } + } + + private async void RollbackRepairQuests_Click(object sender, RoutedEventArgs e) + { + if (await ConfirmAsync(ViewModel.Copy.ConfirmRollbackTitle, ViewModel.Copy.ConfirmRollbackBody)) + { + await RunSafelyAsync(ViewModel.RollbackRepairQuestsAsync); + } + } + private async void WriteCec_Click(object sender, RoutedEventArgs e) { if (await ConfirmAsync(ViewModel.Copy.ConfirmCecTitle, ViewModel.Copy.ConfirmCecBody)) diff --git a/apps/mh3g-save-converter-windows/Models/ConverterModels.cs b/apps/mh3g-save-converter-windows/Models/ConverterModels.cs index 906eb77..5202bf4 100644 --- a/apps/mh3g-save-converter-windows/Models/ConverterModels.cs +++ b/apps/mh3g-save-converter-windows/Models/ConverterModels.cs @@ -67,6 +67,38 @@ public sealed record SystemDryRunAuthorization( string SourceReportHash, DateTimeOffset CompletedAt); +public sealed record RepairSystemDryRunAuthorization( + string SourcePath, + string CurrentPath, + string OutputPath, + string SourceSetSha256, + string CurrentSetSha256, + string OutputSetSha256, + string PreviewSha256, + DateTimeOffset CompletedAt); + +public sealed record RepairExtrasDryRunAuthorization( + string Group, + string SourceDirectory, + string CurrentDirectory, + string OutputDirectory, + string? FromVersion, + string SourceSetSha256, + string CurrentSetSha256, + string OutputSetSha256, + string PreviewSha256, + DateTimeOffset CompletedAt); + +public sealed record RepairCecDryRunAuthorization( + string SourceDirectory, + string CurrentPath, + string OutputPath, + string SourceRecordSetSha256, + string CurrentSetSha256, + string OutputSetSha256, + string PreviewSha256, + DateTimeOffset CompletedAt); + public sealed record ExtrasStageDryRunAuthorization( string SourceDirectory, string StagingDirectory, diff --git a/apps/mh3g-save-converter-windows/README.md b/apps/mh3g-save-converter-windows/README.md index 0e46f75..7b32599 100644 --- a/apps/mh3g-save-converter-windows/README.md +++ b/apps/mh3g-save-converter-windows/README.md @@ -15,15 +15,22 @@ code. - It does not search an SD card, MLC, ZIP, 7z, RAR, or a generic save folder. The user selects exact paths. - New conversion exposes `inspect` -> `convert --dry-run` -> final SHA-256 - recheck -> `convert --write`. Repair mode exposes separate controls for the - original 3DS `user#`, the read-only current same-slot Cemu `user#`, and the - repaired output file/directory. It passes all three paths to - `repair-converted`; optional complete 3DS ExtData enables guild-card repair. - A separate card output must already be initialized. Ambiguous detection - requires choosing 0.0.3 through 0.0.6 and repeating Dry Run. + recheck -> `convert --write`. Repair mode splits core, guild cards, quests, + shared `system`, and experimental CEC into independent transactions. Every + domain has separate original 3DS, read-only current Cemu, and output path + roles plus its own Dry Run, write authorization, manifest, and rollback. + Path values never cascade between those controls. Core accepts an exact + `user#` file or its direct parent; `system` and CEC use exact files. + Cards and quests share one physical ExtData directory triplet but retain + separate actions/manifests. Each output group must already contain all four + initialized files. Quest repair preserves current Wii U bytes exactly. + Ambiguous core/card/quest detection requires choosing 0.0.3 through 0.0.6 + and repeating that domain's Dry Run. - The UI opens a confirmation dialog before writing. Normal conversion records - its single-file manifest; repair records - `.mh3g-compatibility-repair-.json` and uses `rollback-repair`. + its single-file manifest. Core repair uses + `.mh3g-compatibility-repair-.json`; cards/quests, `system`, and CEC each + retain their own matching manifest and rollback route. Failure in one domain + does not revoke another domain's successful Dry Run. - The process bridge uses `ProcessStartInfo.ArgumentList`, sets `UseShellExecute = false`, and parses the CLI's JSON stdout. No shell command string is built and no conversion behavior is duplicated in C#. @@ -39,8 +46,9 @@ code. The Windows backend installs complete ExtData groups through `ReplaceFileW`, manifest-bound backups, and a durable recovery journal. The UI never guesses a Cemu MLC directory or silently installs a group. Compatibility repair - field-updates only `user#` and guild-card values that still match an older - conversion; current `quest1` through `quest4` bytes are preserved. + field-updates only core/guild-card values that still match an older + conversion; current `quest1` through `quest4` bytes are preserved. An + incomplete optional domain never blocks core or another complete domain. Quit Nemessix, Azahar, and Cemu before any write or rollback. See the root [English CLI contract](../../docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md) and diff --git a/apps/mh3g-save-converter-windows/README.zh-CN.md b/apps/mh3g-save-converter-windows/README.zh-CN.md index badba16..c29d079 100644 --- a/apps/mh3g-save-converter-windows/README.zh-CN.md +++ b/apps/mh3g-save-converter-windows/README.zh-CN.md @@ -14,13 +14,17 @@ - 不扫描 SD 卡、MLC、ZIP、7z、RAR 或任意存档文件夹。所有输入都必须由用户 选择准确文件或目录。 - 全新转换流程固定为 `inspect` -> `convert --dry-run` -> 最终 SHA-256 - 复核 -> `convert --write`。修复模式明确提供三个独立控件:原始 3DS `user#`、 - 只读的当前同槽位 Cemu `user#`、修复结果输出文件/目录;三条路径都会传给 - `repair-converted`。可选完整 3DS ExtData 用于修复公会名片;独立输出目录必须 - 已初始化相应卡片文件。自动检测结果为歧义时,界面会要求选择 0.0.3 至 - 0.0.6 后重新 Dry Run。 -- 写入前有确认对话框。普通转换记录单文件 manifest;兼容修复记录 - `.mh3g-compatibility-repair-.json`,并通过 `rollback-repair` 总体回滚。 + 复核 -> `convert --write`。修复模式把核心、公会名片、任务、共享 `system`、 + 实验性 CEC 分成独立事务。每个域分别选择原始 3DS、只读当前 Cemu、输出,并 + 各自执行 Dry Run、写入授权、记录 manifest 和回滚;路径不会在这些控件之间 + 自动级联。核心可选准确 `user#` 文件或其直接父目录,`system` 和 CEC 使用 + 准确文件。公会名片与任务在物理上共用一组 ExtData 目录选择器,但操作与 + manifest 分开;输出必须已有完整四个初始化文件。任务修复逐字节保留当前 + Wii U 数据。核心/名片/任务检测为歧义时,必须选择 0.0.3 至 0.0.6 并重新 + 运行该域 Dry Run。 +- 写入前有确认对话框。普通转换记录单文件 manifest;核心修复使用 + `.mh3g-compatibility-repair-.json`,名片/任务、`system`、CEC 分别保留 + 自己的 manifest 和回滚入口。一个域失败不会撤销另一个域已成功的 Dry Run。 - C# 仅用 `ProcessStartInfo.ArgumentList` 逐个传递 argv,且 `UseShellExecute = false`;它只解析 CLI 的 JSON stdout,不拼接 shell 命令, 也不重复实现转换逻辑。 @@ -33,7 +37,8 @@ 其余目标字节和其他角色槽位共享数据全部保留。Windows 后端使用 `ReplaceFileW`、manifest 绑定备份和持久恢复日志安装完整 ExtData 组件组; 界面不会猜测 Cemu MLC 目录或静默安装。兼容修复模式只字段级更新仍保持旧版 - 转换结果的 `user#` 与公会名片字段,当前 `quest1` 至 `quest4` 保持原字节。 + 转换结果的核心/公会名片字段,当前 `quest1` 至 `quest4` 保持原字节。某个 + 可选域配置不完整时,不会阻塞核心或另一个已经完整的域。 执行写入或回滚前,必须退出 Nemessix、Azahar 和 Cemu。准确源文件、目标范围 及事务边界参见根目录的 diff --git a/apps/mh3g-save-converter-windows/ViewModels/MainViewModel.cs b/apps/mh3g-save-converter-windows/ViewModels/MainViewModel.cs index e4dc835..4ff772f 100644 --- a/apps/mh3g-save-converter-windows/ViewModels/MainViewModel.cs +++ b/apps/mh3g-save-converter-windows/ViewModels/MainViewModel.cs @@ -35,15 +35,20 @@ public sealed class MainViewModel : ObservableObject private string _rollbackManifestPath = string.Empty; private bool _isSystemEnabled; private string _systemSourcePath = string.Empty; + private string _systemCurrentPath = string.Empty; private string _systemTargetPath = string.Empty; private string _systemRollbackManifestPath = string.Empty; private bool _includeGuildCards; private bool _includeQuests; private string _extrasSourceDirectory = string.Empty; + private string _extrasCurrentDirectory = string.Empty; private string _extrasStagingDirectory = string.Empty; private string _extrasTargetDirectory = string.Empty; private string _extrasRollbackManifestPath = string.Empty; + private string _repairGuildCardsRollbackManifestPath = string.Empty; + private string _repairQuestsRollbackManifestPath = string.Empty; private string _cecSourceDirectory = string.Empty; + private string _cecCurrentPath = string.Empty; private string _cecTargetPath = string.Empty; private string _cecRollbackManifestPath = string.Empty; private bool _isCecEnabled; @@ -62,10 +67,16 @@ public sealed class MainViewModel : ObservableObject private DryRunAuthorization? _coreAuthorization; private RepairDryRunAuthorization? _repairAuthorization; private SystemDryRunAuthorization? _systemAuthorization; + private RepairSystemDryRunAuthorization? _repairSystemAuthorization; private ExtrasStageDryRunAuthorization? _extrasStageAuthorization; private ExtrasInstallDryRunAuthorization? _extrasInstallAuthorization; + private RepairExtrasDryRunAuthorization? _repairGuildCardsAuthorization; + private RepairExtrasDryRunAuthorization? _repairQuestsAuthorization; private CecDryRunAuthorization? _cecAuthorization; + private RepairCecDryRunAuthorization? _repairCecAuthorization; private bool _systemWriteCompleted; + private bool _repairGuildCardsWriteCompleted; + private bool _repairQuestsWriteCompleted; private WorkflowGuidance _workflowGuidance; private enum AuthorizationDomain @@ -73,6 +84,8 @@ private enum AuthorizationDomain Core, System, Extras, + GuildCards, + Quests, Cec, } @@ -87,6 +100,7 @@ private enum WorkflowGuidance } private sealed record RepairRevisionDetection(bool IsAmbiguous, string Summary); + private sealed record RepairExtrasPaths(string Source, string Current, string Output); public MainViewModel( ConverterCliClient? cliClient = null, @@ -118,6 +132,8 @@ public AppLanguageOverride LanguageOverride ? Copy.ConversionModeRepairDescription : Copy.ConversionModeNewDescription; public Visibility RepairCurrentVisibility => IsRepairMode ? Visibility.Visible : Visibility.Collapsed; + public Visibility RepairModeVisibility => IsRepairMode ? Visibility.Visible : Visibility.Collapsed; + public Visibility NewConversionVisibility => IsRepairMode ? Visibility.Collapsed : Visibility.Visible; public string CoreTargetTitle => IsRepairMode ? Copy.RepairOutputSlot : Copy.TargetSlot; public string CoreTargetHint => IsRepairMode ? Copy.RepairOutputSlotHint : Copy.TargetSlotHint; public string CoreTargetPlaceholder => IsRepairMode ? Copy.RepairOutputPlaceholder : Copy.NewOutputPlaceholder; @@ -143,9 +159,12 @@ public void SetRepairFromVersion(string? tag) } _repairFromVersion = revision; _repairAuthorization = null; + _repairGuildCardsAuthorization = null; + _repairQuestsAuthorization = null; IsRepairRevisionSelectionRequired = false; RepairDetectionSummary = string.Empty; OnPropertyChanged(nameof(CanWriteCore)); + RaiseRepairExtrasActionAvailability(); OnPropertyChanged(nameof(WriteUnavailableVisibility)); } @@ -159,19 +178,19 @@ public void SetConversionMode(string? tag) return; } _conversionMode = mode; - if (IsRepairMode) - { - IsSystemEnabled = false; - IncludeQuests = false; - } _repairFromVersion = null; IsRepairRevisionSelectionRequired = false; RepairDetectionSummary = string.Empty; InvalidateCoreAuthorization(); + InvalidateSystemAuthorization(); + InvalidateExtrasAuthorization(); + InvalidateCecAuthorization(); OnPropertyChanged(nameof(SelectedConversionMode)); OnPropertyChanged(nameof(IsRepairMode)); OnPropertyChanged(nameof(ConversionModeDescription)); OnPropertyChanged(nameof(RepairCurrentVisibility)); + OnPropertyChanged(nameof(RepairModeVisibility)); + OnPropertyChanged(nameof(NewConversionVisibility)); OnPropertyChanged(nameof(CoreTargetTitle)); OnPropertyChanged(nameof(CoreTargetHint)); OnPropertyChanged(nameof(CoreTargetPlaceholder)); @@ -332,6 +351,18 @@ public string SystemTargetPath } } + public string SystemCurrentPath + { + get => _systemCurrentPath; + set + { + if (SetProperty(ref _systemCurrentPath, value)) + { + InvalidateSystemAuthorization(); + } + } + } + public string SystemRollbackManifestPath { get => _systemRollbackManifestPath; @@ -408,6 +439,18 @@ public string ExtrasStagingDirectory } } + public string ExtrasCurrentDirectory + { + get => _extrasCurrentDirectory; + set + { + if (SetProperty(ref _extrasCurrentDirectory, value)) + { + InvalidateExtrasAuthorization(); + } + } + } + public string ExtrasTargetDirectory { get => _extrasTargetDirectory; @@ -432,6 +475,30 @@ public string ExtrasRollbackManifestPath } } + public string RepairGuildCardsRollbackManifestPath + { + get => _repairGuildCardsRollbackManifestPath; + set + { + if (SetProperty(ref _repairGuildCardsRollbackManifestPath, value)) + { + OnPropertyChanged(nameof(CanRollbackRepairGuildCards)); + } + } + } + + public string RepairQuestsRollbackManifestPath + { + get => _repairQuestsRollbackManifestPath; + set + { + if (SetProperty(ref _repairQuestsRollbackManifestPath, value)) + { + OnPropertyChanged(nameof(CanRollbackRepairQuests)); + } + } + } + public bool IsCecEnabled { get => _isCecEnabled; @@ -439,11 +506,7 @@ public bool IsCecEnabled { if (SetProperty(ref _isCecEnabled, value)) { - _cecAuthorization = null; - ClearOptionalGuidance(); - OnPropertyChanged(nameof(CanInspectCec)); - OnPropertyChanged(nameof(CanRunCecDryRun)); - OnPropertyChanged(nameof(CanWriteCec)); + InvalidateCecAuthorization(); } } } @@ -467,11 +530,7 @@ public string CecSourceDirectory { if (SetProperty(ref _cecSourceDirectory, value)) { - _cecAuthorization = null; - ClearOptionalGuidance(); - OnPropertyChanged(nameof(CanInspectCec)); - OnPropertyChanged(nameof(CanRunCecDryRun)); - OnPropertyChanged(nameof(CanWriteCec)); + InvalidateCecAuthorization(); } } } @@ -483,11 +542,19 @@ public string CecTargetPath { if (SetProperty(ref _cecTargetPath, value)) { - _cecAuthorization = null; - ClearOptionalGuidance(); - OnPropertyChanged(nameof(CanInspectCec)); - OnPropertyChanged(nameof(CanRunCecDryRun)); - OnPropertyChanged(nameof(CanWriteCec)); + InvalidateCecAuthorization(); + } + } + } + + public string CecCurrentPath + { + get => _cecCurrentPath; + set + { + if (SetProperty(ref _cecCurrentPath, value)) + { + InvalidateCecAuthorization(); } } } @@ -526,6 +593,12 @@ private set OnPropertyChanged(nameof(CanRunExtrasInstallDryRun)); OnPropertyChanged(nameof(CanInstallExtras)); OnPropertyChanged(nameof(CanRollbackExtras)); + OnPropertyChanged(nameof(CanRunRepairGuildCardsDryRun)); + OnPropertyChanged(nameof(CanWriteRepairGuildCards)); + OnPropertyChanged(nameof(CanRollbackRepairGuildCards)); + OnPropertyChanged(nameof(CanRunRepairQuestsDryRun)); + OnPropertyChanged(nameof(CanWriteRepairQuests)); + OnPropertyChanged(nameof(CanRollbackRepairQuests)); OnPropertyChanged(nameof(CanInspectCec)); OnPropertyChanged(nameof(CanRunCecDryRun)); OnPropertyChanged(nameof(CanWriteCec)); @@ -580,11 +653,13 @@ private set public bool ShowPostOptionalGuidance => _workflowGuidance == WorkflowGuidance.OptionalStepComplete; public bool ShowPostRollbackGuidance => _workflowGuidance == WorkflowGuidance.RolledBack; public bool SelectedOptionalDataIsConfigured => IsRepairMode - ? !IncludeGuildCards || SavePathResolver.TryResolveExtDataUserDirectory( - ExtrasSourceDirectory, out _, out _) + ? (!IsSystemEnabled || HasSystemPaths()) + && (!HasSelectedExtraGroups() || HasRepairExtrasPaths()) : (!IsSystemEnabled || HasSystemPaths()) && (!SupportsSafeExtrasInstall || !HasSelectedExtraGroups() || HasExtrasInstallPaths()); - public bool HasPendingSelectedOptionalWork => IsSystemEnabled && !_systemWriteCompleted; + public bool HasPendingSelectedOptionalWork => (IsSystemEnabled && !_systemWriteCompleted) + || (IsRepairMode && IncludeGuildCards && !_repairGuildCardsWriteCompleted) + || (IsRepairMode && IncludeQuests && !_repairQuestsWriteCompleted); public string PostWriteGuidanceMessage => HasPendingSelectedOptionalWork ? Copy.NextAfterCoreWriteWithOptionalData : Copy.NextAfterWrite; @@ -598,25 +673,35 @@ private set && _sourceInspected && _targetInspected && (!IsRepairMode || _currentInspected && _inspectedCurrent?.Exists == true) - && SelectedOptionalDataIsConfigured && HasValidCorePaths(); public bool CanWriteCore => !IsBusy - && SelectedOptionalDataIsConfigured && (IsRepairMode ? _repairAuthorization is not null : _coreAuthorization is not null) && HasValidCorePaths(); public Visibility WriteUnavailableVisibility => CanWriteCore ? Visibility.Collapsed : Visibility.Visible; public bool CanRollbackCore => !IsBusy && !string.IsNullOrWhiteSpace(RollbackManifestPath); public bool CanRunSystemDryRun => !IsBusy && IsSystemEnabled && HasSystemPaths(); - public bool CanWriteSystem => !IsBusy && IsSystemEnabled && _systemAuthorization is not null && HasSystemPaths(); + public bool CanWriteSystem => !IsBusy && IsSystemEnabled + && (IsRepairMode ? _repairSystemAuthorization is not null : _systemAuthorization is not null) + && HasSystemPaths(); public bool CanRollbackSystem => !IsBusy && IsSystemEnabled && !string.IsNullOrWhiteSpace(SystemRollbackManifestPath); public bool CanRunExtrasStageDryRun => !IsBusy && HasExtrasStagePaths(); public bool CanStageExtras => !IsBusy && _extrasStageAuthorization is not null && HasExtrasStagePaths(); public bool CanRunExtrasInstallDryRun => !IsBusy && HasExtrasInstallPaths(); public bool CanInstallExtras => SupportsSafeExtrasInstall && !IsBusy && _extrasInstallAuthorization is not null && HasExtrasInstallPaths(); public bool CanRollbackExtras => SupportsSafeExtrasInstall && !IsBusy && HasSelectedExtraGroups() && !string.IsNullOrWhiteSpace(ExtrasRollbackManifestPath); + public bool CanRunRepairGuildCardsDryRun => !IsBusy && IsRepairMode && IncludeGuildCards && HasRepairExtrasPaths(); + public bool CanWriteRepairGuildCards => !IsBusy && IsRepairMode && IncludeGuildCards + && _repairGuildCardsAuthorization is not null && HasRepairExtrasPaths(); + public bool CanRollbackRepairGuildCards => !IsBusy && !string.IsNullOrWhiteSpace(RepairGuildCardsRollbackManifestPath); + public bool CanRunRepairQuestsDryRun => !IsBusy && IsRepairMode && IncludeQuests && HasRepairExtrasPaths(); + public bool CanWriteRepairQuests => !IsBusy && IsRepairMode && IncludeQuests + && _repairQuestsAuthorization is not null && HasRepairExtrasPaths(); + public bool CanRollbackRepairQuests => !IsBusy && !string.IsNullOrWhiteSpace(RepairQuestsRollbackManifestPath); public bool CanInspectCec => !IsBusy && IsCecEnabled && HasCecPaths(); public bool CanRunCecDryRun => !IsBusy && IsCecEnabled && HasCecPaths(); - public bool CanWriteCec => !IsBusy && IsCecEnabled && IsCecAcknowledged && _cecAuthorization is not null && HasCecPaths(); + public bool CanWriteCec => !IsBusy && IsCecEnabled && IsCecAcknowledged + && (IsRepairMode ? _repairCecAuthorization is not null : _cecAuthorization is not null) + && HasCecPaths(); public bool CanRollbackCec => !IsBusy && IsCecEnabled && !string.IsNullOrWhiteSpace(CecRollbackManifestPath); public void SetLanguage(string? tag) @@ -749,11 +834,6 @@ await RunOperationAsync("inspect-events", async cancellationToken => public async Task RunCoreDryRunAsync() { - if (!SelectedOptionalDataIsConfigured) - { - Fail(Copy.OptionalDataNeedsConfiguration); - return; - } if (!CanRunCoreDryRun) { Fail(Copy.WriteUnavailable); @@ -803,17 +883,6 @@ await RunOperationAsync(operation, async cancellationToken => "--current", paths.Current!, "--output", paths.Target, }; - string? extDataSource = null; - if (IncludeGuildCards) - { - if (!SavePathResolver.TryResolveExtDataUserDirectory( - ExtrasSourceDirectory, out extDataSource, out _)) - { - throw new InvalidOperationException(Copy.ExtrasPathsRequired); - } - arguments.Add("--source-extdata-dir"); - arguments.Add(extDataSource); - } if (!string.IsNullOrWhiteSpace(_repairFromVersion)) { arguments.Add("--from-version"); @@ -842,7 +911,7 @@ await RunOperationAsync(operation, async cancellationToken => sourceBefore, currentBefore!, targetBefore, - extDataSource, + null, _repairFromVersion, sourceSet, currentSet, @@ -909,11 +978,6 @@ await RunOperationAsync(operation, async cancellationToken => public async Task WriteCoreAsync() { - if (!SelectedOptionalDataIsConfigured) - { - Fail(Copy.OptionalDataNeedsConfiguration); - return; - } var authorization = _coreAuthorization; var repairAuthorization = _repairAuthorization; if (IsRepairMode ? repairAuthorization is null : authorization is null) @@ -955,11 +1019,6 @@ await RunOperationAsync(operation, async cancellationToken => "--current", paths.Current!, "--output", paths.Target, }; - if (!string.IsNullOrWhiteSpace(repairAuthorization!.ExtDataSource)) - { - repairArguments.Add("--source-extdata-dir"); - repairArguments.Add(repairAuthorization.ExtDataSource); - } if (!string.IsNullOrWhiteSpace(repairAuthorization.FromVersion)) { repairArguments.Add("--from-version"); @@ -1057,6 +1116,45 @@ public async Task RunSystemDryRunAsync() return; } + if (IsRepairMode) + { + _repairSystemAuthorization = null; + RaiseSystemActionAvailability(); + await RunOperationAsync("repair-system --dry-run", async cancellationToken => + { + var result = await ExecuteAsync( + "repair-system --dry-run", + new[] + { + "repair-system", SystemSourcePath, + "--current", SystemCurrentPath, + "--output", SystemTargetPath, + "--dry-run", + }, + cancellationToken); + RequireSuccess(result, "system repair Dry Run"); + RequireStatus(result, "dry-run", "system repair Dry Run"); + var sourceSet = RequireReportSha256(result, "source_set_sha256"); + var currentSet = RequireReportSha256(result, "current_set_sha256"); + var outputSet = RequireReportSha256(result, "output_set_sha256"); + var preview = RequireReportSha256(result, "preview_sha256"); + _repairSystemAuthorization = new RepairSystemDryRunAuthorization( + Path.GetFullPath(SystemSourcePath), + Path.GetFullPath(SystemCurrentPath), + Path.GetFullPath(SystemTargetPath), + sourceSet, + currentSet, + outputSet, + preview, + DateTimeOffset.UtcNow); + Stage = WorkflowStage.DryRunAuthorized; + StatusText = Copy.DryRunAuthorized; + SetWorkflowGuidance(WorkflowGuidance.OptionalStepComplete); + RaiseSystemActionAvailability(); + }, AuthorizationDomain.System); + return; + } + _systemAuthorization = null; RaiseSystemActionAvailability(); await RunOperationAsync("convert-system --dry-run", async cancellationToken => @@ -1091,6 +1189,47 @@ await RunOperationAsync("convert-system --dry-run", async cancellationToken => public async Task WriteSystemAsync() { + if (IsRepairMode) + { + var repairAuthorization = _repairSystemAuthorization; + if (repairAuthorization is null || !MatchesRepairSystemAuthorization(repairAuthorization)) + { + InvalidateSystemAuthorization(); + Fail(Copy.WriteUnavailable); + return; + } + await RunOperationAsync("repair-system --write", async cancellationToken => + { + var result = await ExecuteAsync( + "repair-system --write", + new[] + { + "repair-system", SystemSourcePath, + "--current", SystemCurrentPath, + "--output", SystemTargetPath, + "--write", + "--expected-source-set-sha256", repairAuthorization.SourceSetSha256, + "--expected-current-set-sha256", repairAuthorization.CurrentSetSha256, + "--expected-output-set-sha256", repairAuthorization.OutputSetSha256, + "--expected-preview-sha256", repairAuthorization.PreviewSha256, + }, + cancellationToken); + RequireSuccess(result, "repair system"); + if (result.Status is not ("written" or "no-changes")) + { + throw new InvalidOperationException(Copy.FileChangedAfterDryRun); + } + SystemRollbackManifestPath = result.TryGetString("manifest") ?? SystemRollbackManifestPath; + _repairSystemAuthorization = null; + _systemWriteCompleted = true; + Stage = WorkflowStage.Written; + StatusText = Copy.Written; + SetWorkflowGuidance(WorkflowGuidance.OptionalStepComplete); + RaiseSystemActionAvailability(); + RaiseOptionalConfigurationAvailability(); + }, AuthorizationDomain.System); + return; + } var authorization = _systemAuthorization; if (authorization is null || !HasSystemPaths()) { @@ -1151,6 +1290,7 @@ await RunOperationAsync("rollback system", async cancellationToken => cancellationToken); RequireSuccess(result, "rollback system"); RequireStatus(result, "rolled-back", "rollback system"); + SystemRollbackManifestPath = string.Empty; _systemWriteCompleted = false; Stage = WorkflowStage.RolledBack; StatusText = Copy.RolledBack; @@ -1159,6 +1299,176 @@ await RunOperationAsync("rollback system", async cancellationToken => }, AuthorizationDomain.System); } + public Task RunRepairGuildCardsDryRunAsync() => RunRepairExtrasDryRunAsync("guild-cards"); + + public Task WriteRepairGuildCardsAsync() => WriteRepairExtrasAsync("guild-cards"); + + public Task RollbackRepairGuildCardsAsync() => RollbackRepairExtrasAsync("guild-cards"); + + public Task RunRepairQuestsDryRunAsync() => RunRepairExtrasDryRunAsync("quests"); + + public Task WriteRepairQuestsAsync() => WriteRepairExtrasAsync("quests"); + + public Task RollbackRepairQuestsAsync() => RollbackRepairExtrasAsync("quests"); + + private async Task RunRepairExtrasDryRunAsync(string group) + { + if (!IsRepairMode || !IsRepairGroupSelected(group) || !TryRequireRepairExtrasPaths(out var paths)) + { + return; + } + SetRepairExtrasAuthorization(group, null); + RaiseRepairExtrasActionAvailability(); + await RunOperationAsync($"repair-extras {group} --dry-run", async cancellationToken => + { + var arguments = new List + { + "repair-extras", + "--source-dir", paths.Source, + "--current-dir", paths.Current, + "--output-dir", paths.Output, + "--group", group, + }; + if (!string.IsNullOrWhiteSpace(_repairFromVersion)) + { + arguments.Add("--from-version"); + arguments.Add(_repairFromVersion); + } + arguments.Add("--dry-run"); + var result = await ExecuteAsync($"repair-extras {group} --dry-run", arguments, cancellationToken); + RequireSuccess(result, $"{group} repair Dry Run"); + RequireStatus(result, "dry-run", $"{group} repair Dry Run"); + if (!string.Equals(result.TryGetString("group"), group, StringComparison.Ordinal)) + { + throw new InvalidOperationException(Copy.FileChangedAfterDryRun); + } + var detection = ReadRepairRevisionDetection(result); + IsRepairRevisionSelectionRequired = + detection.IsAmbiguous && string.IsNullOrWhiteSpace(_repairFromVersion); + RepairDetectionSummary = detection.Summary; + if (IsRepairRevisionSelectionRequired) + { + SetRepairExtrasAuthorization(group, null); + Stage = WorkflowStage.Inspected; + StatusText = Copy.RepairVersionRequired; + RaiseRepairExtrasActionAvailability(); + return; + } + var authorization = new RepairExtrasDryRunAuthorization( + group, + paths.Source, + paths.Current, + paths.Output, + _repairFromVersion, + RequireReportSha256(result, "source_set_sha256"), + RequireReportSha256(result, "current_set_sha256"), + RequireReportSha256(result, "output_set_sha256"), + RequireReportSha256(result, "preview_sha256"), + DateTimeOffset.UtcNow); + SetRepairExtrasAuthorization(group, authorization); + Stage = WorkflowStage.DryRunAuthorized; + StatusText = Copy.DryRunAuthorized; + SetWorkflowGuidance(WorkflowGuidance.OptionalStepComplete); + RaiseRepairExtrasActionAvailability(); + }, group == "guild-cards" ? AuthorizationDomain.GuildCards : AuthorizationDomain.Quests); + } + + private async Task WriteRepairExtrasAsync(string group) + { + var authorization = GetRepairExtrasAuthorization(group); + if (authorization is null + || !TryRequireRepairExtrasPaths(out var paths) + || !MatchesRepairExtrasAuthorization(authorization, paths)) + { + SetRepairExtrasAuthorization(group, null); + RaiseRepairExtrasActionAvailability(); + Fail(Copy.WriteUnavailable); + return; + } + await RunOperationAsync($"repair-extras {group} --write", async cancellationToken => + { + var arguments = new List + { + "repair-extras", + "--source-dir", paths.Source, + "--current-dir", paths.Current, + "--output-dir", paths.Output, + "--group", group, + }; + if (!string.IsNullOrWhiteSpace(authorization.FromVersion)) + { + arguments.Add("--from-version"); + arguments.Add(authorization.FromVersion); + } + arguments.AddRange(new[] + { + "--write", + "--expected-source-set-sha256", authorization.SourceSetSha256, + "--expected-current-set-sha256", authorization.CurrentSetSha256, + "--expected-output-set-sha256", authorization.OutputSetSha256, + "--expected-preview-sha256", authorization.PreviewSha256, + }); + var result = await ExecuteAsync($"repair-extras {group} --write", arguments, cancellationToken); + RequireSuccess(result, $"repair {group}"); + if (result.Status is not ("written" or "no-changes") + || !string.Equals(result.TryGetString("group"), group, StringComparison.Ordinal)) + { + throw new InvalidOperationException(Copy.FileChangedAfterDryRun); + } + var manifest = result.TryGetString("manifest"); + if (group == "guild-cards") + { + RepairGuildCardsRollbackManifestPath = manifest ?? RepairGuildCardsRollbackManifestPath; + _repairGuildCardsWriteCompleted = true; + } + else + { + RepairQuestsRollbackManifestPath = manifest ?? RepairQuestsRollbackManifestPath; + _repairQuestsWriteCompleted = true; + } + SetRepairExtrasAuthorization(group, null); + Stage = WorkflowStage.Written; + StatusText = Copy.Written; + SetWorkflowGuidance(WorkflowGuidance.OptionalStepComplete); + RaiseRepairExtrasActionAvailability(); + RaiseOptionalConfigurationAvailability(); + }, group == "guild-cards" ? AuthorizationDomain.GuildCards : AuthorizationDomain.Quests); + } + + private async Task RollbackRepairExtrasAsync(string group) + { + var manifest = group == "guild-cards" + ? RepairGuildCardsRollbackManifestPath + : RepairQuestsRollbackManifestPath; + if (string.IsNullOrWhiteSpace(manifest)) + { + return; + } + await RunOperationAsync($"rollback-extras {group}", async cancellationToken => + { + var result = await ExecuteAsync( + $"rollback-extras {group}", + new[] { "rollback-extras", "--manifest", manifest }, + cancellationToken); + RequireSuccess(result, $"rollback {group}"); + RequireStatus(result, "rolled-back", $"rollback {group}"); + if (group == "guild-cards") + { + _repairGuildCardsWriteCompleted = false; + RepairGuildCardsRollbackManifestPath = string.Empty; + } + else + { + _repairQuestsWriteCompleted = false; + RepairQuestsRollbackManifestPath = string.Empty; + } + Stage = WorkflowStage.RolledBack; + StatusText = Copy.RolledBack; + SetWorkflowGuidance(WorkflowGuidance.RolledBack); + RaiseOptionalConfigurationAvailability(); + }, group == "guild-cards" ? AuthorizationDomain.GuildCards : AuthorizationDomain.Quests); + } + public async Task RunExtrasStageDryRunAsync() { if (!TryRequireExtrasStagePaths()) @@ -1319,6 +1629,7 @@ await RunOperationAsync("rollback-extras", async cancellationToken => cancellationToken); RequireSuccess(result, "rollback ExtData"); RequireStatus(result, "rolled-back", "rollback ExtData"); + ExtrasRollbackManifestPath = string.Empty; Stage = WorkflowStage.RolledBack; StatusText = Copy.RolledBack; SetWorkflowGuidance(WorkflowGuidance.RolledBack); @@ -1336,10 +1647,11 @@ public async Task InspectCecAsync() await RunOperationAsync("inspect-cec", async cancellationToken => { var arguments = new List { "inspect-cec", "--source-dir", CecSourceDirectory }; - if (File.Exists(CecTargetPath)) + var inspectionTarget = IsRepairMode ? CecCurrentPath : CecTargetPath; + if (File.Exists(inspectionTarget)) { arguments.Add("--target"); - arguments.Add(CecTargetPath); + arguments.Add(inspectionTarget); } if (SavePathResolver.TryResolveSource(SourcePath, SelectedSlot, out var sourceSlot, out _)) { @@ -1362,8 +1674,42 @@ public async Task RunCecDryRunAsync() return; } _cecAuthorization = null; + _repairCecAuthorization = null; OnPropertyChanged(nameof(CanWriteCec)); + if (IsRepairMode) + { + await RunOperationAsync("repair-cec --dry-run", async cancellationToken => + { + var result = await ExecuteAsync( + "repair-cec --dry-run", + new[] + { + "repair-cec", "--source-dir", CecSourceDirectory, + "--current", CecCurrentPath, + "--output", CecTargetPath, + "--dry-run", + }, + cancellationToken); + RequireSuccess(result, "CEC repair Dry Run"); + RequireStatus(result, "dry-run", "CEC repair Dry Run"); + _repairCecAuthorization = new RepairCecDryRunAuthorization( + Path.GetFullPath(CecSourceDirectory), + Path.GetFullPath(CecCurrentPath), + Path.GetFullPath(CecTargetPath), + RequireReportSha256(result, "source_record_set_sha256"), + RequireReportSha256(result, "current_set_sha256"), + RequireReportSha256(result, "output_set_sha256"), + RequireReportSha256(result, "preview_sha256"), + DateTimeOffset.UtcNow); + Stage = WorkflowStage.DryRunAuthorized; + StatusText = Copy.DryRunAuthorized; + SetWorkflowGuidance(WorkflowGuidance.OptionalStepComplete); + OnPropertyChanged(nameof(CanWriteCec)); + }, AuthorizationDomain.Cec); + return; + } + await RunOperationAsync("convert-cec --dry-run", async cancellationToken => { var result = await ExecuteAsync( @@ -1399,6 +1745,46 @@ public async Task WriteCecAsync() Fail(Copy.CecAcknowledgementRequired); return; } + if (IsRepairMode) + { + var repairAuthorization = _repairCecAuthorization; + if (repairAuthorization is null || !MatchesRepairCecAuthorization(repairAuthorization)) + { + _repairCecAuthorization = null; + OnPropertyChanged(nameof(CanWriteCec)); + Fail(Copy.WriteUnavailable); + return; + } + await RunOperationAsync("repair-cec --write", async cancellationToken => + { + var result = await ExecuteAsync( + "repair-cec --write", + new[] + { + "repair-cec", "--source-dir", CecSourceDirectory, + "--current", CecCurrentPath, + "--output", CecTargetPath, + "--expected-source-record-set-sha256", repairAuthorization.SourceRecordSetSha256, + "--expected-current-set-sha256", repairAuthorization.CurrentSetSha256, + "--expected-output-set-sha256", repairAuthorization.OutputSetSha256, + "--expected-preview-sha256", repairAuthorization.PreviewSha256, + "--write", "--experimental", + }, + cancellationToken); + RequireSuccess(result, "repair CEC"); + if (result.Status is not ("written" or "no-changes")) + { + throw new InvalidOperationException(Copy.FileChangedAfterDryRun); + } + CecRollbackManifestPath = result.TryGetString("manifest") ?? CecRollbackManifestPath; + _repairCecAuthorization = null; + Stage = WorkflowStage.Written; + StatusText = Copy.Written; + SetWorkflowGuidance(WorkflowGuidance.OptionalStepComplete); + OnPropertyChanged(nameof(CanWriteCec)); + }, AuthorizationDomain.Cec); + return; + } var authorization = _cecAuthorization; if (authorization is null || !HasCecPaths()) { @@ -1464,6 +1850,7 @@ await RunOperationAsync("rollback-cec", async cancellationToken => cancellationToken); RequireSuccess(result, "rollback CEC"); RequireStatus(result, "rolled-back", "rollback CEC"); + CecRollbackManifestPath = string.Empty; Stage = WorkflowStage.RolledBack; StatusText = Copy.RolledBack; SetWorkflowGuidance(WorkflowGuidance.RolledBack); @@ -1531,6 +1918,18 @@ private static void RequireStatus(CliExecutionResult result, string expected, st } } + private static string RequireReportSha256(CliExecutionResult result, string property) + { + var value = result.TryGetString(property); + if (value is null + || value.Length != 64 + || value.Any(character => !Uri.IsHexDigit(character))) + { + throw new InvalidOperationException($"converter report is missing a valid {property}"); + } + return value; + } + private static string ComposeFailure(CliExecutionResult result) { var details = new[] { result.StandardError, result.JsonParseError, result.StandardOutput } @@ -1592,7 +1991,7 @@ private bool TryRequireSystemPaths() return true; } - Fail(Copy.SystemPathsRequired); + Fail(IsRepairMode ? Copy.RepairSystemPathsRequired : Copy.SystemPathsRequired); return false; } @@ -1644,7 +2043,7 @@ private bool TryRequireCecPaths() return true; } - Fail(Copy.CecPathsRequired); + Fail(IsRepairMode ? Copy.RepairCecPathsRequired : Copy.CecPathsRequired); return false; } @@ -1689,11 +2088,15 @@ private bool TryResolveCorePaths(out CoreSavePaths paths, out SavePathResolution private bool HasSystemPaths() { - return IsSystemEnabled + var common = IsSystemEnabled && !string.IsNullOrWhiteSpace(SystemSourcePath) && !string.IsNullOrWhiteSpace(SystemTargetPath) && string.Equals(Path.GetFileName(SystemSourcePath), "system", StringComparison.Ordinal) && string.Equals(Path.GetFileName(SystemTargetPath), "system", StringComparison.Ordinal); + return common + && (!IsRepairMode + || (!string.IsNullOrWhiteSpace(SystemCurrentPath) + && string.Equals(Path.GetFileName(SystemCurrentPath), "system", StringComparison.Ordinal))); } private bool HasSelectedExtraGroups() => IncludeGuildCards || IncludeQuests; @@ -1711,6 +2114,91 @@ private bool HasExtrasInstallPaths() && !string.IsNullOrWhiteSpace(ExtrasTargetDirectory); } + private bool HasRepairExtrasPaths() + { + return TryResolveRepairExtrasPaths(out _); + } + + private bool TryRequireRepairExtrasPaths(out RepairExtrasPaths paths) + { + if (TryResolveRepairExtrasPaths(out paths)) + { + return true; + } + Fail(Copy.RepairExtrasPathsRequired); + paths = null!; + return false; + } + + private bool TryResolveRepairExtrasPaths(out RepairExtrasPaths paths) + { + if (IsRepairMode + && HasSelectedExtraGroups() + && SavePathResolver.TryResolveExtDataUserDirectory(ExtrasSourceDirectory, out var source, out _) + && Directory.Exists(ExtrasCurrentDirectory) + && Directory.Exists(ExtrasTargetDirectory)) + { + paths = new RepairExtrasPaths( + Path.GetFullPath(source), + Path.GetFullPath(ExtrasCurrentDirectory), + Path.GetFullPath(ExtrasTargetDirectory)); + return true; + } + paths = null!; + return false; + } + + private bool IsRepairGroupSelected(string group) => group switch + { + "guild-cards" => IncludeGuildCards, + "quests" => IncludeQuests, + _ => false, + }; + + private RepairExtrasDryRunAuthorization? GetRepairExtrasAuthorization(string group) => group switch + { + "guild-cards" => _repairGuildCardsAuthorization, + "quests" => _repairQuestsAuthorization, + _ => null, + }; + + private void SetRepairExtrasAuthorization(string group, RepairExtrasDryRunAuthorization? authorization) + { + if (group == "guild-cards") + { + _repairGuildCardsAuthorization = authorization; + } + else if (group == "quests") + { + _repairQuestsAuthorization = authorization; + } + } + + private static bool MatchesRepairExtrasAuthorization( + RepairExtrasDryRunAuthorization authorization, + RepairExtrasPaths paths) + { + return string.Equals(authorization.SourceDirectory, paths.Source, StringComparison.OrdinalIgnoreCase) + && string.Equals(authorization.CurrentDirectory, paths.Current, StringComparison.OrdinalIgnoreCase) + && string.Equals(authorization.OutputDirectory, paths.Output, StringComparison.OrdinalIgnoreCase); + } + + private bool MatchesRepairSystemAuthorization(RepairSystemDryRunAuthorization authorization) + { + return HasSystemPaths() + && string.Equals(authorization.SourcePath, Path.GetFullPath(SystemSourcePath), StringComparison.OrdinalIgnoreCase) + && string.Equals(authorization.CurrentPath, Path.GetFullPath(SystemCurrentPath), StringComparison.OrdinalIgnoreCase) + && string.Equals(authorization.OutputPath, Path.GetFullPath(SystemTargetPath), StringComparison.OrdinalIgnoreCase); + } + + private bool MatchesRepairCecAuthorization(RepairCecDryRunAuthorization authorization) + { + return HasCecPaths() + && string.Equals(authorization.SourceDirectory, Path.GetFullPath(CecSourceDirectory), StringComparison.OrdinalIgnoreCase) + && string.Equals(authorization.CurrentPath, Path.GetFullPath(CecCurrentPath), StringComparison.OrdinalIgnoreCase) + && string.Equals(authorization.OutputPath, Path.GetFullPath(CecTargetPath), StringComparison.OrdinalIgnoreCase); + } + private string SelectedExtraGroups() { var groups = new List(); @@ -1785,7 +2273,8 @@ private bool MatchesExtrasInstallAuthorization(ExtrasInstallDryRunAuthorization private bool HasCecPaths() { return !string.IsNullOrWhiteSpace(CecSourceDirectory) - && !string.IsNullOrWhiteSpace(CecTargetPath); + && !string.IsNullOrWhiteSpace(CecTargetPath) + && (!IsRepairMode || !string.IsNullOrWhiteSpace(CecCurrentPath)); } private void InvalidateCoreAuthorization() @@ -1815,6 +2304,7 @@ or WorkflowGuidance.CoreDryRunAuthorized private void InvalidateSystemAuthorization() { _systemAuthorization = null; + _repairSystemAuthorization = null; _systemWriteCompleted = false; ClearOptionalGuidance(); RaiseSystemActionAvailability(); @@ -1825,11 +2315,26 @@ private void InvalidateExtrasAuthorization() { _extrasStageAuthorization = null; _extrasInstallAuthorization = null; + _repairGuildCardsAuthorization = null; + _repairQuestsAuthorization = null; + _repairGuildCardsWriteCompleted = false; + _repairQuestsWriteCompleted = false; ClearOptionalGuidance(); RaiseExtrasActionAvailability(); RaiseOptionalConfigurationAvailability(); } + private void InvalidateCecAuthorization() + { + _cecAuthorization = null; + _repairCecAuthorization = null; + ClearOptionalGuidance(); + OnPropertyChanged(nameof(CanInspectCec)); + OnPropertyChanged(nameof(CanRunCecDryRun)); + OnPropertyChanged(nameof(CanWriteCec)); + OnPropertyChanged(nameof(CanRollbackCec)); + } + private void ClearWriteAuthorization(AuthorizationDomain domain) { switch (domain) @@ -1841,15 +2346,28 @@ private void ClearWriteAuthorization(AuthorizationDomain domain) break; case AuthorizationDomain.System: _systemAuthorization = null; + _repairSystemAuthorization = null; RaiseSystemActionAvailability(); break; case AuthorizationDomain.Extras: _extrasStageAuthorization = null; _extrasInstallAuthorization = null; + _repairGuildCardsAuthorization = null; + _repairQuestsAuthorization = null; RaiseExtrasActionAvailability(); + RaiseRepairExtrasActionAvailability(); + break; + case AuthorizationDomain.GuildCards: + _repairGuildCardsAuthorization = null; + RaiseRepairExtrasActionAvailability(); + break; + case AuthorizationDomain.Quests: + _repairQuestsAuthorization = null; + RaiseRepairExtrasActionAvailability(); break; case AuthorizationDomain.Cec: _cecAuthorization = null; + _repairCecAuthorization = null; OnPropertyChanged(nameof(CanWriteCec)); break; default: @@ -1917,6 +2435,17 @@ private void RaiseExtrasActionAvailability() OnPropertyChanged(nameof(CanRunExtrasInstallDryRun)); OnPropertyChanged(nameof(CanInstallExtras)); OnPropertyChanged(nameof(CanRollbackExtras)); + RaiseRepairExtrasActionAvailability(); + } + + private void RaiseRepairExtrasActionAvailability() + { + OnPropertyChanged(nameof(CanRunRepairGuildCardsDryRun)); + OnPropertyChanged(nameof(CanWriteRepairGuildCards)); + OnPropertyChanged(nameof(CanRollbackRepairGuildCards)); + OnPropertyChanged(nameof(CanRunRepairQuestsDryRun)); + OnPropertyChanged(nameof(CanWriteRepairQuests)); + OnPropertyChanged(nameof(CanRollbackRepairQuests)); } private void Fail(string message, string? operation = null) diff --git a/crates/mh3g-save-convert/src/cec.rs b/crates/mh3g-save-convert/src/cec.rs index 04f8c3a..4d8db04 100644 --- a/crates/mh3g-save-convert/src/cec.rs +++ b/crates/mh3g-save-convert/src/cec.rs @@ -11,11 +11,15 @@ use crate::{ ConversionError, io_at_path, process_probe::{PlatformProcessProbe, ProcessProbe}, profile::build_jp_cemu_header, + revision::ConverterRevision, transaction::{ atomic_replace, remove_if_regular_file, sha256_hex, sync_directory, unique_path, write_new_file, }, - transforms::{GUILD_CARD_SLOT_SIZE, apply_japanese_wiiu_guild_card_slot_corrections}, + transforms::{ + GUILD_CARD_SLOT_SIZE, apply_japanese_wiiu_guild_card_slot_corrections, + apply_japanese_wiiu_guild_card_slot_corrections_for_revision, + }, }; const BOX_INFO_SIZE: usize = 0x20; @@ -551,6 +555,12 @@ fn validate_cemu_cec_bytes(bytes: &[u8]) -> Result<(), ConversionError> { Ok(()) } +/// Validate an in-memory Japanese MH3G Cemu `cec` container without reading +/// or mutating emulator state. +pub fn validate_cemu_cec(bytes: &[u8]) -> Result<(), ConversionError> { + validate_cemu_cec_bytes(bytes) +} + pub fn empty_cemu_cec() -> Result, ConversionError> { let header = build_jp_cemu_header("cec", CEMU_CEC_PAYLOAD_SIZE)?; let mut bytes = Vec::with_capacity(CEMU_HEADER_SIZE + CEMU_CEC_PAYLOAD_SIZE); @@ -585,6 +595,138 @@ pub fn convert_cec_records( convert_collected_cec_records(records, target_bytes, requested_slot) } +/// Repair a Cemu CEC cache without treating the independently selected output +/// as the read authority. +/// +/// Received records produced by one of the supported historical converter +/// revisions are replaced in their existing slots with the current mapping. +/// Already-current records and records unrelated to the supplied 3DS mailbox +/// are preserved byte-for-byte. Missing source records are inserted into empty +/// slots using the same deterministic assignment as a fresh conversion. +pub fn repair_cec_records( + source_dir: &Path, + current_bytes: &[u8], + requested_slot: Option, +) -> Result { + validate_cemu_cec_bytes(current_bytes)?; + let mut records = collect_received_mh3g_records(source_dir)?; + if records.is_empty() { + return Err(ConversionError::InvalidSave( + "3DS CEC InBox___ contains no non-empty received MH3G records".to_owned(), + )); + } + records.sort_by(|left, right| left.sha256.cmp(&right.sha256)); + let source_record_set_sha256 = source_record_set_sha256(&records); + let before_sha256 = sha256_hex(current_bytes); + let mut output = current_bytes.to_vec(); + let mut changed_records = Vec::new(); + let mut changed_slots = Vec::new(); + let mut missing_records = Vec::new(); + + for mut record in records { + let source = record.record.clone(); + let current = convert_cec_record(&source)?; + let already_current = (0..CEMU_RECORD_SLOT_COUNT).any(|slot| { + cemu_record_range(slot) + .ok() + .is_some_and(|range| output[range] == current) + }); + if already_current { + continue; + } + + let historical = ConverterRevision::ALL + .into_iter() + .map(|revision| convert_cec_record_for_revision(&source, revision)) + .collect::, ConversionError>>()?; + let historical_slot = (0..CEMU_RECORD_SLOT_COUNT).find(|slot| { + cemu_record_range(*slot).ok().is_some_and(|range| { + historical + .iter() + .any(|candidate| candidate != ¤t && output[range.clone()] == *candidate) + }) + }); + record.record = current; + if let Some(slot) = historical_slot { + let range = cemu_record_range(slot)?; + output[range].copy_from_slice(&record.record); + changed_slots.push(slot); + changed_records.push(record); + } else { + missing_records.push(record); + } + } + + let mut empty_slots = (0..CEMU_RECORD_SLOT_COUNT) + .filter(|slot| { + cemu_record_range(*slot) + .ok() + .is_some_and(|range| output[range].iter().all(|byte| *byte == 0)) + }) + .collect::>(); + if let Some(start) = requested_slot { + if start >= CEMU_RECORD_SLOT_COUNT { + return Err(ConversionError::InvalidSave(format!( + "Cemu cec slot is out of range: {start} (max {})", + CEMU_RECORD_SLOT_COUNT - 1 + ))); + } + empty_slots.retain(|slot| *slot >= start); + } + if missing_records.len() > empty_slots.len() { + return Err(ConversionError::UnsafeInstall(format!( + "Cemu cec has {} empty slots but {} MH3G records need import", + empty_slots.len(), + missing_records.len() + ))); + } + for (record, slot) in missing_records.into_iter().zip(empty_slots) { + let range = cemu_record_range(slot)?; + output[range].copy_from_slice(&record.record); + changed_slots.push(slot); + changed_records.push(record); + } + + Ok(CecConversion { + after_sha256: sha256_hex(&output), + bytes: output, + records: changed_records, + slots: changed_slots, + source_record_set_sha256, + before_sha256, + }) +} + +fn convert_cec_record(source: &[u8]) -> Result, ConversionError> { + let mut converted = source.to_vec(); + for slot in 0..CEC_GUILD_CARD_SLOT_COUNT { + let start = slot * GUILD_CARD_SLOT_SIZE; + let end = start + GUILD_CARD_SLOT_SIZE; + apply_japanese_wiiu_guild_card_slot_corrections( + &source[start..end], + &mut converted[start..end], + )?; + } + Ok(converted) +} + +fn convert_cec_record_for_revision( + source: &[u8], + revision: ConverterRevision, +) -> Result, ConversionError> { + let mut converted = source.to_vec(); + for slot in 0..CEC_GUILD_CARD_SLOT_COUNT { + let start = slot * GUILD_CARD_SLOT_SIZE; + let end = start + GUILD_CARD_SLOT_SIZE; + apply_japanese_wiiu_guild_card_slot_corrections_for_revision( + &source[start..end], + &mut converted[start..end], + revision, + )?; + } + Ok(converted) +} + fn convert_collected_cec_records( mut records: Vec, target_bytes: &[u8], @@ -609,16 +751,7 @@ fn convert_collected_cec_records( .into_iter() .map(|mut record| { let source = record.record.clone(); - let mut converted = source.clone(); - for slot in 0..CEC_GUILD_CARD_SLOT_COUNT { - let start = slot * GUILD_CARD_SLOT_SIZE; - let end = start + GUILD_CARD_SLOT_SIZE; - apply_japanese_wiiu_guild_card_slot_corrections( - &source[start..end], - &mut converted[start..end], - )?; - } - record.record = converted; + record.record = convert_cec_record(&source)?; Ok(record) }) .collect::, ConversionError>>()?; @@ -827,6 +960,39 @@ pub fn install_cec_with( install_cec_transaction(source_dir, target, conversion, &context) } +/// Install a conversion derived from an independent read-only Cemu baseline. +/// +/// Unlike [`install_cec_with`], `conversion.before_sha256` identifies the +/// separately selected current/reference cache rather than the output path. +/// The output is therefore guarded by its own expected hash before the +/// precomputed bytes are installed. A missing output uses the same canonical +/// empty-container fingerprint as the existing CEC export flow. +pub fn install_precomputed_cec_with_target_expectation( + source_dir: &Path, + target: &Path, + conversion: &CecConversion, + expected_target_sha256: &str, +) -> Result { + validate_cec_install_expectations(CecInstallExpectations { + source_record_set_sha256: None, + target_sha256: Some(expected_target_sha256), + })?; + validate_cemu_cec_bytes(&conversion.bytes)?; + if sha256_hex(&conversion.bytes) != conversion.after_sha256 { + return Err(ConversionError::UnsafeInstall( + "precomputed CEC conversion bytes do not match their planned hash".to_owned(), + )); + } + let context = prepare_cec_install(target, &PlatformProcessProbe::default())?; + let observed_target_sha256 = observed_cec_target_sha256(&context)?; + ensure_expected_cec_hash( + Some(expected_target_sha256), + &observed_target_sha256, + "output target", + )?; + install_cec_transaction(source_dir, target, conversion, &context) +} + /// Rebuild and install a CEC conversion while holding the per-target lock. /// /// This is the write counterpart to a CEC Dry Run: it re-reads both the Cemu @@ -1379,6 +1545,51 @@ mod tests { assert_eq!(before.bytes, after.bytes); } + #[test] + fn repair_replaces_a_historical_cec_record_in_place() { + let temp = tempdir().unwrap(); + let inbox = temp.path().join("InBox___"); + fs::create_dir_all(&inbox).unwrap(); + fs::write(inbox.join("BoxInfo_____"), [0_u8; BOX_INFO_SIZE]).unwrap(); + + let mut source_record = vec![0_u8; CEMU_RECORD_SLOT_SIZE]; + // Arena record conversion was added after 0.0.3, which gives this + // fixture a stable historical signature without inventing a corrupt + // container. + let late_arena_record = 0x9B4 + 109 * 4; + source_record[late_arena_record..late_arena_record + 4] + .copy_from_slice(&[0x34, 0x12, 0x78, 0x56]); + fs::write( + inbox.join("_A"), + received_message_with_record(&source_record), + ) + .unwrap(); + + let old = + convert_cec_record_for_revision(&source_record, ConverterRevision::V0_0_3).unwrap(); + let latest = convert_cec_record(&source_record).unwrap(); + assert_ne!(old, latest); + + let mut current = empty_cemu_cec().unwrap(); + let occupied_slot = 7; + let range = cemu_record_range(occupied_slot).unwrap(); + current[range.clone()].copy_from_slice(&old); + // An unrelated current Cemu record must remain untouched. + let unrelated_slot = 2; + let unrelated_range = cemu_record_range(unrelated_slot).unwrap(); + current[unrelated_range.clone()].fill(0xA5); + + let repair = repair_cec_records(temp.path(), ¤t, None).unwrap(); + + assert_eq!(repair.slots, vec![occupied_slot]); + assert_eq!(&repair.bytes[range], latest.as_slice()); + assert_eq!( + &repair.bytes[unrelated_range.clone()], + ¤t[unrelated_range] + ); + assert_eq!(repair.records.len(), 1); + } + #[test] fn transaction_file_create_error_identifies_the_actual_path() { let temp = tempdir().unwrap(); diff --git a/crates/mh3g-save-convert/src/main.rs b/crates/mh3g-save-convert/src/main.rs index 26e7980..ecb98fe 100644 --- a/crates/mh3g-save-convert/src/main.rs +++ b/crates/mh3g-save-convert/src/main.rs @@ -12,7 +12,8 @@ use mh3g_save_convert::{ ConversionError, cec::{ CecInstallExpectations, convert_cec_records, empty_cemu_cec, inspect_cec, - install_cec_from_source_with_expectations, rollback_cec, + install_cec_from_source_with_expectations, install_precomputed_cec_with_target_expectation, + repair_cec_records, rollback_cec, validate_cemu_cec, }, compatibility::{ CompatibilityMerge, DetectionConfidence, RevisionDetection, combine_revision_detections, @@ -173,6 +174,88 @@ enum Command { #[arg(long, conflicts_with = "dry_run")] write: bool, }, + /// Repair one complete ExtData domain independently of the core slot. + RepairExtras { + /// Original 3DS MH3G ExtData `user` directory. + #[arg(long)] + source_dir: PathBuf, + /// Continued-play Wii U/Cemu save directory used as the read-only authority. + #[arg(long)] + current_dir: PathBuf, + /// Initialized Wii U/Cemu output directory for this repaired domain. + #[arg(long)] + output_dir: PathBuf, + /// Repair exactly one indivisible ExtData group per transaction. + #[arg(long, value_enum)] + group: ExtraGroup, + /// Override automatic historical-version classification for this domain. + #[arg(long, value_enum)] + from_version: Option, + #[arg(long, requires = "write")] + expected_source_set_sha256: Option, + #[arg(long, requires = "write")] + expected_current_set_sha256: Option, + #[arg(long, requires = "write")] + expected_output_set_sha256: Option, + #[arg(long, requires = "write")] + expected_preview_sha256: Option, + #[arg(long, conflicts_with = "write")] + dry_run: bool, + #[arg(long, conflicts_with = "dry_run")] + write: bool, + }, + /// Repair the shared gallery/movie flags as an independent system transaction. + RepairSystem { + /// Original Japanese 3DS shared `system` file. + source: PathBuf, + /// Continued-play Wii U/Cemu `system` used as the read-only authority. + #[arg(long)] + current: PathBuf, + /// Independent Wii U/Cemu `system` output. It may be absent before write. + #[arg(long)] + output: PathBuf, + #[arg(long, requires = "write")] + expected_source_set_sha256: Option, + #[arg(long, requires = "write")] + expected_current_set_sha256: Option, + #[arg(long, requires = "write")] + expected_output_set_sha256: Option, + #[arg(long, requires = "write")] + expected_preview_sha256: Option, + #[arg(long, conflicts_with = "write")] + dry_run: bool, + #[arg(long, conflicts_with = "dry_run")] + write: bool, + }, + /// Experimentally repair/import CEC records using a separate current cache and output. + RepairCec { + /// Original 3DS MH3G CEC mailbox directory. + #[arg(long)] + source_dir: PathBuf, + /// Continued-play Wii U/Cemu `cec` cache used as the read-only authority. + #[arg(long)] + current: PathBuf, + /// Independent Wii U/Cemu `cec` output. It may be absent before write. + #[arg(long)] + output: PathBuf, + #[arg(long)] + slot: Option, + #[arg(long, requires = "write")] + expected_source_record_set_sha256: Option, + #[arg(long, requires = "write")] + expected_current_set_sha256: Option, + #[arg(long, requires = "write")] + expected_output_set_sha256: Option, + #[arg(long, requires = "write")] + expected_preview_sha256: Option, + #[arg(long, conflicts_with = "write")] + dry_run: bool, + #[arg(long, conflicts_with = "dry_run", requires = "experimental")] + write: bool, + /// Acknowledge that CEC conversion still has file-level evidence only. + #[arg(long)] + experimental: bool, + }, /// Roll back a compatibility repair as one coordinated transaction. RollbackRepair { #[arg(long)] @@ -271,7 +354,7 @@ struct CecConversionOptions { experimental: bool, } -#[derive(Debug)] +#[derive(Debug, Default)] struct RepairWriteOptions { expected_source_set_sha256: Option, expected_current_set_sha256: Option, @@ -456,6 +539,60 @@ struct RepairConvertedReport { compatibility_manifest: Option, } +#[derive(Debug, Serialize)] +struct RepairExtrasReport { + operation: &'static str, + status: &'static str, + group: ExtraGroup, + source_dir: PathBuf, + current_dir: PathBuf, + output_dir: PathBuf, + source_set_sha256: String, + current_set_sha256: String, + output_set_sha256: String, + preview_sha256: String, + detection: RevisionDetection, + components: Vec, + manifest: Option, +} + +#[derive(Debug, Serialize)] +struct RepairSystemReport { + operation: &'static str, + status: &'static str, + source: PathBuf, + current: PathBuf, + output: PathBuf, + source_set_sha256: String, + current_set_sha256: String, + output_set_sha256: String, + preview_sha256: String, + source_gallery_sha256: String, + current_gallery_sha256: String, + repaired_gallery_sha256: String, + write_required: bool, + manifest: Option, +} + +#[derive(Debug, Serialize)] +struct RepairCecReport { + operation: &'static str, + status: &'static str, + source_dir: PathBuf, + current: PathBuf, + output: PathBuf, + imported_messages: usize, + slots: Vec, + source_record_set_sha256: String, + current_set_sha256: String, + output_set_sha256: String, + preview_sha256: String, + current_sha256: String, + output_sha256_before: Option, + output_sha256_after: String, + manifest: Option, +} + const COMPATIBILITY_REPAIR_MANIFEST_VERSION: u32 = 2; const COMPATIBILITY_REPAIR_MANIFEST_PREFIX: &str = ".mh3g-compatibility-repair-"; @@ -617,6 +754,93 @@ fn run(cli: Cli) -> Result<(), ConversionError> { }, )?)? ), + Command::RepairExtras { + source_dir, + current_dir, + output_dir, + group, + from_version, + expected_source_set_sha256, + expected_current_set_sha256, + expected_output_set_sha256, + expected_preview_sha256, + dry_run, + write, + } => println!( + "{}", + serde_json::to_string(&repair_extras( + source_dir, + current_dir, + output_dir, + group, + from_version, + RepairWriteOptions { + expected_source_set_sha256, + expected_current_set_sha256, + expected_output_set_sha256, + expected_preview_sha256, + dry_run, + write, + }, + )?)? + ), + Command::RepairSystem { + source, + current, + output, + expected_source_set_sha256, + expected_current_set_sha256, + expected_output_set_sha256, + expected_preview_sha256, + dry_run, + write, + } => println!( + "{}", + serde_json::to_string(&repair_system( + source, + current, + output, + RepairWriteOptions { + expected_source_set_sha256, + expected_current_set_sha256, + expected_output_set_sha256, + expected_preview_sha256, + dry_run, + write, + }, + )?)? + ), + Command::RepairCec { + source_dir, + current, + output, + slot, + expected_source_record_set_sha256, + expected_current_set_sha256, + expected_output_set_sha256, + expected_preview_sha256, + dry_run, + write, + experimental, + } => println!( + "{}", + serde_json::to_string(&repair_cec( + source_dir, + current, + output, + slot, + expected_source_record_set_sha256, + RepairWriteOptions { + expected_source_set_sha256: None, + expected_current_set_sha256, + expected_output_set_sha256, + expected_preview_sha256, + dry_run, + write, + }, + experimental, + )?)? + ), Command::RollbackRepair { manifest } => { println!("{}", serde_json::to_string(&rollback_repair(manifest)?)?) } @@ -665,6 +889,9 @@ fn run(cli: Cli) -> Result<(), ConversionError> { | Command::InstallExtras { .. } | Command::RollbackExtras { .. } | Command::RepairConverted { .. } + | Command::RepairExtras { .. } + | Command::RepairSystem { .. } + | Command::RepairCec { .. } | Command::RollbackRepair { .. } => unreachable!(), }; println!("{}", serde_json::to_string(&report)?); @@ -1525,6 +1752,453 @@ fn repair_converted( }) } +fn repair_extras( + source_dir: PathBuf, + current_dir: PathBuf, + output_dir: PathBuf, + group: ExtraGroup, + from_version: Option, + options: RepairWriteOptions, +) -> Result { + debug_assert!(!(options.dry_run && options.write)); + let source_dir = canonical_directory(&source_dir, "3DS ExtData source directory")?; + let current_dir = canonical_directory(¤t_dir, "current Cemu ExtData directory")?; + let output_dir = canonical_directory(&output_dir, "output Cemu ExtData directory")?; + + let mut source_set = BTreeMap::new(); + let mut current_set = BTreeMap::new(); + let mut output_set = BTreeMap::new(); + let mut inputs = Vec::new(); + for component in group.components() { + let source_path = source_dir.join(component); + let current_path = current_dir.join(component); + let output_path = output_dir.join(component); + let source = read_file(&source_path, "reading original 3DS ExtData repair source")?; + let current = read_file( + ¤t_path, + "reading current Cemu ExtData repair authority", + )?; + let output = read_file( + &output_path, + "reading initialized Cemu ExtData repair output", + )?; + convert_external_component_to_cemu_named(&source, component)?; + mh3g_save_convert::converter::validate_cemu_external_component_named(¤t, component)?; + mh3g_save_convert::converter::validate_cemu_external_component_named(&output, component)?; + let detection = detect_component_revision(&source, ¤t, component)?; + source_set.insert((*component).to_owned(), source.clone()); + current_set.insert((*component).to_owned(), current.clone()); + output_set.insert((*component).to_owned(), Some(output.clone())); + inputs.push(RepairComponentInput { + component: (*component).to_owned(), + source, + current, + target: output_path, + target_before: Some(output), + detection, + }); + } + + let detection = combine_revision_detections( + &inputs + .iter() + .map(|input| input.detection.clone()) + .collect::>(), + ); + let revision = select_repair_revision(&detection, from_version, !options.write)?; + let components = inputs + .into_iter() + .map(|input| { + let merge = merge_component(&input.source, &input.current, &input.component, revision)?; + let target_sha256_before = input.target_before.as_deref().map(sha256_hex); + let write_required = input + .target_before + .as_deref() + .is_none_or(|before| before != merge.bytes); + Ok(RepairComponentReport { + component: input.component, + detection: input.detection, + modified: merge.current_sha256 != merge.merged_sha256, + write_required, + merge, + target: input.target, + target_sha256_before, + }) + }) + .collect::, ConversionError>>()?; + + let source_set_sha256 = component_set_sha256(&source_set); + let current_set_sha256 = component_set_sha256(¤t_set); + let output_set_sha256 = component_state_set_sha256(&output_set); + let preview_bytes = serde_json::to_vec(&( + group, + &source_set_sha256, + ¤t_set_sha256, + &output_set_sha256, + &detection, + &components, + ))?; + let preview_sha256 = hex::encode(Sha256::digest(preview_bytes)); + + if options.write { + require_repair_expectation( + options.expected_source_set_sha256.as_deref(), + &source_set_sha256, + "source set", + )?; + require_repair_expectation( + options.expected_current_set_sha256.as_deref(), + ¤t_set_sha256, + "current set", + )?; + require_repair_expectation( + options.expected_output_set_sha256.as_deref(), + &output_set_sha256, + "output set", + )?; + require_repair_expectation( + options.expected_preview_sha256.as_deref(), + &preview_sha256, + "preview", + )?; + } + + let write_required = components.iter().any(|component| component.write_required); + let manifest = if options.write && write_required { + let staging_parent = output_dir.parent().ok_or_else(|| { + ConversionError::InvalidSave( + "output ExtData directory has no parent for repair staging".to_owned(), + ) + })?; + let staging_dir = + staging_parent.join(format!(".mh3g-domain-repair-staging-{}", Uuid::new_v4())); + io_at_path( + fs::create_dir(&staging_dir), + "creating domain repair staging directory", + &staging_dir, + )?; + let result = (|| { + for component in &components { + let path = staging_dir.join(&component.component); + io_at_path( + fs::write(&path, &component.merge.bytes), + "writing domain repair staging component", + &path, + )?; + } + let groups = [group]; + let dry_run = dry_run_extra_groups(&staging_dir, &output_dir, &groups, None, None)?; + install_extra_groups( + &staging_dir, + &output_dir, + &groups, + Some(&dry_run.staging_set_sha256), + Some(&dry_run.target_set_sha256), + ) + })(); + let _ = fs::remove_dir_all(&staging_dir); + Some(result?.manifest_path) + } else { + None + }; + + Ok(RepairExtrasReport { + operation: "repair-extras", + status: if options.write { + if write_required { + "written" + } else { + "no-changes" + } + } else { + "dry-run" + }, + group, + source_dir, + current_dir, + output_dir, + source_set_sha256, + current_set_sha256, + output_set_sha256, + preview_sha256, + detection, + components, + manifest, + }) +} + +fn repair_system( + source: PathBuf, + current: PathBuf, + output: PathBuf, + options: RepairWriteOptions, +) -> Result { + debug_assert!(!(options.dry_run && options.write)); + validate_system_path(&source)?; + validate_system_path(¤t)?; + validate_system_path(&output)?; + let output_parent = output.parent().ok_or_else(|| { + ConversionError::InvalidSave("system output has no parent directory".to_owned()) + })?; + let output_parent = canonical_directory(output_parent, "system output directory")?; + let output = output_parent.join("system"); + + let source_bytes = read_file(&source, "reading original 3DS system repair source")?; + let current_bytes = read_file(¤t, "reading current Cemu system repair authority")?; + if inspect_bytes(&source_bytes)?.profile != SaveProfile::JpThreeDsSystem { + return Err(ConversionError::InvalidSave( + "repair-system requires a Japanese MH3G 3DS system source".to_owned(), + )); + } + if inspect_bytes(¤t_bytes)?.profile != SaveProfile::JpCemuSystem { + return Err(ConversionError::InvalidSave( + "repair-system requires an initialized Japanese MH3G Cemu current system".to_owned(), + )); + } + let output_before = read_optional_file(&output, "reading Cemu system repair output")?; + if let Some(bytes) = output_before.as_deref() + && inspect_bytes(bytes)?.profile != SaveProfile::JpCemuSystem + { + return Err(ConversionError::InvalidSave(format!( + "repair-system output is not an initialized Japanese MH3G Cemu system: {}", + output.display() + ))); + } + + let merged = merge_3ds_system_gallery_into_cemu_named(&source_bytes, ¤t_bytes, "system")?; + let source_set_sha256 = component_set_sha256(&BTreeMap::from([( + "system".to_owned(), + source_bytes.clone(), + )])); + let current_set_sha256 = component_set_sha256(&BTreeMap::from([( + "system".to_owned(), + current_bytes.clone(), + )])); + let output_set_sha256 = component_state_set_sha256(&BTreeMap::from([( + "system".to_owned(), + output_before.clone(), + )])); + let source_gallery_start = 4 + SYSTEM_GALLERY_PAYLOAD_RANGE.start; + let source_gallery_end = 4 + SYSTEM_GALLERY_PAYLOAD_RANGE.end; + let current_payload_start = + current_bytes.len() - mh3g_save_convert::profile::SYSTEM_PAYLOAD_SIZE; + let current_gallery_start = current_payload_start + SYSTEM_GALLERY_PAYLOAD_RANGE.start; + let current_gallery_end = current_payload_start + SYSTEM_GALLERY_PAYLOAD_RANGE.end; + let source_gallery_sha256 = sha256_hex(&source_bytes[source_gallery_start..source_gallery_end]); + let current_gallery_sha256 = + sha256_hex(¤t_bytes[current_gallery_start..current_gallery_end]); + let repaired_gallery_sha256 = sha256_hex(&merged[current_gallery_start..current_gallery_end]); + let write_required = output_before + .as_deref() + .is_none_or(|before| before != merged); + let source_sha256 = sha256_hex(&source_bytes); + let output_before_sha256 = output_before.as_deref().map(sha256_hex); + let preview_sha256 = hex::encode(Sha256::digest(serde_json::to_vec(&( + &source_set_sha256, + ¤t_set_sha256, + &output_set_sha256, + &source_gallery_sha256, + ¤t_gallery_sha256, + &repaired_gallery_sha256, + sha256_hex(&merged), + ))?)); + + if options.write { + require_repair_expectation( + options.expected_source_set_sha256.as_deref(), + &source_set_sha256, + "source set", + )?; + require_repair_expectation( + options.expected_current_set_sha256.as_deref(), + ¤t_set_sha256, + "current set", + )?; + require_repair_expectation( + options.expected_output_set_sha256.as_deref(), + &output_set_sha256, + "output set", + )?; + require_repair_expectation( + options.expected_preview_sha256.as_deref(), + &preview_sha256, + "preview", + )?; + } + + let manifest = if options.write && write_required { + let manifest_path = manifest_path_for_target(&output)?; + install_merged_component_with_expectations( + &source_bytes, + &merged, + &output, + &manifest_path, + InstallExpectations { + source_sha256: Some(&source_sha256), + target_sha256: output_before_sha256.as_deref(), + target_must_be_absent: output_before.is_none(), + }, + )?; + Some(manifest_path) + } else { + None + }; + + Ok(RepairSystemReport { + operation: "repair-system", + status: if options.write { + if write_required { + "written" + } else { + "no-changes" + } + } else { + "dry-run" + }, + source, + current, + output, + source_set_sha256, + current_set_sha256, + output_set_sha256, + preview_sha256, + source_gallery_sha256, + current_gallery_sha256, + repaired_gallery_sha256, + write_required, + manifest, + }) +} + +fn repair_cec( + source_dir: PathBuf, + current: PathBuf, + output: PathBuf, + slot: Option, + expected_source_record_set_sha256: Option, + options: RepairWriteOptions, + experimental: bool, +) -> Result { + debug_assert!(!(options.dry_run && options.write)); + if options.write && !experimental { + return Err(ConversionError::UnsafeInstall( + "repair-cec --write requires --experimental acknowledgement".to_owned(), + )); + } + let source_dir = canonical_directory(&source_dir, "3DS CEC source directory")?; + if current.file_name().and_then(|name| name.to_str()) != Some("cec") + || output.file_name().and_then(|name| name.to_str()) != Some("cec") + { + return Err(ConversionError::InvalidSave( + "repair-cec current and output files must both be named cec".to_owned(), + )); + } + let output_parent = output.parent().ok_or_else(|| { + ConversionError::InvalidSave("CEC repair output has no parent directory".to_owned()) + })?; + let output = canonical_directory(output_parent, "CEC repair output directory")?.join("cec"); + let current_bytes = read_file(¤t, "reading current Cemu CEC repair authority")?; + validate_cemu_cec(¤t_bytes)?; + let output_before = read_optional_file(&output, "reading Cemu CEC repair output")?; + if let Some(bytes) = output_before.as_deref() { + validate_cemu_cec(bytes)?; + } + let conversion = repair_cec_records(&source_dir, ¤t_bytes, slot)?; + let current_sha256 = sha256_hex(¤t_bytes); + let output_sha256_before = output_before.as_deref().map(sha256_hex); + let output_sha256_after = conversion.after_sha256.clone(); + let current_set_sha256 = + component_set_sha256(&BTreeMap::from([("cec".to_owned(), current_bytes)])); + let output_set_sha256 = + component_state_set_sha256(&BTreeMap::from([("cec".to_owned(), output_before.clone())])); + let preview_sha256 = hex::encode(Sha256::digest(serde_json::to_vec(&( + &conversion.source_record_set_sha256, + ¤t_set_sha256, + &output_set_sha256, + &conversion.after_sha256, + &conversion.slots, + ))?)); + + if options.write { + require_repair_expectation( + expected_source_record_set_sha256.as_deref(), + &conversion.source_record_set_sha256, + "source record set", + )?; + require_repair_expectation( + options.expected_current_set_sha256.as_deref(), + ¤t_set_sha256, + "current set", + )?; + require_repair_expectation( + options.expected_output_set_sha256.as_deref(), + &output_set_sha256, + "output set", + )?; + require_repair_expectation( + options.expected_preview_sha256.as_deref(), + &preview_sha256, + "preview", + )?; + } + + let write_required = output_before + .as_deref() + .is_none_or(|before| before != conversion.bytes); + let manifest = if options.write && write_required { + let expected_output_sha256 = output_sha256_before + .clone() + .unwrap_or(sha256_hex(&empty_cemu_cec()?)); + Some( + install_precomputed_cec_with_target_expectation( + &source_dir, + &output, + &conversion, + &expected_output_sha256, + )? + .manifest, + ) + } else { + None + }; + + Ok(RepairCecReport { + operation: "repair-cec", + status: if options.write { + if write_required { + "written" + } else { + "no-changes" + } + } else { + "dry-run" + }, + source_dir, + current, + output, + imported_messages: conversion.records.len(), + slots: conversion.slots, + source_record_set_sha256: conversion.source_record_set_sha256, + current_set_sha256, + output_set_sha256, + preview_sha256, + current_sha256, + output_sha256_before, + output_sha256_after, + manifest, + }) +} + +fn canonical_directory(path: &Path, label: &str) -> Result { + if !path.is_dir() { + return Err(ConversionError::InvalidSave(format!( + "{label} is not a directory: {}", + path.display() + ))); + } + io_at_path(fs::canonicalize(path), "resolving repair directory", path) +} + fn write_compatibility_manifest( path: &Path, manifest: &CompatibilityRepairManifest, diff --git a/crates/mh3g-save-convert/tests/cli.rs b/crates/mh3g-save-convert/tests/cli.rs index 3300770..02eda37 100644 --- a/crates/mh3g-save-convert/tests/cli.rs +++ b/crates/mh3g-save-convert/tests/cli.rs @@ -825,6 +825,287 @@ fn repair_converted_preserves_the_played_directory_and_rolls_back_every_change() } } +#[test] +fn repair_quests_copies_current_wiiu_bytes_to_an_independent_output_and_is_idempotent() { + #[cfg(target_os = "macos")] + let _guard = PROCESS_GUARD.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let source_dir = temp.path().join("3ds-extdata"); + let current_dir = temp.path().join("current-cemu"); + let output_dir = temp.path().join("output-cemu"); + fs::create_dir(&source_dir).unwrap(); + fs::create_dir(¤t_dir).unwrap(); + fs::create_dir(&output_dir).unwrap(); + + let components = ["quest1", "quest2", "quest3", "quest4"]; + let mut output_before = BTreeMap::new(); + for (index, component) in components.iter().enumerate() { + let mut source = vec![0_u8; 0x29_000]; + source[..JP_3DS_HEADER.len()].copy_from_slice(&JP_3DS_HEADER); + source[4] = index as u8; + let baseline = convert_external_component_to_cemu_named(&source, component).unwrap(); + let mut current = baseline.clone(); + current[0x120 + index] ^= 0x5A; + fs::write(source_dir.join(component), source).unwrap(); + fs::write(current_dir.join(component), current).unwrap(); + fs::write(output_dir.join(component), &baseline).unwrap(); + output_before.insert(*component, baseline); + } + let current_before = components + .iter() + .map(|component| (*component, fs::read(current_dir.join(component)).unwrap())) + .collect::>(); + + let dry = run_json(&[ + "repair-extras".into(), + "--source-dir".into(), + source_dir.to_string_lossy().into_owned(), + "--current-dir".into(), + current_dir.to_string_lossy().into_owned(), + "--output-dir".into(), + output_dir.to_string_lossy().into_owned(), + "--group".into(), + "quests".into(), + "--dry-run".into(), + ]); + assert_eq!(dry["status"], "dry-run"); + assert!( + dry["components"] + .as_array() + .unwrap() + .iter() + .all(|component| component["modified"] == false) + ); + + let written = run_json_with_stopped_emulators(&[ + "repair-extras".into(), + "--source-dir".into(), + source_dir.to_string_lossy().into_owned(), + "--current-dir".into(), + current_dir.to_string_lossy().into_owned(), + "--output-dir".into(), + output_dir.to_string_lossy().into_owned(), + "--group".into(), + "quests".into(), + "--write".into(), + "--expected-source-set-sha256".into(), + dry["source_set_sha256"].as_str().unwrap().to_owned(), + "--expected-current-set-sha256".into(), + dry["current_set_sha256"].as_str().unwrap().to_owned(), + "--expected-output-set-sha256".into(), + dry["output_set_sha256"].as_str().unwrap().to_owned(), + "--expected-preview-sha256".into(), + dry["preview_sha256"].as_str().unwrap().to_owned(), + ]); + assert_eq!(written["status"], "written"); + for component in components { + assert_eq!( + fs::read(output_dir.join(component)).unwrap(), + current_before[component] + ); + assert_eq!( + fs::read(current_dir.join(component)).unwrap(), + current_before[component] + ); + } + + let second = run_json(&[ + "repair-extras".into(), + "--source-dir".into(), + source_dir.to_string_lossy().into_owned(), + "--current-dir".into(), + current_dir.to_string_lossy().into_owned(), + "--output-dir".into(), + output_dir.to_string_lossy().into_owned(), + "--group".into(), + "quests".into(), + "--dry-run".into(), + ]); + assert!( + second["components"] + .as_array() + .unwrap() + .iter() + .all(|component| component["write_required"] == false) + ); + + let manifest = written["manifest"].as_str().unwrap(); + let rolled_back = run_json_with_stopped_emulators(&[ + "rollback-extras".into(), + "--manifest".into(), + manifest.to_owned(), + ]); + assert_eq!(rolled_back["status"], "rolled-back"); + for component in components { + assert_eq!( + fs::read(output_dir.join(component)).unwrap(), + output_before[component] + ); + assert_eq!( + fs::read(current_dir.join(component)).unwrap(), + current_before[component] + ); + } +} + +#[test] +fn repair_system_preserves_current_shared_bytes_and_rolls_back_a_new_output() { + #[cfg(target_os = "macos")] + let _guard = PROCESS_GUARD.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let source_dir = temp.path().join("source"); + let current_dir = temp.path().join("current"); + let output_dir = temp.path().join("output"); + fs::create_dir(&source_dir).unwrap(); + fs::create_dir(¤t_dir).unwrap(); + fs::create_dir(&output_dir).unwrap(); + let source = source_dir.join("system"); + let current = current_dir.join("system"); + let output = output_dir.join("system"); + + let mut source_bytes = vec![0_u8; THREE_DS_SYSTEM_SIZE]; + source_bytes[..JP_3DS_HEADER.len()].copy_from_slice(&JP_3DS_HEADER); + source_bytes[0x44..0x48].copy_from_slice(&1_u32.to_le_bytes()); + fs::write(&source, source_bytes).unwrap(); + let mut current_bytes = build_jp_cemu_header("system", CEMU_SYSTEM_SIZE - 40) + .unwrap() + .to_vec(); + current_bytes.resize(CEMU_SYSTEM_SIZE, 0); + current_bytes[0x180] = 0xA5; + fs::write(¤t, ¤t_bytes).unwrap(); + + let dry = run_json(&[ + "repair-system".into(), + source.to_string_lossy().into_owned(), + "--current".into(), + current.to_string_lossy().into_owned(), + "--output".into(), + output.to_string_lossy().into_owned(), + "--dry-run".into(), + ]); + assert_eq!(dry["status"], "dry-run"); + assert_eq!(dry["write_required"], true); + assert!(!output.exists()); + + let written = run_json_with_stopped_emulators(&[ + "repair-system".into(), + source.to_string_lossy().into_owned(), + "--current".into(), + current.to_string_lossy().into_owned(), + "--output".into(), + output.to_string_lossy().into_owned(), + "--write".into(), + "--expected-source-set-sha256".into(), + dry["source_set_sha256"].as_str().unwrap().to_owned(), + "--expected-current-set-sha256".into(), + dry["current_set_sha256"].as_str().unwrap().to_owned(), + "--expected-output-set-sha256".into(), + dry["output_set_sha256"].as_str().unwrap().to_owned(), + "--expected-preview-sha256".into(), + dry["preview_sha256"].as_str().unwrap().to_owned(), + ]); + assert_eq!(written["status"], "written"); + let repaired = fs::read(&output).unwrap(); + assert_eq!(repaired[0x180], 0xA5); + assert_eq!(&repaired[0x68..0x6C], &1_u32.to_be_bytes()); + assert_eq!(fs::read(¤t).unwrap(), current_bytes); + + let second = run_json(&[ + "repair-system".into(), + source.to_string_lossy().into_owned(), + "--current".into(), + current.to_string_lossy().into_owned(), + "--output".into(), + output.to_string_lossy().into_owned(), + "--dry-run".into(), + ]); + assert_eq!(second["write_required"], false); + + let manifest = written["manifest"].as_str().unwrap(); + let rolled_back = run_json_with_stopped_emulators(&[ + "rollback".into(), + "--manifest".into(), + manifest.to_owned(), + ]); + assert_eq!(rolled_back["status"], "rolled-back"); + assert!(!output.exists()); + assert_eq!(fs::read(¤t).unwrap(), current_bytes); +} + +#[test] +fn repair_cec_keeps_current_read_only_and_rolls_back_an_independent_output() { + #[cfg(target_os = "macos")] + let _guard = PROCESS_GUARD.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let source_dir = cec_fixture(&temp); + let current = cemu_cec_fixture(&temp); + let current_before = fs::read(¤t).unwrap(); + let output_dir = temp.path().join("cec-output"); + fs::create_dir(&output_dir).unwrap(); + let output = output_dir.join("cec"); + + let dry = run_json(&[ + "repair-cec".into(), + "--source-dir".into(), + source_dir.to_string_lossy().into_owned(), + "--current".into(), + current.to_string_lossy().into_owned(), + "--output".into(), + output.to_string_lossy().into_owned(), + "--dry-run".into(), + ]); + assert_eq!(dry["status"], "dry-run"); + assert!(!output.exists()); + + let written = run_json_with_stopped_emulators(&[ + "repair-cec".into(), + "--source-dir".into(), + source_dir.to_string_lossy().into_owned(), + "--current".into(), + current.to_string_lossy().into_owned(), + "--output".into(), + output.to_string_lossy().into_owned(), + "--write".into(), + "--experimental".into(), + "--expected-source-record-set-sha256".into(), + dry["source_record_set_sha256"].as_str().unwrap().to_owned(), + "--expected-current-set-sha256".into(), + dry["current_set_sha256"].as_str().unwrap().to_owned(), + "--expected-output-set-sha256".into(), + dry["output_set_sha256"].as_str().unwrap().to_owned(), + "--expected-preview-sha256".into(), + dry["preview_sha256"].as_str().unwrap().to_owned(), + ]); + assert_eq!(written["status"], "written"); + assert_ne!(fs::read(&output).unwrap(), current_before); + assert_eq!(fs::read(¤t).unwrap(), current_before); + + let second = run_json(&[ + "repair-cec".into(), + "--source-dir".into(), + source_dir.to_string_lossy().into_owned(), + "--current".into(), + current.to_string_lossy().into_owned(), + "--output".into(), + output.to_string_lossy().into_owned(), + "--dry-run".into(), + ]); + assert_eq!( + second["output_sha256_before"], + second["output_sha256_after"] + ); + + let manifest = written["manifest"].as_str().unwrap(); + let rolled_back = run_json_with_stopped_emulators(&[ + "rollback-cec".into(), + "--manifest".into(), + manifest.to_owned(), + ]); + assert_eq!(rolled_back["status"], "rolled-back"); + assert!(!output.exists()); + assert_eq!(fs::read(¤t).unwrap(), current_before); +} + #[test] fn inspect_reports_metadata_without_decoded_player_data() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md index dbff05d..c8bc031 100644 --- a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md +++ b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md @@ -84,52 +84,102 @@ candidate/cache data. `convert` never automatically opens `system`, ### Compatibility Repair for an Older Conversion -`repair-converted` is separate from a new `convert`. It requires one original -3DS `user#` plus the same-named Cemu `user#` that was created by 0.0.3 through -0.0.6 and may have been played further: +Compatibility repair is separate from a new conversion. It has five +independently authorized domains; selecting or completing one never authorizes, +writes, or rolls back another: + +| Repair domain | Original 3DS input | Read-only current Wii U/Cemu authority | Independent output | Command | +| --- | --- | --- | --- | --- | +| Core slot | Exact `user1`, `user2`, or `user3` | Same-named `user#` | Same-named `user#` | `repair-converted` | +| Guild cards | ExtData `user` directory containing `card1`-`card3`, `cardbox` | Directory containing initialized matching files | Directory containing initialized matching files | `repair-extras --group guild-cards` | +| Quests | ExtData `user` directory containing `quest1`-`quest4` | Directory containing initialized matching files | Directory containing initialized matching files | `repair-extras --group quests` | +| Shared gallery/movie state | Exact 3DS `system` | Exact initialized Cemu `system` | Exact `system` path | `repair-system` | +| StreetPass/CEC cache | Exact MH3G CEC mailbox directory | Exact initialized Cemu `cec` | Exact `cec` path | `repair-cec` | + +Every domain has its own Dry Run fingerprint, write authorization, transaction +manifest, and rollback. Source, current, and output path values are never +copied or cascaded between controls. The native interfaces accept a core +`user#` file or its direct parent directory; they do not recursively scan a +3DS SD root, a Cemu MLC, or an archive. `system` and `cec` are exact files. +Cards and quests share the same three ExtData directory selectors because they +are siblings in one physical directory, but each group still has a separate +Dry Run, write button, manifest, and rollback. + +The core contract is: ```text mh3g-save-convert repair-converted <3DS-user#> --current \ - [--output ] \ - [--source-extdata-dir <3DS-ExtData-user>] [--from-version <0.0.3|0.0.4|0.0.5|0.0.6>] \ + --output \ + [--from-version <0.0.3|0.0.4|0.0.5|0.0.6>] \ [--dry-run | --write --expected-source-set-sha256 \ --expected-current-set-sha256 --expected-output-set-sha256 \ --expected-preview-sha256 ] ``` -These are three distinct path roles. The original 3DS slot and `--current` -Wii U/Cemu slot are read-only merge inputs; `--output` is the write -destination. All must name the same `user1`, `user2`, or `user3` slot. Omitting -`--output` preserves the legacy CLI in-place behavior (`output = current`), but -the native UIs always expose and pass an explicit output selection. - -Current Cemu data is authoritative for continued gameplay. The operation -three-way compares complete semantic fields known to differ across 0.0.3 -through 0.0.6. A field is replaced with the current converter result only when -its current value still equals the historical output. A value different from -both the historical and current-converter outputs is preserved as later Wii U -progress and reported as a conflict. The operation does not blindly merge bytes -or rebuild the whole Cemu slot. - -Omit `--source-extdata-dir` for a core-only repair. Guild-card repair requires -all eight 3DS ExtData files in that directory and all eight current Cemu files -beside the current `user#`. When output differs from current, its directory -must already contain initialized `card1`, `card2`, `card3`, and `cardbox` -targets. The `user#` and four `card*` components are -repairable; `quest1` through `quest4` are validated and included in the set -SHA-256 but retain the current Cemu bytes exactly. This command does not handle -`system`, `cec`, or `phrase*`. - -Dry Run aggregates every selected component into one top-level revision -decision: `exact`, `compatible-range`, `ambiguous`, or `unknown`. Every -component shares that final historical revision. An `ambiguous` write requires -an explicit, non-contradicted `--from-version` followed by another Dry Run; -`unknown` is refused. A write with explicit output must submit the immediate -Dry Run's `source_set_sha256`, `current_set_sha256`, `output_set_sha256`, and -`preview_sha256`. Success returns -`.mh3g-compatibility-repair-.json`; `rollback-repair --manifest ` -restores the guild-card subtransaction first and the core subtransaction -second. +The original 3DS slot and `--current` slot are read-only merge inputs; +`--output` is the only payload path that may be written. All three must name +the same slot. Current Cemu data is authoritative for continued gameplay. A +known historical field is replaced only when the current value still equals +the selected old converter output; later Wii U changes are preserved and +reported as conflicts. Omitting `--output` retains legacy CLI in-place behavior +only; both native UIs always display and pass a separate output. + +The two ExtData domains use: + +```text +mh3g-save-convert repair-extras --group \ + --source-dir <3DS-ExtData-user> --current-dir \ + --output-dir \ + [--from-version <0.0.3|0.0.4|0.0.5|0.0.6>] [--dry-run | --write ...] +``` + +The selected output group must already be initialized and complete: +`card1`, `card2`, `card3`, and `cardbox` for `guild-cards`; `quest1`, +`quest2`, `quest3`, and `quest4` for `quests`. Partial-file repair is refused. +Guild-card fields use the historical three-way merge. Quest payloads have no +reviewed historical defect map, so the quest repair transaction validates the +complete group and copies the **current Wii U bytes** to output exactly; it +does not restore original 3DS quest data. + +The shared `system` repair uses: + +```text +mh3g-save-convert repair-system <3DS-system> --current \ + --output [--dry-run | --write ...] +``` + +Only the verified gallery/movie bit range is unioned from the source into the +current Cemu authority; all other current bytes, including other-slot shared +state, are preserved. The output file may be new, but its parent directory must +exist. This is intentionally independent from both core and ExtData repair. + +Experimental CEC repair uses: + +```text +mh3g-save-convert repair-cec --source-dir <3DS-MH3G-CEC-mailbox> \ + --current --output \ + [--slot ] [--dry-run | --write --experimental ...] +``` + +It replaces exact recognized historical records, preserves unrelated current +slots, and otherwise fills empty slots only. CEC remains experimental and its +write requires explicit acknowledgement. Its output may be new, but both +current and output basenames must be `cec`. + +For core, guild cards, and quests, Dry Run reports `exact`, +`compatible-range`, `ambiguous`, or `unknown`. An `ambiguous` result requires +an explicit, non-contradicted `--from-version` and a new Dry Run; `unknown` is +refused. The native workflow reuses one manually selected historical revision +for these three domains because they originated from one converter run, while +each domain still produces independent evidence and authorization. `system` +and CEC do not use a historical converter revision. + +`repair-converted --source-extdata-dir` remains accepted for older scripts and +can still coordinate core plus cards through one legacy manifest. New native +UI flows do not use this coupled option. Prefer the domain-scoped commands and +their matching rollback routes: `rollback-repair` for core, +`rollback-extras` for one ExtData group, `rollback` for `system`, and +`rollback-cec` for CEC. `phrase1` through `phrase3` remain outside repair. ### Optional Shared `system` @@ -261,7 +311,11 @@ record import remains explicitly experimental. | `inspect-progress [--target ]` | Source and optional target slots | Nothing | N/A | | `inspect-events [--target ]` | Source and optional target slots | Nothing | N/A | | `convert --output ` | Source slot; existing target and prior transaction records only when installing | Nothing | Named target slot plus core transaction artifacts below | -| `repair-converted <3DS-user#> --current --output ` | Original 3DS slot, read-only current Cemu slot, independent output slot, and optional complete 3DS/current-Cemu ExtData sets | Nothing | Only the output-side same-named `user#` and complete guild-card group fields proven to need repair, plus a coordinator manifest; current reference and quest files remain unchanged | +| `repair-converted <3DS-user#> --current --output ` | Original 3DS slot, read-only current Cemu slot, and independent output state | Nothing | Only the output-side same-named `user#` fields proven to need repair, plus a core compatibility manifest; the current reference remains unchanged | +| `repair-extras --group guild-cards ...` | Complete original, current, and output guild-card groups | Nothing | Only the complete output `card1`-`card3`/`cardbox` group and its independent ExtData transaction | +| `repair-extras --group quests ...` | Complete original, current, and output quest groups | Nothing | Copies the complete current `quest1`-`quest4` group to the independent output and records its own transaction; original quest payload is never restored | +| `repair-system system --current system --output system` | Original 3DS `system`, read-only current Cemu `system`, and independent output state | Nothing | Only the verified gallery/movie union based on current Cemu bytes, plus a core-style manifest | +| `repair-cec --source-dir ... --current cec --output cec` | 3DS CEC mailbox, read-only current Cemu `cec`, and independent output state | Nothing | Exact historical record replacements/empty-slot additions in output `cec`, plus its CEC manifest; requires `--experimental` | | `convert-system system --output ` | 3DS source `system` and existing initialized Cemu target on Dry Run and write | Nothing | Only the verified gallery/movie flag union in the named target, plus the same transaction artifact pattern | | `convert-extras --source-dir ... --output-dir ...` | All eight extdata files | Nothing, and no output directory is created | Only the eight generated files under `output-dir` | | `install-extras --staging-dir ... --target-dir ... --groups ...` | Complete staged ExtData set and selected initialized target group(s) | Nothing | Only the selected complete Cemu group(s), plus one manifest-bound ExtData recovery transaction below | @@ -291,9 +345,12 @@ compare the reported hashes. `install-extras` provides the controlled install step. It writes a unique hidden `.mh3g-extra-transaction-.../` directory below the target containing the returned `.mh3g-extra-recovery.json` manifest and retained prior component -bytes. Its `.mh3g-extra-install.lock` is short-lived. `rollback-extras` accepts -only that returned manifest and restores the complete group(s) named by it; it -does not accept individual component paths. +bytes. The advisory lock is held only during an operation, while the regular +`.mh3g-extra-install.lock` pathname intentionally remains as a stable lock +inode. `rollback-extras` accepts only the returned manifest and restores the +complete group(s) named by it; it does not accept individual component paths. +The transaction directory and manifest remain afterward as audit evidence, +even though every selected payload byte is restored. For experimental CEC, the equivalent persistent names are: @@ -317,9 +374,14 @@ particular: or a non-selected ExtData group; it changes only the selected complete target group(s) and their controlled transaction artifacts. - `convert-cec` does not modify `user#`, `system`, `card*`, or `quest*`. -- `repair-converted` does not modify another `user#`, `system`, `cec`, - `phrase*`, or `quest1` through `quest4`; without ExtData selected it also - does not read or modify any `card*`. +- `repair-converted` in the native domain-scoped flow changes only its explicit + output `user#`. The legacy `--source-extdata-dir` option is the sole coupled + exception retained for old CLI scripts. +- `repair-extras` changes only the selected complete output group. Guild-card + and quest runs do not authorize or roll back each other, even when their + directory selectors contain the same path values. +- `repair-system` changes only its explicit output `system`; `repair-cec` + changes only its explicit output `cec`. - `phrase1`, `phrase2`, and `phrase3` are not enumerated by any converter command and are not read or written by the MH3G conversion implementation. - The source 3DS save files are always read-only from this CLI's perspective. @@ -332,8 +394,9 @@ manifest, history, lock, and temporary transaction artifacts described above. This contract is derived from the executable implementation and tests: - `crates/mh3g-save-convert/src/main.rs`: CLI parameters, same-name slot - validation, the eight-file `convert-extras` loop, dry-run behavior, and its - new-output-directory refusal. + validation, independent `repair-converted`/`repair-extras`/`repair-system`/ + `repair-cec` authorization and manifests, the eight-file `convert-extras` + loop, and dry-run non-write behavior. - `crates/mh3g-save-convert/src/converter.rs`: the exact eight extdata names, per-component validation, guild-card versus quest conversion behavior, and the source-read-only pure slot conversion. @@ -342,8 +405,10 @@ This contract is derived from the executable implementation and tests: - `crates/mh3g-save-convert/src/transaction.rs`: atomic core/system install, backup, manifest, history, lock, and rollback boundaries. - `crates/mh3g-save-convert/src/cec.rs`: inbox-only experimental CEC import, - `cec` target validation, and CEC backup/manifest behavior. + exact historical-record replacement for repair, `cec` target validation, + and CEC backup/manifest behavior. - `crates/mh3g-save-convert/tests/cli.rs` and `crates/mh3g-save-convert/src/converter.rs` tests: dry-run non-write, - cross-slot rejection, eight-component staging, CEC outbox rejection, and + cross-slot rejection, independent output/current preservation, complete-group + quest copying, system shared-byte preservation, CEC outbox rejection, and offline-hunter/card-anchor regression coverage. diff --git a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md index 6fed30d..adda58f 100644 --- a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md +++ b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md @@ -58,27 +58,64 @@ CLI 只接受普通文件系统中的文件和目录。它不能打开 ZIP、7z ### 旧转换存档兼容修复 -`repair-converted` 与全新 `convert` 是两个独立契约。它需要一个原始 3DS `user#` 和一个已经由 0.0.3 至 0.0.6 转换、之后可能继续游玩的同名 Cemu `user#`: +兼容修复与全新转换是两个独立流程。兼容修复分成五个彼此独立授权的域;选择或完成其中一个域,不会替另一个域授权、写入或回滚: + +| 修复域 | 原始 3DS 输入 | 只读当前 Wii U/Cemu 权威数据 | 独立输出 | 命令 | +| --- | --- | --- | --- | --- | +| 核心槽位 | 准确的 `user1`、`user2` 或 `user3` | 同名 `user#` | 同名 `user#` | `repair-converted` | +| 公会名片 | 含 `card1`-`card3`、`cardbox` 的 ExtData `user` 目录 | 含已初始化同名文件的目录 | 含已初始化同名文件的目录 | `repair-extras --group guild-cards` | +| 任务 | 含 `quest1`-`quest4` 的 ExtData `user` 目录 | 含已初始化同名文件的目录 | 含已初始化同名文件的目录 | `repair-extras --group quests` | +| 共享画廊/动画 | 准确的 3DS `system` | 准确且已初始化的 Cemu `system` | 准确的 `system` 路径 | `repair-system` | +| 擦身/CEC 缓存 | 准确的 MH3G CEC 邮箱目录 | 准确且已初始化的 Cemu `cec` | 准确的 `cec` 路径 | `repair-cec` | + +每个域都有自己的 Dry Run 指纹、写入授权、事务 manifest 和回滚。原始 3DS、当前 Wii U/Cemu 与输出路径不会在控件之间自动复制或级联。原生界面的核心槽位可选择准确 `user#` 文件或其直接父目录,但不会递归扫描 3DS SD 根目录、Cemu MLC 或压缩包;`system` 和 `cec` 使用准确文件。公会名片与任务在物理上位于同一个目录,因此共用三组 ExtData 目录选择器,但两组仍各自执行 Dry Run、写入、记录 manifest 和回滚。 + +核心槽位契约为: ```text mh3g-save-convert repair-converted <3DS-user#> --current <当前-Cemu-user#> \ - [--output <修复后-Cemu-user#>] \ - [--source-extdata-dir <3DS-ExtData-user>] [--from-version <0.0.3|0.0.4|0.0.5|0.0.6>] \ + --output <修复后-Cemu-user#> \ + [--from-version <0.0.3|0.0.4|0.0.5|0.0.6>] \ [--dry-run | --write --expected-source-set-sha256 \ --expected-current-set-sha256 --expected-output-set-sha256 \ --expected-preview-sha256 ] ``` -这是三个独立的路径职责:原始 3DS 槽位与 `--current` 当前 Wii U/Cemu -槽位都是只读合并输入,`--output` 才是写入目标;三者必须指向同名的 -`user1`、`user2` 或 `user3`。省略 `--output` 只用于兼容 CLI 旧脚本,此时 -仍采用原地写回 `--current`;原生 UI 始终独立显示并显式传入输出路径。 +原始 3DS 槽位与 `--current` 都是只读合并输入,只有 `--output` 可以写 payload;三者必须为同名槽位。当前 Cemu 数据是继续游玩的权威数据。只有仍等于所选旧转换器输出的已知历史字段才会更新,之后在 Wii U 侧变化的字段会保留并报告为冲突。省略 `--output` 仅保留给 CLI 旧脚本原地写回;macOS/Windows 原生界面始终单独显示并传入输出。 + +两个 ExtData 域使用: + +```text +mh3g-save-convert repair-extras --group \ + --source-dir <3DS-ExtData-user> --current-dir <当前-Cemu-存档目录> \ + --output-dir <已初始化的输出-Cemu-存档目录> \ + [--from-version <0.0.3|0.0.4|0.0.5|0.0.6>] [--dry-run | --write ...] +``` + +所选输出组必须已经初始化并且完整:公会名片组必须同时包含 `card1`、`card2`、`card3`、`cardbox`;任务组必须同时包含 `quest1`、`quest2`、`quest3`、`quest4`。不允许单文件修复。公会名片字段使用历史三方合并;任务 payload 目前没有经过审查的历史缺陷映射,所以任务修复只校验完整组,并把**当前 Wii U 字节**逐字节写入独立输出,不会恢复成原始 3DS 任务数据。 + +共享 `system` 修复使用: + +```text +mh3g-save-convert repair-system <3DS-system> --current <当前-Cemu-system> \ + --output <修复后-Cemu-system> [--dry-run | --write ...] +``` + +它只把已确认的画廊/动画位范围并集合并到当前 Cemu 权威数据,其余当前字节(包括其他槽位共享状态)全部保留。输出文件可以尚不存在,但父目录必须存在;该事务与核心槽位和 ExtData 完全独立。 + +实验性 CEC 修复使用: + +```text +mh3g-save-convert repair-cec --source-dir <3DS-MH3G-CEC-邮箱目录> \ + --current <当前-Cemu-cec> --output <修复后-Cemu-cec> \ + [--slot ] [--dry-run | --write --experimental ...] +``` -该操作以当前 Cemu 数据为继续游玩的权威数据,只对 0.0.3 至 0.0.6 之间已知变化的完整语义字段执行三方比较。当前字段仍等于历史版本输出时才替换为当前转换器输出;若当前字段不同于历史值和当前转换器值,则按 Wii U 后续进度保留并报告冲突。它不会按字节盲合并,也不会重建整个 Cemu 槽位。 +它会替换准确识别出的历史记录,保留无关当前槽位,其余记录只填入空槽。CEC 仍是实验性功能,写入必须显式确认;输出可以尚不存在,但当前与输出文件名都必须为 `cec`。 -仅修核心槽位时不传 `--source-extdata-dir`。修复公会名片时,该目录必须含全部八个 3DS ExtData 文件,当前 `user#` 的父目录也必须含全部八个 Cemu 文件。若输出与当前引用不同,输出目录还必须已有初始化的 `card1`、`card2`、`card3`、`cardbox`。`user#` 和四个 `card*` 是可修复组件;`quest1` 至 `quest4` 会被验证并纳入集合 SHA-256,但逐字节保持当前 Cemu 内容。该命令不处理 `system`、`cec` 或 `phrase*`。 +核心、公会名片、任务的 Dry Run 会分别报告 `exact`、`compatible-range`、`ambiguous` 或 `unknown`。`ambiguous` 必须显式选择未被证据否定的 `--from-version` 并重新 Dry Run,`unknown` 会拒绝修复。因为三个域来自同一次历史转换,原生界面会复用同一个手动选择的 0.0.3-0.0.6 版本,但每个域仍独立产生证据与写入授权;`system` 和 CEC 不使用历史转换器版本。 -Dry Run 会把全部选中组件汇总成一个顶层版本判断,可能报告 `exact`、`compatible-range`、`ambiguous` 或 `unknown`;所有组件共用同一个最终历史版本。`ambiguous` 写入必须显式指定一个未被证据否定的 `--from-version` 并重新 Dry Run;`unknown` 拒绝修复。显式输出写入必须提交紧邻 Dry Run 返回的 `source_set_sha256`、`current_set_sha256`、`output_set_sha256` 与 `preview_sha256`。成功时返回 `.mh3g-compatibility-repair-.json`;`rollback-repair --manifest ` 会按“公会名片子事务,再核心子事务”的顺序恢复。 +为兼容旧脚本,`repair-converted --source-extdata-dir` 仍可使用,并可通过一个旧式总 manifest 协调核心与公会名片;新的原生界面不再使用该耦合参数。应优先使用独立命令及其对应回滚:核心用 `rollback-repair`,单个 ExtData 组用 `rollback-extras`,`system` 用 `rollback`,CEC 用 `rollback-cec`。`phrase1` 至 `phrase3` 仍不属于修复范围。 ### 可选的共享 `system` @@ -159,7 +196,11 @@ CEC 既不属于 `user#`,也不属于 `card*`,正常公会名片/离线伙 | `inspect-progress [--target ]` | 源槽位和可选目标槽位 | 无 | 不适用 | | `inspect-events [--target ]` | 源槽位和可选目标槽位 | 无 | 不适用 | | `convert --output ` | 源槽位;只有安装时才读取已有目标和旧事务记录 | 无 | 指定目标槽位及下述核心事务文件 | -| `repair-converted <3DS-user#> --current <当前-Cemu-user#> --output <修复后-Cemu-user#>` | 原始 3DS 槽位、只读当前 Cemu 槽位、独立输出槽位;可选完整 3DS/当前 Cemu ExtData 集合 | 无 | 只改变输出侧报告中确认需要修复的同名 `user#` 和完整公会名片组,并创建协调 manifest;当前引用与任务文件保持不变 | +| `repair-converted <3DS-user#> --current <当前-Cemu-user#> --output <修复后-Cemu-user#>` | 原始 3DS 槽位、只读当前 Cemu 槽位和独立输出状态 | 无 | 只改变输出侧确认需要修复的同名 `user#` 字段,并创建核心兼容 manifest;当前引用保持不变 | +| `repair-extras --group guild-cards ...` | 完整的原始、当前、输出公会名片组 | 无 | 只改变完整输出 `card1`-`card3`/`cardbox` 组,并创建独立 ExtData 事务 | +| `repair-extras --group quests ...` | 完整的原始、当前、输出任务组 | 无 | 把完整当前 `quest1`-`quest4` 组复制到独立输出并记录自己的事务;绝不恢复原始任务 payload | +| `repair-system system --current system --output system` | 原始 3DS `system`、只读当前 Cemu `system` 和独立输出状态 | 无 | 基于当前 Cemu 字节只写已确认的画廊/动画并集,并创建核心式 manifest | +| `repair-cec --source-dir ... --current cec --output cec` | 3DS CEC 邮箱、只读当前 Cemu `cec` 和独立输出状态 | 无 | 在输出 `cec` 中替换准确历史记录/填充空槽并创建 CEC manifest;要求 `--experimental` | | `convert-system system --output <已有 Cemu system>` | Dry Run 和写入都会读取 3DS 源 `system` 与已初始化的 Cemu 目标 | 无 | 只把已确认的画廊/动画标记并集合并到指定目标,并创建相同模式的事务文件 | | `convert-extras --source-dir ... --output-dir ...` | 全部八个 ExtData 文件 | 无,也不会创建输出目录 | 只写入 `output-dir` 下生成的八个文件 | | `install-extras --staging-dir ... --target-dir ... --groups ...` | 完整暂存 ExtData 集合及被选中、已初始化的目标组件组 | 无 | 只改变被选中的完整 Cemu 组件组,以及下文一个绑定 manifest 的 ExtData 恢复事务 | @@ -180,7 +221,7 @@ CEC 既不属于 `user#`,也不属于 `card*`,正常公会名片/离线伙 短暂存在的 `..mh3g-install.lock` 和临时文件会在事务结束后删除。`convert-extras` 故意不提供目标备份、manifest 或覆盖路径:如果八个同名暂存输出中的任何一个已经存在,`--write` 会拒绝执行。应使用新暂存目录,并对比报告的哈希。 -`install-extras` 提供受控安装步骤。它会在目标下创建唯一隐藏的 `.mh3g-extra-transaction-.../` 目录,目录内包含返回的 `.mh3g-extra-recovery.json` manifest 和保留的旧组件字节;`.mh3g-extra-install.lock` 只短暂存在。`rollback-extras` 只接受这个返回的 manifest,并恢复其中记录的完整组件组;不接受单个组件路径。 +`install-extras` 提供受控安装步骤。它会在目标下创建唯一隐藏的 `.mh3g-extra-transaction-.../` 目录,目录内包含返回的 `.mh3g-extra-recovery.json` manifest 和保留的旧组件字节。 advisory lock 只会在操作期间持有,但常规文件 `.mh3g-extra-install.lock` 会作为稳定 lock inode 持续保留。`rollback-extras` 只接受返回的 manifest,并恢复其中记录的完整组件组;不接受单个组件路径。回滚后事务目录和 manifest 仍作为审计证据保留,但被选中 payload 的每一个字节都会恢复。 实验性 CEC 对应的持久文件名为: @@ -198,7 +239,9 @@ CEC 既不属于 `user#`,也不属于 `card*`,正常公会名片/离线伙 - `convert-extras` 不会修改任何源文件、用户槽位、`system` 或 `cec`;它只写入明确的暂存输出。 - `install-extras` 不会修改源文件、任何 `user#`、`system`、`cec` 或未被选中的 ExtData 组件组;只会改变选中的完整目标组件组及其受控事务文件。 - `convert-cec` 不会修改 `user#`、`system`、`card*` 或 `quest*`。 -- `repair-converted` 不会修改其他 `user#`、`system`、`cec`、`phrase*` 或 `quest1` 至 `quest4`;未选择 ExtData 时也不会读取或修改任何 `card*`。 +- 原生独立域流程中的 `repair-converted` 只改变明确输出的 `user#`;唯一耦合例外是为旧 CLI 脚本保留的 `--source-extdata-dir`。 +- `repair-extras` 只改变选中的完整输出组。即使公会名片和任务三组目录控件填写了相同路径,两次运行也不会互相授权或回滚。 +- `repair-system` 只改变明确输出的 `system`;`repair-cec` 只改变明确输出的 `cec`。 - 任何转换器命令都没有枚举 `phrase1`、`phrase2` 或 `phrase3`,MH3G 转换实现不会读取或写入它们。 - 从该 CLI 的视角看,3DS 源存档文件始终只读。 @@ -208,9 +251,9 @@ CEC 既不属于 `user#`,也不属于 `card*`,正常公会名片/离线伙 本契约来自可执行实现和测试: -- `crates/mh3g-save-convert/src/main.rs`:CLI 参数、同名槽位校验、八文件 `convert-extras` 循环、dry-run 行为和新输出目录拒绝规则。 +- `crates/mh3g-save-convert/src/main.rs`:CLI 参数、同名槽位校验、独立的 `repair-converted`/`repair-extras`/`repair-system`/`repair-cec` 授权与 manifest、八文件 `convert-extras` 循环和 Dry Run 不写入行为。 - `crates/mh3g-save-convert/src/converter.rs`:准确的八个 ExtData 文件名、逐组件校验、公会名片与任务的不同转换行为,以及源只读的纯槽位转换。 - `crates/mh3g-save-convert/src/profile.rs`:接受的 `user1`/`user2`/`user3` 和 `system` basename,以及源/Cemu 字节大小 profile。 - `crates/mh3g-save-convert/src/transaction.rs`:核心/system 原子安装、backup、manifest、history、lock 和 rollback 边界。 -- `crates/mh3g-save-convert/src/cec.rs`:仅收件箱的实验性 CEC 导入、`cec` 目标校验以及 CEC backup/manifest 行为。 -- `crates/mh3g-save-convert/tests/cli.rs` 和 `crates/mh3g-save-convert/src/converter.rs` 测试:dry-run 不写入、跨槽位拒绝、八组件暂存、CEC outbox 拒绝以及离线猎人/名片 anchor 回归覆盖。 +- `crates/mh3g-save-convert/src/cec.rs`:仅收件箱的实验性 CEC 导入、修复时准确历史记录替换、`cec` 目标校验以及 CEC backup/manifest 行为。 +- `crates/mh3g-save-convert/tests/cli.rs` 和 `crates/mh3g-save-convert/src/converter.rs` 测试:Dry Run 不写入、跨槽位拒绝、独立输出/当前引用保留、完整任务组复制、system 共享字节保留、CEC outbox 拒绝以及离线猎人/名片 anchor 回归覆盖。 diff --git a/docs/adr/0017-mh3g-domain-scoped-repair.md b/docs/adr/0017-mh3g-domain-scoped-repair.md new file mode 100644 index 0000000..f78b9fc --- /dev/null +++ b/docs/adr/0017-mh3g-domain-scoped-repair.md @@ -0,0 +1,145 @@ +# ADR 0017: MH3G domain-scoped compatibility repair + +- Status: Proposed +- Date: 2026-08-12 +- Owners: MHToolkit maintainers +- Review date: 2026-11-12 + +## Context + +The compatibility workflow introduced by ADR 0015 has three distinct core +paths: the original 3DS slot, the continued-play Wii U/Cemu slot, and an +independent output. Its first implementation can optionally repair received +guild cards as part of the core `repair-converted` command. Other shared data +is handled by unrelated commands: quests are only validated and preserved, +`system` uses `convert-system`, and CEC uses an experimental import. + +That split is safe at the byte level but confusing in the native interfaces. +Selecting "repair" looks like one operation while some data is repaired with +the core slot, some is not selected at all, and some uses an independent +transaction page. Coupling cards to the core also makes one historical-version +decision and one coordinator manifest cover two different ownership domains. + +The relevant data does not have one physical 3DS root. A core `user#` and +`system` normally live in title save data, `card*` and `quest*` live in the +ExtData `user` directory, and CEC records live in a mailbox tree. Recursively +discovering them from a broad SD-card root would be ambiguous and would violate +the explicit-path contract. + +## Decision + +Compatibility repair is presented as one guided workflow, but every selected +data domain is inspected, dry-run authorized, written, and rolled back +independently. + +| Domain | Original input | Current authority | Output | Repair rule | +| --- | --- | --- | --- | --- | +| Core slot | 3DS `user#` | Wii U/Cemu `user#` | `user#` | Historical field-aware three-way merge | +| Guild cards | 3DS `card1`-`card3`, `cardbox` | Current matching files | Matching files | Historical field-aware three-way merge | +| Quests | 3DS `quest1`-`quest4` | Current matching files | Matching files | Preserve current bytes until a reviewed historical defect map exists | +| Shared system | 3DS `system` | Current Wii U/Cemu `system` | `system` | Union only the proven gallery/movie flag range | +| CEC | 3DS MH3G CEC mailbox | Current Wii U/Cemu `cec` | `cec` | Experimental deduplicating import; no implicit execution | + +`phrase1` through `phrase3` and unknown files remain outside repair. + +### Independent authorization + +Each domain has its own: + +1. exact source/current/output paths; +2. profile and completeness validation; +3. source/current/output-state fingerprints; +4. deterministic preview fingerprint; +5. write authorization derived from the immediately preceding Dry Run; +6. target lock, backup, manifest, and rollback action. + +A successful core write does not authorize or mark cards, quests, `system`, or +CEC complete. A failure in one domain does not silently roll back another +already completed domain. The UI reports every selected domain separately. + +The legacy `repair-converted --source-extdata-dir` option remains accepted for +existing scripts during the compatibility window. Native UIs stop using that +coupled path and use the domain-scoped commands instead. + +### File and directory selection + +The native interfaces use one core selection granularity per workflow: + +- directory mode resolves each independently selected root to its direct + `user#` child; +- file mode resolves each independently selected exact `user#` file. + +Changing the granularity updates the source, current, and output pickers, but +never copies one path value into another. 3DS, current Wii U/Cemu, and output +roots are independent authorities. The CLI keeps accepting explicit mixed +file/directory resolution performed by callers for backward compatibility. + +For repair, the UI never derives another domain from the core paths. `system` +and CEC use exact file selectors. Guild cards and quests use one shared set of +three ExtData directory selectors because both groups physically contain +direct children of the same `user` directory; nevertheless, each group binds +that directory triplet to its own Dry Run, authorization, manifest, and +rollback. The UI shows the exact resolved files before Dry Run. The 3DS +ExtData and CEC sources stay explicit because they are not safe to infer from +the core save parent. + +### Quest boundary + +The released 0.0.3-0.0.6 quest conversion only replaces the platform container +header; the quest payload is byte-compatible. Consequently, domain-scoped +quest repair currently validates the complete group and preserves the current +Wii U bytes. It must not overwrite continued-play quest data with the original +3DS payload. A future write-changing quest repair requires a new reviewed +field map and synthetic historical fixtures. + +### CEC boundary + +CEC remains experimental. Showing it in the same repair-scope list does not +remove its acknowledgement gate or promote file-level evidence to runtime +verification. It keeps an independent Dry Run, manifest, and rollback. + +## Consequences + +- The workflow is consistent without turning unrelated files into one unsafe + monolithic write. +- Users can repair only the affected domain and can roll it back without + reverting later repairs in other domains. +- Every historical domain reports its detection evidence independently. When + the user must override an ambiguous result, the native workflow reuses one + explicit 0.0.3-0.0.6 revision for core, guild cards, and quests because they + came from one historical converter run. `system` and CEC do not use that + revision selector. +- More manifests can be produced for one user-guided repair session; the UI + must retain and label each one. +- Complete ExtData groups remain indivisible. Selecting one card or quest + file does not permit a partial group write. + +## Rejected alternatives + +- **One recursive 3DS/MLC root scan:** ambiguous and too broad. +- **Copy the source path into the output control:** risks writing Wii U data + beside or over 3DS data. +- **One manifest for every selected domain:** couples unrelated rollback + lifetimes and increases partial-failure recovery complexity. +- **Blindly reconvert quests:** destroys current downloaded/created quest data. +- **Treat CEC as ordinary ExtData:** its source layout and evidence level are + different. + +## Verification and rollback + +Synthetic tests must prove, for each domain: + +- exact path and complete-group rejection; +- source/current/output/preview race rejection; +- current Wii U changes outside reviewed repair fields remain identical; +- missing or conflicting output fails closed unless that domain explicitly + supports a new export target; +- write failure retains a usable recovery journal; +- rollback restores the exact previous output state; +- a second repair is idempotent. + +Native UI tests must prove that changing file/directory mode changes picker +behavior without mutating another control's path, and that every selected +domain requires its own successful Dry Run before its write button is enabled. +Runtime verification remains a separate user-controlled Cemu test and is not +implied by unit, build, or synthetic file-level evidence. diff --git a/scripts/verify-mh3g-save-converter-windows-source.py b/scripts/verify-mh3g-save-converter-windows-source.py index 9009b35..e5a22d6 100644 --- a/scripts/verify-mh3g-save-converter-windows-source.py +++ b/scripts/verify-mh3g-save-converter-windows-source.py @@ -477,7 +477,6 @@ def main() -> int: '"repair-converted", paths.Source', '"--current", paths.Current!', '"--output", paths.Target', - 'arguments.Add("--source-extdata-dir");', 'arguments.Add("--from-version");', '"--expected-source-set-sha256"', '"--expected-current-set-sha256"', @@ -489,6 +488,15 @@ def main() -> int: '"rollback-repair"', ): require(expected in workflow, f"Windows compatibility-repair workflow is missing {expected}") + repair_core = workflow.split("public async Task RunCoreDryRunAsync()", 1)[1].split( + "public async Task WriteCoreAsync()", 1 + )[0] + workflow.split("public async Task WriteCoreAsync()", 1)[1].split( + "public async Task RollbackCoreAsync()", 1 + )[0] + require( + "--source-extdata-dir" not in repair_core, + "core repair must not silently own an ExtData repair transaction", + ) resolver = read("Models/SavePathResolution.cs") for expected in ( @@ -567,16 +575,19 @@ def main() -> int: ): require(expected in core_write, f"core write is missing dry-run hash binding {expected}") - require("public bool SelectedOptionalDataIsConfigured" in workflow, "Windows core workflow must gate selected optional setup") + require("public bool SelectedOptionalDataIsConfigured" in workflow, "Windows UI must report selected optional setup") require("public bool HasPendingSelectedOptionalWork" in workflow, "Windows core workflow must retain selected optional work") - require("!SelectedOptionalDataIsConfigured" in core_dry_run, "core Dry Run must not bypass incomplete optional setup") require( - "SelectedOptionalDataIsConfigured" in workflow.split("public bool CanWriteCore", 1)[1].split("public bool CanRollbackCore", 1)[0], - "core write availability must not bypass incomplete optional setup", + "!SelectedOptionalDataIsConfigured" not in core_dry_run, + "an incomplete optional domain must not block the independent core Dry Run", ) require( - "!SelectedOptionalDataIsConfigured" in core_write, - "core write entry point must reject incomplete optional setup", + "SelectedOptionalDataIsConfigured" not in workflow.split("public bool CanWriteCore", 1)[1].split("public bool CanRollbackCore", 1)[0], + "core write availability must remain independent from optional-domain paths", + ) + require( + "!SelectedOptionalDataIsConfigured" not in core_write, + "core write entry point must not reject an unrelated incomplete optional domain", ) optional_availability = workflow.split("private void RaiseOptionalConfigurationAvailability()", 1)[1].split( "private void SetWorkflowGuidance", 1 @@ -587,6 +598,29 @@ def main() -> int: ) system_write = public_method_body(workflow, "WriteSystemAsync") require("_systemWriteCompleted = true;" in system_write, "system completion must be tracked independently") + for expected in ( + '"repair-system", SystemSourcePath', + '"--current", SystemCurrentPath', + '"--output", SystemTargetPath', + '"repair-extras"', + '"--source-dir", paths.Source', + '"--current-dir", paths.Current', + '"--output-dir", paths.Output', + '"--group", group', + '"repair-cec", "--source-dir", CecSourceDirectory', + '"--current", CecCurrentPath', + '"--output", CecTargetPath', + ): + require(expected in workflow, f"independent repair workflow is missing {expected}") + for expected in ( + "public Task RunRepairGuildCardsDryRunAsync()", + "public Task WriteRepairGuildCardsAsync()", + "public Task RollbackRepairGuildCardsAsync()", + "public Task RunRepairQuestsDryRunAsync()", + "public Task WriteRepairQuestsAsync()", + "public Task RollbackRepairQuestsAsync()", + ): + require(expected in workflow, f"ExtData repair group is missing independent action {expected}") require( "_extrasInstallCompleted" not in workflow, "Windows must not track a completed ExtData install while that capability is unavailable", @@ -616,6 +650,21 @@ def main() -> int: 'Click="ChooseCurrentFolder_Click"', ): require(expected in window, f"repair mode must expose independent current/output controls: {expected}") + for expected in ( + 'x:Name="SystemCurrentBox"', + 'x:Name="ExtrasCurrentBox"', + 'x:Name="ExtrasRepairTargetBox"', + 'x:Name="CecCurrentBox"', + 'Click="RepairGuildCardsDryRun_Click"', + 'Click="WriteRepairGuildCards_Click"', + 'Click="RollbackRepairGuildCards_Click"', + 'Click="RepairQuestsDryRun_Click"', + 'Click="WriteRepairQuests_Click"', + 'Click="RollbackRepairQuests_Click"', + 'Visibility="{Binding RepairModeVisibility}"', + 'Visibility="{Binding NewConversionVisibility}"', + ): + require(expected in window, f"domain-scoped repair UI is missing {expected}") require('x:Name="OptionalConfigurationAnchor"' in window, "optional configuration requires a stable destination") require('Message="{Binding PostWriteGuidanceMessage}"' in window, "post-write guidance must account for selected optional data") require('Click="GoToPostWriteDestination_Click"' in window, "post-write CTA must choose its actual next destination") @@ -731,6 +780,8 @@ def main() -> int: "AuthorizationDomain.Core", "AuthorizationDomain.System", "AuthorizationDomain.Extras", + "AuthorizationDomain.GuildCards", + "AuthorizationDomain.Quests", "AuthorizationDomain.Cec", ): require(expected in workflow, f"Windows workflow is missing isolated authorization handling {expected}") @@ -810,6 +861,12 @@ def main() -> int: "case AuthorizationDomain.Extras:", 1 )[0] extras_case = clear_authorization.split("case AuthorizationDomain.Extras:", 1)[1].split( + "case AuthorizationDomain.GuildCards:", 1 + )[0] + guild_cards_case = clear_authorization.split("case AuthorizationDomain.GuildCards:", 1)[1].split( + "case AuthorizationDomain.Quests:", 1 + )[0] + quests_case = clear_authorization.split("case AuthorizationDomain.Quests:", 1)[1].split( "case AuthorizationDomain.Cec:", 1 )[0] cec_case = clear_authorization.split("case AuthorizationDomain.Cec:", 1)[1].split("default:", 1)[0] @@ -817,6 +874,8 @@ def main() -> int: (core_case, "_coreAuthorization = null;", ("_systemAuthorization", "_extras", "_cecAuthorization")), (system_case, "_systemAuthorization = null;", ("_coreAuthorization", "_extras", "_cecAuthorization")), (extras_case, "_extrasStageAuthorization = null;", ("_coreAuthorization", "_systemAuthorization", "_cecAuthorization")), + (guild_cards_case, "_repairGuildCardsAuthorization = null;", ("_coreAuthorization", "_systemAuthorization", "_repairQuestsAuthorization", "_cecAuthorization")), + (quests_case, "_repairQuestsAuthorization = null;", ("_coreAuthorization", "_systemAuthorization", "_repairGuildCardsAuthorization", "_cecAuthorization")), (cec_case, "_cecAuthorization = null;", ("_coreAuthorization", "_systemAuthorization", "_extras")), ): require(expected in case, f"authorization clearing case is missing {expected}") @@ -845,7 +904,7 @@ def main() -> int: ) require( "' in window, + and ' int: "ConversionModePicker_SelectionChanged", "RepairVersionPicker_SelectionChanged", "ChooseSystemSource_Click", + "ChooseSystemCurrent_Click", "ChooseExtrasSource_Click", + "ChooseExtrasCurrent_Click", + "ChooseRepairExtrasTarget_Click", + "ChooseCecCurrent_Click", "SystemDryRun_Click", "InstallExtras_Click", + "RepairGuildCardsDryRun_Click", + "WriteRepairGuildCards_Click", + "RollbackRepairGuildCards_Click", + "RepairQuestsDryRun_Click", + "WriteRepairQuests_Click", + "RollbackRepairQuests_Click", ): require(expected in code_behind, f"WinUI optional transaction handler is missing {expected}") @@ -874,6 +943,10 @@ def main() -> int: "共享 system", "Optional ExtData", "可选 ExtData", + "Current Wii U / Cemu ExtData directory", + "当前 Wii U / Cemu ExtData 目录", + "Repaired ExtData output directory", + "修复后的 ExtData 输出目录", "ExtDataInstallUnavailable", "About & Updates", "关于与更新",