From fffb008a307d5d79eae523abc155dcb3603a83e0 Mon Sep 17 00:00:00 2001 From: vincentadamnemessis Date: Tue, 11 Aug 2026 13:32:46 +0800 Subject: [PATCH] fix(mh3g): separate repair reference and output paths --- README.md | 31 +- README.zh-CN.md | 19 +- apps/mh3g-save-converter-macos/README.md | 17 +- .../mh3g-save-converter-macos/README.zh-CN.md | 13 +- .../ConversionTypes.swift | 18 +- .../ConversionWorkflow.swift | 82 +++++- .../ConverterPresentation/Localization.swift | 42 ++- .../WorkflowStageRailPresentation.swift | 6 +- .../WorkflowStatusPresentation.swift | 2 +- .../MH3GSaveConverterMac/DryRunView.swift | 19 +- .../InputInspectionView.swift | 104 ++++++- .../Resources/Localizable.xcstrings | 9 +- .../WriteRollbackView.swift | 29 +- .../ConversionWorkflowTests.swift | 57 +++- .../WorkflowStatusPresentationTests.swift | 14 + .../Infrastructure/ConverterCopy.cs | 69 +++-- .../MainWindow.xaml | 22 +- .../MainWindow.xaml.cs | 24 ++ .../Models/ConverterModels.cs | 2 + .../Models/SavePathResolution.cs | 88 +++++- apps/mh3g-save-converter-windows/README.md | 10 +- .../README.zh-CN.md | 8 +- .../ViewModels/MainViewModel.cs | 143 +++++++++- crates/mh3g-save-convert/src/main.rs | 213 ++++++++++++-- crates/mh3g-save-convert/tests/cli.rs | 265 ++++++++++++++++++ docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md | 23 +- docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md | 17 +- .../adr/0015-mh3g-compatibility-save-merge.md | 38 ++- scripts/mh3g-docs-contract.py | 13 + ...rify-mh3g-save-converter-windows-source.py | 44 ++- 30 files changed, 1255 insertions(+), 186 deletions(-) diff --git a/README.md b/README.md index dc5e316..e43a32d 100644 --- a/README.md +++ b/README.md @@ -395,9 +395,11 @@ Cemu validation succeeds. ```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>] \ [--dry-run | --write --expected-source-set-sha256 \ - --expected-current-set-sha256 --expected-preview-sha256 ] + --expected-current-set-sha256 --expected-output-set-sha256 \ + --expected-preview-sha256 ] ``` Use this command when a save was converted with 0.0.3 through 0.0.6 and then @@ -409,12 +411,22 @@ are preserved and reported as conflicts. Current HR, equipment, materials, storage, quest progress, farm, fleet, and other later gameplay therefore remain owned by the current Cemu save. -The CLI requires two exact, same-numbered `user1`, `user2`, or `user3` files. +The three paths have deliberately separate roles: the original 3DS slot is a +read-only conversion source, `--current` is the read-only Wii U/Cemu state that +owns later gameplay progress, and `--output` is the only core slot that may be +written. All three must be exact, same-numbered `user1`, `user2`, or `user3` +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. 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 @@ -423,7 +435,8 @@ are not read or written by this command. Start with a read-only preview: ```bash -REPAIR_JSON=$("${CLI[@]}" repair-converted "$SOURCE" --current "$TARGET" \ +# 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) ``` @@ -432,24 +445,26 @@ the original version from its `candidates`, then repeat Dry Run with `--from-version`. Every selected component shares this one revision decision; the converter never repairs `user#` and `card*` as different historical releases. Detection cannot read an embedded converter version because older -releases did not store a trustworthy marker. A write must reuse all three set -hashes from that final Dry Run: +releases did not store a trustworthy marker. A write must reuse all four +authorization hashes from that final Dry Run: ```bash SOURCE_SET_SHA256=$(jq -er '.source_set_sha256' <<<"$REPAIR_JSON") CURRENT_SET_SHA256=$(jq -er '.current_set_sha256' <<<"$REPAIR_JSON") +OUTPUT_SET_SHA256=$(jq -er '.output_set_sha256' <<<"$REPAIR_JSON") PREVIEW_SHA256=$(jq -er '.preview_sha256' <<<"$REPAIR_JSON") -"${CLI[@]}" repair-converted "$SOURCE" --current "$TARGET" \ +"${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" \ --expected-preview-sha256 "$PREVIEW_SHA256" \ --write ``` If Dry Run used `--from-version`, pass the same value to the write. Any source, -current target, or preview change between the two steps fails closed. A +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: diff --git a/README.zh-CN.md b/README.zh-CN.md index 6aec25d..7df3b6f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -279,38 +279,45 @@ NEW_SOURCE_SHA256=$(jq -er '.hashes.source' <<<"$NEW_DRY_RUN_JSON") ```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>] \ [--dry-run | --write --expected-source-set-sha256 \ - --expected-current-set-sha256 --expected-preview-sha256 ] + --expected-current-set-sha256 --expected-output-set-sha256 \ + --expected-preview-sha256 ] ``` 该命令用于“曾用 0.0.3 至 0.0.6 转换,之后又在 Wii U/Cemu 中继续游玩”的存档。它不会把当前存档整体替换为旧 3DS 状态,而是为每个已知历史转换字段比较“旧版预期值、当前值、当前转换器预期值”:仍等于旧版结果的字段才修复;已在 Wii U 侧变化的整字段保留并报告冲突。因此当前 HR、装备、素材、仓库、任务进度、农场、狩猎船和其他继续游玩的数据以当前 Cemu 存档为准。 -CLI 必须接收两个编号相同、文件名相同的准确 `user1`、`user2` 或 `user3` 文件。原生 macOS/Windows 工作台可以让用户选择文件或其直接父目录,但最终仍只把解析出的准确文件传给 CLI。若启用公会名片修复,`--source-extdata-dir` 必须是含全部八个源文件的 3DS `.../00000481/user` 目录,并且当前 `user#` 的父目录必须含全部八个同名 Cemu 文件。当前转换器会字段级修复 `user#`、`card1`、`card2`、`card3`、`cardbox`;`quest1` 至 `quest4` 只参与集合校验并逐字节保留。`system`、`cec`、`phrase1` 至 `phrase3` 和未知文件不会被该命令读取或写入。 +三个路径的职责刻意分开:原始 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` 和未知文件不会被该命令读取或写入。 先运行只读预览: ```bash -REPAIR_JSON=$("${CLI[@]}" repair-converted "$SOURCE" --current "$TARGET" \ +# SOURCE、CURRENT、OUTPUT 是三个独立但同槽位的 user# 路径。 +REPAIR_JSON=$("${CLI[@]}" repair-converted "$SOURCE" --current "$CURRENT" --output "$OUTPUT" \ --source-extdata-dir "$EXTRAS_SOURCE" --dry-run) ``` -如果 JSON 顶层的 `detection.confidence` 为 `ambiguous`,不要直接写入;从其 `candidates` 中确认当时使用的版本,并用 `--from-version` 重新运行 Dry Run。所有选中组件始终共用这一份版本判断,不会把 `user#` 和 `card*` 分别按不同历史版本修复。自动检测不是读取存档内嵌版本号,因为旧版本没有写入可靠标记。写入必须复用同一次最终 Dry Run 的三个集合哈希: +如果 JSON 顶层的 `detection.confidence` 为 `ambiguous`,不要直接写入;从其 `candidates` 中确认当时使用的版本,并用 `--from-version` 重新运行 Dry Run。所有选中组件始终共用这一份版本判断,不会把 `user#` 和 `card*` 分别按不同历史版本修复。自动检测不是读取存档内嵌版本号,因为旧版本没有写入可靠标记。写入必须复用同一次最终 Dry Run 的四个授权哈希: ```bash SOURCE_SET_SHA256=$(jq -er '.source_set_sha256' <<<"$REPAIR_JSON") CURRENT_SET_SHA256=$(jq -er '.current_set_sha256' <<<"$REPAIR_JSON") +OUTPUT_SET_SHA256=$(jq -er '.output_set_sha256' <<<"$REPAIR_JSON") PREVIEW_SHA256=$(jq -er '.preview_sha256' <<<"$REPAIR_JSON") -"${CLI[@]}" repair-converted "$SOURCE" --current "$TARGET" \ +"${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" \ --expected-preview-sha256 "$PREVIEW_SHA256" \ --write ``` -若 Dry Run 使用了 `--from-version`,写入必须传入同一个值。任一源文件、当前目标或预览在两步之间变化都会失败关闭。成功写入会返回总 manifest `.mh3g-compatibility-repair-.json`;它协调核心槽位与可选公会名片子事务。完整回滚使用: +若 Dry Run 使用了 `--from-version`,写入必须传入同一个值。任一原始源、当前 Wii U 引用、输出状态或预览在两步之间变化都会失败关闭。成功写入会返回总 manifest `.mh3g-compatibility-repair-.json`;它协调核心槽位与可选公会名片子事务。完整回滚使用: ```bash "${CLI[@]}" rollback-repair --manifest "$COMPATIBILITY_MANIFEST" diff --git a/apps/mh3g-save-converter-macos/README.md b/apps/mh3g-save-converter-macos/README.md index 7871cd5..0ee39f4 100644 --- a/apps/mh3g-save-converter-macos/README.md +++ b/apps/mh3g-save-converter-macos/README.md @@ -10,16 +10,19 @@ no save conversion is reimplemented in Swift. - **New conversion**: convert an original 3DS `user#` into the same-named Cemu `user#`. -- **Repair converted save**: merge the original 3DS `user#` with the current - Cemu `user#` after continued play, repairing only fields that still retain a - 0.0.3 through 0.0.6 conversion result. +- **Repair converted save**: use the original 3DS `user#` and the current, + continued-play Cemu `user#` as separate read inputs, then write the repaired + 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 may also select the complete 3DS ExtData `user` directory for -guild-card repair. Current Cemu `card1`, `card2`, `card3`, `cardbox`, and +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. Ambiguous detection requires an explicit historical version followed -by another Dry Run. +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. 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 diff --git a/apps/mh3g-save-converter-macos/README.zh-CN.md b/apps/mh3g-save-converter-macos/README.zh-CN.md index 2b5da83..4f6be58 100644 --- a/apps/mh3g-save-converter-macos/README.zh-CN.md +++ b/apps/mh3g-save-converter-macos/README.zh-CN.md @@ -8,13 +8,16 @@ sidecar 并展示 JSON 报告,不在 Swift 中重复实现存档转换。 ## 两种处理方式 - **全新转换**:原始 3DS `user#` 转成同名 Cemu `user#`。 -- **修复已转换存档**:合并原始 3DS `user#` 与玩家继续游玩后的当前 Cemu - `user#`,只修复仍保持 0.0.3 至 0.0.6 旧转换结果的字段。 +- **修复已转换存档**:把原始 3DS `user#` 与玩家继续游玩后的当前 Cemu + `user#` 作为两个独立只读输入,并把结果写到独立选择的同槽位输出;只修复仍 + 保持 0.0.3 至 0.0.6 旧转换结果的字段。 -修复模式可以额外选择完整 3DS ExtData `user` 目录以修复公会名片。当前 Cemu +因此修复模式会明确显示三个选择器:原始 3DS、只读当前 Wii U/Cemu 引用、修复后 +输出。它还可以额外选择完整 3DS ExtData `user` 目录以修复公会名片。当前 Cemu `card1`、`card2`、`card3`、`cardbox` 和 `quest1` 至 `quest4` 从所选当前 -`user#` 的父目录解析;任务文件只校验并保留,不会被兼容修复改写。自动检测结果 -为歧义时,必须明确选择历史版本并重新运行 Dry Run。 +`user#` 的父目录解析;任务文件只校验并保留,不会被兼容修复改写。若输出目录与 +当前引用目录不同,输出目录必须已有初始化的 `card1`、`card2`、`card3`、 +`cardbox`。自动检测结果为歧义时,必须明确选择历史版本并重新运行 Dry Run。 核心选择器接受准确 `user1`、`user2`、`user3` 文件或其直接父目录。它不会递归 扫描 SD 卡/MLC,不接受 ZIP、7z、RAR。目录只解析成当前选中槽位的同名直接子文件。 diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionTypes.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionTypes.swift index 10d04d5..df11047 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionTypes.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionTypes.swift @@ -69,15 +69,17 @@ public struct InputInspection: Equatable, Sendable { } } -/// The two mandatory slot files. The UI does not discover an MLC root or -/// expand a directory recursively: both URLs originate from explicit user -/// selection. +/// Explicit core paths. New conversion uses source + target. Compatibility +/// repair additionally requires a read-only current Wii U/Cemu reference; +/// target always remains the independent write destination. public struct ConversionInput: Equatable, Sendable { public let source: URL + public let current: URL? public let target: URL - public init(source: URL, target: URL) { + public init(source: URL, target: URL, current: URL? = nil) { self.source = source.standardizedFileURL + self.current = current?.standardizedFileURL self.target = target.standardizedFileURL } } @@ -260,29 +262,35 @@ public struct DryRunFingerprint: Equatable, Sendable { public struct RepairDryRunFingerprint: Equatable, Sendable { public let source: URL public let current: URL + public let output: URL public let extDataSource: 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( source: URL, current: URL, + output: URL, extDataSource: URL?, fromVersion: HistoricalConverterRevision?, sourceSetSHA256: String, currentSetSHA256: String, + outputSetSHA256: String, previewSHA256: String, components: [RepairComponentFingerprint] ) { self.source = source.standardizedFileURL self.current = current.standardizedFileURL + self.output = output.standardizedFileURL self.extDataSource = extDataSource?.standardizedFileURL self.fromVersion = fromVersion self.sourceSetSHA256 = sourceSetSHA256 self.currentSetSHA256 = currentSetSHA256 + self.outputSetSHA256 = outputSetSHA256 self.previewSHA256 = previewSHA256 self.components = components } @@ -506,6 +514,7 @@ public struct ConverterReport: Decodable, Sendable { public let targetSetSHA256Before: String? public let sourceSetSHA256: String? public let currentSetSHA256: String? + public let outputSetSHA256: String? public let previewSHA256: String? public let detection: ConverterRevisionDetection? public let manifests: [String]? @@ -542,6 +551,7 @@ public struct ConverterReport: Decodable, Sendable { case targetSetSHA256Before = "target_set_sha256_before" case sourceSetSHA256 = "source_set_sha256" case currentSetSHA256 = "current_set_sha256" + case outputSetSHA256 = "output_set_sha256" case previewSHA256 = "preview_sha256" } diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionWorkflow.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionWorkflow.swift index 56ac462..fe1b467 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionWorkflow.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionWorkflow.swift @@ -8,6 +8,7 @@ public final class ConversionWorkflow { public private(set) var state: WorkflowState = .input public private(set) var input: ConversionInput? public private(set) var sourceInspection: InputInspection? + public private(set) var currentInspection: InputInspection? public private(set) var targetInspection: InputInspection? public private(set) var components = ComponentSelection() public private(set) var dryRunFingerprint: DryRunFingerprint? @@ -37,16 +38,23 @@ public final class ConversionWorkflow { public var canStartDryRun: Bool { input != nil - && sourceInspection != nil - && (mode == .newConversion || targetInspection != nil) + && coreInspectionComplete && selectedOptionalDataIsConfigured && activeOperation == nil } + /// Repair mode has two independent read inputs. The input stage is not + /// complete until both the original 3DS slot and the current Wii U/Cemu + /// reference have been inspected; the output may legitimately be absent. + public var coreInspectionComplete: Bool { + sourceInspection != nil + && (mode == .newConversion || currentInspection != nil) + } + /// A missing target inspection is expected for an export directory: the /// selected `user#` does not exist until the guarded transactional write. public var isNewTargetExport: Bool { - input != nil && sourceInspection != nil && targetInspection == nil + input != nil && coreInspectionComplete && targetInspection == nil } /// A selected optional domain cannot be treated as ready until every path @@ -92,7 +100,8 @@ public final class ConversionWorkflow { let input else { return false } return authorized.source == input.source.standardizedFileURL - && authorized.current == input.target.standardizedFileURL + && authorized.current == input.current?.standardizedFileURL + && authorized.output == input.target.standardizedFileURL && authorized.extDataSource == components.extraSourceDirectory?.standardizedFileURL && authorized.fromVersion == repairFromVersion && !repairRevisionSelectionRequired @@ -154,6 +163,7 @@ public final class ConversionWorkflow { guard self.input != input else { return } self.input = input sourceInspection = nil + currentInspection = nil targetInspection = nil coreWriteCompleted = false invalidateCoreAuthorization(nextState: .input, clearsPresentation: true) @@ -167,6 +177,9 @@ public final class ConversionWorkflow { repairRevisionSelectionRequired = false components.includeSystem = false components.includeQuests = false + sourceInspection = nil + currentInspection = nil + targetInspection = nil coreWriteCompleted = false invalidateCoreAuthorization(nextState: .input, clearsPresentation: true) } @@ -181,9 +194,14 @@ public final class ConversionWorkflow { } } - public func applyInspections(source: InputInspection, target: InputInspection?) { + public func applyInspections( + source: InputInspection, + current: InputInspection? = nil, + target: InputInspection? + ) { guard activeOperation == nil else { return } sourceInspection = source + currentInspection = current targetInspection = target coreWriteCompleted = false invalidateCoreAuthorization(nextState: .componentSelection, clearsPresentation: true) @@ -239,6 +257,27 @@ public final class ConversionWorkflow { stderr: "" ) } + let current: InputInspection? + if self.mode == .repairConverted { + guard let currentURL = input.current, + FileManager.default.fileExists(atPath: currentURL.path) + else { throw ConversionWorkflowError.inputNotInspected } + let currentReport = try await self.execute( + .inspect, + arguments: [ConverterOperation.inspect.rawValue, currentURL.path], + lease: lease + ) + guard let inspectedCurrent = self.inspection(from: currentReport) else { + throw self.failureAndRethrow( + .inspect, + ConversionWorkflowError.invalidReport("current Wii U inspect requires profile, size, and source SHA-256"), + stderr: "" + ) + } + current = inspectedCurrent + } else { + current = nil + } let target: InputInspection? if FileManager.default.fileExists(atPath: input.target.path) { let targetReport = try await self.execute( @@ -258,6 +297,7 @@ public final class ConversionWorkflow { target = nil } self.sourceInspection = source + self.currentInspection = current self.targetInspection = target self.invalidateCoreAuthorization(nextState: .componentSelection, clearsPresentation: false) } @@ -277,10 +317,14 @@ public final class ConversionWorkflow { self.repairRevisionCandidates = [] self.repairRevisionSelectionRequired = false if self.mode == .repairConverted { + guard let current = input.current, + self.currentInspection != nil + else { throw ConversionWorkflowError.inputNotInspected } var arguments = [ ConverterOperation.repairConverted.rawValue, input.source.path, - "--current", input.target.path, + "--current", current.path, + "--output", input.target.path, ] if self.components.includeGuildCards, let extData = self.components.extraSourceDirectory { @@ -300,6 +344,8 @@ public final class ConversionWorkflow { 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, @@ -309,7 +355,7 @@ public final class ConversionWorkflow { else { throw self.failureAndRethrow( .repairConverted, - ConversionWorkflowError.invalidReport("repair Dry Run requires source/current set and preview SHA-256"), + ConversionWorkflowError.invalidReport("repair Dry Run requires source/current/output set and preview SHA-256"), stderr: report.stderr ?? "" ) } @@ -324,11 +370,13 @@ public final class ConversionWorkflow { } self.repairDryRunFingerprint = RepairDryRunFingerprint( source: input.source, - current: input.target, + current: current, + output: input.target, extDataSource: self.components.includeGuildCards ? self.components.extraSourceDirectory : nil, fromVersion: self.repairFromVersion, sourceSetSHA256: sourceSetSHA256, currentSetSHA256: currentSetSHA256, + outputSetSHA256: outputSetSHA256, previewSHA256: previewSHA256, components: repairComponents.compactMap { $0.repairFingerprint() } ) @@ -414,8 +462,10 @@ public final class ConversionWorkflow { guard let input else { throw ConversionWorkflowError.inputNotInspected } if mode == .repairConverted { guard let fingerprint = repairDryRunFingerprint, + let current = input.current, fingerprint.source == input.source.standardizedFileURL, - fingerprint.current == input.target.standardizedFileURL, + fingerprint.current == current.standardizedFileURL, + fingerprint.output == input.target.standardizedFileURL, fingerprint.extDataSource == components.extraSourceDirectory?.standardizedFileURL, fingerprint.fromVersion == repairFromVersion, !repairRevisionSelectionRequired @@ -424,7 +474,8 @@ public final class ConversionWorkflow { var arguments = [ ConverterOperation.repairConverted.rawValue, input.source.path, - "--current", input.target.path, + "--current", current.path, + "--output", input.target.path, ] if self.components.includeGuildCards, let extData = self.components.extraSourceDirectory { @@ -437,6 +488,7 @@ public final class ConversionWorkflow { "--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( @@ -1160,21 +1212,25 @@ public final class ConversionWorkflow { ) } do { - guard report.operation == ConverterOperation.repairConverted.rawValue, + guard let inputCurrent = input.current, + report.operation == ConverterOperation.repairConverted.rawValue, ConverterEvidence.path(report.source, equals: input.source), - ConverterEvidence.path(report.current, equals: input.target), + ConverterEvidence.path(report.current, equals: inputCurrent), + ConverterEvidence.path(report.output, equals: input.target), report.sourceSetSHA256 == fingerprint.sourceSetSHA256, report.currentSetSHA256 == fingerprint.currentSetSHA256, + report.outputSetSHA256 == fingerprint.outputSetSHA256, report.previewSHA256 == fingerprint.previewSHA256, ConverterEvidence.isValidSHA256(report.sourceSetSHA256), ConverterEvidence.isValidSHA256(report.currentSetSHA256), + ConverterEvidence.isValidSHA256(report.outputSetSHA256), ConverterEvidence.isValidSHA256(report.previewSHA256), let components = report.components, !components.isEmpty, components.compactMap({ $0.repairFingerprint() }) == fingerprint.components else { throw ConversionWorkflowError.invalidReport( - "repair completion requires exact source/current paths, set hashes, preview hash, and components" + "repair completion requires exact source/current/output paths, set hashes, preview hash, and components" ) } if report.status == "written" { diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift index 9f41529..e375f63 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift @@ -114,11 +114,15 @@ public enum ConverterCopy { "Navigation.ExperimentalCEC": "实验性 CEC", "Navigation.Settings": "设置", "Input.Source": "3DS 源角色文件", - "Input.Target": "Cemu 目标或导出目录", + "Input.Current": "当前 Wii U / Cemu 引用存档", + "Input.CurrentReadOnly": "该存档只用于读取继续游玩后的数据,修复流程不会写入它。", + "Input.Target": "Wii U / Cemu 输出位置", + "Input.RepairOutput": "修复后的 Wii U / Cemu 输出位置", + "Input.Required": "必选", "Input.Mode": "处理方式", "Input.Mode.New": "全新转换", "Input.Mode.Repair": "修复已转换存档", - "Input.RepairHint": "已经转换并继续游玩?添加原始 3DS 存档和当前 Wii U/Cemu 存档。修复只更新仍保持旧版转换结果的字段。", + "Input.RepairHint": "请分别选择原始 3DS 存档、继续游玩后的当前 Wii U/Cemu 存档,以及独立输出位置。当前 Wii U 存档始终只读。", "Repair.Version": "原转换器版本", "Repair.Version.Auto": "自动检测", "Repair.Version.Hint": "不知道版本时先自动检测;若多个历史版本都匹配,Dry Run 会要求你明确选择后再运行。", @@ -138,7 +142,7 @@ public enum ConverterCopy { "Components.Staging": "临时转换目录", "Components.Target": "Cemu ExtData 目录", "DryRun.Start": "运行 Dry Run", - "DryRun.NotAuthorized": "完成检查后运行 Dry Run,写入授权会绑定当前 SHA-256 与已选组件。", + "DryRun.NotAuthorized": "完成全部输入与输出检查后运行 Dry Run;修复授权会分别绑定 3DS 源、当前 Wii U 引用、输出状态与预览。", "Write.Confirm": "确认写入", "Write.Unavailable": "需要当前有效的 Dry Run 才能写入。", "Write.Rollback": "从 manifest 回滚", @@ -161,7 +165,7 @@ public enum ConverterCopy { "Status.Running": "正在执行…", "Status.Succeeded": "操作完成", "Status.Failed": "操作失败", - "Status.Detail.NeedsInput": "明确选择 3DS 源角色文件与 Cemu 目标;应用不会自动扫描存档目录。", + "Status.Detail.NeedsInput": "全新转换需选择 3DS 源和输出;修复模式还必须独立选择当前 Wii U/Cemu 引用存档。应用不会自动扫描目录。", "Status.Detail.NeedsInspection": "运行只读检查以确认存档类型、大小与 SHA-256;此步骤不会写入文件。", "Status.Detail.ReadyForDryRun": "输入检查已通过。Dry Run 会把写入授权绑定到当前文件指纹。", "Status.Detail.RevisionRequired": "多个历史转换版本同时匹配;明确选择版本并重新运行 Dry Run 前不会写入。", @@ -175,9 +179,11 @@ public enum ConverterCopy { "Input.SHA256": "SHA-256", "Input.Profile": "存档类型", "Input.Bytes": "字节数", - "Input.Subtitle": "选择 3DS 源存档,再选择已有 Cemu 存档或一个导出目录。目录会明确输出同名 user# 文件。", + "Input.Subtitle": "每一种概念使用独立控件:3DS 源、修复时的当前 Wii U 引用,以及最终输出。目录只解析直接同名 user#。", "Input.SourceMessage": "请选择一个 3DS user1、user2 或 user3 文件,或包含它们的直接父目录。", - "Input.TargetMessage": "请选择匹配的现有 Cemu user# 文件,或选择一个已有目录以导出同名 user# 文件。", + "Input.CurrentMessage": "请选择之前转换过且继续游玩后的同槽位 Wii U/Cemu user#,或直接包含它的目录。该输入不会被写入。", + "Input.TargetMessage": "请选择新转换结果的输出 user# 文件或已有输出目录。", + "Input.RepairOutputMessage": "请选择修复结果的独立输出控件;若明确选择与当前引用相同的 user#,则执行原地修复。已有输出会先备份;若修复公会名片,目录必须已初始化并包含 card1/card2/card3/cardbox。", "Input.InvalidSlot": "请选择 user1、user2 或 user3 文件,或一个直接包含所选槽位的目录。", "Guide.InputComplete": "检查已完成。下一步可按需添加名片、任务或其他可选数据;也可以直接继续 Dry Run。", "Guide.ComponentsReady": "可选数据已确认。接下来运行只读 Dry Run,确认本次转换会写入什么。", @@ -227,9 +233,12 @@ public enum ConverterCopy { "Write.RollbackFooter": "回滚绑定到所选 manifest,不能指向普通目标目录。", "Write.Files": "文件", "Write.OneTarget": "1 个选定目标", + "Write.RepairTargets": "核心输出 + 公会名片完整组件组", "Write.Target": "目标", "Write.SourceSHA256": "源文件 SHA-256", "Write.TargetSHA256": "目标文件 SHA-256", + "Write.CurrentSetSHA256": "当前 Wii U 引用集合 SHA-256", + "Write.OutputSetSHA256": "输出状态集合 SHA-256", "Write.NewExport": "新输出文件(写入前必须保持不存在)", "Write.StagingSetSHA256": "暂存集合 SHA-256", "Write.TargetSetSHA256": "目标集合 SHA-256", @@ -318,11 +327,15 @@ public enum ConverterCopy { "Navigation.ExperimentalCEC": "Experimental CEC", "Navigation.Settings": "Settings", "Input.Source": "3DS source character file", - "Input.Target": "Cemu target or export directory", + "Input.Current": "Current Wii U / Cemu reference save", + "Input.CurrentReadOnly": "This save supplies continued-play data only. Repair never writes to it.", + "Input.Target": "Wii U / Cemu output", + "Input.RepairOutput": "Repaired Wii U / Cemu output", + "Input.Required": "Required", "Input.Mode": "Operation", "Input.Mode.New": "New conversion", "Input.Mode.Repair": "Repair converted save", - "Input.RepairHint": "Already converted and kept playing? Add the original 3DS save and the current Wii U/Cemu save. Only fields that still match an older conversion are repaired.", + "Input.RepairHint": "Choose three separate paths: the original 3DS save, the current Wii U/Cemu save after continued play, and an independent output. The current Wii U input is always read-only.", "Repair.Version": "Original converter version", "Repair.Version.Auto": "Detect automatically", "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.", @@ -342,7 +355,7 @@ public enum ConverterCopy { "Components.Staging": "Conversion staging directory", "Components.Target": "Cemu ExtData directory", "DryRun.Start": "Run Dry Run", - "DryRun.NotAuthorized": "Inspect both files, then run Dry Run. Write authorization is bound to the current SHA-256 values and selected components.", + "DryRun.NotAuthorized": "Inspect every input and output, then run Dry Run. Repair authorization independently binds the 3DS source, current Wii U reference, output state, and preview.", "Write.Confirm": "Confirm write", "Write.Unavailable": "A current successful Dry Run is required before writing.", "Write.Rollback": "Roll back from manifest", @@ -365,7 +378,7 @@ public enum ConverterCopy { "Status.Running": "Working…", "Status.Succeeded": "Operation complete", "Status.Failed": "Operation failed", - "Status.Detail.NeedsInput": "Choose an explicit 3DS source slot and Cemu target. The app never scans save directories automatically.", + "Status.Detail.NeedsInput": "New conversion requires a 3DS source and output. Repair additionally requires a separately selected current Wii U/Cemu reference save. The app never scans directories automatically.", "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.", @@ -379,9 +392,11 @@ public enum ConverterCopy { "Input.SHA256": "SHA-256", "Input.Profile": "Profile", "Input.Bytes": "Bytes", - "Input.Subtitle": "Choose a 3DS source, then an existing Cemu slot or an export directory. A directory resolves explicitly to the same user# file.", + "Input.Subtitle": "Each concept has its own control: 3DS source, current Wii U reference for repair, and final output. A folder resolves only its direct matching user#.", "Input.SourceMessage": "Choose a 3DS user1, user2, or user3 file, or its direct parent directory.", - "Input.TargetMessage": "Choose a matching existing Cemu user# file, or an existing directory for a same-named user# export.", + "Input.CurrentMessage": "Choose the same-slot Wii U/Cemu user# that was converted and then played, or its direct parent folder. This input is never written.", + "Input.TargetMessage": "Choose the output user# file or an existing output directory for a new conversion.", + "Input.RepairOutputMessage": "Choose the repaired output independently. Explicitly selecting the same user# as the current reference performs an in-place repair. Existing output is backed up; guild-card repair requires an initialized directory containing card1/card2/card3/cardbox.", "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.", @@ -431,9 +446,12 @@ public enum ConverterCopy { "Write.RollbackFooter": "Rollback is bound to the selected manifest. It cannot be pointed at a generic target directory.", "Write.Files": "Files", "Write.OneTarget": "1 selected target", + "Write.RepairTargets": "Core output + complete guild-card component group", "Write.Target": "Target", "Write.SourceSHA256": "Source SHA-256", "Write.TargetSHA256": "Target SHA-256", + "Write.CurrentSetSHA256": "Current Wii U reference set SHA-256", + "Write.OutputSetSHA256": "Output-state set SHA-256", "Write.NewExport": "New export (must remain absent before write)", "Write.StagingSetSHA256": "Staging set SHA-256", "Write.TargetSetSHA256": "Target set SHA-256", diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/WorkflowStageRailPresentation.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/WorkflowStageRailPresentation.swift index ec6919f..db7ecaf 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/WorkflowStageRailPresentation.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/WorkflowStageRailPresentation.swift @@ -57,13 +57,13 @@ public extension ConversionWorkflow { [ railStep( route: .input, - isCurrent: input == nil || sourceInspection == nil, - isComplete: sourceInspection != nil, + isCurrent: input == nil || !coreInspectionComplete, + isComplete: coreInspectionComplete, isBlocked: input == nil ), railStep( route: .dryRun, - isCurrent: sourceInspection != nil && !canWrite && state != .success, + isCurrent: coreInspectionComplete && !canWrite && state != .success, isComplete: canWrite || state == .success, isBlocked: statusPresentation.kind == .blocked ), diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/WorkflowStatusPresentation.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/WorkflowStatusPresentation.swift index b6af8a7..66ac72c 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/WorkflowStatusPresentation.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/WorkflowStatusPresentation.swift @@ -95,7 +95,7 @@ public extension ConversionWorkflow { isBlocking: true ) } - if sourceInspection == nil { + if !coreInspectionComplete { return .init( kind: .needsInspection, titleKey: "Status.NeedsInspection", diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/DryRunView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/DryRunView.swift index 82fb954..d5dd117 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/DryRunView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/DryRunView.swift @@ -20,8 +20,18 @@ struct DryRunView: View { path: workflow.input?.source.path, state: workflow.sourceInspection == nil ? .pending : .ready ) + if workflow.mode == .repairConverted { + DryRunFlowRow( + title: ConverterCopy.text("Input.Current", language: language), + path: workflow.input?.current?.path, + state: workflow.currentInspection == nil ? .pending : .ready + ) + } DryRunFlowRow( - title: ConverterCopy.text("Input.Target", language: language), + title: ConverterCopy.text( + workflow.mode == .repairConverted ? "Input.RepairOutput" : "Input.Target", + language: language + ), path: targetPath, state: workflow.targetInspection == nil && !workflow.isNewTargetExport ? .pending : .ready ) @@ -176,11 +186,16 @@ private struct RepairHashRows: View { .font(.caption.monospaced()) .textSelection(.enabled) } - LabeledContent(ConverterCopy.text("Write.TargetSHA256", language: language)) { + LabeledContent(ConverterCopy.text("Write.CurrentSetSHA256", language: language)) { Text(fingerprint.currentSetSHA256) .font(.caption.monospaced()) .textSelection(.enabled) } + LabeledContent(ConverterCopy.text("Write.OutputSetSHA256", language: language)) { + Text(fingerprint.outputSetSHA256) + .font(.caption.monospaced()) + .textSelection(.enabled) + } LabeledContent(ConverterCopy.text("Repair.PreviewSHA256", language: language)) { Text(fingerprint.previewSHA256) .font(.caption.monospaced()) diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/InputInspectionView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/InputInspectionView.swift index c8d358a..de1974d 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/InputInspectionView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/InputInspectionView.swift @@ -7,8 +7,10 @@ struct InputInspectionView: View { @Binding var navigation: ConverterNavigation? @State private var slot: SaveSlot = .user2 @State private var sourceSelection: URL? + @State private var currentSelection: URL? @State private var targetSelection: URL? @State private var source: URL? + @State private var current: URL? @State private var target: URL? @State private var selectionError: String? @State private var isInspecting = false @@ -23,7 +25,10 @@ struct InputInspectionView: View { Section { Picker(ConverterCopy.text("Input.Mode", language: language), selection: Binding( get: { workflow.mode }, - set: { workflow.setMode($0) } + set: { + workflow.setMode($0) + updateInput() + } )) { Text(ConverterCopy.text("Input.Mode.New", language: language)) .tag(ConversionMode.newConversion) @@ -55,8 +60,26 @@ struct InputInspectionView: View { ) { chooseSource() } + if workflow.mode == .repairConverted { + SelectedPathRow( + title: ConverterCopy.text("Input.Current", language: language), + value: current ?? workflow.input?.current, + chooseTitle: ConverterCopy.text("Input.Select", language: language) + ) { + chooseCurrent() + } + Label( + ConverterCopy.text("Input.CurrentReadOnly", language: language), + systemImage: "lock.shield" + ) + .font(.caption) + .foregroundStyle(.secondary) + } SelectedPathRow( - title: ConverterCopy.text("Input.Target", language: language), + title: ConverterCopy.text( + workflow.mode == .repairConverted ? "Input.RepairOutput" : "Input.Target", + language: language + ), value: target ?? workflow.input?.target, chooseTitle: ConverterCopy.text("Input.Select", language: language) ) { @@ -96,7 +119,9 @@ struct InputInspectionView: View { Section(ConverterCopy.text("Input.SHA256", language: language)) { InspectionTable( source: sourceInspection, + current: workflow.currentInspection, target: workflow.targetInspection, + mode: workflow.mode, language: language ) } @@ -115,7 +140,7 @@ struct InputInspectionView: View { } } - if workflow.sourceInspection != nil, workflow.state == .componentSelection { + if workflow.coreInspectionComplete, workflow.state == .componentSelection { WorkflowGuidanceSection( messageKey: "Guide.InputComplete", actionKey: "Guide.ToComponents", @@ -131,8 +156,10 @@ struct InputInspectionView: View { } .onAppear { source = workflow.input?.source + current = workflow.input?.current target = workflow.input?.target sourceSelection = source + currentSelection = current targetSelection = target if let source, let resolvedSlot = SavePathResolver.slot(for: source) { slot = resolvedSlot @@ -140,7 +167,11 @@ struct InputInspectionView: View { } } - private var hasInput: Bool { (source ?? workflow.input?.source) != nil && (target ?? workflow.input?.target) != nil } + private var hasInput: Bool { + (source ?? workflow.input?.source) != nil + && (target ?? workflow.input?.target) != nil + && (workflow.mode == .newConversion || (current ?? workflow.input?.current) != nil) + } private func chooseSource() { guard let url = OpenPanel.selectFileOrDirectory( @@ -156,18 +187,36 @@ struct InputInspectionView: View { private func chooseTarget() { guard let url = OpenPanel.selectFileOrDirectory( - title: ConverterCopy.text("Input.Target", language: language), - message: ConverterCopy.text("Input.TargetMessage", language: language) + title: ConverterCopy.text( + workflow.mode == .repairConverted ? "Input.RepairOutput" : "Input.Target", + language: language + ), + message: ConverterCopy.text( + workflow.mode == .repairConverted ? "Input.RepairOutputMessage" : "Input.TargetMessage", + language: language + ) ) else { return } targetSelection = url resolveSelections() } + private func chooseCurrent() { + guard let url = OpenPanel.selectFileOrDirectory( + title: ConverterCopy.text("Input.Current", language: language), + message: ConverterCopy.text("Input.CurrentMessage", language: language) + ) else { return } + currentSelection = url + resolveSelections() + } + private func resolveSelections() { do { if let sourceSelection { source = try SavePathResolver.resolveSource(selection: sourceSelection, slot: slot) } + if let currentSelection { + current = try SavePathResolver.resolveSource(selection: currentSelection, slot: slot) + } if let targetSelection { target = try SavePathResolver.resolveTarget(selection: targetSelection, slot: slot) } @@ -180,7 +229,12 @@ struct InputInspectionView: View { private func updateInput() { guard let source, let target else { return } - workflow.configure(input: ConversionInput(source: source, target: target)) + if workflow.mode == .repairConverted { + guard let current else { return } + workflow.configure(input: ConversionInput(source: source, target: target, current: current)) + } else { + workflow.configure(input: ConversionInput(source: source, target: target)) + } } private func inspect() { @@ -317,7 +371,9 @@ struct SelectedPathRow: View { struct InspectionTable: View { let source: InputInspection + let current: InputInspection? let target: InputInspection? + let mode: ConversionMode let language: ConverterLanguage var body: some View { @@ -325,27 +381,59 @@ struct InspectionTable: View { GridRow { Text("") Text(ConverterCopy.text("Input.Source", language: language)).font(.caption).foregroundStyle(.secondary) - Text(ConverterCopy.text("Input.Target", language: language)).font(.caption).foregroundStyle(.secondary) + if mode == .repairConverted { + Text(ConverterCopy.text("Input.Current", language: language)).font(.caption).foregroundStyle(.secondary) + } + Text( + ConverterCopy.text( + mode == .repairConverted ? "Input.RepairOutput" : "Input.Target", + language: language + ) + ) + .font(.caption) + .foregroundStyle(.secondary) } GridRow { Text(ConverterCopy.text("Input.Profile", language: language)).foregroundStyle(.secondary) Text(source.profile) + if mode == .repairConverted { + inspectionValue(current?.profile) + } targetValue(target?.profile) } GridRow { Text(ConverterCopy.text("Input.Bytes", language: language)).foregroundStyle(.secondary) Text(source.size, format: .number) + if mode == .repairConverted { + inspectionValue(current.map { String($0.size) }) + } targetValue(target.map { String($0.size) }) } GridRow { Text(ConverterCopy.text("Input.SHA256", language: language)).foregroundStyle(.secondary) Text(source.sha256).font(.caption.monospaced()).textSelection(.enabled) + if mode == .repairConverted { + inspectionValue(current?.sha256, monospaced: true) + } targetValue(target?.sha256, monospaced: true) } } .frame(maxWidth: .infinity, alignment: .leading) } + @ViewBuilder + private func inspectionValue(_ value: String?, monospaced: Bool = false) -> some View { + if let value { + Text(value) + .font(monospaced ? .caption.monospaced() : .body) + .textSelection(.enabled) + } else { + Label(ConverterCopy.text("Input.Required", language: language), systemImage: "exclamationmark.circle") + .font(.caption) + .foregroundStyle(.orange) + } + } + @ViewBuilder private func targetValue(_ value: String?, monospaced: Bool = false) -> some View { if let value { 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 f9d4da8..b7d35a3 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings @@ -8,6 +8,10 @@ "Navigation.Input" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Input & Inspect" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "输入与检查" } } } }, "Navigation.Settings" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Settings" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "设置" } } } }, "Navigation.WriteRollback" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Write & Rollback" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "写入与回滚" } } } }, + "Input.Current" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Current Wii U / Cemu reference save" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "当前 Wii U / Cemu 引用存档" } } } }, + "Input.CurrentReadOnly" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "This save supplies continued-play data only. Repair never writes to it." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "该存档只用于读取继续游玩后的数据,修复流程不会写入它。" } } } }, + "Input.RepairOutput" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Repaired Wii U / Cemu output" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "修复后的 Wii U / Cemu 输出位置" } } } }, + "Input.Required" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Required" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "必选" } } } }, "Repair.Version" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Original converter version" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "原转换器版本" } } } }, "Repair.Version.Auto" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Detect automatically" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "自动检测" } } } }, "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 会要求你明确选择后再运行。" } } } }, @@ -38,7 +42,7 @@ "Status.SelectedWorkPending" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Selected data is not complete" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "所选数据尚未全部完成" } } } }, "Status.Detail.Authorized" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Authorization applies only to the current SHA-256 values and explicit target. Any change revokes it." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "写入只对当前 SHA-256 与明确目标有效;任何变化都会撤销授权。" } } } }, "Status.Detail.Failed" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Authorization was revoked. Review the error and inspect again; the target is never silently replaced." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "授权已撤销。查看错误详情并重新检查;不会静默覆盖目标。" } } } }, - "Status.Detail.NeedsInput" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Choose an explicit 3DS source slot and Cemu target. The app never scans save directories automatically." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "明确选择 3DS 源角色文件与 Cemu 目标;应用不会自动扫描存档目录。" } } } }, + "Status.Detail.NeedsInput" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "New conversion requires a 3DS source and output. Repair additionally requires a separately selected current Wii U/Cemu reference save. The app never scans directories automatically." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "全新转换需选择 3DS 源和输出;修复模式还必须独立选择当前 Wii U/Cemu 引用存档。应用不会自动扫描目录。" } } } }, "Status.Detail.NeedsInspection" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Run the read-only inspection to verify profile, size, and SHA-256. No file is written." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "运行只读检查以确认存档类型、大小与 SHA-256;此步骤不会写入文件。" } } } }, "Status.Detail.OptionalDataBlocked" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "A selected system or ExtData domain lacks an explicit path. All writes remain disabled until it is complete." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已启用的 system 或 ExtData 缺少明确路径;补齐前所有写入保持禁用。" } } } }, "Status.Detail.ReadyForDryRun" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Inspection passed. Dry Run will bind write authorization to the current file fingerprints." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "输入检查已通过。Dry Run 会把写入授权绑定到当前文件指纹。" } } } }, @@ -51,6 +55,9 @@ "Status.Running" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Working…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在执行…" } } } }, "Status.Succeeded" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Operation complete" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "操作完成" } } } }, "Write.SourceSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Source SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "源文件 SHA-256" } } } }, + "Write.CurrentSetSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Current Wii U reference set SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "当前 Wii U 引用集合 SHA-256" } } } }, + "Write.OutputSetSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Output-state set SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "输出状态集合 SHA-256" } } } }, + "Write.RepairTargets" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Core output + complete guild-card component group" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "核心输出 + 公会名片完整组件组" } } } }, "Write.StagingSetSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Staging set SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "暂存集合 SHA-256" } } } }, "Write.TargetSetSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Target set SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "目标集合 SHA-256" } } } }, "Write.TargetSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Target SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "目标文件 SHA-256" } } } }, diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/WriteRollbackView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/WriteRollbackView.swift index 8053d5e..bd2378a 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/WriteRollbackView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/WriteRollbackView.swift @@ -68,9 +68,14 @@ struct WriteRollbackView: View { .sheet(isPresented: $showCoreConfirmation) { TransactionConfirmationSheet( title: ConverterCopy.text("Write.SelectedSave", language: language), - targetLabel: ConverterCopy.text("Write.Target", language: language), + targetLabel: ConverterCopy.text( + workflow.mode == .repairConverted ? "Input.RepairOutput" : "Write.Target", + language: language + ), target: workflow.input?.target, - files: ConverterCopy.text("Write.OneTarget", language: language), + files: workflow.mode == .repairConverted && workflow.components.includeGuildCards + ? ConverterCopy.text("Write.RepairTargets", language: language) + : ConverterCopy.text("Write.OneTarget", language: language), language: language, verificationDetails: coreConfirmationDetails, onConfirm: writeCore, @@ -243,6 +248,26 @@ struct WriteRollbackView: View { } private var coreConfirmationDetails: [TransactionConfirmationDetail] { + if let fingerprint = workflow.repairDryRunFingerprint { + 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 + ), + TransactionConfirmationDetail( + label: ConverterCopy.text("Repair.PreviewSHA256", language: language), + value: fingerprint.previewSHA256 + ), + ] + } guard let fingerprint = workflow.dryRunFingerprint else { return [] } return [ TransactionConfirmationDetail( diff --git a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/ConversionWorkflowTests.swift b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/ConversionWorkflowTests.swift index a4e52e5..83ad1f7 100644 --- a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/ConversionWorkflowTests.swift +++ b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/ConversionWorkflowTests.swift @@ -33,8 +33,12 @@ final class ConversionWorkflowTests: XCTestCase { ]) let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) workflow.setMode(.repairConverted) - workflow.configure(input: fixtureInput) - workflow.applyInspections(source: fixtureSourceInspection, target: fixtureTargetInspection) + workflow.configure(input: fixtureRepairInput) + workflow.applyInspections( + source: fixtureSourceInspection, + current: fixtureCurrentInspection, + target: fixtureRepairOutputInspection + ) try await workflow.runCoreDryRun() @@ -52,6 +56,8 @@ final class ConversionWorkflowTests: XCTestCase { let commands = await executor.recordedCommands() XCTAssertFalse(commands[0].arguments.contains("--from-version")) XCTAssertTrue(commands[1].arguments.containsAdjacent("--from-version", "0.0.3")) + XCTAssertTrue(commands[1].arguments.containsAdjacent("--current", fixtureRepairInput.current!.path)) + XCTAssertTrue(commands[1].arguments.containsAdjacent("--output", fixtureRepairInput.target.path)) } func testRepairWriteReusesTheAuthorizedRevisionAndPublishesCoordinatorManifest() async throws { @@ -63,8 +69,12 @@ final class ConversionWorkflowTests: XCTestCase { let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) workflow.setMode(.repairConverted) workflow.setRepairFromVersion(.v0_0_5) - workflow.configure(input: fixtureInput) - workflow.applyInspections(source: fixtureSourceInspection, target: fixtureTargetInspection) + workflow.configure(input: fixtureRepairInput) + workflow.applyInspections( + source: fixtureSourceInspection, + current: fixtureCurrentInspection, + target: fixtureRepairOutputInspection + ) try await workflow.runCoreDryRun() try await workflow.writeCore() @@ -76,6 +86,7 @@ final class ConversionWorkflowTests: XCTestCase { XCTAssertTrue(commands[1].arguments.containsAdjacent("--from-version", "0.0.5")) XCTAssertTrue(commands[1].arguments.containsAdjacent("--expected-source-set-sha256", fixtureRepairSourceSetSHA256)) XCTAssertTrue(commands[1].arguments.containsAdjacent("--expected-current-set-sha256", fixtureRepairCurrentSetSHA256)) + XCTAssertTrue(commands[1].arguments.containsAdjacent("--expected-output-set-sha256", fixtureRepairOutputSetSHA256)) XCTAssertTrue(commands[1].arguments.containsAdjacent("--expected-preview-sha256", fixtureRepairPreviewSHA256)) } @@ -87,8 +98,12 @@ final class ConversionWorkflowTests: XCTestCase { let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) workflow.setMode(.repairConverted) workflow.setRepairFromVersion(.v0_0_5) - workflow.configure(input: fixtureInput) - workflow.applyInspections(source: fixtureSourceInspection, target: fixtureTargetInspection) + workflow.configure(input: fixtureRepairInput) + workflow.applyInspections( + source: fixtureSourceInspection, + current: fixtureCurrentInspection, + target: fixtureRepairOutputInspection + ) try await workflow.runCoreDryRun() try await workflow.writeCore() @@ -948,8 +963,12 @@ final class ConversionWorkflowTests: XCTestCase { let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) workflow.setMode(.repairConverted) workflow.setRepairFromVersion(.v0_0_5) - workflow.configure(input: fixtureInput) - workflow.applyInspections(source: fixtureSourceInspection, target: fixtureTargetInspection) + workflow.configure(input: fixtureRepairInput) + workflow.applyInspections( + source: fixtureSourceInspection, + current: fixtureCurrentInspection, + target: fixtureRepairOutputInspection + ) try await workflow.runCoreDryRun() do { @@ -977,8 +996,12 @@ final class ConversionWorkflowTests: XCTestCase { let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) workflow.setMode(.repairConverted) workflow.setRepairFromVersion(.v0_0_5) - workflow.configure(input: fixtureInput) - workflow.applyInspections(source: fixtureSourceInspection, target: fixtureTargetInspection) + workflow.configure(input: fixtureRepairInput) + workflow.applyInspections( + source: fixtureSourceInspection, + current: fixtureCurrentInspection, + target: fixtureRepairOutputInspection + ) try await workflow.runCoreDryRun() do { @@ -1102,8 +1125,15 @@ private let fixtureInput = ConversionInput( source: URL(fileURLWithPath: "/tmp/3ds/user2"), target: URL(fileURLWithPath: "/tmp/cemu/user2") ) +private let fixtureRepairInput = ConversionInput( + source: URL(fileURLWithPath: "/tmp/3ds/user2"), + target: URL(fileURLWithPath: "/tmp/cemu-repaired/user2"), + current: URL(fileURLWithPath: "/tmp/cemu-current/user2") +) private let fixtureSourceInspection = InputInspection(profile: "JpThreeDs", size: 35_328, sha256: "a".repeated(64)) private let fixtureTargetInspection = InputInspection(profile: "JpCemu", size: 35_392, sha256: "b".repeated(64)) +private let fixtureCurrentInspection = InputInspection(profile: "JpCemu", size: 35_392, sha256: "b".repeated(64)) +private let fixtureRepairOutputInspection = InputInspection(profile: "JpCemu", size: 35_392, sha256: "c".repeated(64)) private let fixtureSystemSource = URL(fileURLWithPath: "/tmp/3ds/system") private let fixtureSystemTarget = URL(fileURLWithPath: "/tmp/cemu/system") private let fixtureSystemSourceSHA256 = "c".repeated(64) @@ -1112,6 +1142,7 @@ private let fixtureCECSourceRecordSetSHA256 = validSHA("e") private let fixtureCECTargetSHA256 = validSHA("f") private let fixtureRepairSourceSetSHA256 = validSHA("1") private let fixtureRepairCurrentSetSHA256 = validSHA("2") +private let fixtureRepairOutputSetSHA256 = validSHA("9") private let fixtureRepairPreviewSHA256 = validSHA("3") private let fixtureExtrasSelection = ComponentSelection( includeGuildCards: true, @@ -1198,7 +1229,7 @@ private func repairDryRunResult( let candidateJSON = candidates.map { "\"\($0)\"" }.joined(separator: ",") let mergedSHA256 = modified ? validSHA("6") : validSHA("5") let json = """ - {"operation":"repair-converted","status":"dry-run","source_set_sha256":"\(fixtureRepairSourceSetSHA256)","current_set_sha256":"\(fixtureRepairCurrentSetSHA256)","preview_sha256":"\(fixtureRepairPreviewSHA256)","detection":{"confidence":"\(confidence)","candidates":[\(candidateJSON)]},"components":[{"component":"user2","target":"\(fixtureInput.target.path)","modified":\(modified),"detection":{"confidence":"\(confidence)","candidates":[\(candidateJSON)]},"merge":{"component":"user2","source_sha256":"\(validSHA("4"))","current_sha256":"\(validSHA("5"))","merged_sha256":"\(mergedSHA256)"}}]} + {"operation":"repair-converted","status":"dry-run","source":"\(fixtureRepairInput.source.path)","current":"\(fixtureRepairInput.current!.path)","output":"\(fixtureRepairInput.target.path)","source_set_sha256":"\(fixtureRepairSourceSetSHA256)","current_set_sha256":"\(fixtureRepairCurrentSetSHA256)","output_set_sha256":"\(fixtureRepairOutputSetSHA256)","preview_sha256":"\(fixtureRepairPreviewSHA256)","detection":{"confidence":"\(confidence)","candidates":[\(candidateJSON)]},"components":[{"component":"user2","target":"\(fixtureRepairInput.target.path)","modified":\(modified),"detection":{"confidence":"\(confidence)","candidates":[\(candidateJSON)]},"merge":{"component":"user2","source_sha256":"\(validSHA("4"))","current_sha256":"\(validSHA("5"))","merged_sha256":"\(mergedSHA256)"}}]} """ return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) } @@ -1208,14 +1239,14 @@ private func repairWrittenResult( mergedSHA256: String = validSHA("6") ) -> ConverterCommandResult { let json = """ - {"operation":"repair-converted","status":"written","source":"\(fixtureInput.source.path)","current":"\(fixtureInput.target.path)","source_set_sha256":"\(fixtureRepairSourceSetSHA256)","current_set_sha256":"\(fixtureRepairCurrentSetSHA256)","preview_sha256":"\(fixtureRepairPreviewSHA256)","components":[{"component":"user2","target":"\(fixtureInput.target.path)","modified":true,"detection":{"confidence":"selected","candidates":["0.0.5"]},"merge":{"component":"user2","source_sha256":"\(validSHA("4"))","current_sha256":"\(validSHA("5"))","merged_sha256":"\(mergedSHA256)"}}],"manifests":["/tmp/.mh3g-user2-repair.json"],"compatibility_manifest":"\(manifest)"} + {"operation":"repair-converted","status":"written","source":"\(fixtureRepairInput.source.path)","current":"\(fixtureRepairInput.current!.path)","output":"\(fixtureRepairInput.target.path)","source_set_sha256":"\(fixtureRepairSourceSetSHA256)","current_set_sha256":"\(fixtureRepairCurrentSetSHA256)","output_set_sha256":"\(fixtureRepairOutputSetSHA256)","preview_sha256":"\(fixtureRepairPreviewSHA256)","components":[{"component":"user2","target":"\(fixtureRepairInput.target.path)","modified":true,"detection":{"confidence":"selected","candidates":["0.0.5"]},"merge":{"component":"user2","source_sha256":"\(validSHA("4"))","current_sha256":"\(validSHA("5"))","merged_sha256":"\(mergedSHA256)"}}],"manifests":["/tmp/.mh3g-user2-repair.json"],"compatibility_manifest":"\(manifest)"} """ return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) } private func repairNoChangesResult() -> ConverterCommandResult { let json = """ - {"operation":"repair-converted","status":"no-changes","source":"\(fixtureInput.source.path)","current":"\(fixtureInput.target.path)","source_set_sha256":"\(fixtureRepairSourceSetSHA256)","current_set_sha256":"\(fixtureRepairCurrentSetSHA256)","preview_sha256":"\(fixtureRepairPreviewSHA256)","components":[{"component":"user2","target":"\(fixtureInput.target.path)","modified":false,"detection":{"confidence":"selected","candidates":["0.0.5"]},"merge":{"component":"user2","source_sha256":"\(validSHA("4"))","current_sha256":"\(validSHA("5"))","merged_sha256":"\(validSHA("5"))"}}],"manifests":[],"compatibility_manifest":null} + {"operation":"repair-converted","status":"no-changes","source":"\(fixtureRepairInput.source.path)","current":"\(fixtureRepairInput.current!.path)","output":"\(fixtureRepairInput.target.path)","source_set_sha256":"\(fixtureRepairSourceSetSHA256)","current_set_sha256":"\(fixtureRepairCurrentSetSHA256)","output_set_sha256":"\(fixtureRepairOutputSetSHA256)","preview_sha256":"\(fixtureRepairPreviewSHA256)","components":[{"component":"user2","target":"\(fixtureRepairInput.target.path)","modified":false,"detection":{"confidence":"selected","candidates":["0.0.5"]},"merge":{"component":"user2","source_sha256":"\(validSHA("4"))","current_sha256":"\(validSHA("5"))","merged_sha256":"\(validSHA("5"))"}}],"manifests":[],"compatibility_manifest":null} """ return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) } diff --git a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/WorkflowStatusPresentationTests.swift b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/WorkflowStatusPresentationTests.swift index 51f112d..f96cbf5 100644 --- a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/WorkflowStatusPresentationTests.swift +++ b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/WorkflowStatusPresentationTests.swift @@ -48,6 +48,20 @@ final class WorkflowStatusPresentationTests: XCTestCase { XCTAssertEqual(dryRunStep?.iconName, "checkmark.shield") } + func testRepairInputRemainsIncompleteUntilCurrentReferenceIsInspected() { + let workflow = ConversionWorkflow(executable: URL(fileURLWithPath: "/tmp/converter")) + let current = URL(fileURLWithPath: "/tmp/apple-design-current/user1") + workflow.setMode(.repairConverted) + workflow.configure(input: ConversionInput(source: source, target: target, current: current)) + workflow.applyInspections(source: sourceInspection, target: targetInspection) + + XCTAssertFalse(workflow.coreInspectionComplete) + XCTAssertFalse(workflow.canStartDryRun) + XCTAssertEqual(workflow.statusPresentation.kind, .needsInspection) + let inputStep = workflow.stageRailPresentation.first { $0.route == .input } + XCTAssertEqual(inputStep?.tone, .current) + } + func testStageRailLayoutContractRequiresAdaptiveReadableFallbacks() { XCTAssertEqual( WorkflowStageRailLayoutContract.adaptive.fallbackOrder, diff --git a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs index 3dd3a32..e3605d7 100644 --- a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs +++ b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs @@ -85,8 +85,14 @@ public void SetLanguage(AppLanguageOverride language) public string SaveSlotHint => Text(nameof(SaveSlotHint)); public string SourceSlot => Text(nameof(SourceSlot)); public string SourceSlotHint => Text(nameof(SourceSlotHint)); + public string CurrentSlot => Text(nameof(CurrentSlot)); + public string CurrentSlotHint => Text(nameof(CurrentSlotHint)); public string TargetSlot => Text(nameof(TargetSlot)); public string TargetSlotHint => Text(nameof(TargetSlotHint)); + public string RepairOutputSlot => Text(nameof(RepairOutputSlot)); + public string RepairOutputSlotHint => Text(nameof(RepairOutputSlotHint)); + public string NewOutputPlaceholder => Text(nameof(NewOutputPlaceholder)); + public string RepairOutputPlaceholder => Text(nameof(RepairOutputPlaceholder)); public string ChooseFile => Text(nameof(ChooseFile)); public string ChooseFolder => Text(nameof(ChooseFolder)); public string CliPath => Text(nameof(CliPath)); @@ -176,6 +182,7 @@ public void SetLanguage(AppLanguageOverride language) public string CecTargetCacheHint => Text(nameof(CecTargetCacheHint)); public string DescribeResolvedSource(string path) => string.Format(Text("ResolvedSource"), path); + public string DescribeResolvedCurrent(string path) => string.Format(Text("ResolvedCurrent"), path); public string DescribeResolvedTarget(string path) => string.Format(Text("ResolvedTarget"), path); public string DescribeResolvedExtData(string path) => string.Format(Text("ResolvedExtData"), path); public string DescribeRepairVersionAmbiguous(string candidates) => @@ -240,7 +247,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)] = "Already converted? Add the original 3DS user# and the latest matching Wii U / Cemu user# that you have continued playing. The repair keeps later Wii U progress and only updates fields that still match a supported older converter. To repair received guild cards and offline partners too, enable Guild cards below and select the original 3DS ExtData user directory.", + [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(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.", @@ -248,13 +255,19 @@ private string Text(string key) ["RepairVersionAmbiguous"] = "Dry Run found incompatible candidates: {0}. Select the version used originally, then run Dry Run again.", ["RepairVersionDetected"] = "Detection is safe to apply automatically. Matching revision range: {0}.", [nameof(CoreSectionTitle)] = "Core slot", - [nameof(CoreSectionDescription)] = "Choose one user# slot. A source folder resolves only its direct child; an output folder resolves to that same user# name.", + [nameof(CoreSectionDescription)] = "Choose one user# slot. Each selected input folder resolves only its direct child; the output folder resolves to the same user# name.", [nameof(SaveSlot)] = "Save slot", [nameof(SaveSlotHint)] = "This selects the direct user# child used when you choose a folder. A directly selected source file must match it.", [nameof(SourceSlot)] = "3DS source", [nameof(SourceSlotHint)] = "Choose user1, user2, or user3, or a folder that directly contains the selected file. No archive or recursive SD-card search is used.", - [nameof(TargetSlot)] = "Cemu target or export folder", - [nameof(TargetSlotHint)] = "Choose a matching existing Cemu user# file, or an output folder. A folder becomes /; nothing is created until Write.", + [nameof(CurrentSlot)] = "Current Wii U / Cemu reference save", + [nameof(CurrentSlotHint)] = "Required only for repair. Choose the converted, same-slot user# you continued playing, or its direct parent folder. This input is never written.", + [nameof(TargetSlot)] = "Wii U / Cemu output", + [nameof(TargetSlotHint)] = "Choose an output user# file or folder. A folder becomes /; nothing is created until Write.", + [nameof(RepairOutputSlot)] = "Repaired Wii U / Cemu output", + [nameof(RepairOutputSlotHint)] = "Choose where to write the repaired result. This control is independent from the current Wii U reference; explicitly selecting the same path performs an in-place repair. Existing output is backed up; a missing user# is created only during Write. Guild-card repair requires an initialized output directory containing card1/card2/card3/cardbox.", + [nameof(NewOutputPlaceholder)] = "output user# or folder", + [nameof(RepairOutputPlaceholder)] = "repaired user# or output folder", [nameof(ChooseFile)] = "File…", [nameof(ChooseFolder)] = "Folder…", [nameof(CliPath)] = "Converter CLI", @@ -266,14 +279,14 @@ private string Text(string key) [nameof(DryRunTitle)] = "Preview before any write", [nameof(DryRunDescription)] = "Dry Run previews the resolved source and target without writing an output, backup, manifest, or CEC data.", [nameof(RunDryRun)] = "Run Dry Run", - [nameof(DryRunGate)] = "A successful Dry Run binds this screen to the source SHA-256 and either the target SHA-256 or the target's required absence.", + [nameof(DryRunGate)] = "A successful repair Dry Run independently binds the original 3DS input, current Wii U reference, output state, and exact merge preview.", [nameof(WriteTitle)] = "Write one authorized target", [nameof(WriteDescription)] = "Before Write, the app rechecks the resolved source and target state. The transaction creates its own backup and manifest.", [nameof(WriteSelectedSave)] = "Write selected save", - [nameof(WriteUnavailable)] = "Inspect both paths and complete a current Dry Run before writing.", + [nameof(WriteUnavailable)] = "Inspect every required input and output path, then complete a current Dry Run before writing.", [nameof(TransactionDetails)] = "Transaction scope", - [nameof(BackupAndManifest)] = "Backup and manifest are created beside this one resolved target only.", - [nameof(OneTarget)] = "1 resolved target", + [nameof(BackupAndManifest)] = "Backups and manifests cover only the explicitly selected output scope.", + [nameof(OneTarget)] = "Explicit output scope only", [nameof(RollbackTitle)] = "Rollback a known transaction", [nameof(RollbackDescription)] = "Rollback accepts only the exact manifest created by the converter. It never treats a directory as a rollback request.", [nameof(ManifestPath)] = "Transaction manifest", @@ -304,7 +317,7 @@ private string Text(string key) [nameof(Written)] = "Written with transaction evidence", [nameof(RolledBack)] = "Rolled back from manifest", [nameof(ConfirmWriteTitle)] = "Write the selected Cemu save?", - [nameof(ConfirmWriteBody)] = "This replaces exactly one selected target after a final SHA-256 check. The Rust CLI will create its transaction backup and manifest.", + [nameof(ConfirmWriteBody)] = "This writes only the selected output after final source, current-reference, output-state, and preview checks. The Rust CLI creates transaction backups and a manifest; the current Wii U reference remains unchanged.", [nameof(ConfirmRollbackTitle)] = "Restore from this manifest?", [nameof(ConfirmRollbackBody)] = "Rollback restores only the target bound to this converter-created manifest.", [nameof(ConfirmCecTitle)] = "Write experimental CEC data?", @@ -314,7 +327,7 @@ private string Text(string key) [nameof(OpenFolder)] = "Open folder", [nameof(ExpectedCliMissing)] = "The bundled converter CLI is missing. Choose the signed mh3g-save-convert.exe sidecar before running a command.", [nameof(InvalidCorePaths)] = "Choose a valid user1, user2, or user3 source and a matching target file or export folder.", - [nameof(FileChangedAfterDryRun)] = "The source or target state changed after Dry Run. Inspect and run Dry Run again before writing.", + [nameof(FileChangedAfterDryRun)] = "A source, current Wii U reference, or output state changed after Dry Run. Inspect and run Dry Run again before writing.", [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.", @@ -343,16 +356,21 @@ private string Text(string key) [nameof(InstallExtData)] = "Install selected ExtData", [nameof(ExtDataManifest)] = "ExtData recovery manifest", ["ResolvedSource"] = "Resolved source: {0}", + ["ResolvedCurrent"] = "Read-only current Wii U input: {0}", ["ResolvedTarget"] = "Output will be: {0}", ["ResolvedExtData"] = "Resolved ExtData user directory: {0}", ["PathError_MissingSourceSelection"] = "Choose a 3DS source file or folder first.", ["PathError_MissingTargetSelection"] = "Choose a Cemu target file or export folder first.", + ["PathError_MissingCurrentSelection"] = "Choose the current Wii U / Cemu reference save or its direct folder first.", ["PathError_InvalidSlot"] = "Choose user1, user2, or user3.", ["PathError_SourcePathMissing"] = "The selected 3DS source file is no longer available.", ["PathError_SourceSlotMissing"] = "The selected source folder does not directly contain the selected user# file.", ["PathError_SourceSlotNameMismatch"] = "The selected source file does not match the selected user# slot.", ["PathError_TargetPathMissing"] = "Choose an existing Cemu user# file or an existing export folder.", ["PathError_TargetSlotNameMismatch"] = "The selected Cemu target file does not match the selected user# slot.", + ["PathError_CurrentPathMissing"] = "The selected current Wii U / Cemu user# is no longer available.", + ["PathError_CurrentSlotMissing"] = "The selected current Wii U / Cemu folder does not directly contain the selected user#.", + ["PathError_CurrentSlotNameMismatch"] = "The selected current Wii U / Cemu file does not match the selected user# slot.", ["PathError_ExtDataPathMissing"] = "The selected ExtData location is not an available directory.", ["PathError_ExtDataUserDirectoryMissing"] = "The selected ExtData parent does not directly contain a user directory.", ["PathError_InvalidPath"] = "The selected path is invalid.", @@ -408,7 +426,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)] = "修复模式使用三个明确路径:原始 3DS user#、继续游玩后的最新同槽位 Wii U / Cemu user#,以及独立输出位置。当前 Wii U 存档只读引用,只有输出位置会被写入。还要修复收到的公会名片和离线伙伴时,请在下方启用“公会名片与离线伙伴”,并选择原始 3DS ExtData user 目录。", [nameof(RepairVersionTitle)] = "原转换器版本", [nameof(RepairVersionAutomatic)] = "自动检测", [nameof(RepairVersionHint)] = "默认保持自动检测。只有 Dry Run 报告多个互不兼容的候选版本时才需要手动选择;选择后旧预览会失效,必须重新 Dry Run。", @@ -416,13 +434,19 @@ private string Text(string key) ["RepairVersionAmbiguous"] = "Dry Run 检测到互不兼容的候选版本:{0}。请选择当时使用的版本,然后重新运行 Dry Run。", ["RepairVersionDetected"] = "当前检测结果可以安全自动应用。匹配的版本范围:{0}。", [nameof(CoreSectionTitle)] = "核心角色槽位", - [nameof(CoreSectionDescription)] = "选择一个 user# 槽位。源目录只解析它的直接子文件;输出目录会解析为同名 user#。", + [nameof(CoreSectionDescription)] = "选择一个 user# 槽位。每个输入目录只解析其直接子文件;输出目录会解析为同名 user#。", [nameof(SaveSlot)] = "存档槽位", [nameof(SaveSlotHint)] = "选择目录时会使用这个直接子文件。直接选择的源文件必须与这个槽位同名。", [nameof(SourceSlot)] = "3DS 源存档", [nameof(SourceSlotHint)] = "选择 user1、user2 或 user3,或直接包含所选文件的目录。不扫描压缩包,也不会递归扫描 SD 卡。", - [nameof(TargetSlot)] = "Cemu 目标或导出目录", - [nameof(TargetSlotHint)] = "选择同名且已初始化的 Cemu user# 文件,或输出目录。目录会解析为 <目录>/<所选 user#>;只有写入时才会创建文件。", + [nameof(CurrentSlot)] = "当前 Wii U / Cemu 引用存档", + [nameof(CurrentSlotHint)] = "仅修复模式必选。请选择之前转换过、后来继续游玩的同槽位 user#,或直接包含它的目录。该输入始终只读,不会被写入。", + [nameof(TargetSlot)] = "Wii U / Cemu 输出位置", + [nameof(TargetSlotHint)] = "选择输出 user# 文件或目录。目录会解析为 <目录>/<所选 user#>;只有写入时才会创建文件。", + [nameof(RepairOutputSlot)] = "修复后的 Wii U / Cemu 输出位置", + [nameof(RepairOutputSlotHint)] = "请选择修复结果的写入位置,该控件与当前 Wii U 引用存档相互独立;若明确选择同一路径则执行原地修复。已有输出会先备份;不存在的 user# 只在正式写入时创建。若同时修复公会名片,输出目录必须已初始化并包含 card1/card2/card3/cardbox。", + [nameof(NewOutputPlaceholder)] = "输出 user# 或目录", + [nameof(RepairOutputPlaceholder)] = "修复输出 user# 或目录", [nameof(ChooseFile)] = "文件…", [nameof(ChooseFolder)] = "文件夹…", [nameof(CliPath)] = "转换器 CLI", @@ -434,14 +458,14 @@ private string Text(string key) [nameof(DryRunTitle)] = "任何写入之前先预览", [nameof(DryRunDescription)] = "Dry Run 会预览解析后的源和目标,不会写入输出、备份、manifest 或 CEC 数据。", [nameof(RunDryRun)] = "运行 Dry Run", - [nameof(DryRunGate)] = "成功的 Dry Run 会绑定源文件 SHA-256,以及目标 SHA-256 或目标必须保持不存在的状态。", + [nameof(DryRunGate)] = "修复模式的成功 Dry Run 会分别绑定原始 3DS 输入、当前 Wii U 引用、输出状态和准确的合并预览。", [nameof(WriteTitle)] = "写入一个已授权目标", [nameof(WriteDescription)] = "写入前应用会重新检查解析后的源文件和目标状态。事务会自行创建备份和 manifest。", [nameof(WriteSelectedSave)] = "写入所选存档", - [nameof(WriteUnavailable)] = "请先检查两个路径并完成当前有效的 Dry Run。", + [nameof(WriteUnavailable)] = "请先检查全部必需输入与输出路径,并完成当前有效的 Dry Run。", [nameof(TransactionDetails)] = "事务范围", - [nameof(BackupAndManifest)] = "备份和 manifest 只会创建在这个解析后的目标文件旁。", - [nameof(OneTarget)] = "1 个解析后的目标", + [nameof(BackupAndManifest)] = "备份和 manifest 只覆盖用户明确选择的输出范围。", + [nameof(OneTarget)] = "仅明确选择的输出范围", [nameof(RollbackTitle)] = "回滚已知事务", [nameof(RollbackDescription)] = "回滚只接受转换器生成的准确 manifest,不会把文件夹当成回滚请求。", [nameof(ManifestPath)] = "事务 manifest", @@ -472,7 +496,7 @@ private string Text(string key) [nameof(Written)] = "已写入事务证据", [nameof(RolledBack)] = "已从 manifest 回滚", [nameof(ConfirmWriteTitle)] = "写入所选 Cemu 存档?", - [nameof(ConfirmWriteBody)] = "最终 SHA-256 检查后只会替换一个目标。Rust CLI 会创建事务备份和 manifest。", + [nameof(ConfirmWriteBody)] = "最终检查原始源、当前 Wii U 引用、输出状态和预览后,只会写入所选输出。Rust CLI 会创建事务备份和 manifest;当前 Wii U 引用保持不变。", [nameof(ConfirmRollbackTitle)] = "从这个 manifest 恢复?", [nameof(ConfirmRollbackBody)] = "回滚只会恢复此转换器 manifest 绑定的目标。", [nameof(ConfirmCecTitle)] = "写入实验性 CEC 数据?", @@ -482,7 +506,7 @@ private string Text(string key) [nameof(OpenFolder)] = "打开文件夹", [nameof(ExpectedCliMissing)] = "未找到打包的转换器 CLI。运行命令前请选择已签名的 mh3g-save-convert.exe sidecar。", [nameof(InvalidCorePaths)] = "请选择有效的 user1、user2 或 user3 源文件,以及同名的目标文件或导出目录。", - [nameof(FileChangedAfterDryRun)] = "Dry Run 后源文件或目标状态发生变化。写入前请重新检查并运行 Dry Run。", + [nameof(FileChangedAfterDryRun)] = "Dry Run 后原始源、当前 Wii U 引用或输出状态发生变化。写入前请重新检查并运行 Dry Run。", [nameof(CecDisabled)] = "启用实验性 CEC 后才能使用它的独立命令。", [nameof(CecAcknowledgementRequired)] = "使用 CEC 写入命令前请确认实验性警告。", [nameof(CecPathsRequired)] = "请选择准确的 CEC 邮箱目录和 Cemu cec 目标路径。", @@ -511,16 +535,21 @@ private string Text(string key) [nameof(InstallExtData)] = "安装所选 ExtData", [nameof(ExtDataManifest)] = "ExtData 恢复 manifest", ["ResolvedSource"] = "解析后的源文件:{0}", + ["ResolvedCurrent"] = "只读引用的当前 Wii U 存档:{0}", ["ResolvedTarget"] = "最终输出位置:{0}", ["ResolvedExtData"] = "解析后的 ExtData user 目录:{0}", ["PathError_MissingSourceSelection"] = "请先选择 3DS 源文件或目录。", ["PathError_MissingTargetSelection"] = "请先选择 Cemu 目标文件或导出目录。", + ["PathError_MissingCurrentSelection"] = "请先选择当前 Wii U / Cemu 引用存档或其直接目录。", ["PathError_InvalidSlot"] = "请选择 user1、user2 或 user3。", ["PathError_SourcePathMissing"] = "所选 3DS 源文件已经不可用。", ["PathError_SourceSlotMissing"] = "所选源目录不直接包含当前选择的 user# 文件。", ["PathError_SourceSlotNameMismatch"] = "所选源文件与当前选择的 user# 槽位不匹配。", ["PathError_TargetPathMissing"] = "请选择已存在的 Cemu user# 文件或已存在的导出目录。", ["PathError_TargetSlotNameMismatch"] = "所选 Cemu 目标文件与当前选择的 user# 槽位不匹配。", + ["PathError_CurrentPathMissing"] = "所选当前 Wii U / Cemu user# 已经不可用。", + ["PathError_CurrentSlotMissing"] = "所选当前 Wii U / Cemu 目录不直接包含当前 user#。", + ["PathError_CurrentSlotNameMismatch"] = "所选当前 Wii U / Cemu 文件与当前 user# 槽位不匹配。", ["PathError_ExtDataPathMissing"] = "所选 ExtData 位置不是可用目录。", ["PathError_ExtDataUserDirectoryMissing"] = "所选 ExtData 父目录不直接包含 user 目录。", ["PathError_InvalidPath"] = "所选路径无效。", diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml b/apps/mh3g-save-converter-windows/MainWindow.xaml index a3ab643..288add3 100644 --- a/apps/mh3g-save-converter-windows/MainWindow.xaml +++ b/apps/mh3g-save-converter-windows/MainWindow.xaml @@ -215,11 +215,27 @@ + + + + + + + + + + + + - + - + @@ -227,7 +243,7 @@ - + diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml.cs b/apps/mh3g-save-converter-windows/MainWindow.xaml.cs index 93b1f70..d6042cb 100644 --- a/apps/mh3g-save-converter-windows/MainWindow.xaml.cs +++ b/apps/mh3g-save-converter-windows/MainWindow.xaml.cs @@ -308,6 +308,26 @@ private async void ChooseTargetFile_Click(object sender, RoutedEventArgs e) } } + private async void ChooseCurrentFile_Click(object sender, RoutedEventArgs e) + { + var path = await PickFileAsync("*"); + if (path is not null) + { + CurrentPathBox.Text = path; + ViewModel.CurrentPath = path; + } + } + + private async void ChooseCurrentFolder_Click(object sender, RoutedEventArgs e) + { + var path = await PickFolderAsync(); + if (path is not null) + { + CurrentPathBox.Text = path; + ViewModel.CurrentPath = path; + } + } + private async void ChooseTargetFolder_Click(object sender, RoutedEventArgs e) { var path = await PickFolderAsync(); @@ -448,6 +468,10 @@ private void CorePath_TextChanged(object sender, TextChangedEventArgs e) { ViewModel.TargetPath = TargetPathBox.Text; } + else if (ReferenceEquals(sender, CurrentPathBox)) + { + ViewModel.CurrentPath = CurrentPathBox.Text; + } } private void GoToCoreWorkflow_Click(object sender, RoutedEventArgs e) diff --git a/apps/mh3g-save-converter-windows/Models/ConverterModels.cs b/apps/mh3g-save-converter-windows/Models/ConverterModels.cs index 1125d0c..906eb77 100644 --- a/apps/mh3g-save-converter-windows/Models/ConverterModels.cs +++ b/apps/mh3g-save-converter-windows/Models/ConverterModels.cs @@ -47,10 +47,12 @@ public sealed record DryRunAuthorization( public sealed record RepairDryRunAuthorization( FileFingerprint Source, FileFingerprint Current, + FileFingerprint Output, string? ExtDataSource, string? FromVersion, string SourceSetSha256, string CurrentSetSha256, + string OutputSetSha256, string PreviewSha256, DateTimeOffset CompletedAt); diff --git a/apps/mh3g-save-converter-windows/Models/SavePathResolution.cs b/apps/mh3g-save-converter-windows/Models/SavePathResolution.cs index 4a86210..929e6b9 100644 --- a/apps/mh3g-save-converter-windows/Models/SavePathResolution.cs +++ b/apps/mh3g-save-converter-windows/Models/SavePathResolution.cs @@ -5,18 +5,22 @@ namespace MHToolkit.MH3GSaveConverter.Windows.Models; /// selections are convenience only: they always resolve to one direct child /// and are never searched recursively. /// -public sealed record CoreSavePaths(string Source, string Target, string Slot); +public sealed record CoreSavePaths(string Source, string Target, string Slot, string? Current = null); public enum SavePathResolutionError { MissingSourceSelection, MissingTargetSelection, + MissingCurrentSelection, InvalidSlot, SourcePathMissing, SourceSlotMissing, SourceSlotNameMismatch, TargetPathMissing, TargetSlotNameMismatch, + CurrentPathMissing, + CurrentSlotMissing, + CurrentSlotNameMismatch, ExtDataPathMissing, ExtDataUserDirectoryMissing, InvalidPath, @@ -62,6 +66,32 @@ public static bool TryResolveCore( return true; } + public static bool TryResolveRepairCore( + string sourceSelection, + string currentSelection, + string targetSelection, + string slot, + out CoreSavePaths? paths, + out SavePathResolutionError error) + { + paths = null; + if (!IsSlot(slot)) + { + error = SavePathResolutionError.InvalidSlot; + return false; + } + + if (!TryResolveSource(sourceSelection, slot, out var source, out error) + || !TryResolveCurrent(currentSelection, slot, out var current, out error) + || !TryResolveTarget(targetSelection, slot, out var target, out error)) + { + return false; + } + + paths = new CoreSavePaths(source, target, slot, current); + return true; + } + public static bool TryResolveSource( string selection, string slot, @@ -169,6 +199,62 @@ public static bool TryResolveTarget( return true; } + public static bool TryResolveCurrent( + string selection, + string slot, + out string resolved, + out SavePathResolutionError error) + { + resolved = string.Empty; + if (string.IsNullOrWhiteSpace(selection)) + { + error = SavePathResolutionError.MissingCurrentSelection; + return false; + } + + if (!IsSlot(slot)) + { + error = SavePathResolutionError.InvalidSlot; + return false; + } + + if (!TryGetFullPath(selection, out var fullPath)) + { + error = SavePathResolutionError.InvalidPath; + return false; + } + + if (Directory.Exists(fullPath)) + { + var candidate = Path.Combine(fullPath, slot); + if (!File.Exists(candidate)) + { + error = SavePathResolutionError.CurrentSlotMissing; + return false; + } + + resolved = candidate; + error = default; + return true; + } + + if (!File.Exists(fullPath)) + { + error = SavePathResolutionError.CurrentPathMissing; + return false; + } + + if (!string.Equals(Path.GetFileName(fullPath), slot, StringComparison.OrdinalIgnoreCase)) + { + error = SavePathResolutionError.CurrentSlotNameMismatch; + return false; + } + + resolved = fullPath; + error = default; + return true; + } + public static bool TryResolveExtDataUserDirectory( string selection, out string resolved, diff --git a/apps/mh3g-save-converter-windows/README.md b/apps/mh3g-save-converter-windows/README.md index ccd89ca..0e46f75 100644 --- a/apps/mh3g-save-converter-windows/README.md +++ b/apps/mh3g-save-converter-windows/README.md @@ -15,10 +15,12 @@ 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 uses the original 3DS `user#`, the - current same-slot Cemu `user#`, and optional complete 3DS ExtData through - `repair-converted`. Ambiguous detection requires choosing 0.0.3 through - 0.0.6 and repeating Dry Run. + 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. - 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`. diff --git a/apps/mh3g-save-converter-windows/README.zh-CN.md b/apps/mh3g-save-converter-windows/README.zh-CN.md index 71f08f2..badba16 100644 --- a/apps/mh3g-save-converter-windows/README.zh-CN.md +++ b/apps/mh3g-save-converter-windows/README.zh-CN.md @@ -14,9 +14,11 @@ - 不扫描 SD 卡、MLC、ZIP、7z、RAR 或任意存档文件夹。所有输入都必须由用户 选择准确文件或目录。 - 全新转换流程固定为 `inspect` -> `convert --dry-run` -> 最终 SHA-256 - 复核 -> `convert --write`。修复模式则使用原始 3DS `user#`、当前同槽位 - Cemu `user#` 与可选的完整 3DS ExtData,调用 `repair-converted`。自动检测 - 结果为歧义时,界面会要求选择 0.0.3 至 0.0.6 后重新 Dry Run。 + 复核 -> `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` 总体回滚。 - C# 仅用 `ProcessStartInfo.ArgumentList` 逐个传递 argv,且 diff --git a/apps/mh3g-save-converter-windows/ViewModels/MainViewModel.cs b/apps/mh3g-save-converter-windows/ViewModels/MainViewModel.cs index 9494633..e4dc835 100644 --- a/apps/mh3g-save-converter-windows/ViewModels/MainViewModel.cs +++ b/apps/mh3g-save-converter-windows/ViewModels/MainViewModel.cs @@ -29,6 +29,7 @@ public sealed class MainViewModel : ObservableObject private string _repairDetectionSummary = string.Empty; private string _selectedSlot = SavePathResolver.AvailableSlots[1]; private string _sourcePath = string.Empty; + private string _currentPath = string.Empty; private string _targetPath = string.Empty; private string _cliPath; private string _rollbackManifestPath = string.Empty; @@ -53,8 +54,10 @@ public sealed class MainViewModel : ObservableObject private string _latestReport = string.Empty; private string _latestError = string.Empty; private bool _sourceInspected; + private bool _currentInspected; private bool _targetInspected; private FileFingerprint? _inspectedSource; + private FileFingerprint? _inspectedCurrent; private FileFingerprint? _inspectedTarget; private DryRunAuthorization? _coreAuthorization; private RepairDryRunAuthorization? _repairAuthorization; @@ -114,6 +117,10 @@ public AppLanguageOverride LanguageOverride public string ConversionModeDescription => IsRepairMode ? Copy.ConversionModeRepairDescription : Copy.ConversionModeNewDescription; + public Visibility RepairCurrentVisibility => IsRepairMode ? Visibility.Visible : Visibility.Collapsed; + public string CoreTargetTitle => IsRepairMode ? Copy.RepairOutputSlot : Copy.TargetSlot; + public string CoreTargetHint => IsRepairMode ? Copy.RepairOutputSlotHint : Copy.TargetSlotHint; + public string CoreTargetPlaceholder => IsRepairMode ? Copy.RepairOutputPlaceholder : Copy.NewOutputPlaceholder; public bool IsRepairRevisionSelectionRequired { get => _repairRevisionSelectionRequired; @@ -164,6 +171,10 @@ public void SetConversionMode(string? tag) OnPropertyChanged(nameof(SelectedConversionMode)); OnPropertyChanged(nameof(IsRepairMode)); OnPropertyChanged(nameof(ConversionModeDescription)); + OnPropertyChanged(nameof(RepairCurrentVisibility)); + OnPropertyChanged(nameof(CoreTargetTitle)); + OnPropertyChanged(nameof(CoreTargetHint)); + OnPropertyChanged(nameof(CoreTargetPlaceholder)); RaiseOptionalConfigurationAvailability(); } @@ -193,6 +204,19 @@ public string TargetPath } } + public string CurrentPath + { + get => _currentPath; + set + { + if (SetProperty(ref _currentPath, value)) + { + InvalidateCoreAuthorization(); + OnPropertyChanged(nameof(CurrentPathPreview)); + } + } + } + public string SelectedSlot { get => _selectedSlot; @@ -202,6 +226,7 @@ public string SelectedSlot { InvalidateCoreAuthorization(); OnPropertyChanged(nameof(SourcePathPreview)); + OnPropertyChanged(nameof(CurrentPathPreview)); OnPropertyChanged(nameof(TargetPathPreview)); } } @@ -237,6 +262,21 @@ public string TargetPathPreview } } + public string CurrentPathPreview + { + get + { + if (string.IsNullOrWhiteSpace(CurrentPath)) + { + return string.Empty; + } + + return SavePathResolver.TryResolveCurrent(CurrentPath, SelectedSlot, out var current, out var error) + ? Copy.DescribeResolvedCurrent(current) + : Copy.DescribePathError(error); + } + } + public string CliPath { get => _cliPath; @@ -557,7 +597,7 @@ private set public bool CanRunCoreDryRun => !IsBusy && _sourceInspected && _targetInspected - && (!IsRepairMode || _inspectedTarget?.Exists == true) + && (!IsRepairMode || _currentInspected && _inspectedCurrent?.Exists == true) && SelectedOptionalDataIsConfigured && HasValidCorePaths(); public bool CanWriteCore => !IsBusy @@ -618,23 +658,42 @@ public async Task InspectCoreAsync() await RunOperationAsync("inspect", async cancellationToken => { var sourceAtInspection = await _fingerprints.CaptureAsync(paths.Source, cancellationToken); + var currentAtInspection = IsRepairMode + ? await _fingerprints.CaptureAsync(paths.Current!, cancellationToken) + : null; var targetAtInspection = await _fingerprints.CaptureAsync(paths.Target, cancellationToken); var sourceReport = await ExecuteAsync("inspect", new[] { "inspect", paths.Source }, cancellationToken); RequireSuccess(sourceReport, "inspect source"); + if (currentAtInspection is not null) + { + var currentReport = await ExecuteAsync( + "inspect current", + new[] { "inspect", paths.Current! }, + cancellationToken); + RequireSuccess(currentReport, "inspect current"); + } if (targetAtInspection.Exists) { var targetReport = await ExecuteAsync("inspect", new[] { "inspect", paths.Target }, cancellationToken); RequireSuccess(targetReport, "inspect target"); } var sourceAfterInspection = await _fingerprints.CaptureAsync(paths.Source, cancellationToken); + var currentAfterInspection = IsRepairMode + ? await _fingerprints.CaptureAsync(paths.Current!, cancellationToken) + : null; var targetAfterInspection = await _fingerprints.CaptureAsync(paths.Target, cancellationToken); - if (!sourceAtInspection.Matches(sourceAfterInspection) || !targetAtInspection.Matches(targetAfterInspection)) + if (!sourceAtInspection.Matches(sourceAfterInspection) + || (currentAtInspection is not null + && (currentAfterInspection is null || !currentAtInspection.Matches(currentAfterInspection))) + || !targetAtInspection.Matches(targetAfterInspection)) { throw new InvalidOperationException(Copy.FileChangedAfterDryRun); } _sourceInspected = true; + _currentInspected = !IsRepairMode || currentAfterInspection?.Exists == true; _targetInspected = true; _inspectedSource = sourceAfterInspection; + _inspectedCurrent = currentAfterInspection; _inspectedTarget = targetAfterInspection; _coreAuthorization = null; Stage = WorkflowStage.Inspected; @@ -654,10 +713,11 @@ public async Task InspectProgressAsync() await RunOperationAsync("inspect-progress", async cancellationToken => { var arguments = new List { "inspect-progress", paths.Source }; - if (File.Exists(paths.Target)) + var comparisonTarget = IsRepairMode ? paths.Current! : paths.Target; + if (File.Exists(comparisonTarget)) { arguments.Add("--target"); - arguments.Add(paths.Target); + arguments.Add(comparisonTarget); } var result = await ExecuteAsync("inspect-progress", arguments, cancellationToken); RequireSuccess(result, "inspect progress"); @@ -675,10 +735,11 @@ public async Task InspectEventsAsync() await RunOperationAsync("inspect-events", async cancellationToken => { var arguments = new List { "inspect-events", paths.Source }; - if (File.Exists(paths.Target)) + var comparisonTarget = IsRepairMode ? paths.Current! : paths.Target; + if (File.Exists(comparisonTarget)) { arguments.Add("--target"); - arguments.Add(paths.Target); + arguments.Add(comparisonTarget); } var result = await ExecuteAsync("inspect-events", arguments, cancellationToken); RequireSuccess(result, "inspect events"); @@ -703,8 +764,11 @@ public async Task RunCoreDryRunAsync() return; } var inspectedSource = _inspectedSource; + var inspectedCurrent = _inspectedCurrent; var inspectedTarget = _inspectedTarget; - if (inspectedSource is null || inspectedTarget is null) + if (inspectedSource is null + || inspectedTarget is null + || (IsRepairMode && inspectedCurrent is null)) { Fail(Copy.WriteUnavailable); return; @@ -718,8 +782,14 @@ public async Task RunCoreDryRunAsync() await RunOperationAsync(operation, async cancellationToken => { var sourceBefore = await _fingerprints.CaptureAsync(paths.Source, cancellationToken); + var currentBefore = IsRepairMode + ? await _fingerprints.CaptureAsync(paths.Current!, cancellationToken) + : null; var targetBefore = await _fingerprints.CaptureAsync(paths.Target, cancellationToken); - if (!inspectedSource.Matches(sourceBefore) || !inspectedTarget.Matches(targetBefore)) + if (!inspectedSource.Matches(sourceBefore) + || (IsRepairMode + && (currentBefore is null || !inspectedCurrent!.Matches(currentBefore))) + || !inspectedTarget.Matches(targetBefore)) { InvalidateCoreAuthorization(); throw new InvalidOperationException(Copy.FileChangedAfterDryRun); @@ -729,7 +799,9 @@ await RunOperationAsync(operation, async cancellationToken => { var arguments = new List { - "repair-converted", paths.Source, "--current", paths.Target, + "repair-converted", paths.Source, + "--current", paths.Current!, + "--output", paths.Target, }; string? extDataSource = null; if (IncludeGuildCards) @@ -753,9 +825,11 @@ await RunOperationAsync(operation, async cancellationToken => RequireStatus(result, "dry-run", "repair Dry Run"); var sourceSet = result.TryGetString("source_set_sha256"); var currentSet = result.TryGetString("current_set_sha256"); + var outputSet = result.TryGetString("output_set_sha256"); var preview = result.TryGetString("preview_sha256"); if (string.IsNullOrWhiteSpace(sourceSet) || string.IsNullOrWhiteSpace(currentSet) + || string.IsNullOrWhiteSpace(outputSet) || string.IsNullOrWhiteSpace(preview)) { throw new InvalidOperationException(Copy.FileChangedAfterDryRun); @@ -766,11 +840,13 @@ await RunOperationAsync(operation, async cancellationToken => RepairDetectionSummary = detection.Summary; _repairAuthorization = new RepairDryRunAuthorization( sourceBefore, + currentBefore!, targetBefore, extDataSource, _repairFromVersion, sourceSet, currentSet, + outputSet, preview, DateTimeOffset.UtcNow); } @@ -785,8 +861,14 @@ await RunOperationAsync(operation, async cancellationToken => } var sourceAfter = await _fingerprints.CaptureAsync(paths.Source, cancellationToken); + var currentAfter = IsRepairMode + ? await _fingerprints.CaptureAsync(paths.Current!, cancellationToken) + : null; var targetAfter = await _fingerprints.CaptureAsync(paths.Target, cancellationToken); - if (!sourceBefore.Matches(sourceAfter) || !targetBefore.Matches(targetAfter)) + if (!sourceBefore.Matches(sourceAfter) + || (currentBefore is not null + && (currentAfter is null || !currentBefore.Matches(currentAfter))) + || !targetBefore.Matches(targetAfter)) { throw new InvalidOperationException(Copy.FileChangedAfterDryRun); } @@ -848,10 +930,17 @@ public async Task WriteCoreAsync() await RunOperationAsync(operation, async cancellationToken => { var currentSource = await _fingerprints.CaptureAsync(paths.Source, cancellationToken); + var currentReference = IsRepairMode + ? await _fingerprints.CaptureAsync(paths.Current!, cancellationToken) + : null; var currentTarget = await _fingerprints.CaptureAsync(paths.Target, cancellationToken); var authorizedSource = IsRepairMode ? repairAuthorization!.Source : authorization!.Source; - var authorizedTarget = IsRepairMode ? repairAuthorization!.Current : authorization!.Target; - if (!authorizedSource.Matches(currentSource) || !authorizedTarget.Matches(currentTarget)) + var authorizedTarget = IsRepairMode ? repairAuthorization!.Output : authorization!.Target; + if (!authorizedSource.Matches(currentSource) + || (IsRepairMode + && (currentReference is null + || !repairAuthorization!.Current.Matches(currentReference))) + || !authorizedTarget.Matches(currentTarget)) { InvalidateCoreAuthorization(); throw new InvalidOperationException(Copy.FileChangedAfterDryRun); @@ -863,7 +952,8 @@ await RunOperationAsync(operation, async cancellationToken => var repairArguments = new List { "repair-converted", paths.Source, - "--current", paths.Target, + "--current", paths.Current!, + "--output", paths.Target, }; if (!string.IsNullOrWhiteSpace(repairAuthorization!.ExtDataSource)) { @@ -880,6 +970,7 @@ await RunOperationAsync(operation, async cancellationToken => "--write", "--expected-source-set-sha256", repairAuthorization.SourceSetSha256, "--expected-current-set-sha256", repairAuthorization.CurrentSetSha256, + "--expected-output-set-sha256", repairAuthorization.OutputSetSha256, "--expected-preview-sha256", repairAuthorization.PreviewSha256, }); var repairResult = await ExecuteAsync(operation, repairArguments, cancellationToken); @@ -1564,7 +1655,29 @@ private bool HasValidCorePaths() private bool TryResolveCorePaths(out CoreSavePaths paths, out SavePathResolutionError error) { - if (SavePathResolver.TryResolveCore(SourcePath, TargetPath, SelectedSlot, out var resolved, out error)) + CoreSavePaths? resolved; + bool resolvedSuccessfully; + if (IsRepairMode) + { + resolvedSuccessfully = SavePathResolver.TryResolveRepairCore( + SourcePath, + CurrentPath, + TargetPath, + SelectedSlot, + out resolved, + out error); + } + else + { + resolvedSuccessfully = SavePathResolver.TryResolveCore( + SourcePath, + TargetPath, + SelectedSlot, + out resolved, + out error); + } + + if (resolvedSuccessfully) { paths = resolved!; return true; @@ -1678,8 +1791,10 @@ private bool HasCecPaths() private void InvalidateCoreAuthorization() { _sourceInspected = false; + _currentInspected = false; _targetInspected = false; _inspectedSource = null; + _inspectedCurrent = null; _inspectedTarget = null; _coreAuthorization = null; _repairAuthorization = null; diff --git a/crates/mh3g-save-convert/src/main.rs b/crates/mh3g-save-convert/src/main.rs index c68cb73..26e7980 100644 --- a/crates/mh3g-save-convert/src/main.rs +++ b/crates/mh3g-save-convert/src/main.rs @@ -144,8 +144,13 @@ enum Command { /// Current same-numbered Wii U/Cemu slot after continued play. #[arg(long)] current: PathBuf, - /// Optional complete 3DS ExtData `user` directory. When present, the - /// current Cemu directory must contain all card*/quest* components. + /// Destination Wii U/Cemu slot for the repaired result. When omitted, + /// the legacy in-place behavior writes back to --current. + #[arg(long)] + output: Option, + /// Optional complete 3DS ExtData `user` directory. The current Cemu + /// directory must contain all card*/quest* components; a separate + /// output directory must already contain card1/card2/card3/cardbox. #[arg(long)] source_extdata_dir: Option, /// Override automatic historical-version classification. @@ -157,6 +162,9 @@ enum Command { /// Require the complete current Cemu input-set SHA-256 from Dry Run. #[arg(long, requires = "write")] expected_current_set_sha256: Option, + /// Require the selected output-state SHA-256 from Dry Run. + #[arg(long, requires = "write")] + expected_output_set_sha256: Option, /// Require the exact merge-preview SHA-256 from Dry Run. #[arg(long, requires = "write")] expected_preview_sha256: Option, @@ -267,6 +275,7 @@ struct CecConversionOptions { struct RepairWriteOptions { expected_source_set_sha256: Option, expected_current_set_sha256: Option, + expected_output_set_sha256: Option, expected_preview_sha256: Option, dry_run: bool, write: bool, @@ -414,7 +423,9 @@ struct RepairComponentReport { detection: RevisionDetection, merge: CompatibilityMerge, target: PathBuf, + target_sha256_before: Option, modified: bool, + write_required: bool, } struct RepairComponentInput { @@ -422,6 +433,7 @@ struct RepairComponentInput { source: Vec, current: Vec, target: PathBuf, + target_before: Option>, detection: RevisionDetection, } @@ -431,9 +443,11 @@ struct RepairConvertedReport { status: &'static str, source: PathBuf, current: PathBuf, + output: PathBuf, source_extdata_dir: Option, source_set_sha256: String, current_set_sha256: String, + output_set_sha256: String, preview_sha256: String, detection: RevisionDetection, components: Vec, @@ -442,16 +456,19 @@ struct RepairConvertedReport { compatibility_manifest: Option, } -const COMPATIBILITY_REPAIR_MANIFEST_VERSION: u32 = 1; +const COMPATIBILITY_REPAIR_MANIFEST_VERSION: u32 = 2; const COMPATIBILITY_REPAIR_MANIFEST_PREFIX: &str = ".mh3g-compatibility-repair-"; #[derive(Debug, Serialize, serde::Deserialize)] struct CompatibilityRepairManifest { version: u32, transaction_id: String, - current_dir: PathBuf, + #[serde(alias = "current_dir")] + output_dir: PathBuf, source_set_sha256: String, current_set_sha256: String, + #[serde(default)] + output_set_sha256: Option, preview_sha256: String, core_manifest: Option, extras_manifest: Option, @@ -573,10 +590,12 @@ fn run(cli: Cli) -> Result<(), ConversionError> { Command::RepairConverted { source, current, + output, source_extdata_dir, from_version, expected_source_set_sha256, expected_current_set_sha256, + expected_output_set_sha256, expected_preview_sha256, dry_run, write, @@ -585,11 +604,13 @@ fn run(cli: Cli) -> Result<(), ConversionError> { serde_json::to_string(&repair_converted( source, current, + output, source_extdata_dir, from_version, RepairWriteOptions { expected_source_set_sha256, expected_current_set_sha256, + expected_output_set_sha256, expected_preview_sha256, dry_run, write, @@ -1122,9 +1143,21 @@ fn read_file(path: &Path, operation: &'static str) -> Result, Conversion io_at_path(fs::read(path), operation, path) } +fn read_optional_file( + path: &Path, + operation: &'static str, +) -> Result>, ConversionError> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => io_at_path(Err(error), operation, path), + } +} + fn repair_converted( source: PathBuf, current: PathBuf, + output: Option, source_extdata_dir: Option, from_version: Option, options: RepairWriteOptions, @@ -1132,11 +1165,17 @@ fn repair_converted( debug_assert!(!(options.dry_run && options.write)); validate_slot_path(&source)?; validate_slot_path(¤t)?; - if source.file_name() != current.file_name() { + let require_output_expectation = output.is_some(); + let output_selection = output.unwrap_or_else(|| current.clone()); + validate_slot_path(&output_selection)?; + if source.file_name() != current.file_name() + || source.file_name() != output_selection.file_name() + { return Err(ConversionError::InvalidSave(format!( - "original 3DS and current Cemu slots must have the same basename: {} vs {}", + "original 3DS, current Cemu, and output slots must have the same basename: {}, {}, {}", source.display(), - current.display() + current.display(), + output_selection.display() ))); } let slot_name = source @@ -1148,14 +1187,34 @@ fn repair_converted( })?; let current_dir = io_at_path( fs::canonicalize(current_parent), - "resolving compatibility target directory", + "resolving compatibility current directory", current_parent, )?; + let output_parent = output_selection.parent().ok_or_else(|| { + ConversionError::InvalidSave("compatibility output slot has no parent directory".to_owned()) + })?; + let output_dir = io_at_path( + fs::canonicalize(output_parent), + "resolving compatibility output directory", + output_parent, + )?; + let output_path = output_dir.join(slot_name); let source_slot = read_file(&source, "reading original 3DS compatibility source")?; let current_slot = read_file(¤t, "reading current Cemu compatibility target")?; + let output_slot_before = + read_optional_file(&output_path, "reading compatibility output target")?; + if let Some(bytes) = output_slot_before.as_deref() + && inspect_bytes(bytes)?.profile != SaveProfile::JpCemu + { + return Err(ConversionError::InvalidSave(format!( + "compatibility output must be a Japanese Cemu slot or an absent user# path: {}", + output_path.display() + ))); + } let mut source_set = BTreeMap::from([(slot_name.to_owned(), source_slot.clone())]); let mut current_set = BTreeMap::from([(slot_name.to_owned(), current_slot.clone())]); + let mut output_set = BTreeMap::from([(slot_name.to_owned(), output_slot_before.clone())]); let slot_detection = detect_component_revision(&source_slot, ¤t_slot, slot_name)?; let mut repair_inputs = vec![RepairComponentInput { @@ -1163,7 +1222,8 @@ fn repair_converted( detection: slot_detection, source: source_slot.clone(), current: current_slot.clone(), - target: current.clone(), + target: output_path.clone(), + target_before: output_slot_before, }]; let mut preserved_components = Vec::new(); @@ -1198,6 +1258,22 @@ fn repair_converted( current_set.insert(component.to_owned(), current_bytes.clone()); if matches!(component, "card1" | "card2" | "card3" | "cardbox") { + let output_path = output_dir.join(component); + let output_bytes = read_optional_file( + &output_path, + "reading compatibility ExtData output target", + )? + .ok_or_else(|| { + ConversionError::InvalidSave(format!( + "compatibility output ExtData component is missing; choose an initialized Wii U/Cemu output directory: {}", + output_path.display() + )) + })?; + mh3g_save_convert::converter::validate_cemu_external_component_named( + &output_bytes, + component, + )?; + output_set.insert(component.to_owned(), Some(output_bytes.clone())); let detection = detect_component_revision(&source_bytes, ¤t_bytes, component)?; repair_inputs.push(RepairComponentInput { @@ -1205,7 +1281,8 @@ fn repair_converted( detection, source: source_bytes, current: current_bytes, - target: current_path, + target: output_path, + target_before: Some(output_bytes), }); } else { preserved_components.push(component.to_owned()); @@ -1224,21 +1301,30 @@ fn repair_converted( .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(&( &source_set_sha256, ¤t_set_sha256, + &output_set_sha256, &detection, &components, &preserved_components, @@ -1256,6 +1342,13 @@ fn repair_converted( ¤t_set_sha256, "current set", )?; + if require_output_expectation { + 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, @@ -1266,17 +1359,17 @@ fn repair_converted( let mut manifests = Vec::new(); let mut core_manifest = None; let mut extras_manifest = None; - if options.write && components[0].modified { - let manifest_path = manifest_path_for_target(¤t)?; + if options.write && components[0].write_required { + let manifest_path = manifest_path_for_target(&output_path)?; install_compatibility_merge_with_expectations( &source_slot, &components[0].merge.bytes, - ¤t, + &output_path, &manifest_path, InstallExpectations { source_sha256: Some(components[0].merge.source_sha256.as_str()), - target_sha256: Some(components[0].merge.current_sha256.as_str()), - target_must_be_absent: false, + target_sha256: components[0].target_sha256_before.as_deref(), + target_must_be_absent: components[0].target_sha256_before.is_none(), }, )?; manifests.push(manifest_path.clone()); @@ -1293,11 +1386,13 @@ fn repair_converted( ) }) .collect::>(); - let cards_modified = card_components.iter().any(|component| component.modified); - if options.write && cards_modified { - let staging_parent = current_dir.parent().ok_or_else(|| { + let cards_write_required = card_components + .iter() + .any(|component| component.write_required); + if options.write && cards_write_required { + let staging_parent = output_dir.parent().ok_or_else(|| { ConversionError::InvalidSave( - "current Cemu save directory has no parent for compatibility staging".to_owned(), + "output Cemu save directory has no parent for compatibility staging".to_owned(), ) })?; let staging_dir = staging_parent.join(format!(".mh3g-compat-staging-{}", Uuid::new_v4())); @@ -1327,10 +1422,10 @@ fn repair_converted( )?; } let groups = [ExtraGroup::GuildCards]; - let dry_run = dry_run_extra_groups(&staging_dir, ¤t_dir, &groups, None, None)?; + let dry_run = dry_run_extra_groups(&staging_dir, &output_dir, &groups, None, None)?; install_extra_groups( &staging_dir, - ¤t_dir, + &output_dir, &groups, Some(&dry_run.staging_set_sha256), Some(&dry_run.target_set_sha256), @@ -1356,18 +1451,19 @@ fn repair_converted( } } - let any_modified = components.iter().any(|component| component.modified); + let any_write_required = components.iter().any(|component| component.write_required); let compatibility_manifest = if options.write && !manifests.is_empty() { let transaction_id = Uuid::new_v4().hyphenated().to_string(); - let manifest_path = current_dir.join(format!( + let manifest_path = output_dir.join(format!( "{COMPATIBILITY_REPAIR_MANIFEST_PREFIX}{transaction_id}.json" )); let manifest = CompatibilityRepairManifest { version: COMPATIBILITY_REPAIR_MANIFEST_VERSION, transaction_id, - current_dir: current_dir.clone(), + output_dir: output_dir.clone(), source_set_sha256: source_set_sha256.clone(), current_set_sha256: current_set_sha256.clone(), + output_set_sha256: Some(output_set_sha256.clone()), preview_sha256: preview_sha256.clone(), core_manifest: core_manifest.clone(), extras_manifest: extras_manifest.clone(), @@ -1405,7 +1501,7 @@ fn repair_converted( Ok(RepairConvertedReport { operation: "repair-converted", status: if options.write { - if any_modified { + if any_write_required { "written" } else { "no-changes" @@ -1415,9 +1511,11 @@ fn repair_converted( }, source, current, + output: output_selection, source_extdata_dir, source_set_sha256, current_set_sha256, + output_set_sha256, preview_sha256, detection, components, @@ -1484,9 +1582,9 @@ fn rollback_repair(manifest_path: PathBuf) -> Result Result {} + (COMPATIBILITY_REPAIR_MANIFEST_VERSION, Some(hash)) + if hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) => {} + _ => { + return Err(ConversionError::InvalidSave( + "compatibility repair output set SHA-256 is invalid".to_owned(), + )); + } + } for child in [ manifest.core_manifest.as_deref(), manifest.extras_manifest.as_deref(), @@ -1601,6 +1709,23 @@ fn component_set_sha256(components: &BTreeMap>) -> String { hex::encode(digest.finalize()) } +fn component_state_set_sha256(components: &BTreeMap>>) -> String { + let mut digest = Sha256::new(); + digest.update(b"mh3g-compatibility-output-state-v1\0"); + for (name, bytes) in components { + digest.update((name.len() as u64).to_be_bytes()); + digest.update(name.as_bytes()); + match bytes { + Some(bytes) => { + digest.update([1]); + digest.update(sha256_hex(bytes).as_bytes()); + } + None => digest.update([0]), + } + } + hex::encode(digest.finalize()) +} + fn require_repair_expectation( expected: Option<&str>, observed: &str, @@ -1803,6 +1928,40 @@ mod tests { assert!(error.to_string().contains(env!("CARGO_PKG_VERSION"))); } + #[test] + fn compatibility_manifest_v2_renames_output_directory_but_reads_v1_alias() { + let legacy: CompatibilityRepairManifest = serde_json::from_str( + r#"{ + "version": 1, + "transaction_id": "legacy-id", + "current_dir": "legacy-output", + "source_set_sha256": "source", + "current_set_sha256": "current", + "preview_sha256": "preview", + "core_manifest": "core.json", + "extras_manifest": null + }"#, + ) + .unwrap(); + assert_eq!(legacy.output_dir, PathBuf::from("legacy-output")); + assert_eq!(legacy.output_set_sha256, None); + + let current = CompatibilityRepairManifest { + version: COMPATIBILITY_REPAIR_MANIFEST_VERSION, + transaction_id: "current-id".to_owned(), + output_dir: PathBuf::from("repaired-output"), + source_set_sha256: "source".to_owned(), + current_set_sha256: "current".to_owned(), + output_set_sha256: Some("output".to_owned()), + preview_sha256: "preview".to_owned(), + core_manifest: Some(PathBuf::from("core.json")), + extras_manifest: None, + }; + let encoded = serde_json::to_value(current).unwrap(); + assert_eq!(encoded["output_dir"], "repaired-output"); + assert!(encoded.get("current_dir").is_none()); + } + #[test] fn convert_extras_accepts_an_explicit_guild_card_reset_flag() { let parsed = Cli::try_parse_from([ diff --git a/crates/mh3g-save-convert/tests/cli.rs b/crates/mh3g-save-convert/tests/cli.rs index bda6f71..3300770 100644 --- a/crates/mh3g-save-convert/tests/cli.rs +++ b/crates/mh3g-save-convert/tests/cli.rs @@ -369,6 +369,271 @@ fn repair_converted_dry_run_then_write_repairs_only_an_old_lamp_field() { assert_eq!(fs::read(¤t_path).unwrap(), before_repair); } +#[test] +fn repair_converted_can_read_current_and_write_a_separate_output() { + #[cfg(target_os = "macos")] + let _guard = PROCESS_GUARD.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let source_path = slot_fixture(&temp, "user2"); + let mut source = fs::read(&source_path).unwrap(); + let source_lamp = JP_3DS_HEADER.len() + 0x6F44 + 0xE4; + source[source_lamp..source_lamp + 2].copy_from_slice(&[0x1E, 0x00]); + fs::write(&source_path, &source).unwrap(); + let current_path = temp.path().join("played-cemu").join("user2"); + let output_path = temp.path().join("repaired-export").join("user2"); + fs::create_dir_all(current_path.parent().unwrap()).unwrap(); + fs::create_dir_all(output_path.parent().unwrap()).unwrap(); + + let mut current = convert_3ds_to_cemu_named(&source, "user2").unwrap(); + let lamp = JP_CEMU_HEADER.len() + 0x6F44 + 0xE4; + current[lamp..lamp + 2].copy_from_slice(&source[source_lamp..source_lamp + 2]); + let unrelated = JP_CEMU_HEADER.len() + 0x240; + current[unrelated] ^= 0x5A; + let current_before = current.clone(); + fs::write(¤t_path, ¤t).unwrap(); + + let dry = run_json(&[ + "repair-converted".into(), + source_path.to_string_lossy().into_owned(), + "--current".into(), + current_path.to_string_lossy().into_owned(), + "--output".into(), + output_path.to_string_lossy().into_owned(), + "--from-version".into(), + "0.0.5".into(), + "--dry-run".into(), + ]); + assert_eq!(dry["status"], "dry-run"); + assert_eq!(dry["output"], output_path.to_string_lossy().as_ref()); + assert_eq!(dry["components"][0]["write_required"], true); + assert!(!output_path.exists()); + assert_eq!(fs::read(¤t_path).unwrap(), current_before); + + let written = run_json_with_stopped_emulators(&[ + "repair-converted".into(), + source_path.to_string_lossy().into_owned(), + "--current".into(), + current_path.to_string_lossy().into_owned(), + "--output".into(), + output_path.to_string_lossy().into_owned(), + "--from-version".into(), + "0.0.5".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"); + assert_eq!(fs::read(¤t_path).unwrap(), current_before); + let installed = fs::read(&output_path).unwrap(); + assert_eq!(&installed[lamp..lamp + 2], &[0x00, 0x1E]); + assert_eq!(installed[unrelated], current_before[unrelated]); + + let compatibility_manifest = written["compatibility_manifest"] + .as_str() + .expect("separate-output repair has a coordinator manifest"); + let rolled_back = run_json_with_stopped_emulators(&[ + "rollback-repair".into(), + "--manifest".into(), + compatibility_manifest.to_owned(), + ]); + assert_eq!(rolled_back["status"], "rolled-back"); + assert!(!output_path.exists()); + assert_eq!(fs::read(¤t_path).unwrap(), current_before); +} + +#[test] +fn repair_converted_rejects_a_separate_output_changed_after_dry_run() { + #[cfg(target_os = "macos")] + let _guard = PROCESS_GUARD.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let source_path = slot_fixture(&temp, "user2"); + let source = fs::read(&source_path).unwrap(); + let current_path = target_slot(&temp, "user2"); + let output_dir = temp.path().join("separate-output"); + let output_path = output_dir.join("user2"); + fs::create_dir_all(&output_dir).unwrap(); + let current = convert_3ds_to_cemu_named(&source, "user2").unwrap(); + fs::write(¤t_path, ¤t).unwrap(); + + let dry = run_json(&[ + "repair-converted".into(), + source_path.to_string_lossy().into_owned(), + "--current".into(), + current_path.to_string_lossy().into_owned(), + "--output".into(), + output_path.to_string_lossy().into_owned(), + "--from-version".into(), + "0.0.6".into(), + "--dry-run".into(), + ]); + fs::write(&output_path, ¤t).unwrap(); + + let output = run_output_with_stopped_emulators(&[ + "repair-converted".into(), + source_path.to_string_lossy().into_owned(), + "--current".into(), + current_path.to_string_lossy().into_owned(), + "--output".into(), + output_path.to_string_lossy().into_owned(), + "--from-version".into(), + "0.0.6".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!(!output.status.success()); + assert_eq!(fs::read(¤t_path).unwrap(), current); + assert_eq!(fs::read(&output_path).unwrap(), current); +} + +#[test] +fn repair_converted_rejects_an_existing_non_cemu_output_slot() { + let temp = tempfile::tempdir().unwrap(); + let source_path = slot_fixture(&temp, "user2"); + let source = fs::read(&source_path).unwrap(); + let current_path = target_slot(&temp, "user2"); + let output_dir = temp.path().join("invalid-output"); + let output_path = output_dir.join("user2"); + fs::create_dir_all(&output_dir).unwrap(); + fs::write( + ¤t_path, + convert_3ds_to_cemu_named(&source, "user2").unwrap(), + ) + .unwrap(); + fs::write(&output_path, b"not a Cemu slot").unwrap(); + + let output = run_output_with_stopped_emulators(&[ + "repair-converted".into(), + source_path.to_string_lossy().into_owned(), + "--current".into(), + current_path.to_string_lossy().into_owned(), + "--output".into(), + output_path.to_string_lossy().into_owned(), + "--from-version".into(), + "0.0.6".into(), + "--dry-run".into(), + ]); + + assert!(!output.status.success()); + assert_eq!(fs::read(&output_path).unwrap(), b"not a Cemu slot"); +} + +#[test] +fn repair_converted_writes_guild_cards_to_the_separate_output_only() { + #[cfg(target_os = "macos")] + let _guard = PROCESS_GUARD.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let source_path = slot_fixture(&temp, "user2"); + let source_slot = fs::read(&source_path).unwrap(); + let current_dir = temp.path().join("played-cemu"); + let output_dir = temp.path().join("repair-output"); + fs::create_dir_all(¤t_dir).unwrap(); + fs::create_dir_all(&output_dir).unwrap(); + let current_path = current_dir.join("user2"); + let output_path = output_dir.join("user2"); + fs::write( + ¤t_path, + convert_3ds_to_cemu_named(&source_slot, "user2").unwrap(), + ) + .unwrap(); + + let extdata = extras_fixture(&temp); + let card1_path = extdata.join("card1"); + let mut card1_source = fs::read(&card1_path).unwrap(); + let card_row = JP_3DS_HEADER.len() + 0x7C0; + card1_source[card_row..card_row + 2].copy_from_slice(&[0x01, 0x00]); + card1_source[card_row + 8] = 0; + fs::write(&card1_path, &card1_source).unwrap(); + + for component in [ + "card1", "card2", "card3", "cardbox", "quest1", "quest2", "quest3", "quest4", + ] { + let source_bytes = fs::read(extdata.join(component)).unwrap(); + let mut current_bytes = + convert_external_component_to_cemu_named(&source_bytes, component).unwrap(); + if component == "card1" { + current_bytes[JP_CEMU_HEADER.len() + 0x7C0 + 8] = 0; + } + fs::write(current_dir.join(component), ¤t_bytes).unwrap(); + if matches!(component, "card1" | "card2" | "card3" | "cardbox") { + fs::write(output_dir.join(component), ¤t_bytes).unwrap(); + } + } + let current_card1_before = fs::read(current_dir.join("card1")).unwrap(); + let output_card1_before = fs::read(output_dir.join("card1")).unwrap(); + + let dry = run_json(&[ + "repair-converted".into(), + source_path.to_string_lossy().into_owned(), + "--current".into(), + current_path.to_string_lossy().into_owned(), + "--output".into(), + output_path.to_string_lossy().into_owned(), + "--source-extdata-dir".into(), + extdata.to_string_lossy().into_owned(), + "--dry-run".into(), + ]); + assert_eq!(dry["status"], "dry-run"); + + let written = run_json_with_stopped_emulators(&[ + "repair-converted".into(), + source_path.to_string_lossy().into_owned(), + "--current".into(), + current_path.to_string_lossy().into_owned(), + "--output".into(), + output_path.to_string_lossy().into_owned(), + "--source-extdata-dir".into(), + extdata.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"); + assert_eq!( + fs::read(current_dir.join("card1")).unwrap(), + current_card1_before + ); + assert_eq!( + fs::read(output_dir.join("card1")).unwrap()[JP_CEMU_HEADER.len() + 0x7C0 + 8], + 0x80 + ); + + let compatibility_manifest = written["compatibility_manifest"].as_str().unwrap(); + let rolled_back = run_json_with_stopped_emulators(&[ + "rollback-repair".into(), + "--manifest".into(), + compatibility_manifest.to_owned(), + ]); + assert_eq!(rolled_back["status"], "rolled-back"); + assert!(!output_path.exists()); + assert_eq!( + fs::read(output_dir.join("card1")).unwrap(), + output_card1_before + ); + assert_eq!( + fs::read(current_dir.join("card1")).unwrap(), + current_card1_before + ); +} + #[test] fn repair_converted_write_rejects_a_current_save_changed_after_dry_run() { #[cfg(target_os = "macos")] diff --git a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md index 846e97a..dbff05d 100644 --- a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md +++ b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md @@ -89,12 +89,20 @@ candidate/cache data. `convert` never automatically opens `system`, 0.0.6 and may have been played further: ```text -mh3g-save-convert repair-converted <3DS-user#> --current \ +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>] \ [--dry-run | --write --expected-source-set-sha256 \ - --expected-current-set-sha256 --expected-preview-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 @@ -105,7 +113,9 @@ 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#`. The `user#` and four `card*` components are +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*`. @@ -114,8 +124,9 @@ 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 must submit the immediate Dry Run's `source_set_sha256`, -`current_set_sha256`, and `preview_sha256`. Success returns +`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. @@ -250,7 +261,7 @@ 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 ` | Original 3DS slot, current Cemu slot, and optional complete 3DS/Cemu ExtData sets | Nothing | Only the same-named `user#` and complete guild-card group fields proven to need repair, plus a coordinator manifest; quest files remain unchanged | +| `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 | | `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 | 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 ed5514f..6fed30d 100644 --- a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md +++ b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md @@ -61,17 +61,24 @@ CLI 只接受普通文件系统中的文件和目录。它不能打开 ZIP、7z `repair-converted` 与全新 `convert` 是两个独立契约。它需要一个原始 3DS `user#` 和一个已经由 0.0.3 至 0.0.6 转换、之后可能继续游玩的同名 Cemu `user#`: ```text -mh3g-save-convert repair-converted <3DS-user#> --current \ +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>] \ [--dry-run | --write --expected-source-set-sha256 \ - --expected-current-set-sha256 --expected-preview-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 始终独立显示并显式传入输出路径。 + 该操作以当前 Cemu 数据为继续游玩的权威数据,只对 0.0.3 至 0.0.6 之间已知变化的完整语义字段执行三方比较。当前字段仍等于历史版本输出时才替换为当前转换器输出;若当前字段不同于历史值和当前转换器值,则按 Wii U 后续进度保留并报告冲突。它不会按字节盲合并,也不会重建整个 Cemu 槽位。 -仅修核心槽位时不传 `--source-extdata-dir`。修复公会名片时,该目录必须含全部八个 3DS ExtData 文件,当前 `user#` 的父目录也必须含全部八个 Cemu 文件。`user#` 和四个 `card*` 是可修复组件;`quest1` 至 `quest4` 会被验证并纳入集合 SHA-256,但逐字节保持当前 Cemu 内容。该命令不处理 `system`、`cec` 或 `phrase*`。 +仅修核心槽位时不传 `--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` 拒绝修复。写入必须提交紧邻 Dry Run 返回的 `source_set_sha256`、`current_set_sha256` 与 `preview_sha256`。成功时返回 `.mh3g-compatibility-repair-.json`;`rollback-repair --manifest ` 会按“公会名片子事务,再核心子事务”的顺序恢复。 +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 ` 会按“公会名片子事务,再核心子事务”的顺序恢复。 ### 可选的共享 `system` @@ -152,7 +159,7 @@ CEC 既不属于 `user#`,也不属于 `card*`,正常公会名片/离线伙 | `inspect-progress [--target ]` | 源槽位和可选目标槽位 | 无 | 不适用 | | `inspect-events [--target ]` | 源槽位和可选目标槽位 | 无 | 不适用 | | `convert --output ` | 源槽位;只有安装时才读取已有目标和旧事务记录 | 无 | 指定目标槽位及下述核心事务文件 | -| `repair-converted <3DS-user#> --current ` | 原始 3DS 槽位、当前 Cemu 槽位;可选完整 3DS/Cemu ExtData 集合 | 无 | 只改变报告中确认需要修复的同名 `user#` 和完整公会名片组,并创建协调 manifest;任务文件保持不变 | +| `repair-converted <3DS-user#> --current <当前-Cemu-user#> --output <修复后-Cemu-user#>` | 原始 3DS 槽位、只读当前 Cemu 槽位、独立输出槽位;可选完整 3DS/当前 Cemu ExtData 集合 | 无 | 只改变输出侧报告中确认需要修复的同名 `user#` 和完整公会名片组,并创建协调 manifest;当前引用与任务文件保持不变 | | `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 恢复事务 | diff --git a/docs/adr/0015-mh3g-compatibility-save-merge.md b/docs/adr/0015-mh3g-compatibility-save-merge.md index 43ca3d5..cc0e0b6 100644 --- a/docs/adr/0015-mh3g-compatibility-save-merge.md +++ b/docs/adr/0015-mh3g-compatibility-save-merge.md @@ -158,14 +158,17 @@ the installed set hash and restores the complete pre-merge snapshot. The implemented CLI uses separate commands: ```text -repair-converted <3DS-user#> --current \ +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>] --dry-run -repair-converted <3DS-user#> --current \ +repair-converted <3DS-user#> --current \ + --output \ [--source-extdata-dir <3DS-ExtData-user>] \ [--from-version ] --write \ --expected-source-set-sha256 ... \ --expected-current-set-sha256 ... \ + --expected-output-set-sha256 ... \ --expected-preview-sha256 ... rollback-repair --manifest ... ``` @@ -180,12 +183,17 @@ macOS SwiftUI and Windows WinUI expose a first-step mode selector: Repair mode guides the user in order: 1. “选择当时使用的原始 3DS 存档” -2. “已经转换并继续游玩?添加当前 Wii U/Cemu 存档目录或同槽位文件” -3. “可选:添加原始 3DS ExtData 以修复已收到的公会名片” -4. inspect and show detected release/confidence; -5. show exact preserved/repaired/conflicting components; -6. dry-run; -7. guarded write and rollback location. +2. “添加继续游玩后的当前 Wii U/Cemu 存档(只读引用)” +3. “选择修复结果的输出文件或目录” +4. “可选:添加原始 3DS ExtData 以修复已收到的公会名片” +5. inspect and show detected release/confidence; +6. show exact preserved/repaired/conflicting components; +7. dry-run; +8. guarded write and rollback location. + +The native interfaces always keep current/reference and output as two visible +controls. The CLI alone retains omitted-`--output` in-place behavior for old +scripts; it is not used as a UI shortcut. The interfaces default to system language, retain manual Chinese/English switching, and keep feature parity. The write button remains unavailable until @@ -215,7 +223,7 @@ Core tests must include: - proof that unrelated Wii U bytes remain byte-identical; - field-atomic conflicts, including one changed byte inside a multi-byte field; - complete guild-card group behavior and incomplete-group rejection; -- source/current/preview hash race rejection; +- source/current/output/preview hash race rejection; - injected failure after each replacement and complete rollback; - idempotence: applying the same merge twice produces identical bytes; - all existing conversion, ExtData, CEC, CLI, macOS presentation, and Windows @@ -227,10 +235,12 @@ separately; the implementation must not launch Cemu automatically. ## Migration and rollback -The compatibility merge introduces a new manifest version and does not reuse -the single-file conversion manifest. Existing 0.0.3-0.0.6 manifests remain +The compatibility merge does not reuse the single-file conversion manifest. +Coordinator manifest version 2 records the output-state hash introduced by the +three-path flow; `rollback-repair` continues accepting version 1 manifests +created by the original in-place flow. Existing 0.0.3-0.0.6 manifests remain valid for their original rollback commands. -The feature is additive. Removing 0.0.7 leaves old conversion commands -unchanged; compatibility-merged saves are restored through their retained -merge manifest and snapshots. +The feature is additive. Existing conversion commands remain unchanged; +compatibility-merged saves are restored through their retained merge manifest +and snapshots. diff --git a/scripts/mh3g-docs-contract.py b/scripts/mh3g-docs-contract.py index 04273b1..c20433f 100644 --- a/scripts/mh3g-docs-contract.py +++ b/scripts/mh3g-docs-contract.py @@ -23,6 +23,7 @@ "inspect-events", "inspect-cec", "convert", + "repair-converted", "convert-system", "convert-extras", "install-extras", @@ -133,6 +134,12 @@ def main() -> int: failures.append( f"{relative_path}: stale ExtData installer claim: {claim}" ) + for token, purpose in ( + ("--current", "read-only current Wii U compatibility input"), + ("--output", "independent compatibility repair output"), + ("expected-output-set-sha256", "output-state Dry Run binding"), + ): + require_contains(failures, relative_path, content, token, purpose) for relative_path, content in root_docs.items(): if content is None: @@ -146,6 +153,12 @@ def main() -> int: ) require_contains( failures, relative_path, content, CEC_SUFFIX, "CEC input suffix") + for token, purpose in ( + ("--current", "read-only current Wii U compatibility input"), + ("--output", "independent compatibility repair output"), + ("expected-output-set-sha256", "output-state Dry Run binding"), + ): + require_contains(failures, relative_path, content, token, purpose) for claim in DIRECT_ZIP_CLAIMS: if claim in content: failures.append(f"{relative_path}: unsupported direct ZIP claim: {claim}") diff --git a/scripts/verify-mh3g-save-converter-windows-source.py b/scripts/verify-mh3g-save-converter-windows-source.py index 7fd0a2c..9009b35 100644 --- a/scripts/verify-mh3g-save-converter-windows-source.py +++ b/scripts/verify-mh3g-save-converter-windows-source.py @@ -474,12 +474,16 @@ def main() -> int: for expected in ( "ConversionMode.RepairConverted", "RepairDryRunAuthorization", - '"repair-converted", paths.Source, "--current", paths.Target', + '"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"', + '"--expected-output-set-sha256"', '"--expected-preview-sha256"', + 'result.TryGetString("output_set_sha256")', 'TryGetProperty("detection", out var detection)', 'TryGetString("compatibility_manifest")', '"rollback-repair"', @@ -489,7 +493,12 @@ def main() -> int: resolver = read("Models/SavePathResolution.cs") for expected in ( "TryResolveSource", + "TryResolveCurrent", "TryResolveTarget", + "TryResolveRepairCore", + "MissingCurrentSelection", + "CurrentSlotMissing", + "CurrentSlotNameMismatch", "TryResolveExtDataUserDirectory", "Path.Combine(fullPath, slot)", "Path.GetFileName(fullPath)", @@ -520,17 +529,27 @@ def main() -> int: inspect_core = public_method_body(workflow, "InspectCoreAsync") for expected in ( "var sourceAtInspection = await _fingerprints.CaptureAsync(paths.Source, cancellationToken);", + "var currentAtInspection = IsRepairMode", + "await _fingerprints.CaptureAsync(paths.Current!, cancellationToken)", "var targetAtInspection = await _fingerprints.CaptureAsync(paths.Target, cancellationToken);", - "if (!sourceAtInspection.Matches(sourceAfterInspection) || !targetAtInspection.Matches(targetAfterInspection))", + "!sourceAtInspection.Matches(sourceAfterInspection)", + "!currentAtInspection.Matches(currentAfterInspection)", + "!targetAtInspection.Matches(targetAfterInspection)", "_inspectedSource = sourceAfterInspection;", + "_inspectedCurrent = currentAfterInspection;", "_inspectedTarget = targetAfterInspection;", ): require(expected in inspect_core, f"core Inspect is missing stable target intent validation {expected}") for expected in ( "var inspectedSource = _inspectedSource", + "var inspectedCurrent = _inspectedCurrent", "var inspectedTarget = _inspectedTarget", - "if (!inspectedSource.Matches(sourceBefore) || !inspectedTarget.Matches(targetBefore))", - "!sourceBefore.Matches(sourceAfter) || !targetBefore.Matches(targetAfter)", + "!inspectedSource.Matches(sourceBefore)", + "!inspectedCurrent!.Matches(currentBefore)", + "!inspectedTarget.Matches(targetBefore)", + "!sourceBefore.Matches(sourceAfter)", + "!currentBefore.Matches(currentAfter)", + "!targetBefore.Matches(targetAfter)", ): require(expected in core_dry_run, f"core Dry Run must preserve inspected target intent {expected}") @@ -587,6 +606,16 @@ def main() -> int: "WinUI must avoid an App-resource converter that dotnet publish cannot resolve", ) require('Click="GoToOptionalConfiguration_Click"' in window, "post-Inspect guidance must lead to optional setup") + for expected in ( + 'x:Name="CurrentPathBox"', + 'Visibility="{Binding RepairCurrentVisibility}"', + 'Text="{Binding CoreTargetTitle}"', + 'Text="{Binding CoreTargetHint}"', + 'PlaceholderText="{Binding CoreTargetPlaceholder}"', + 'Click="ChooseCurrentFile_Click"', + 'Click="ChooseCurrentFolder_Click"', + ): + require(expected in window, f"repair mode must expose independent current/output controls: {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") @@ -606,6 +635,13 @@ def main() -> int: and "ReferenceEquals(sender, SourcePathBox)" in code_behind, "WinUI TextChanged routing must use explicit reference equality", ) + for expected in ( + "private async void ChooseCurrentFile_Click", + "private async void ChooseCurrentFolder_Click", + "ReferenceEquals(sender, CurrentPathBox)", + "ViewModel.CurrentPath = CurrentPathBox.Text;", + ): + require(expected in code_behind, f"independent Wii U reference picker is missing {expected}") write_core = public_method_body(workflow, "WriteCoreAsync") require( "var repairArguments = new List" in write_core