From e7f7a8a4316ccb82b497c5dd903eca55a9ea9635 Mon Sep 17 00:00:00 2001 From: vincentadamnemessis Date: Mon, 10 Aug 2026 15:24:10 +0800 Subject: [PATCH 1/6] test(windows): satisfy current cfg clippy checks --- crates/mh3g-save-convert/tests/extras_transaction.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/mh3g-save-convert/tests/extras_transaction.rs b/crates/mh3g-save-convert/tests/extras_transaction.rs index 0fc1d88..1837891 100644 --- a/crates/mh3g-save-convert/tests/extras_transaction.rs +++ b/crates/mh3g-save-convert/tests/extras_transaction.rs @@ -582,7 +582,7 @@ impl ExtraFileOperations for BackupCollision { #[cfg(windows)] { let _ = (&self.collided, path, bytes); - return StdExtraFileOperations.write_new_file(path, bytes); + StdExtraFileOperations.write_new_file(path, bytes) } #[cfg(not(windows))] @@ -650,7 +650,7 @@ impl ExtraFileOperations for PanicAfterFirstBackup { #[cfg(windows)] { let _ = (&self.backups, path, bytes); - return StdExtraFileOperations.write_new_file(path, bytes); + StdExtraFileOperations.write_new_file(path, bytes) } #[cfg(not(windows))] From 3eff31ca911058e36ac1c20efc99d3f0cf01a17f Mon Sep 17 00:00:00 2001 From: vincentadamnemessis Date: Mon, 10 Aug 2026 15:24:10 +0800 Subject: [PATCH 2/6] fix(converter): align guide and appearance records --- README.md | 24 ++- README.zh-CN.md | 12 +- .../ConverterPresentation/Localization.swift | 4 +- .../Infrastructure/ConverterCopy.cs | 4 +- crates/mh3g-save-convert/src/compatibility.rs | 114 +++++++++-- crates/mh3g-save-convert/src/converter.rs | 27 ++- crates/mh3g-save-convert/src/revision.rs | 6 +- crates/mh3g-save-convert/src/transforms.rs | 182 +++++++++++++++++- docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md | 15 +- docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md | 4 +- 10 files changed, 336 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 9de81e4..8eab6f3 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ child. CEC is a system NAND mailbox, not SD-card ExtData. | Data group | Give this exact input to the CLI | Do **not** give it | Required? | Purpose / affected files | | --- | --- | --- | --- | --- | | Core slot | One explicit `user1`, `user2`, or `user3` file under `title/00040000/00048100/data/00000001/` | The title directory, all slots, ExtData, a ZIP | Yes: choose one slot | Character, story/progress, farm, fleet, local offline-hunter data; writes only the named same-number Cemu `user#` target | -| Shared system | One explicit `system` file in the same title savedata directory | The whole title directory, a ZIP | Optional | Shared system data; writes only the named Cemu `system` target | +| Shared system | One explicit `system` file in the same title savedata directory | The whole title directory, a ZIP | Optional | Housekeeper gallery/movie unlock history and other shared settings; writes only the named Cemu `system` target | | Shared ExtData | The complete `extdata/00000000/00000481/user/` directory containing `card1`, `card2`, `card3`, `cardbox`, `quest1`, `quest2`, `quest3`, `quest4` directly inside | The `00000481` parent, `boss/`, a partial set, a ZIP | Optional | Converts all eight files into a new staging directory; a separate guarded `install-extras` transaction can install complete `guild-cards`, `quests`, or both into an initialized Cemu target | | StreetPass / Hunter Search CEC | The exact `CEC/00048100/` directory containing `InBox___` | SD-card ExtData, the `InBox___` child alone, a ZIP | Optional and experimental | Reads received raw StreetPass records and can write only Cemu `cec` | @@ -192,6 +192,22 @@ through `quest4`, pass that folder only to `convert-extras`. If it contains an extra wrapper directory, enter it first; the expected filenames must be immediate children of the CLI path or the narrow GUI selection described above. +If the 3DS `system` file is omitted, a core `user#` conversion cannot migrate +the housekeeper's gallery/movie unlock history. The converter deliberately +leaves any existing Cemu `system` unchanged; use the separate guarded +`convert-system` transaction when those shared records are part of the move. + +### Legacy Wii U save-editor caveat + +Talisman skill points are signed one-byte values in the equipment record. Some +older Wii U editors read them as unsigned bytes, so a legitimate `-5` appears +as `251`. The converter preserves the raw signed byte and converts only the +record fields whose byte order actually differs. Some legacy armor writers +also rebuild an armor header without preserving its RGB bytes; editing armor +with such a tool can turn its pigment black even when the converted talisman is +valid. These editor behaviors must not be "fixed" by clamping or rewriting +otherwise valid converted equipment records. + ### Before you write: paths, inspection, and dry-run The examples below run from this repository after Rust is installed. Define a @@ -375,7 +391,7 @@ mh3g-save-convert repair-converted --current --current <当前-Cemu-use --expected-current-set-sha256 --expected-preview-sha256 ] ``` -该命令用于“曾用 0.0.3 至 0.0.6 转换,之后又在 Wii U/Cemu 中继续游玩”的存档。它不会把当前存档整体替换为旧 3DS 状态,而是为每个已知历史转换字段比较“旧版预期值、当前值、0.0.7 预期值”:仍等于旧版结果的字段才修复;已在 Wii U 侧变化的整字段保留并报告冲突。因此当前 HR、装备、素材、仓库、任务进度、农场、狩猎船和其他继续游玩的数据以当前 Cemu 存档为准。 +该命令用于“曾用 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 文件。0.0.7 会字段级修复 `user#`、`card1`、`card2`、`card3`、`cardbox`;`quest1` 至 `quest4` 只参与集合校验并逐字节保留。`system`、`cec`、`phrase1` 至 `phrase3` 和未知文件不会被该命令读取或写入。 +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` 和未知文件不会被该命令读取或写入。 先运行只读预览: diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift index 5d707c1..216355d 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift @@ -198,7 +198,7 @@ public enum ConverterCopy { "Components.QuestsDetail": "迁移任务数据。", "Components.SystemSource": "3DS system", "Components.SystemTarget": "Cemu system", - "Components.SystemFooter": "已选择的 system 只对应一个明确文件,不会扩展为整个存档目录。", + "Components.SystemFooter": "system 包含管家画廊/动画解锁记录及共享设置。若要迁移这些内容,请明确选择一对 3DS 与 Cemu system 文件;应用不会扩展为整个存档目录。", "Components.SystemSourceMessage": "请选择一个 3DS system 文件。", "Components.SystemTargetMessage": "请选择一个 Cemu system 文件。", "Components.ExtrasFooter": "选择 00000481 或其直接 user 子目录即可。应用只处理已选的名片/任务组件,不会扫描 SD 卡或覆盖整个 Cemu 存档目录。", @@ -387,7 +387,7 @@ public enum ConverterCopy { "Components.QuestsDetail": "Migrate quest data.", "Components.SystemSource": "3DS system", "Components.SystemTarget": "Cemu system", - "Components.SystemFooter": "A selected system is one explicit file. It never expands to a save directory.", + "Components.SystemFooter": "System carries housekeeper gallery/movie unlocks and shared settings. To migrate them, choose one explicit 3DS/Cemu system pair; it never expands to a save directory.", "Components.SystemSourceMessage": "Choose exactly one 3DS system file.", "Components.SystemTargetMessage": "Choose exactly one Cemu system file.", "Components.ExtrasFooter": "Choose 00000481 or its direct user child. The app only handles selected card/quest components; it does not scan an SD card or replace an entire Cemu save directory.", diff --git a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs index 1e33762..822e115 100644 --- a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs +++ b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs @@ -248,7 +248,7 @@ private string Text(string key) [nameof(ManifestPath)] = "Transaction manifest", [nameof(RollbackFromManifest)] = "Rollback from manifest", [nameof(OptionalSectionTitle)] = "Optional data", - [nameof(OptionalSectionDescription)] = "Shared system and ExtData are separate, explicit transactions. CEC stays isolated and disabled until you enable it.", + [nameof(OptionalSectionDescription)] = "Shared system carries housekeeper gallery/movie unlocks; ExtData carries cards and quests. They are separate, explicit transactions. CEC stays isolated and disabled until you enable it.", [nameof(ExperimentalCec)] = "Experimental CEC", [nameof(ExperimentalCecHint)] = "CEC is a separate StreetPass / Hunter Search cache. Guild-card and offline-partner migration do not require it.", [nameof(CecSourceDirectory)] = "3DS CEC mailbox directory", @@ -400,7 +400,7 @@ private string Text(string key) [nameof(ManifestPath)] = "事务 manifest", [nameof(RollbackFromManifest)] = "从 manifest 回滚", [nameof(OptionalSectionTitle)] = "可选数据", - [nameof(OptionalSectionDescription)] = "共享 system 和 ExtData 是独立、明确的事务。CEC 完全隔离,默认关闭。", + [nameof(OptionalSectionDescription)] = "共享 system 包含管家画廊/动画解锁记录,ExtData 包含名片与任务;两者都是独立、明确的事务。CEC 完全隔离,默认关闭。", [nameof(ExperimentalCec)] = "实验性 CEC", [nameof(ExperimentalCecHint)] = "CEC 是独立的擦身通信 / 猎人搜索缓存。公会名片和离线伙伴迁移不依赖它。", [nameof(CecSourceDirectory)] = "3DS CEC 邮箱目录", diff --git a/crates/mh3g-save-convert/src/compatibility.rs b/crates/mh3g-save-convert/src/compatibility.rs index aa64003..92048b1 100644 --- a/crates/mh3g-save-convert/src/compatibility.rs +++ b/crates/mh3g-save-convert/src/compatibility.rs @@ -3,7 +3,8 @@ use serde::{Deserialize, Serialize}; use crate::{ ConversionError, converter::{ - convert_3ds_to_cemu_named_for_revision, + convert_3ds_to_cemu_named, convert_3ds_to_cemu_named_for_revision, + convert_external_component_to_cemu_named, convert_external_component_to_cemu_named_for_revision, validate_cemu_external_component_named, }, @@ -31,6 +32,12 @@ const GUILD_CARD_ARENA_RECORD_COUNT: usize = 110; const GUILD_CARD_MONSTER_LOG_START: usize = 0x7C0; const GUILD_CARD_MONSTER_LOG_COUNT: usize = 50; const GUILD_CARD_MONSTER_LOG_STRIDE: usize = 10; +const USER_MONSTER_GUIDE_RECORD_START: usize = 0x65C4; +const USER_MONSTER_GUIDE_RECORD_COUNT: usize = 48; +const USER_MONSTER_GUIDE_RECORD_STRIDE: usize = 4; +const USER_APPEARANCE_SCALAR_OFFSETS: [usize; 3] = [0x73B8, 0x73BC, 0x73C8]; +const USER_APPEARANCE_PACKED_STYLE_OFFSET: usize = 0x73D0; +const USER_APPEARANCE_RGBA_OFFSET: usize = 0x73D8; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -101,9 +108,7 @@ pub fn detect_component_revision( filename: &str, ) -> Result { let expected = historical_outputs(source, current, filename)?; - let latest = expected - .last() - .expect("historical revision list is non-empty"); + let latest = convert_component_current(source, filename)?; let fields = repair_fields(filename)?; if fields.is_empty() { return Ok(RevisionDetection { @@ -136,7 +141,7 @@ pub fn detect_component_revision( for field in &fields { let current_field = field_bytes(current, field); let candidate_field = field_bytes(candidate, field); - let latest_field = field_bytes(latest, field); + let latest_field = field_bytes(&latest, field); if current_field == candidate_field { score.matching_fields += 1; if candidate_field != latest_field { @@ -175,8 +180,8 @@ pub fn detect_component_revision( let tied_merges_identical = best.windows(2).all(|pair| { let left = &expected[revision_index(pair[0])]; let right = &expected[revision_index(pair[1])]; - merged_candidate_bytes(current, left, latest, &fields) - == merged_candidate_bytes(current, right, latest, &fields) + merged_candidate_bytes(current, left, &latest, &fields) + == merged_candidate_bytes(current, right, &latest, &fields) }); if tied_merges_identical { DetectionConfidence::CompatibleRange @@ -288,7 +293,7 @@ pub fn merge_component( ) -> Result { validate_component_pair(source, current, filename)?; let historical = convert_component_for_revision(source, filename, assumed_revision)?; - let latest = convert_component_for_revision(source, filename, ConverterRevision::LATEST)?; + let latest = convert_component_current(source, filename)?; let fields = repair_fields(filename)?; let mut bytes = current.to_vec(); let mut changes = Vec::new(); @@ -370,6 +375,18 @@ fn convert_component_for_revision( } } +fn convert_component_current(source: &[u8], filename: &str) -> Result, ConversionError> { + match filename { + "user1" | "user2" | "user3" => convert_3ds_to_cemu_named(source, filename), + "card1" | "card2" | "card3" | "cardbox" | "quest1" | "quest2" | "quest3" | "quest4" => { + convert_external_component_to_cemu_named(source, filename) + } + _ => Err(ConversionError::InvalidSave(format!( + "unsupported compatibility component: {filename}" + ))), + } +} + fn validate_component_pair( source: &[u8], current: &[u8], @@ -388,7 +405,7 @@ fn validate_component_pair( } "card1" | "card2" | "card3" | "cardbox" | "quest1" | "quest2" | "quest3" | "quest4" => { // Conversion validates the 3DS side, including its component size. - convert_component_for_revision(source, filename, ConverterRevision::LATEST)?; + convert_component_current(source, filename)?; validate_cemu_external_component_named(current, filename)?; } _ => { @@ -419,6 +436,32 @@ fn repair_fields(filename: &str) -> Result, ConversionError> { let mut fields = Vec::new(); match filename { "user1" | "user2" | "user3" => { + for record in 0..USER_MONSTER_GUIDE_RECORD_COUNT { + fields.push(FieldSpec { + name: format!("monster-guide-record-{record}"), + offset: header + + USER_MONSTER_GUIDE_RECORD_START + + record * USER_MONSTER_GUIDE_RECORD_STRIDE, + width: USER_MONSTER_GUIDE_RECORD_STRIDE, + }); + } + for (index, offset) in USER_APPEARANCE_SCALAR_OFFSETS.into_iter().enumerate() { + fields.push(FieldSpec { + name: format!("player-appearance-scalar-{index}"), + offset: header + offset, + width: 4, + }); + } + fields.push(FieldSpec { + name: "player-appearance-packed-style".to_owned(), + offset: header + USER_APPEARANCE_PACKED_STYLE_OFFSET, + width: 4, + }); + fields.push(FieldSpec { + name: "player-appearance-rgba".to_owned(), + offset: header + USER_APPEARANCE_RGBA_OFFSET, + width: 4, + }); for record in 0..USER_ARENA_RECORD_COUNT { fields.push(FieldSpec { name: format!("personal-arena-{record}"), @@ -635,6 +678,41 @@ mod tests { assert_eq!(second.repaired_fields, 0); } + #[test] + fn repairs_new_official_parity_fields_without_reverting_wiiu_progress() { + let mut source = source(); + let source_guide = JP_3DS_HEADER.len() + USER_MONSTER_GUIDE_RECORD_START; + source[source_guide..source_guide + 4].copy_from_slice(&0x1234_5678_u32.to_le_bytes()); + let source_appearance = JP_3DS_HEADER.len() + USER_APPEARANCE_RGBA_OFFSET; + source[source_appearance..source_appearance + 4].copy_from_slice(&[0xFF, 0xE6, 0xEF, 0xFA]); + let mut current = + convert_3ds_to_cemu_named_for_revision(&source, "user2", ConverterRevision::V0_0_6) + .unwrap(); + let unrelated = JP_CEMU_HEADER.len() + 0x240; + current[unrelated] ^= 0x5A; + + let merged = + merge_component(&source, ¤t, "user2", ConverterRevision::V0_0_6).unwrap(); + let guide = JP_CEMU_HEADER.len() + USER_MONSTER_GUIDE_RECORD_START; + let appearance = JP_CEMU_HEADER.len() + USER_APPEARANCE_RGBA_OFFSET; + + assert_eq!( + &merged.bytes[guide..guide + 4], + &0x1234_5678_u32.to_be_bytes() + ); + assert_eq!( + &merged.bytes[appearance..appearance + 4], + &[0xFA, 0xEF, 0xE6, 0xFF] + ); + assert_eq!(merged.bytes[unrelated], current[unrelated]); + assert!(merged.fields.iter().any(|field| { + field.name == "monster-guide-record-0" && field.status == MergeFieldStatus::Repaired + })); + assert!(merged.fields.iter().any(|field| { + field.name == "player-appearance-rgba" && field.status == MergeFieldStatus::Repaired + })); + } + #[test] fn detects_each_unmodified_historical_core_output() { let source = source(); @@ -669,15 +747,10 @@ mod tests { #[test] fn reports_unknown_when_every_revision_field_contradicts_all_outputs() { let source = source(); - let outputs = historical_outputs( - &source, - &convert_3ds_to_cemu_named_for_revision(&source, "user2", ConverterRevision::LATEST) - .unwrap(), - "user2", - ) - .unwrap(); + let latest = convert_component_current(&source, "user2").unwrap(); + let outputs = historical_outputs(&source, &latest, "user2").unwrap(); let fields = repair_fields("user2").unwrap(); - let mut current = outputs.last().unwrap().clone(); + let mut current = latest.clone(); for field in &fields { let replacement = (0u8..=u8::MAX) .map(|byte| vec![byte; field.width]) @@ -685,8 +758,9 @@ mod tests { outputs .iter() .all(|output| field_bytes(output, field) != candidate) + && field_bytes(&latest, field) != candidate }) - .expect("four historical values cannot exhaust all byte patterns"); + .expect("historical and current values cannot exhaust all byte patterns"); current[field.offset..field.offset + field.width].copy_from_slice(&replacement); } @@ -699,9 +773,7 @@ mod tests { #[test] fn reports_ambiguous_when_current_fields_support_conflicting_revisions() { let source = source(); - let latest = - convert_3ds_to_cemu_named_for_revision(&source, "user2", ConverterRevision::LATEST) - .unwrap(); + let latest = convert_component_current(&source, "user2").unwrap(); let outputs = historical_outputs(&source, &latest, "user2").unwrap(); let fields = repair_fields("user2").unwrap(); let mut observed = None; diff --git a/crates/mh3g-save-convert/src/converter.rs b/crates/mh3g-save-convert/src/converter.rs index 85d8c30..25b1517 100644 --- a/crates/mh3g-save-convert/src/converter.rs +++ b/crates/mh3g-save-convert/src/converter.rs @@ -6,7 +6,8 @@ use crate::{ }, revision::ConverterRevision, transforms::{ - GuildCardBodyKind, apply_japanese_wiiu_corrections_for_revision, + GuildCardBodyKind, apply_japanese_wiiu_corrections, + apply_japanese_wiiu_corrections_for_revision, apply_japanese_wiiu_guild_card_corrections_for_revision, }, }; @@ -68,7 +69,7 @@ pub fn convert_external_component_to_cemu_named( convert_external_component_to_cemu_named_for_revision( source, filename, - ConverterRevision::LATEST, + ConverterRevision::LAST_HISTORICAL, ) } @@ -152,13 +153,23 @@ pub fn convert_3ds_to_cemu_named( source: &[u8], filename: &str, ) -> Result, ConversionError> { - convert_3ds_to_cemu_named_for_revision(source, filename, ConverterRevision::LATEST) + convert_3ds_to_cemu_named_with(source, filename, apply_japanese_wiiu_corrections) } pub(crate) fn convert_3ds_to_cemu_named_for_revision( source: &[u8], filename: &str, revision: ConverterRevision, +) -> Result, ConversionError> { + convert_3ds_to_cemu_named_with(source, filename, |source_payload, payload| { + apply_japanese_wiiu_corrections_for_revision(source_payload, payload, revision) + }) +} + +fn convert_3ds_to_cemu_named_with( + source: &[u8], + filename: &str, + corrections: impl FnOnce(&[u8], &mut [u8]) -> Result<(), ConversionError>, ) -> Result, ConversionError> { let inspection = inspect_bytes(source)?; if inspection.profile != SaveProfile::JpThreeDs { @@ -170,7 +181,7 @@ pub(crate) fn convert_3ds_to_cemu_named_for_revision( let source_payload = &source[JP_3DS_HEADER.len()..]; let mut payload = source_payload.to_vec(); - apply_japanese_wiiu_corrections_for_revision(source_payload, &mut payload, revision)?; + corrections(source_payload, &mut payload)?; let mut output = Vec::with_capacity(CEMU_SIZE); output.extend_from_slice(&build_jp_cemu_header(filename, PAYLOAD_SIZE)?); @@ -182,9 +193,9 @@ pub(crate) fn convert_3ds_to_cemu_named_for_revision( /// Convert the Japanese MH3G 3DS shared system data into the Cemu container. /// -/// The `system` payload is already serialized in the same byte order in both -/// versions. Unlike character slots, this conversion only replaces the outer -/// save container header. +/// The first 48 payload bytes are packed metadata shared by both versions. +/// Remaining four-byte system records are little-endian on 3DS and big-endian +/// on Wii U, so the conversion swaps them before replacing the outer container. pub fn convert_3ds_system_to_cemu(source: &[u8]) -> Result, ConversionError> { convert_3ds_system_to_cemu_named(source, "system") } @@ -247,7 +258,7 @@ mod tests { } #[test] - fn converts_a_japanese_3ds_system_by_replacing_only_the_container_header() { + fn converts_a_japanese_3ds_system_header_and_record_endianness() { use crate::profile::{ CEMU_SYSTEM_SIZE, JP_CEMU_SYSTEM_HEADER, SYSTEM_PAYLOAD_SIZE, THREE_DS_SYSTEM_SIZE, }; diff --git a/crates/mh3g-save-convert/src/revision.rs b/crates/mh3g-save-convert/src/revision.rs index 8154d6b..41932dd 100644 --- a/crates/mh3g-save-convert/src/revision.rs +++ b/crates/mh3g-save-convert/src/revision.rs @@ -24,7 +24,11 @@ pub enum ConverterRevision { impl ConverterRevision { pub const ALL: [Self; 4] = [Self::V0_0_3, Self::V0_0_4, Self::V0_0_5, Self::V0_0_6]; - pub const LATEST: Self = Self::V0_0_6; + /// Last released historical algorithm that compatibility repair can replay. + /// + /// This is intentionally not named `LATEST`: current conversion may layer + /// newer corrections on top while keeping 0.0.3-0.0.6 byte-reproducible. + pub const LAST_HISTORICAL: Self = Self::V0_0_6; pub const fn label(self) -> &'static str { match self { diff --git a/crates/mh3g-save-convert/src/transforms.rs b/crates/mh3g-save-convert/src/transforms.rs index 93a50e5..8ec4f35 100644 --- a/crates/mh3g-save-convert/src/transforms.rs +++ b/crates/mh3g-save-convert/src/transforms.rs @@ -25,6 +25,20 @@ const CURRENT_EQUIPMENT_START: usize = 31280; const CURRENT_EQUIPMENT_COUNT: usize = 7; const EQUIPMENT_STRIDE: usize = 16; const SECOND_RGBA_OFFSET: usize = 0x73E4; +// Five independently paired 3DS -> Wii U official-transfer saves agree that +// this is a contiguous table of 48 endian-sensitive monster-guide records. +// Leaving the little-endian words untouched makes unlocked guide details look +// incomplete on Wii U even though the source slot contains them. +const MONSTER_GUIDE_RECORD_START: usize = 0x65C4; +const MONSTER_GUIDE_RECORD_COUNT: usize = 48; +const MONSTER_GUIDE_RECORD_STRIDE: usize = 4; +// These appearance scalars and the adjacent RGBA value are serialized as +// little-endian four-byte values on 3DS and big-endian values on Wii U. The +// older static table covered only the scalar at 0x73C4 and the later RGBA +// values, leaving this subset in mixed byte order. +const PLAYER_APPEARANCE_SCALAR_OFFSETS: [usize; 3] = [0x73B8, 0x73BC, 0x73C8]; +const PLAYER_APPEARANCE_PACKED_STYLE_OFFSET: usize = 0x73D0; +const PLAYER_APPEARANCE_RGBA_OFFSET: usize = 0x73D8; const FULL_WIDTH_COUNTER_OFFSETS: [usize; 3] = [0x5BA4, 0x5CC8, 0x5CD4]; const MONSTER_IDS: [usize; 50] = [ 0x0C, 0x0E, 0x2D, 0x03, 0x33, 0x2A, 0x2B, 0x2C, 0x08, 0x36, 0x09, 0x37, 0x2E, 0x49, 0x07, 0x10, @@ -587,7 +601,7 @@ pub fn apply_japanese_wiiu_guild_card_slot_corrections( apply_japanese_wiiu_guild_card_slot_corrections_for_revision( source, target, - ConverterRevision::LATEST, + ConverterRevision::LAST_HISTORICAL, ) } @@ -681,7 +695,7 @@ pub fn apply_japanese_wiiu_guild_card_corrections( kind, source, target, - ConverterRevision::LATEST, + ConverterRevision::LAST_HISTORICAL, ) } @@ -883,12 +897,54 @@ fn apply_shakalaka_companion_corrections( Ok(()) } +/// Apply corrections proven by official-transfer pairs after the last +/// historically reproducible 0.0.6 conversion semantics. +/// +/// Keep this separate from `apply_japanese_wiiu_corrections_for_revision`: +/// compatibility repair must still be able to recreate the exact 0.0.3- +/// 0.0.6 output before comparing it with the current Wii U save. +fn apply_current_official_transfer_corrections( + source: &[u8], + target: &mut [u8], +) -> Result<(), ConversionError> { + for record in 0..MONSTER_GUIDE_RECORD_COUNT { + copy_reversed( + source, + target, + MONSTER_GUIDE_RECORD_START + record * MONSTER_GUIDE_RECORD_STRIDE, + MONSTER_GUIDE_RECORD_STRIDE, + )?; + } + + for offset in PLAYER_APPEARANCE_SCALAR_OFFSETS { + copy_reversed(source, target, offset, 4)?; + } + + // 0x73D0 is not one u32: the leading style ID is a u16 while the final + // two bytes are packed selectors. Reassert that field boundary after the + // historical blanket four-byte transform. + copy_reversed(source, target, PLAYER_APPEARANCE_PACKED_STYLE_OFFSET, 2)?; + target[PLAYER_APPEARANCE_PACKED_STYLE_OFFSET + 2..PLAYER_APPEARANCE_PACKED_STYLE_OFFSET + 4] + .copy_from_slice( + &source[PLAYER_APPEARANCE_PACKED_STYLE_OFFSET + 2 + ..PLAYER_APPEARANCE_PACKED_STYLE_OFFSET + 4], + ); + copy_reversed(source, target, PLAYER_APPEARANCE_RGBA_OFFSET, 4)?; + + Ok(()) +} + /// Complete the statically recovered Wii U record corrections. pub fn apply_japanese_wiiu_corrections( source: &[u8], target: &mut [u8], ) -> Result<(), ConversionError> { - apply_japanese_wiiu_corrections_for_revision(source, target, ConverterRevision::LATEST) + apply_japanese_wiiu_corrections_for_revision( + source, + target, + ConverterRevision::LAST_HISTORICAL, + )?; + apply_current_official_transfer_corrections(source, target) } pub(crate) fn apply_japanese_wiiu_corrections_for_revision( @@ -1002,11 +1058,15 @@ mod tests { }; use super::{ - EQUIPMENT_BOX_START, EVENT_FLAG_START, GUILD_CARD_SLOT_SIZE, MONSTER_IDS, - QUEST_COMPLETION_START, SECOND_RGBA_OFFSET, apply_arena_records, apply_endian_swaps, - apply_japanese_wiiu_corrections, apply_japanese_wiiu_guild_card_slot_corrections, - apply_monster_discovery, + EQUIPMENT_BOX_START, EVENT_FLAG_START, GUILD_CARD_SLOT_SIZE, MONSTER_GUIDE_RECORD_COUNT, + MONSTER_GUIDE_RECORD_START, MONSTER_GUIDE_RECORD_STRIDE, MONSTER_IDS, + PLAYER_APPEARANCE_PACKED_STYLE_OFFSET, PLAYER_APPEARANCE_RGBA_OFFSET, + PLAYER_APPEARANCE_SCALAR_OFFSETS, QUEST_COMPLETION_START, SECOND_RGBA_OFFSET, + apply_arena_records, apply_endian_swaps, apply_japanese_wiiu_corrections, + apply_japanese_wiiu_corrections_for_revision, + apply_japanese_wiiu_guild_card_slot_corrections, apply_monster_discovery, }; + use crate::revision::ConverterRevision; #[test] fn transforms_endian_swaps_reverse_declared_spans_only() { @@ -1143,6 +1203,114 @@ mod tests { ); } + #[test] + fn japanese_wiiu_corrections_preserve_signed_charm_points_as_raw_i8_bytes() { + let mut source = vec![0_u8; PAYLOAD_SIZE]; + source[EQUIPMENT_BOX_START..EQUIPMENT_BOX_START + 16].copy_from_slice(&[ + 0x06, 0x03, 0x34, 0x12, 0x13, 0xF6, 0x20, 0x05, 0x34, 0x12, 0x78, 0x56, 0xBC, 0x9A, + 0xAA, 0x55, + ]); + let mut target = source.clone(); + + apply_japanese_wiiu_corrections(&source, &mut target).unwrap(); + + assert_eq!( + &target[EQUIPMENT_BOX_START..EQUIPMENT_BOX_START + 16], + &[ + 0x06, 0x03, 0x12, 0x34, 0x13, 0xF6, 0x20, 0x05, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, + 0xAA, 0x55, + ] + ); + assert_eq!(target[EQUIPMENT_BOX_START + 5] as i8, -10); + assert_eq!(target[EQUIPMENT_BOX_START + 7] as i8, 5); + } + + #[test] + fn current_corrections_swap_every_official_monster_guide_record() { + let mut source = vec![0_u8; PAYLOAD_SIZE]; + for record in 0..MONSTER_GUIDE_RECORD_COUNT { + let offset = MONSTER_GUIDE_RECORD_START + record * MONSTER_GUIDE_RECORD_STRIDE; + let value = 0x1020_3000_u32 + record as u32; + source[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + source[MONSTER_GUIDE_RECORD_START - 1] = 0x5A; + source[MONSTER_GUIDE_RECORD_START + + MONSTER_GUIDE_RECORD_COUNT * MONSTER_GUIDE_RECORD_STRIDE] = 0xA5; + + let mut historical = source.clone(); + apply_japanese_wiiu_corrections_for_revision( + &source, + &mut historical, + ConverterRevision::V0_0_6, + ) + .unwrap(); + let mut current = source.clone(); + apply_japanese_wiiu_corrections(&source, &mut current).unwrap(); + + for record in 0..MONSTER_GUIDE_RECORD_COUNT { + let offset = MONSTER_GUIDE_RECORD_START + record * MONSTER_GUIDE_RECORD_STRIDE; + assert_eq!( + ¤t[offset..offset + 4], + &source[offset..offset + 4] + .iter() + .rev() + .copied() + .collect::>(), + "monster-guide record {record}" + ); + assert_eq!( + &historical[offset..offset + 4], + &source[offset..offset + 4], + "0.0.6 replay must remain byte-identical" + ); + } + assert_eq!( + current[MONSTER_GUIDE_RECORD_START - 1], + historical[MONSTER_GUIDE_RECORD_START - 1] + ); + assert_eq!( + current[MONSTER_GUIDE_RECORD_START + + MONSTER_GUIDE_RECORD_COUNT * MONSTER_GUIDE_RECORD_STRIDE], + historical[MONSTER_GUIDE_RECORD_START + + MONSTER_GUIDE_RECORD_COUNT * MONSTER_GUIDE_RECORD_STRIDE] + ); + } + + #[test] + fn current_corrections_match_official_appearance_field_boundaries() { + let mut source = vec![0_u8; PAYLOAD_SIZE]; + for (index, offset) in PLAYER_APPEARANCE_SCALAR_OFFSETS.into_iter().enumerate() { + source[offset..offset + 4].copy_from_slice(&(0.15_f32 + index as f32).to_le_bytes()); + } + source[PLAYER_APPEARANCE_PACKED_STYLE_OFFSET..PLAYER_APPEARANCE_PACKED_STYLE_OFFSET + 4] + .copy_from_slice(&[0x08, 0x00, 0x08, 0x01]); + source[PLAYER_APPEARANCE_RGBA_OFFSET..PLAYER_APPEARANCE_RGBA_OFFSET + 4] + .copy_from_slice(&[0xFF, 0xE6, 0xEF, 0xFA]); + + let mut current = source.clone(); + apply_japanese_wiiu_corrections(&source, &mut current).unwrap(); + + for offset in PLAYER_APPEARANCE_SCALAR_OFFSETS { + assert_eq!( + ¤t[offset..offset + 4], + &source[offset..offset + 4] + .iter() + .rev() + .copied() + .collect::>() + ); + } + assert_eq!( + ¤t + [PLAYER_APPEARANCE_PACKED_STYLE_OFFSET..PLAYER_APPEARANCE_PACKED_STYLE_OFFSET + 4], + &[0x00, 0x08, 0x08, 0x01] + ); + assert_eq!( + ¤t[PLAYER_APPEARANCE_RGBA_OFFSET..PLAYER_APPEARANCE_RGBA_OFFSET + 4], + &[0xFA, 0xEF, 0xE6, 0xFF] + ); + } + #[test] fn japanese_wiiu_corrections_swap_full_width_progress_counters() { let mut source = vec![0_u8; PAYLOAD_SIZE]; diff --git a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md index 20960ec..85dc0cb 100644 --- a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md +++ b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md @@ -97,11 +97,11 @@ mh3g-save-convert repair-converted <3DS-user#> --current \ 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 0.0.7 result only when its current -value still equals the historical output. A value different from both the -historical and 0.0.7 outputs is preserved as later Wii U progress and reported -as a conflict. The operation does not blindly merge bytes or rebuild the whole -Cemu slot. +through 0.0.6. A field is replaced with the current converter result only when +its current value still equals the historical output. A value different from +both the historical and current-converter outputs is preserved as later Wii U +progress and reported as a conflict. The operation does not blindly merge bytes +or rebuild the whole Cemu slot. Omit `--source-extdata-dir` for a core-only repair. Guild-card repair requires all eight 3DS ExtData files in that directory and all eight current Cemu files @@ -131,7 +131,10 @@ second. Provide it only when the migration explicitly includes shared system data. The command reads and writes only the explicitly named `system` paths; it does -not alter any `user#` slot. +not alter any `user#` slot. This component carries the housekeeper +gallery/movie unlock history and other shared settings. Omitting it leaves the +current Cemu `system` untouched, so a core-slot conversion alone cannot fill +missing gallery entries. ### Optional Shared Extdata 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 a6544a6..9e0e57d 100644 --- a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md +++ b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md @@ -67,7 +67,7 @@ mh3g-save-convert repair-converted <3DS-user#> --current \ --expected-current-set-sha256 --expected-preview-sha256 ] ``` -该操作以当前 Cemu 数据为继续游玩的权威数据,只对 0.0.3 至 0.0.6 之间已知变化的完整语义字段执行三方比较。当前字段仍等于历史版本输出时才替换为 0.0.7 输出;若当前字段不同于历史值和 0.0.7 值,则按 Wii U 后续进度保留并报告冲突。它不会按字节盲合并,也不会重建整个 Cemu 槽位。 +该操作以当前 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*`。 @@ -81,7 +81,7 @@ Dry Run 会把全部选中组件汇总成一个顶层版本判断,可能报告 | --- | ---: | --- | ---: | --- | | `system` | `0x3000` | `system` | `0x3024` | `convert-system` | -只有迁移明确包含共享系统数据时才提供它。该命令只读取和写入明确指定的 `system` 路径,不会改变任何 `user#` 槽位。 +只有迁移明确包含共享系统数据时才提供它。该命令只读取和写入明确指定的 `system` 路径,不会改变任何 `user#` 槽位。该组件承载管家画廊/动画解锁记录及其他共享设置;不提供它时会保持当前 Cemu `system` 不变,因此只转换核心槽位不能补齐缺失的画廊记录。 ### 可选的共享 ExtData From 89734d9418ea46e2531ed861f7844426e904c339 Mon Sep 17 00:00:00 2001 From: vincentadamnemessis Date: Mon, 10 Aug 2026 15:54:56 +0800 Subject: [PATCH 3/6] fix(mh3g): merge shared system gallery flags safely --- README.md | 46 +++-- README.zh-CN.md | 19 +- .../ConverterPresentation/Localization.swift | 10 +- .../ComponentSelectionView.swift | 6 + .../Resources/Localizable.xcstrings | 1 + .../Infrastructure/ConverterCopy.cs | 15 +- .../MainWindow.xaml | 17 +- crates/mh3g-save-convert/src/converter.rs | 129 +++++++++++++- crates/mh3g-save-convert/src/main.rs | 145 +++++++++++---- crates/mh3g-save-convert/src/transaction.rs | 27 ++- crates/mh3g-save-convert/tests/cli.rs | 165 ++++++++++++++++-- docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md | 31 ++-- docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md | 8 +- .../0016-mh3g-shared-system-gallery-merge.md | 79 +++++++++ 14 files changed, 579 insertions(+), 119 deletions(-) create mode 100644 docs/adr/0016-mh3g-shared-system-gallery-merge.md diff --git a/README.md b/README.md index 8eab6f3..91cf69c 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ child. CEC is a system NAND mailbox, not SD-card ExtData. | Data group | Give this exact input to the CLI | Do **not** give it | Required? | Purpose / affected files | | --- | --- | --- | --- | --- | | Core slot | One explicit `user1`, `user2`, or `user3` file under `title/00040000/00048100/data/00000001/` | The title directory, all slots, ExtData, a ZIP | Yes: choose one slot | Character, story/progress, farm, fleet, local offline-hunter data; writes only the named same-number Cemu `user#` target | -| Shared system | One explicit `system` file in the same title savedata directory | The whole title directory, a ZIP | Optional | Housekeeper gallery/movie unlock history and other shared settings; writes only the named Cemu `system` target | +| Shared system | One explicit 3DS `system` file in the same title savedata directory **and** one existing initialized Cemu `system` target | The whole title directory, a ZIP, or a missing/new Cemu target | Optional | Unions the housekeeper gallery/movie unlock flags into the existing Cemu `system`; all other Wii U shared bytes are preserved | | Shared ExtData | The complete `extdata/00000000/00000481/user/` directory containing `card1`, `card2`, `card3`, `cardbox`, `quest1`, `quest2`, `quest3`, `quest4` directly inside | The `00000481` parent, `boss/`, a partial set, a ZIP | Optional | Converts all eight files into a new staging directory; a separate guarded `install-extras` transaction can install complete `guild-cards`, `quests`, or both into an initialized Cemu target | | StreetPass / Hunter Search CEC | The exact `CEC/00048100/` directory containing `InBox___` | SD-card ExtData, the `InBox___` child alone, a ZIP | Optional and experimental | Reads received raw StreetPass records and can write only Cemu `cec` | @@ -193,9 +193,12 @@ extra wrapper directory, enter it first; the expected filenames must be immediate children of the CLI path or the narrow GUI selection described above. If the 3DS `system` file is omitted, a core `user#` conversion cannot migrate -the housekeeper's gallery/movie unlock history. The converter deliberately -leaves any existing Cemu `system` unchanged; use the separate guarded -`convert-system` transaction when those shared records are part of the move. +the housekeeper's gallery/movie unlock history. `system` is shared across all +three character slots and also contains settings unrelated to the selected +slot. Therefore `convert-system` requires both the 3DS source and an existing, +initialized Cemu target. It bitwise-unions only the verified gallery/movie +flag range (Cemu file offsets `0x68..0x77`) and preserves every other target +byte. It refuses a new/missing target instead of replacing all shared data. ### Legacy Wii U save-editor caveat @@ -250,10 +253,12 @@ an arbitrary renamed file. With no `--write`, conversion remains a dry-run; pass `--dry-run` explicitly in scripts to make that intention visible. `--write` and `--dry-run` conflict. -For GUI and automation clients, `convert` and `convert-system` expose guarded -write preconditions: `--expected-source-sha256` plus exactly one target -condition, either `--expected-target-sha256` or `--expected-target-absent`. -They are accepted only together with `--write`. Take hash values only from +For GUI and automation clients, `convert` exposes guarded write preconditions: +`--expected-source-sha256` plus exactly one target condition, either +`--expected-target-sha256` or `--expected-target-absent`. `convert-system` +always requires an existing Cemu baseline and therefore requires both +`--expected-source-sha256` and `--expected-target-sha256` when writing. These +arguments are accepted only together with `--write`. Take hash values only from `hashes.source` and `hashes.target_before` in the JSON emitted by the **same** immediately preceding Dry Run for the same source and output paths. This makes the write fail closed if an existing source or target changed; the target hash @@ -449,22 +454,25 @@ guild-card subtransactions. Roll it back with: Omit `--source-extdata-dir` for a core-only repair. A `no-changes` report means the selected scope needed no write, so no empty coordinator manifest is made. -#### `convert-system` — convert shared system data +#### `convert-system` — safely merge shared gallery/movie flags ```text -mh3g-save-convert convert-system [--dry-run | --write [--expected-source-sha256 ] [--expected-target-sha256 | --expected-target-absent]] --output +mh3g-save-convert convert-system [--dry-run | --write --expected-source-sha256 --expected-target-sha256 ] --output <3DS_SYSTEM> ``` -Use explicit `system` files only; it never reads a `user#` or ExtData: +Use an explicit 3DS `system` source and an existing initialized Cemu `system` +target; it never reads a `user#` or ExtData: ```bash "${CLI[@]}" convert-system "$SYSTEM_SOURCE" --output "$CEMU_DIR/system" --dry-run -"${CLI[@]}" convert-system "$SYSTEM_SOURCE" --output "$CEMU_DIR/system" --write ``` -The same transactional backup/manifest pattern applies, using `.system...` -names. `--write` and `--dry-run` conflict. The same optional guarded-write -flow applies; use values from that `convert-system` Dry Run, not from a slot +The command does not replace the complete shared file. It preserves the Cemu +header, settings, and unknown/shared-slot records, and unions only the verified +gallery/movie bitset at Cemu offsets `0x68..0x77`. The same transactional +backup/manifest pattern applies, using `.system...` names. `--write` and +`--dry-run` conflict. A write always requires the two hashes emitted by that +immediately preceding `convert-system` Dry Run, not hashes from a slot conversion: ```bash @@ -479,10 +487,10 @@ SYSTEM_TARGET_SHA256=$(jq -er '.hashes.target_before' <<<"$SYSTEM_DRY_RUN_JSON") --write ``` -For a new `system` export, use its immediate Dry Run's source hash with -`--expected-target-absent` by the same rule as a new `user#` export. It is -mutually exclusive with `--expected-target-sha256` and rejects a target that -appears before the write acquires its lock. +There is intentionally no new-`system` export mode. Start MH3G HD once so it +creates a valid Wii U/Cemu `system`, stop the emulator, then select that file +as the merge baseline. This protects settings and records shared by other +character slots. #### `convert-extras` — stage shared ExtData diff --git a/README.zh-CN.md b/README.zh-CN.md index 99b60eb..4622ba6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -110,7 +110,7 @@ Windows 前置条件、行为和可选参数见 | 数据组 | 需要传给 CLI 的准确输入 | **不能**传入 | 是否必需 | 用途/影响的文件 | | --- | --- | --- | --- | --- | | 核心角色槽位 | `title/00040000/00048100/data/00000001/` 下一个明确的 `user1`、`user2` 或 `user3` 文件 | 整个 title 目录、全部槽位、ExtData、ZIP | 必需:三选一 | 角色、剧情/任务进度、农场、狩猎船、本地离线猎人数据;只写入同编号且同名的 Cemu `user#` 目标 | -| 共享 system | 同一 title savedata 目录中的一个明确 `system` 文件 | 整个 title 目录、ZIP | 可选 | 管家画廊/动画解锁记录及其他共享设置;只写入明确指定的 Cemu `system` 目标 | +| 共享 system | 同一 title savedata 目录中的一个明确 3DS `system` 文件,**同时**提供一个已初始化且已存在的 Cemu `system` 目标 | 整个 title 目录、ZIP、新建或不存在的 Cemu 目标 | 可选 | 将管家画廊/动画解锁标记与现有 Cemu `system` 取并集;保留其他全部 Wii U 共享字节 | | 共享 ExtData | 完整的 `extdata/00000000/00000481/user/` 目录,其直接子文件必须包括 `card1`、`card2`、`card3`、`cardbox`、`quest1`、`quest2`、`quest3`、`quest4` | `00000481` 父目录、`boss/`、不完整文件集合、ZIP | 可选 | 将全部八个文件转换到新的暂存目录;再由独立、受保护的 `install-extras` 事务把完整的 `guild-cards`、`quests` 或两者安装到已初始化的 Cemu 目标 | | 擦身通信/猎人搜索 CEC | 包含 `InBox___` 的准确 `CEC/00048100/` 目录 | SD 卡 ExtData、单独的 `InBox___` 子目录、ZIP | 可选且为实验性功能 | 读取收到的原始擦身消息,只可能写入 Cemu `cec` | @@ -133,7 +133,7 @@ Windows 前置条件、行为和可选参数见 OutBox__/... <- 本机猎人的发出广播 ``` -如果没有提供 3DS `system` 文件,单独转换核心 `user#` 无法迁移管家处可查看的画廊/动画解锁记录。转换器会有意保持已有 Cemu `system` 不变;需要迁移这些共享记录时,请额外执行受保护的 `convert-system` 事务。 +如果没有提供 3DS `system` 文件,单独转换核心 `user#` 无法迁移管家处可查看的画廊/动画解锁记录。`system` 由三个角色槽位共同使用,还包含与当前角色无关的设置。因此 `convert-system` 必须同时提供 3DS 源文件和已初始化、已存在的 Cemu 目标文件。它只会对已确认的画廊/动画标记范围(Cemu 文件偏移 `0x68..0x77`)逐位取并集,其他目标字节全部保留;若目标不存在则拒绝执行,不会整文件覆盖共享数据。 ### 旧版 Wii U 存档修改器注意事项 @@ -171,7 +171,7 @@ CEMU_CEC="$CEMU_DIR/cec" `convert` 接收一个源 `user#` 文件和 `--output <同名-user#>`。`user2` 只能写入名为 `user2` 的目标,不能覆盖 `user1` 或任意改名文件。不传 `--write` 时转换保持 dry-run;在脚本中建议显式传入 `--dry-run`,以便清楚表达只读意图。`--write` 与 `--dry-run` 互斥。 -对于 GUI 和自动化调用,`convert` 与 `convert-system` 提供受保护的写入前置条件:`--expected-source-sha256`,再加上二选一的目标条件 `--expected-target-sha256` 或 `--expected-target-absent`。它们都只能与 `--write` 一起使用。哈希值只能取自**同一次**、针对相同源文件和输出路径的紧邻 dry-run JSON 中的 `hashes.source` 与 `hashes.target_before`。这样已有的源或目标在 dry-run 后发生变化时,写入会失败关闭;目标哈希会在取得单槽位安装锁后再次检查。不要复用旧报告,也不要单独计算替代值。 +对于 GUI 和自动化调用,`convert` 提供受保护的写入前置条件:`--expected-source-sha256`,再加上二选一的目标条件 `--expected-target-sha256` 或 `--expected-target-absent`。`convert-system` 必须使用已存在的 Cemu 基线,因此写入时同时强制要求 `--expected-source-sha256` 和 `--expected-target-sha256`。这些参数都只能与 `--write` 一起使用。哈希值只能取自**同一次**、针对相同源文件和输出路径的紧邻 dry-run JSON 中的 `hashes.source` 与 `hashes.target_before`。这样已有的源或目标在 dry-run 后发生变化时,写入会失败关闭;目标哈希会在取得单槽位安装锁后再次检查。不要复用旧报告,也不要单独计算替代值。 仅当目标文件已存在时,JSON 才会提供 `hashes.target_before`。如果它不存在,不要伪造哨兵哈希或传入 `--expected-target-sha256`。对于受保护的新导出,请传入本次 Dry Run 的源哈希和 `--expected-target-absent`。事务在取得锁后会再次检查;若新目标在此期间出现,则拒绝写入。两种目标条件互斥。 @@ -313,20 +313,19 @@ PREVIEW_SHA256=$(jq -er '.preview_sha256' <<<"$REPAIR_JSON") 只修核心 `user#` 时省略 `--source-extdata-dir`。若报告状态为 `no-changes`,说明所选范围无需修改,不会生成无意义的总 manifest。 -#### `convert-system`:转换共享 system 数据 +#### `convert-system`:安全合并共享画廊/动画标记 ```text -mh3g-save-convert convert-system [--dry-run | --write [--expected-source-sha256 ] [--expected-target-sha256 | --expected-target-absent]] --output +mh3g-save-convert convert-system [--dry-run | --write --expected-source-sha256 --expected-target-sha256 ] --output <已有_CEMU_SYSTEM> <3DS_SYSTEM> ``` -只能使用明确的 `system` 文件;它不会读取 `user#` 或 ExtData: +必须使用明确的 3DS `system` 源文件和已存在、已初始化的 Cemu `system` 目标;它不会读取 `user#` 或 ExtData: ```bash "${CLI[@]}" convert-system "$SYSTEM_SOURCE" --output "$CEMU_DIR/system" --dry-run -"${CLI[@]}" convert-system "$SYSTEM_SOURCE" --output "$CEMU_DIR/system" --write ``` -它使用相同的事务备份/manifest 机制,文件名改为 `.system...`。`--write` 与 `--dry-run` 互斥。相同的受保护写入流程也适用,但必须使用该次 `convert-system` dry-run 的哈希,不能复用角色槽位转换的结果: +它不会替换整个共享文件,而是保留 Cemu 头、设置和未知/跨槽位记录,只对 Cemu 偏移 `0x68..0x77` 的已确认画廊/动画位集合取并集。它使用相同的事务备份/manifest 机制,文件名改为 `.system...`。`--write` 与 `--dry-run` 互斥;写入必须使用紧邻的这一次 `convert-system` Dry Run 输出的两个哈希,不能复用角色槽位转换结果: ```bash SYSTEM_TARGET="$CEMU_DIR/system" @@ -340,9 +339,7 @@ SYSTEM_TARGET_SHA256=$(jq -er '.hashes.target_before' <<<"$SYSTEM_DRY_RUN_JSON") --write ``` -新的 `system` 导出也按相同规则:使用该次紧邻 Dry Run 的源哈希与 -`--expected-target-absent`。它和 `--expected-target-sha256` 互斥;如果目标在写入 -取得锁前出现,写入会被拒绝。 +转换器有意不提供“新建 `system`”模式。请先启动一次 MH3G HD,让游戏创建合法的 Wii U/Cemu `system`,关闭模拟器后再把该文件作为合并基线。这样才能保护其他角色槽位共享的设置和记录。 #### `convert-extras`:生成共享 ExtData 暂存文件 diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift index 216355d..87f545b 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift @@ -198,7 +198,8 @@ public enum ConverterCopy { "Components.QuestsDetail": "迁移任务数据。", "Components.SystemSource": "3DS system", "Components.SystemTarget": "Cemu system", - "Components.SystemFooter": "system 包含管家画廊/动画解锁记录及共享设置。若要迁移这些内容,请明确选择一对 3DS 与 Cemu system 文件;应用不会扩展为整个存档目录。", + "Components.SystemWarning": "system 由三个角色槽位共享。必须同时提供 3DS 源文件和已初始化的 Cemu 目标;转换器只合并管家画廊/动画标记,其他 Cemu 设置与跨槽位数据保持不变。", + "Components.SystemFooter": "目标必须是已存在且格式有效的 Cemu system。若不提供这一对文件,则不会迁移管家画廊/动画记录。", "Components.SystemSourceMessage": "请选择一个 3DS system 文件。", "Components.SystemTargetMessage": "请选择一个 Cemu system 文件。", "Components.ExtrasFooter": "选择 00000481 或其直接 user 子目录即可。应用只处理已选的名片/任务组件,不会扫描 SD 卡或覆盖整个 Cemu 存档目录。", @@ -243,7 +244,7 @@ public enum ConverterCopy { "Write.System": "共享 system", "Write.SystemDryRun": "运行 system Dry Run", "Write.WriteSystem": "写入 system", - "Write.SystemFooter": "system 使用独立的源、目标与 SHA-256 授权。", + "Write.SystemFooter": "system 使用独立的成对输入和 SHA-256 授权;仅合并画廊/动画标记并保留其他 Cemu 共享字节。", "Write.Extras": "可选 ExtData", "Write.ExtrasStageDryRun": "预览临时转换", "Write.ExtrasStage": "写入临时转换", @@ -387,7 +388,8 @@ public enum ConverterCopy { "Components.QuestsDetail": "Migrate quest data.", "Components.SystemSource": "3DS system", "Components.SystemTarget": "Cemu system", - "Components.SystemFooter": "System carries housekeeper gallery/movie unlocks and shared settings. To migrate them, choose one explicit 3DS/Cemu system pair; it never expands to a save directory.", + "Components.SystemWarning": "System is shared by all three character slots. Supply both the 3DS source and an initialized Cemu target; only housekeeper gallery/movie flags are merged while every other Cemu setting and shared-slot byte is retained.", + "Components.SystemFooter": "The target must be an existing valid Cemu system. If this pair is omitted, housekeeper gallery/movie history is not migrated.", "Components.SystemSourceMessage": "Choose exactly one 3DS system file.", "Components.SystemTargetMessage": "Choose exactly one Cemu system file.", "Components.ExtrasFooter": "Choose 00000481 or its direct user child. The app only handles selected card/quest components; it does not scan an SD card or replace an entire Cemu save directory.", @@ -432,7 +434,7 @@ public enum ConverterCopy { "Write.System": "Shared system", "Write.SystemDryRun": "Run system Dry Run", "Write.WriteSystem": "Write system", - "Write.SystemFooter": "System keeps its own source, target, and SHA-256 authorization.", + "Write.SystemFooter": "System uses a separate paired-input SHA-256 authorization; it merges only gallery/movie flags and retains all other Cemu shared bytes.", "Write.Extras": "Optional ExtData", "Write.ExtrasStageDryRun": "Preview staging conversion", "Write.ExtrasStage": "Write staging conversion", diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift index 3dfd008..d9dee84 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift @@ -32,6 +32,12 @@ struct ComponentSelectionView: View { ) { chooseSystemTarget() } + Label( + ConverterCopy.text("Components.SystemWarning", language: language), + systemImage: "exclamationmark.shield" + ) + .font(.caption) + .foregroundStyle(.orange) } } footer: { Text(ConverterCopy.text("Components.SystemFooter", language: language)) 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 bec353b..1a06981 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings @@ -13,6 +13,7 @@ "Repair.Version.Hint" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Use automatic detection if the version is unknown. If several historical versions match, Dry Run will require an explicit selection before it can authorize a write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "不知道版本时先自动检测;若多个历史版本都匹配,Dry Run 会要求你明确选择后再运行。" } } } }, "Repair.Version.Required" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Several versions match this save. Select the converter version originally used, then run Dry Run again." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "检测到多个可能版本。请选择当时使用的转换器版本,然后重新运行 Dry Run。" } } } }, "Repair.PreviewSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Repair preview SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "修复预览 SHA-256" } } } }, + "Components.SystemWarning" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "System is shared by all three character slots. Supply both the 3DS source and an initialized Cemu target; only housekeeper gallery/movie flags are merged while every other Cemu setting and shared-slot byte is retained." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "system 由三个角色槽位共享。必须同时提供 3DS 源文件和已初始化的 Cemu 目标;转换器只合并管家画廊/动画标记,其他 Cemu 设置与跨槽位数据保持不变。" } } } }, "Guide.ComponentsReady" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Optional data is set. Next, run a read-only Dry Run to review what this conversion will write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "可选数据已确认。接下来运行只读 Dry Run,确认本次转换会写入什么。" } } } }, "Guide.CoreDryRunCompleteWithOptionals" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The core-slot Dry Run passed. Continue to the write page; selected system or ExtData data still needs its own Dry Run and write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "核心角色 Dry Run 已通过。接下来进入写入页;已选 system 或 ExtData 仍须分别 Dry Run 和写入。" } } } }, "Guide.DryRunComplete" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run passed. No save has been modified; continue when you are ready to confirm the write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run 已通过。确认写入前不会修改任何存档。" } } } }, diff --git a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs index 822e115..5b40212 100644 --- a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs +++ b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs @@ -141,6 +141,7 @@ public void SetLanguage(AppLanguageOverride language) public string SharedSystem => Text(nameof(SharedSystem)); public string SystemSource => Text(nameof(SystemSource)); public string SystemTarget => Text(nameof(SystemTarget)); + public string SystemMergeWarning => Text(nameof(SystemMergeWarning)); public string RunSystemDryRun => Text(nameof(RunSystemDryRun)); public string WriteSystem => Text(nameof(WriteSystem)); public string SystemManifest => Text(nameof(SystemManifest)); @@ -288,15 +289,16 @@ private string Text(string key) [nameof(CecAcknowledgementRequired)] = "Acknowledge the experimental CEC warning before its write command is available.", [nameof(CecPathsRequired)] = "Choose the exact CEC mailbox directory and Cemu cec target path.", [nameof(CecFingerprintMissing)] = "CEC Dry Run did not provide the aggregate record-set and target JSON fingerprints.", - [nameof(SystemPathsRequired)] = "Choose exact 3DS and Cemu system files before running this separate transaction.", + [nameof(SystemPathsRequired)] = "Choose the exact 3DS source system and an existing initialized Cemu system before running this separate merge.", [nameof(ExtrasPathsRequired)] = "Choose at least one ExtData group plus explicit source and staging directories.", [nameof(ExtrasFingerprintMissing)] = "The ExtData Dry Run did not provide the expected component or set fingerprints.", [nameof(OptionalDataNeedsConfiguration)] = "Finish configuring the selected system or ExtData paths before the core Dry Run.", [nameof(SharedSystem)] = "Shared system", [nameof(SystemSource)] = "3DS system file", - [nameof(SystemTarget)] = "Cemu system file", + [nameof(SystemTarget)] = "Existing initialized Cemu system file", + [nameof(SystemMergeWarning)] = "System is shared by all three character slots and may contain settings or data for the other slots. Supply both the 3DS source and the existing Cemu target. The converter merges only housekeeper gallery/movie flags and preserves every other Cemu byte.", [nameof(RunSystemDryRun)] = "Run system Dry Run", - [nameof(WriteSystem)] = "Write system", + [nameof(WriteSystem)] = "Merge gallery/movie flags", [nameof(SystemManifest)] = "System manifest", [nameof(OptionalExtData)] = "Optional ExtData", [nameof(GuildCards)] = "Guild cards & offline partners", @@ -440,15 +442,16 @@ private string Text(string key) [nameof(CecAcknowledgementRequired)] = "使用 CEC 写入命令前请确认实验性警告。", [nameof(CecPathsRequired)] = "请选择准确的 CEC 邮箱目录和 Cemu cec 目标路径。", [nameof(CecFingerprintMissing)] = "CEC Dry Run 没有提供聚合记录集和目标 JSON 指纹。", - [nameof(SystemPathsRequired)] = "运行这个独立事务前,请选择准确的 3DS 和 Cemu system 文件。", + [nameof(SystemPathsRequired)] = "运行这个独立合并前,请选择准确的 3DS 源 system 和已存在、已初始化的 Cemu system。", [nameof(ExtrasPathsRequired)] = "请至少选择一个 ExtData 组,以及准确的源目录和临时目录。", [nameof(ExtrasFingerprintMissing)] = "ExtData Dry Run 没有提供预期的组件或集合指纹。", [nameof(OptionalDataNeedsConfiguration)] = "请先完成已选择的 system 或 ExtData 路径配置,再运行核心 Dry Run。", [nameof(SharedSystem)] = "共享 system", [nameof(SystemSource)] = "3DS system 文件", - [nameof(SystemTarget)] = "Cemu system 文件", + [nameof(SystemTarget)] = "已存在且已初始化的 Cemu system 文件", + [nameof(SystemMergeWarning)] = "system 由三个角色槽位共享,可能同时保存其他槽位数据或设置。必须同时提供 3DS 源 system 和现有 Cemu 目标 system;转换器只合并管家画廊/动画标记,其他 Cemu 字节全部保持不变。", [nameof(RunSystemDryRun)] = "运行 system Dry Run", - [nameof(WriteSystem)] = "写入 system", + [nameof(WriteSystem)] = "合并画廊/动画标记", [nameof(SystemManifest)] = "system manifest", [nameof(OptionalExtData)] = "可选 ExtData", [nameof(GuildCards)] = "公会名片与离线伙伴", diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml b/apps/mh3g-save-converter-windows/MainWindow.xaml index bad9a1e..50ca378 100644 --- a/apps/mh3g-save-converter-windows/MainWindow.xaml +++ b/apps/mh3g-save-converter-windows/MainWindow.xaml @@ -311,9 +311,11 @@ - - - + + + + + @@ -326,8 +328,8 @@ - - + + @@ -337,8 +339,9 @@ - - + + + diff --git a/crates/mh3g-save-convert/src/converter.rs b/crates/mh3g-save-convert/src/converter.rs index 25b1517..e703188 100644 --- a/crates/mh3g-save-convert/src/converter.rs +++ b/crates/mh3g-save-convert/src/converter.rs @@ -24,6 +24,15 @@ pub const EXTERNAL_COMPONENT_NAMES: [&str; 8] = [ const CARD_PAYLOAD_SIZE: usize = 0x57_FFC; const CARDBOX_PAYLOAD_SIZE: usize = 0x2F_FFC; const QUEST_PAYLOAD_SIZE: usize = 0x28_FFC; +/// Shared gallery/movie unlock bitset inside the logical `system` payload. +/// +/// In a Cemu file this is file range `0x68..0x78` because the Wii U +/// container header is 40 bytes. The corresponding 3DS range is +/// `0x44..0x54`. Four little-endian 3DS words map to four big-endian Wii U +/// words. The flags are shared by all character slots, so migration must +/// union them with an initialized Wii U `system` instead of replacing the +/// complete shared file. +pub const SYSTEM_GALLERY_PAYLOAD_RANGE: std::ops::Range = 0x40..0x50; fn external_component_payload_size(filename: &str) -> Option { match filename { @@ -224,6 +233,58 @@ pub fn convert_3ds_system_to_cemu_named( Ok(output) } +/// Merge 3DS gallery/movie unlock flags into an initialized Cemu `system`. +/// +/// `system` is shared by every character slot. Only the independently +/// verified gallery/movie bitset is portable here; settings and any other +/// slot-shared records retain their current Wii U bytes. Bitwise union keeps +/// unlocks already earned by any Wii U slot while adding unlocks present on +/// the 3DS side. +pub fn merge_3ds_system_gallery_into_cemu_named( + source: &[u8], + current: &[u8], + filename: &str, +) -> Result, ConversionError> { + if filename != "system" { + return Err(ConversionError::InvalidSave(format!( + "shared system target basename must be system: {filename}" + ))); + } + if inspect_bytes(source)?.profile != SaveProfile::JpThreeDsSystem { + return Err(ConversionError::InvalidSave( + "expected a Japanese MH3G 3DS system source".to_owned(), + )); + } + if inspect_bytes(current)?.profile != SaveProfile::JpCemuSystem { + return Err(ConversionError::InvalidSave( + "expected an initialized Japanese MH3G Wii U/Cemu system target".to_owned(), + )); + } + + let source_payload = &source[JP_3DS_HEADER.len()..]; + let current_payload_offset = current.len() - SYSTEM_PAYLOAD_SIZE; + let mut output = current.to_vec(); + + for relative in SYSTEM_GALLERY_PAYLOAD_RANGE.step_by(4) { + let source_flags = u32::from_le_bytes( + source_payload[relative..relative + 4] + .try_into() + .expect("gallery word is four bytes"), + ); + let target_offset = current_payload_offset + relative; + let current_flags = u32::from_be_bytes( + current[target_offset..target_offset + 4] + .try_into() + .expect("gallery word is four bytes"), + ); + output[target_offset..target_offset + 4] + .copy_from_slice(&(source_flags | current_flags).to_be_bytes()); + } + + debug_assert_eq!(inspect_bytes(&output)?.profile, SaveProfile::JpCemuSystem); + Ok(output) +} + pub fn convert_source_to_cemu(source: &[u8], filename: &str) -> Result, ConversionError> { match inspect_bytes(source)?.profile { SaveProfile::JpThreeDs => convert_3ds_to_cemu_named(source, filename), @@ -240,7 +301,8 @@ mod tests { use crate::{ converter::{ convert_3ds_system_to_cemu, convert_3ds_to_cemu, convert_3ds_to_cemu_named, - convert_external_component_to_cemu_named, validate_cemu_external_component_named, + convert_external_component_to_cemu_named, merge_3ds_system_gallery_into_cemu_named, + validate_cemu_external_component_named, }, profile::{ CEMU_SIZE, JP_3DS_HEADER, JP_CEMU_HEADER, PAYLOAD_SIZE, SaveProfile, THREE_DS_SIZE, @@ -287,6 +349,71 @@ mod tests { ); } + #[test] + fn merges_only_shared_gallery_flags_into_an_initialized_cemu_system() { + use crate::profile::{ + CEMU_SYSTEM_SIZE, JP_CEMU_SYSTEM_HEADER, SYSTEM_PAYLOAD_SIZE, THREE_DS_SYSTEM_SIZE, + }; + + let mut source = vec![0_u8; THREE_DS_SYSTEM_SIZE]; + source[..JP_3DS_HEADER.len()].copy_from_slice(&JP_3DS_HEADER); + let source_payload = JP_3DS_HEADER.len(); + source[source_payload + 0x40..source_payload + 0x44] + .copy_from_slice(&0x0000_0005_u32.to_le_bytes()); + source[source_payload + 0x44..source_payload + 0x48] + .copy_from_slice(&0x8000_0000_u32.to_le_bytes()); + source[source_payload + 0x54] = 0xA5; + + let mut current = build_jp_cemu_header("system", SYSTEM_PAYLOAD_SIZE) + .unwrap() + .to_vec(); + current.resize(CEMU_SYSTEM_SIZE, 0); + let target_payload = JP_CEMU_SYSTEM_HEADER.len(); + current[target_payload + 0x40..target_payload + 0x44] + .copy_from_slice(&0x0000_0002_u32.to_be_bytes()); + current[target_payload + 0x48..target_payload + 0x4C] + .copy_from_slice(&0x0000_0010_u32.to_be_bytes()); + current[target_payload + 0x54] = 0x5A; + let before = current.clone(); + + let output = merge_3ds_system_gallery_into_cemu_named(&source, ¤t, "system").unwrap(); + + assert_eq!( + &output[target_payload + 0x40..target_payload + 0x44], + &0x0000_0007_u32.to_be_bytes() + ); + assert_eq!( + &output[target_payload + 0x44..target_payload + 0x48], + &0x8000_0000_u32.to_be_bytes() + ); + assert_eq!( + &output[target_payload + 0x48..target_payload + 0x4C], + &0x0000_0010_u32.to_be_bytes() + ); + assert_eq!(output[target_payload + 0x54], 0x5A); + assert_eq!( + &output[..target_payload + 0x40], + &before[..target_payload + 0x40] + ); + assert_eq!( + &output[target_payload + 0x50..], + &before[target_payload + 0x50..] + ); + } + + #[test] + fn system_gallery_merge_rejects_a_3ds_file_as_the_wii_u_baseline() { + use crate::profile::THREE_DS_SYSTEM_SIZE; + + let mut source = vec![0_u8; THREE_DS_SYSTEM_SIZE]; + source[..JP_3DS_HEADER.len()].copy_from_slice(&JP_3DS_HEADER); + + let error = merge_3ds_system_gallery_into_cemu_named(&source, &source, "system") + .unwrap_err() + .to_string(); + assert!(error.contains("Wii U/Cemu system target")); + } + #[test] fn converts_a_japanese_3ds_save_without_mutating_the_source() { let source = synthetic_3ds_source(); diff --git a/crates/mh3g-save-convert/src/main.rs b/crates/mh3g-save-convert/src/main.rs index 1be57cc..c68cb73 100644 --- a/crates/mh3g-save-convert/src/main.rs +++ b/crates/mh3g-save-convert/src/main.rs @@ -19,8 +19,9 @@ use mh3g_save_convert::{ detect_component_revision, merge_component, }, converter::{ - EXTERNAL_COMPONENT_NAMES, convert_external_component_to_cemu_named, convert_source_to_cemu, - reset_guild_card_component_to_cemu_named, + EXTERNAL_COMPONENT_NAMES, SYSTEM_GALLERY_PAYLOAD_RANGE, + convert_external_component_to_cemu_named, convert_source_to_cemu, + merge_3ds_system_gallery_into_cemu_named, reset_guild_card_component_to_cemu_named, }, events::event_snapshot, extras_transaction::{ @@ -33,7 +34,8 @@ use mh3g_save_convert::{ revision::ConverterRevision, transaction::{ InstallExpectations, existing_target_sha256, install_compatibility_merge_with_expectations, - install_with_expectations, manifest_path_for_target, rollback, sha256_hex, + install_merged_component_with_expectations, install_with_expectations, + manifest_path_for_target, rollback, sha256_hex, }, }; use serde::Serialize; @@ -168,7 +170,7 @@ enum Command { #[arg(long)] manifest: PathBuf, }, - /// Convert the Japanese MH3G 3DS shared system data, dry-running unless --write is given. + /// Merge 3DS gallery/movie flags into an existing Wii U/Cemu system file. ConvertSystem { source: PathBuf, #[arg(long)] @@ -176,13 +178,9 @@ enum Command { /// Require the source SHA-256 observed during the preceding Dry Run. #[arg(long, requires = "write")] expected_source_sha256: Option, - /// Require the target SHA-256 observed during the preceding Dry Run. + /// Require the existing Wii U/Cemu target SHA-256 from the preceding Dry Run. #[arg(long, requires = "write")] expected_target_sha256: Option, - /// Require the target to remain absent from the preceding Dry Run until - /// the transactional write acquires its per-slot lock. - #[arg(long, requires = "write", conflicts_with = "expected_target_sha256")] - expected_target_absent: bool, #[arg(long, conflicts_with = "write")] dry_run: bool, #[arg(long, conflicts_with = "dry_run")] @@ -626,7 +624,6 @@ fn run(cli: Cli) -> Result<(), ConversionError> { output, expected_source_sha256, expected_target_sha256, - expected_target_absent, dry_run, write, } => convert_system( @@ -634,7 +631,6 @@ fn run(cli: Cli) -> Result<(), ConversionError> { output, expected_source_sha256, expected_target_sha256, - expected_target_absent, dry_run, write, )?, @@ -928,26 +924,104 @@ fn convert_system( output: PathBuf, expected_source_sha256: Option, expected_target_sha256: Option, - expected_target_absent: bool, dry_run: bool, write: bool, ) -> Result { - convert_component( - source, - output, - HashPreconditions { - source_sha256: expected_source_sha256, - target_sha256: expected_target_sha256, - target_must_be_absent: expected_target_absent, - }, - dry_run, - write, - ComponentConversionProfile { - validate_path: validate_system_path, - source: SaveProfile::JpThreeDsSystem, - output: SaveProfile::JpCemuSystem, - }, - ) + debug_assert!(!(dry_run && write)); + validate_system_path(&source)?; + validate_system_path(&output)?; + if source.file_name() != output.file_name() { + return Err(ConversionError::InvalidSave( + "source and output shared-system names must both be system".to_owned(), + )); + } + if !output.exists() { + return Err(ConversionError::InvalidSave(format!( + "convert-system requires an existing initialized Wii U/Cemu system target so shared data from other slots can be preserved: {}", + output.display() + ))); + } + if write && (expected_source_sha256.is_none() || expected_target_sha256.is_none()) { + return Err(ConversionError::UnsafeInstall( + "convert-system --write requires --expected-source-sha256 and --expected-target-sha256 from the immediately preceding Dry Run" + .to_owned(), + )); + } + + let source_bytes = read_file(&source, "reading 3DS system source")?; + let source_inspection = inspect_bytes(&source_bytes)?; + if source_inspection.profile != SaveProfile::JpThreeDsSystem { + return Err(ConversionError::InvalidSave(format!( + "unexpected system source profile: {:?}; expected JpThreeDsSystem", + source_inspection.profile + ))); + } + let target_bytes = read_file(&output, "reading Wii U/Cemu system target")?; + let target_inspection = inspect_bytes(&target_bytes)?; + if target_inspection.profile != SaveProfile::JpCemuSystem { + return Err(ConversionError::InvalidSave(format!( + "unexpected system target profile: {:?}; expected JpCemuSystem", + target_inspection.profile + ))); + } + + let filename = output + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| ConversionError::InvalidSave("target filename is invalid".to_owned()))?; + let merged = merge_3ds_system_gallery_into_cemu_named(&source_bytes, &target_bytes, filename)?; + let merged_inspection = inspect_bytes(&merged)?; + let source_gallery_start = 4 + SYSTEM_GALLERY_PAYLOAD_RANGE.start; + let source_gallery_end = 4 + SYSTEM_GALLERY_PAYLOAD_RANGE.end; + let target_payload_start = target_bytes.len() - mh3g_save_convert::profile::SYSTEM_PAYLOAD_SIZE; + let target_gallery_start = target_payload_start + SYSTEM_GALLERY_PAYLOAD_RANGE.start; + let target_gallery_end = target_payload_start + SYSTEM_GALLERY_PAYLOAD_RANGE.end; + + let mut report = Report { + profile: Some(merged_inspection.profile), + size: Some(merged_inspection.size), + hashes: BTreeMap::from([ + ("source".to_owned(), source_inspection.sha256), + ("target_before".to_owned(), target_inspection.sha256), + ("output".to_owned(), merged_inspection.sha256), + ( + "source_gallery".to_owned(), + sha256_hex(&source_bytes[source_gallery_start..source_gallery_end]), + ), + ( + "target_gallery_before".to_owned(), + sha256_hex(&target_bytes[target_gallery_start..target_gallery_end]), + ), + ( + "output_gallery".to_owned(), + sha256_hex(&merged[target_gallery_start..target_gallery_end]), + ), + ]), + output: Some(output.clone()), + backup: None, + manifest: None, + status: "dry-run", + }; + + if write { + let manifest_path = manifest_path_for_target(&output)?; + let manifest = install_merged_component_with_expectations( + &source_bytes, + &merged, + &output, + &manifest_path, + InstallExpectations { + source_sha256: expected_source_sha256.as_deref(), + target_sha256: expected_target_sha256.as_deref(), + target_must_be_absent: false, + }, + )?; + report.backup = manifest.backup; + report.manifest = Some(manifest_path); + report.status = "written"; + } + + Ok(report) } fn convert_component( @@ -1785,7 +1859,7 @@ mod tests { } #[test] - fn convert_system_dry_run_never_creates_the_target_file() { + fn convert_system_dry_run_preserves_the_existing_target_file() { let temp = tempfile::tempdir().unwrap(); let source_dir = temp.path().join("source"); let output_dir = temp.path().join("output"); @@ -1796,14 +1870,21 @@ mod tests { let mut bytes = vec![0_u8; THREE_DS_SYSTEM_SIZE]; bytes[..JP_3DS_HEADER.len()].copy_from_slice(&JP_3DS_HEADER); fs::write(&source, bytes).unwrap(); + let mut current = mh3g_save_convert::profile::build_jp_cemu_header( + "system", + mh3g_save_convert::profile::SYSTEM_PAYLOAD_SIZE, + ) + .unwrap() + .to_vec(); + current.resize(mh3g_save_convert::profile::CEMU_SYSTEM_SIZE, 0); + fs::write(&output, ¤t).unwrap(); - let report = - convert_system(source, output.clone(), None, None, false, false, false).unwrap(); + let report = convert_system(source, output.clone(), None, None, false, false).unwrap(); assert_eq!(report.profile, Some(SaveProfile::JpCemuSystem)); assert_eq!(report.status, "dry-run"); assert_eq!(report.output, Some(output.clone())); - assert!(!output.exists()); + assert_eq!(fs::read(output).unwrap(), current); } #[test] diff --git a/crates/mh3g-save-convert/src/transaction.rs b/crates/mh3g-save-convert/src/transaction.rs index c988aa6..628cfac 100644 --- a/crates/mh3g-save-convert/src/transaction.rs +++ b/crates/mh3g-save-convert/src/transaction.rs @@ -188,15 +188,16 @@ pub fn install_with_expectations( ) } -/// Install a field-aware compatibility merge through the same guarded slot -/// transaction as a fresh conversion. +/// Install a field-aware merge through the same guarded component transaction +/// as a fresh conversion. /// -/// The caller must have produced `installed` through the compatibility merge +/// The caller must have produced `installed` through a separately tested merge /// core. This entry point intentionally skips only the fresh-conversion /// equality check; profile validation, process refusal, target hash pinning, /// backup, atomic replacement, manifest publication, and rollback remain -/// unchanged. -pub fn install_compatibility_merge_with_expectations( +/// unchanged. It is used by both compatibility slot repair and conservative +/// shared-system gallery merging. +pub fn install_merged_component_with_expectations( source: &[u8], installed: &[u8], target: impl AsRef, @@ -218,6 +219,22 @@ pub fn install_compatibility_merge_with_expectations( ) } +pub fn install_compatibility_merge_with_expectations( + source: &[u8], + installed: &[u8], + target: impl AsRef, + manifest_path: impl AsRef, + expectations: InstallExpectations<'_>, +) -> Result { + install_merged_component_with_expectations( + source, + installed, + target, + manifest_path, + expectations, + ) +} + pub fn install_with( source: &[u8], installed: &[u8], diff --git a/crates/mh3g-save-convert/tests/cli.rs b/crates/mh3g-save-convert/tests/cli.rs index a230a0d..f207f38 100644 --- a/crates/mh3g-save-convert/tests/cli.rs +++ b/crates/mh3g-save-convert/tests/cli.rs @@ -13,7 +13,7 @@ use mh3g_save_convert::{ cec::{CEMU_HEADER_SIZE, CEMU_RECORD_AREA_OFFSET, empty_cemu_cec}, converter::{ convert_3ds_system_to_cemu_named, convert_3ds_to_cemu_named, - convert_external_component_to_cemu_named, + convert_external_component_to_cemu_named, merge_3ds_system_gallery_into_cemu_named, }, profile::{JP_3DS_HEADER, JP_CEMU_HEADER, build_jp_cemu_header}, }; @@ -99,6 +99,16 @@ fn system_fixture(temp: &TempDir) -> PathBuf { path } +fn cemu_system_fixture(temp: &TempDir) -> PathBuf { + let path = target_slot(temp, "system"); + let mut bytes = build_jp_cemu_header("system", THREE_DS_SYSTEM_SIZE - JP_3DS_HEADER.len()) + .unwrap() + .to_vec(); + bytes.resize(CEMU_SYSTEM_SIZE, 0); + fs::write(&path, bytes).unwrap(); + path +} + fn target_slot(temp: &TempDir, slot: &str) -> PathBuf { let directory = temp.path().join("cemu"); fs::create_dir_all(&directory).unwrap(); @@ -1017,18 +1027,11 @@ fn convert_write_rejects_an_expected_target_hash_when_the_target_is_missing() { let _guard = PROCESS_GUARD.lock().unwrap(); let temp = tempfile::tempdir().unwrap(); - for (command, source, target) in [ - ( - "convert", - slot_fixture(&temp, "user2"), - target_slot(&temp, "user2"), - ), - ( - "convert-system", - system_fixture(&temp), - target_slot(&temp, "system"), - ), - ] { + for (command, source, target) in [( + "convert", + slot_fixture(&temp, "user2"), + target_slot(&temp, "user2"), + )] { let output = run_output_with_stopped_emulators(&[ command.to_owned(), source.to_string_lossy().into_owned(), @@ -1050,6 +1053,31 @@ fn convert_write_rejects_an_expected_target_hash_when_the_target_is_missing() { } } +#[test] +fn convert_system_requires_an_existing_initialized_cemu_baseline() { + let temp = tempfile::tempdir().unwrap(); + let source = system_fixture(&temp); + let target = target_slot(&temp, "system"); + + let output = binary() + .args([ + "convert-system", + &source.to_string_lossy(), + "--output", + &target.to_string_lossy(), + "--dry-run", + ]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("requires an existing initialized Wii U/Cemu system target") + ); + assert!(!target.exists()); +} + #[test] fn convert_write_creates_a_new_export_only_when_the_target_stays_absent() { #[cfg(target_os = "macos")] @@ -1184,21 +1212,126 @@ fn convert_dry_run_reports_the_existing_target_hash() { ); } +#[test] +fn inspect_recognizes_both_3ds_and_cemu_system_profiles() { + let temp = tempfile::tempdir().unwrap(); + let source = system_fixture(&temp); + let target = cemu_system_fixture(&temp); + + let source_report = run_json(&["inspect".into(), source.to_string_lossy().into_owned()]); + let target_report = run_json(&["inspect".into(), target.to_string_lossy().into_owned()]); + + assert_eq!(source_report["profile"], "JpThreeDsSystem"); + assert_eq!(source_report["size"], THREE_DS_SYSTEM_SIZE); + assert_eq!(target_report["profile"], "JpCemuSystem"); + assert_eq!(target_report["size"], CEMU_SYSTEM_SIZE); +} + +#[test] +fn convert_system_unions_gallery_flags_and_preserves_every_other_cemu_byte() { + #[cfg(target_os = "macos")] + let _guard = PROCESS_GUARD.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let source = system_fixture(&temp); + let target = cemu_system_fixture(&temp); + + let mut source_bytes = fs::read(&source).unwrap(); + source_bytes[4 + 0x40..4 + 0x44].copy_from_slice(&0x0000_0005_u32.to_le_bytes()); + source_bytes[4 + 0x44..4 + 0x48].copy_from_slice(&0x8000_0000_u32.to_le_bytes()); + source_bytes[4 + 0x54] = 0xA5; + fs::write(&source, &source_bytes).unwrap(); + + let mut target_before = fs::read(&target).unwrap(); + target_before[40 + 0x40..40 + 0x44].copy_from_slice(&0x0000_0002_u32.to_be_bytes()); + target_before[40 + 0x48..40 + 0x4C].copy_from_slice(&0x0000_0010_u32.to_be_bytes()); + target_before[40 + 0x54] = 0x5A; + fs::write(&target, &target_before).unwrap(); + let expected = + merge_3ds_system_gallery_into_cemu_named(&source_bytes, &target_before, "system").unwrap(); + + let dry_run = run_json(&[ + "convert-system".into(), + source.to_string_lossy().into_owned(), + "--output".into(), + target.to_string_lossy().into_owned(), + "--dry-run".into(), + ]); + + assert_eq!(dry_run["profile"], "JpCemuSystem"); + assert_eq!(dry_run["status"], "dry-run"); + assert_eq!(fs::read(&target).unwrap(), target_before); + assert_eq!( + dry_run["hashes"]["output"], + hex::encode(sha2::Sha256::digest(&expected)) + ); + for key in ["source_gallery", "target_gallery_before", "output_gallery"] { + assert_eq!(dry_run["hashes"][key].as_str().unwrap().len(), 64); + } + + let written = run_json_with_stopped_emulators(&[ + "convert-system".into(), + source.to_string_lossy().into_owned(), + "--output".into(), + target.to_string_lossy().into_owned(), + "--expected-source-sha256".into(), + dry_run["hashes"]["source"].as_str().unwrap().into(), + "--expected-target-sha256".into(), + dry_run["hashes"]["target_before"].as_str().unwrap().into(), + "--write".into(), + ]); + + assert_eq!(written["status"], "written"); + assert_eq!(fs::read(&target).unwrap(), expected); + assert_eq!(&expected[..40 + 0x40], &target_before[..40 + 0x40]); + assert_eq!(&expected[40 + 0x50..], &target_before[40 + 0x50..]); +} + +#[test] +fn convert_system_write_requires_both_dry_run_hashes() { + #[cfg(target_os = "macos")] + let _guard = PROCESS_GUARD.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let source = system_fixture(&temp); + let target = cemu_system_fixture(&temp); + + for supplied in [None, Some("--expected-source-sha256")] { + let mut arguments = vec![ + "convert-system".to_owned(), + source.to_string_lossy().into_owned(), + "--output".to_owned(), + target.to_string_lossy().into_owned(), + ]; + if let Some(flag) = supplied { + arguments.extend([flag.to_owned(), "0".repeat(64)]); + } + arguments.push("--write".to_owned()); + let output = run_output_with_stopped_emulators(&arguments); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("requires --expected-source-sha256 and --expected-target-sha256") + ); + } +} + #[test] fn convert_system_write_rejects_a_stale_expected_target_hash_without_replacing_target() { #[cfg(target_os = "macos")] let _guard = PROCESS_GUARD.lock().unwrap(); let temp = tempfile::tempdir().unwrap(); let source = system_fixture(&temp); - let target = target_slot(&temp, "system"); - let previous = vec![0xA5; CEMU_SYSTEM_SIZE]; - fs::write(&target, &previous).unwrap(); + let target = cemu_system_fixture(&temp); + let previous = fs::read(&target).unwrap(); + let source_sha256 = hex::encode(sha2::Sha256::digest(fs::read(&source).unwrap())); let output = run_output_with_stopped_emulators(&[ "convert-system".into(), source.to_string_lossy().into_owned(), "--output".into(), target.to_string_lossy().into_owned(), + "--expected-source-sha256".into(), + source_sha256, "--expected-target-sha256".into(), "0".repeat(64), "--write".into(), diff --git a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md index 85dc0cb..846e97a 100644 --- a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md +++ b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md @@ -18,7 +18,7 @@ not mix files copied at different times. | Requested result | Required 3DS input | Current CLI command | Cemu destination that may change | | --- | --- | --- | --- | | Character, village/quest/event state, farm, hunting fleet, player data, and the slot-local offline-hunter cache | Exactly one loose `user1`, `user2`, or `user3` file | `convert --output ` | That same Cemu `user#` file only | -| Shared system data | Loose `system` file | `convert-system system --output system` | Cemu `system` only | +| Shared gallery/movie flags | Loose 3DS `system` plus an existing initialized Cemu `system` baseline | `convert-system system --output ` | Only the verified flag range inside Cemu `system`; all other target bytes are preserved | | Received/local guild-card data and the card side of offline-hall partners | The complete 3DS extdata `user` directory containing all eight files listed below | `convert-extras --source-dir --output-dir `, then `install-extras --staging-dir --target-dir --groups guild-cards` | Converted files in staging only until an explicit complete-group transaction installs `card1`, `card2`, `card3`, and `cardbox`, with a manifest and retained prior bytes | | Downloaded or created quests | The same complete 3DS extdata `user` directory | `convert-extras`, then `install-extras ... --groups quests` | Converted `quest1` through `quest4` in staging only until an explicit complete-group transaction installs them | | StreetPass/Hunter Search cache | Optional 3DS CEC mailbox root with `InBox___/BoxInfo_____` and received message files | `convert-cec --dry-run`, then `convert-cec --write --experimental --expected-source-record-set-sha256 ... --expected-target-sha256 ...` | Cemu `cec` only, plus its transaction artifacts | @@ -57,12 +57,12 @@ The core slot command requires source and destination basenames to match. For example, a source called `user2` can write only a destination called `user2`; it cannot be used to overwrite `user1` or an arbitrarily renamed file. -For a guarded `convert` or `convert-system` write, bind the Dry Run's source -SHA-256 plus one target condition. An existing target uses -`--expected-target-sha256`; a new output uses `--expected-target-absent`. -Those target conditions are mutually exclusive. The transaction obtains its -lock and checks the condition again, so a target that appears after a new-output -Dry Run is refused rather than overwritten. +For a guarded `convert` write, bind the Dry Run's source SHA-256 plus one target +condition. An existing target uses `--expected-target-sha256`; a new output +uses `--expected-target-absent`. Those target conditions are mutually +exclusive. `convert-system` does not permit a new target: its write requires +both the source and existing-target SHA-256 from the immediately preceding Dry +Run. The transaction obtains its lock and checks the condition again. ## Exact Component Groups @@ -129,12 +129,15 @@ second. | --- | ---: | --- | ---: | --- | | `system` | `0x3000` | `system` | `0x3024` | `convert-system` | -Provide it only when the migration explicitly includes shared system data. -The command reads and writes only the explicitly named `system` paths; it does -not alter any `user#` slot. This component carries the housekeeper -gallery/movie unlock history and other shared settings. Omitting it leaves the -current Cemu `system` untouched, so a core-slot conversion alone cannot fill -missing gallery entries. +Provide it only when the migration explicitly includes the housekeeper +gallery/movie history. `system` is shared across all three character slots and +also holds settings that are not owned by the selected slot. The command must +therefore receive both a 3DS source and an existing initialized Cemu target. It +recognizes the `0x3000` 3DS and `0x3024` Cemu profiles, bitwise-unions only the +verified gallery/movie flag range (Cemu file offsets `0x68..0x77`), and +preserves the Cemu header and every other target byte. A missing or malformed +Cemu baseline is rejected. Omitting this transaction leaves Cemu `system` +untouched, so a core-slot conversion alone cannot fill missing gallery entries. ### Optional Shared Extdata @@ -248,7 +251,7 @@ record import remains explicitly experimental. | `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 | -| `convert-system system --output system` | Source `system`; existing target and prior transaction records only when installing | Nothing | Named `system` plus the same transaction artifact pattern | +| `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 | | `inspect-cec --source-dir ... [--target cec] [--source-slot user#]` | CEC `InBox___` and `OutBox__`; optional `cec` and optional user slot | Nothing | N/A | 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 9e0e57d..ed5514f 100644 --- a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md +++ b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md @@ -13,7 +13,7 @@ CLI 会检查文件名、字节大小和存档头。它不会根据父目录推 | 期望结果 | 需要的 3DS 输入 | 当前 CLI 命令 | 可能发生变化的 Cemu 目标 | | --- | --- | --- | --- | | 角色、村/任务/事件状态、农场、狩猎船、玩家数据以及槽位内离线猎人缓存 | 一个明确的散装 `user1`、`user2` 或 `user3` 文件 | `convert --output ` | 只会改变同一个 Cemu `user#` 文件 | -| 共享系统数据 | 散装 `system` 文件 | `convert-system system --output system` | 只会改变 Cemu `system` | +| 共享画廊/动画标记 | 散装 3DS `system`,同时提供已存在且已初始化的 Cemu `system` 基线 | `convert-system system --output <已有 Cemu system>` | 只改变 Cemu `system` 中已确认的标记范围;其余目标字节全部保留 | | 已收到/本地公会名片数据以及离线集会所伙伴对应的名片部分 | 包含下列全部八个文件的完整 3DS ExtData `user` 目录 | `convert-extras --source-dir --output-dir `,随后执行 `install-extras --staging-dir --target-dir --groups guild-cards` | 先只生成暂存文件;明确执行完整组件组事务后才安装 `card1`、`card2`、`card3`、`cardbox`,并保留 manifest 与原目标字节 | | 下载或创建的任务 | 同一完整 3DS ExtData `user` 目录 | `convert-extras`,随后执行 `install-extras ... --groups quests` | 先只在暂存目录中生成 `quest1` 到 `quest4`;明确执行完整组件组事务后才安装 | | 擦身通信/猎人搜索缓存 | 可选的 3DS CEC 邮箱根目录,其中包含 `InBox___/BoxInfo_____` 和收到的消息文件 | 先执行 `convert-cec --dry-run`,随后执行 `convert-cec --write --experimental --expected-source-record-set-sha256 ... --expected-target-sha256 ...` | 只改变 Cemu `cec` 及其事务文件 | @@ -40,7 +40,7 @@ CLI 只接受普通文件系统中的文件和目录。它不能打开 ZIP、7z 核心槽位命令要求源文件和目标文件的 basename 完全相同。例如,名为 `user2` 的源文件只能写入名为 `user2` 的目标,不能用于覆盖 `user1` 或任意改名文件。 -对于受保护的 `convert` 或 `convert-system` 写入,应绑定本次 Dry Run 的源 SHA-256,再加上一种目标条件。已有目标使用 `--expected-target-sha256`;新输出使用 `--expected-target-absent`。两种目标条件互斥。事务会取得锁后再次检查条件,因此新输出 Dry Run 后出现的目标会被拒绝,不会被覆盖。 +对于受保护的 `convert` 写入,应绑定本次 Dry Run 的源 SHA-256,再加上一种目标条件。已有目标使用 `--expected-target-sha256`;新输出使用 `--expected-target-absent`,两种条件互斥。`convert-system` 不允许新目标:写入必须同时提交紧邻 Dry Run 的源 SHA-256 和已有目标 SHA-256。事务会取得锁后再次检查条件。 ## 准确组件组 @@ -81,7 +81,7 @@ Dry Run 会把全部选中组件汇总成一个顶层版本判断,可能报告 | --- | ---: | --- | ---: | --- | | `system` | `0x3000` | `system` | `0x3024` | `convert-system` | -只有迁移明确包含共享系统数据时才提供它。该命令只读取和写入明确指定的 `system` 路径,不会改变任何 `user#` 槽位。该组件承载管家画廊/动画解锁记录及其他共享设置;不提供它时会保持当前 Cemu `system` 不变,因此只转换核心槽位不能补齐缺失的画廊记录。 +只有迁移明确包含管家画廊/动画记录时才提供它。`system` 由三个角色槽位共享,还包含不属于当前角色的设置,因此命令必须同时接收 3DS 源文件和已存在、已初始化的 Cemu 目标。它会识别 `0x3000` 的 3DS profile 与 `0x3024` 的 Cemu profile,只对已确认的画廊/动画标记范围(Cemu 文件偏移 `0x68..0x77`)逐位取并集,并保留 Cemu 头和其他全部目标字节。目标不存在或格式错误时会拒绝执行。不运行该事务会保持 Cemu `system` 不变,因此只转换核心槽位不能补齐缺失的画廊记录。 ### 可选的共享 ExtData @@ -153,7 +153,7 @@ CEC 既不属于 `user#`,也不属于 `card*`,正常公会名片/离线伙 | `inspect-events [--target ]` | 源槽位和可选目标槽位 | 无 | 不适用 | | `convert --output ` | 源槽位;只有安装时才读取已有目标和旧事务记录 | 无 | 指定目标槽位及下述核心事务文件 | | `repair-converted <3DS-user#> --current ` | 原始 3DS 槽位、当前 Cemu 槽位;可选完整 3DS/Cemu ExtData 集合 | 无 | 只改变报告中确认需要修复的同名 `user#` 和完整公会名片组,并创建协调 manifest;任务文件保持不变 | -| `convert-system system --output system` | 源 `system`;只有安装时才读取已有目标和旧事务记录 | 无 | 指定 `system` 及相同模式的事务文件 | +| `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 恢复事务 | | `inspect-cec --source-dir ... [--target cec] [--source-slot user#]` | CEC `InBox___` 和 `OutBox__`;可选 `cec` 和可选用户槽位 | 无 | 不适用 | diff --git a/docs/adr/0016-mh3g-shared-system-gallery-merge.md b/docs/adr/0016-mh3g-shared-system-gallery-merge.md new file mode 100644 index 0000000..119a732 --- /dev/null +++ b/docs/adr/0016-mh3g-shared-system-gallery-merge.md @@ -0,0 +1,79 @@ +# ADR 0016: Merge MH3G shared-system gallery flags conservatively + +- Status: Accepted +- Date: 2026-08-10 +- Scope: Japanese MH3G 3DS `system` to MH3G HD Wii U/Cemu `system` + +## Context + +MH3G stores the housekeeper gallery/movie unlock history in the separate +`system` component rather than in `user1`, `user2`, or `user3`. The same +`system` is shared by all three character slots and also contains settings and +records unrelated to the slot currently being migrated. + +Converter versions through 0.0.16 exposed `convert-system` as a complete 3DS +payload conversion followed by replacement of the Cemu target. Although that +operation had backup, manifest, hash preconditions, and rollback, replacing +the entire shared payload could discard Wii U settings or state contributed by +another character slot. + +The Japanese files have distinct validated containers: + +- 3DS `system`: `0x3000` bytes, `JpThreeDsSystem`; +- Wii U/Cemu `system`: `0x3024` bytes, `JpCemuSystem`. + +Community file-level research identifies the gallery unlock booleans at Cemu +file offsets `0x68..0x77` and reports that gallery state is shared between +profiles. Local comparison of an independently supplied 3DS/Wii U transfer +pair places the corresponding non-zero flag words in the same logical payload +range. These are file-format observations, not game-runtime proof for every +possible `system` field. + +## Decision + +`convert-system` becomes a two-input merge: + +1. require one valid Japanese 3DS `system` source; +2. require one existing, initialized Japanese Wii U/Cemu `system` target; +3. decode four 3DS little-endian flag words at logical payload `0x40..0x4F`; +4. decode the matching four Cemu big-endian words; +5. write their bitwise union back in Wii U byte order; +6. preserve the current Cemu header and every byte outside that 16-byte range. + +There is no implicit or explicit new-`system` export. `--write` requires both +the source SHA-256 and target SHA-256 from the immediately preceding Dry Run. +The installer rechecks the target hash under its component lock, creates a +hash-addressed backup, atomically replaces the target, and publishes the +existing manifest used by `rollback`. + +The lower-level complete container/endian conversion remains available to +unit tests and format analysis, but it is no longer the public +`convert-system` installation behavior. + +## Consequences + +- Existing Wii U gallery unlocks are retained while 3DS unlocks are added. +- Other-slot settings and unknown shared records are not overwritten. +- A missing, malformed, or wrongly selected Cemu `system` fails closed. +- A user must start MH3G HD once to initialize a target before migrating + gallery/movie flags. +- This operation intentionally does **not** claim to migrate every unknown + field in the 3DS `system` payload. + +## Migration and rollback + +Users of 0.0.16 or earlier should not rerun the old complete-file +`convert-system` operation. Select the original 3DS `system` and the current +Cemu `system`, run the new Dry Run, then write with both reported hashes. + +Every write retains the previous Cemu bytes in the standard +`.system.mh3g-backup-` file. `rollback --manifest +<.system.mh3g-install.json>` restores that exact baseline. + +## Verification boundary + +Deterministic tests prove profile recognition, endian-aware flag union, +preservation outside `0x68..0x77`, target/hash refusal, transaction backup, and +manifest behavior. Supplied transfer files are used only as local, uncommitted +comparison evidence. Game UI/runtime behavior remains unverified until a user +opens the gallery on Cemu or Wii U. From 960d14235a3464e5b702c13d477efd2de921fbe2 Mon Sep 17 00:00:00 2001 From: vincentadamnemessis Date: Mon, 10 Aug 2026 16:16:01 +0800 Subject: [PATCH 4/6] test(mh3g): keep missing-target case clippy clean --- crates/mh3g-save-convert/tests/cli.rs | 41 ++++++++++++--------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/crates/mh3g-save-convert/tests/cli.rs b/crates/mh3g-save-convert/tests/cli.rs index f207f38..bda6f71 100644 --- a/crates/mh3g-save-convert/tests/cli.rs +++ b/crates/mh3g-save-convert/tests/cli.rs @@ -1027,30 +1027,25 @@ fn convert_write_rejects_an_expected_target_hash_when_the_target_is_missing() { let _guard = PROCESS_GUARD.lock().unwrap(); let temp = tempfile::tempdir().unwrap(); - for (command, source, target) in [( - "convert", - slot_fixture(&temp, "user2"), - target_slot(&temp, "user2"), - )] { - let output = run_output_with_stopped_emulators(&[ - command.to_owned(), - source.to_string_lossy().into_owned(), - "--output".to_owned(), - target.to_string_lossy().into_owned(), - "--expected-target-sha256".to_owned(), - "0".repeat(64), - "--write".to_owned(), - ]); + let source = slot_fixture(&temp, "user2"); + let target = target_slot(&temp, "user2"); + let output = run_output_with_stopped_emulators(&[ + "convert".to_owned(), + source.to_string_lossy().into_owned(), + "--output".to_owned(), + target.to_string_lossy().into_owned(), + "--expected-target-sha256".to_owned(), + "0".repeat(64), + "--write".to_owned(), + ]); - assert_eq!(output.status.code(), Some(1), "command: {command}"); - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("target is missing but an expected dry-run SHA-256 was supplied"), - "command: {command}" - ); - assert!(!target.exists(), "command: {command}"); - assert_eq!(fs::read_dir(target.parent().unwrap()).unwrap().count(), 0); - } + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("target is missing but an expected dry-run SHA-256 was supplied") + ); + assert!(!target.exists()); + assert_eq!(fs::read_dir(target.parent().unwrap()).unwrap().count(), 0); } #[test] From 9c860e67644e6f8bcba381b3d97439dd77a6bb11 Mon Sep 17 00:00:00 2001 From: vincentadamnemessis Date: Mon, 10 Aug 2026 16:26:56 +0800 Subject: [PATCH 5/6] fix(windows): validate installed MSVC toolchain files --- .../package-mh3g-save-converter-windows.ps1 | 131 +++++++++++++++--- ...rify-mh3g-save-converter-windows-source.py | 28 ++++ 2 files changed, 137 insertions(+), 22 deletions(-) diff --git a/scripts/package-mh3g-save-converter-windows.ps1 b/scripts/package-mh3g-save-converter-windows.ps1 index d9de0a4..94e0943 100644 --- a/scripts/package-mh3g-save-converter-windows.ps1 +++ b/scripts/package-mh3g-save-converter-windows.ps1 @@ -111,40 +111,123 @@ function Get-VsWherePath { return $null } -function Get-VisualStudioInstallation { +function Get-VisualStudioCandidateInstallations { $vswhere = Get-VsWherePath - if ($null -eq $vswhere) { - return $null + $candidates = @() + if ($null -ne $vswhere) { + # Query the component-qualified instance first, but do not pipe the + # native process into Select-Object. On a freshly provisioned hosted + # runner that pipeline can close stdout before vswhere finishes and + # leave a misleading non-zero LASTEXITCODE. + $qualified = @(& $vswhere -latest -products * -requires "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" -property installationPath 2>$null) + $qualifiedExitCode = $LASTEXITCODE + if ($qualifiedExitCode -eq 0) { + $candidates += $qualified + } + + # Visual Studio Installer can finish copying cl.exe before its + # component registration becomes visible to vswhere -requires. Keep + # every registered instance as a fallback and validate the actual + # compiler files below rather than trusting registration alone. + $registered = @(& $vswhere -all -products * -property installationPath 2>$null) + $registeredExitCode = $LASTEXITCODE + if ($registeredExitCode -eq 0) { + $candidates += $registered + } } - $installation = @(& $vswhere -latest -products * -requires "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" -property installationPath | Select-Object -First 1) - if ($LASTEXITCODE -ne 0 -or $installation.Count -eq 0) { - return $null + # Also probe the canonical VS 2022 paths. This covers the short window in + # which Chocolatey has completed successfully but vswhere's instance + # catalog has not yet refreshed in the next PowerShell process. + $programRoots = @( + [Environment]::GetEnvironmentVariable("ProgramFiles(x86)", "Process"), + [Environment]::GetEnvironmentVariable("ProgramFiles", "Process") + ) + foreach ($programRoot in $programRoots) { + if ([string]::IsNullOrWhiteSpace($programRoot)) { + continue + } + foreach ($edition in @("BuildTools", "Enterprise", "Professional", "Community")) { + $candidates += Join-Path $programRoot "Microsoft Visual Studio\2022\$edition" + } } - $path = $installation[0].ToString().Trim() - if ([string]::IsNullOrWhiteSpace($path) -or -not (Test-Path -LiteralPath $path -PathType Container)) { - return $null + $unique = @() + foreach ($candidate in $candidates) { + if ($null -eq $candidate) { + continue + } + $path = $candidate.ToString().Trim() + if (-not [string]::IsNullOrWhiteSpace($path) -and -not ($unique -contains $path)) { + $unique += $path + } } - return $path + return $unique } -function Get-AnyVisualStudioInstallation { - $vswhere = Get-VsWherePath - if ($null -eq $vswhere) { - return $null +function Test-VisualStudioCppInstallation { + param([Parameter(Mandatory = $true)][string]$InstallationPath) + + if (-not (Test-Path -LiteralPath $InstallationPath -PathType Container)) { + return $false } - $installation = @(& $vswhere -latest -products * -property installationPath | Select-Object -First 1) - if ($LASTEXITCODE -ne 0 -or $installation.Count -eq 0) { - return $null + $developerCommand = Join-Path $InstallationPath "Common7\Tools\VsDevCmd.bat" + $toolRoot = Join-Path $InstallationPath "VC\Tools\MSVC" + if (-not (Test-Path -LiteralPath $developerCommand -PathType Leaf) -or + -not (Test-Path -LiteralPath $toolRoot -PathType Container)) { + return $false } - $path = $installation[0].ToString().Trim() - if ([string]::IsNullOrWhiteSpace($path) -or -not (Test-Path -LiteralPath $path -PathType Container)) { - return $null + $toolsets = @(Get-ChildItem -LiteralPath $toolRoot -Directory -ErrorAction SilentlyContinue | Sort-Object -Property Name -Descending) + foreach ($toolset in $toolsets) { + foreach ($hostArchitecture in @("Hostx64", "Hostx86")) { + $compiler = Join-Path $toolset.FullName "bin\$hostArchitecture\x64\cl.exe" + $linker = Join-Path $toolset.FullName "bin\$hostArchitecture\x64\link.exe" + if ((Test-Path -LiteralPath $compiler -PathType Leaf) -and + (Test-Path -LiteralPath $linker -PathType Leaf)) { + return $true + } + } + } + return $false +} + +function Get-VisualStudioInstallation { + foreach ($candidate in @(Get-VisualStudioCandidateInstallations)) { + if (Test-VisualStudioCppInstallation -InstallationPath $candidate) { + return $candidate + } } - return $path + return $null +} + +function Get-AnyVisualStudioInstallation { + foreach ($candidate in @(Get-VisualStudioCandidateInstallations)) { + if (Test-Path -LiteralPath $candidate -PathType Container) { + return $candidate + } + } + return $null +} + +function Wait-VisualStudioCppInstallation { + param( + [ValidateRange(1, 30)][int]$Attempts = 12, + [ValidateRange(1, 30)][int]$DelaySeconds = 5 + ) + + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + $installation = Get-VisualStudioInstallation + if ($null -ne $installation) { + return $installation + } + if ($attempt -lt $Attempts) { + Write-Host ("Waiting for the Visual Studio C++ toolset to settle ({0}/{1})..." -f $attempt, $Attempts) + Start-Sleep -Seconds $DelaySeconds + } + } + return $null } function Get-VisualStudioInstallerPath { @@ -674,7 +757,11 @@ function Install-MissingPrerequisites { } function Initialize-MsvcBuildEnvironment { - $installation = Get-VisualStudioInstallation + # A successful Visual Studio installer can return a few seconds before a + # new process sees a complete vswhere catalog. Retry for at most 60 + # seconds and accept a candidate only when cl.exe, link.exe and + # VsDevCmd.bat are all physically present. + $installation = Wait-VisualStudioCppInstallation if ($null -eq $installation) { throw "MSVC x64 Build Tools were not found. Install Microsoft.VisualStudio.Component.VC.Tools.x86.x64 and a Windows 10/11 SDK." } diff --git a/scripts/verify-mh3g-save-converter-windows-source.py b/scripts/verify-mh3g-save-converter-windows-source.py index f2b8b0b..f0402d4 100644 --- a/scripts/verify-mh3g-save-converter-windows-source.py +++ b/scripts/verify-mh3g-save-converter-windows-source.py @@ -114,6 +114,9 @@ def verify_local_packaging_script() -> None: "[switch]$Bootstrap", "winget", "vswhere.exe", + "Get-VisualStudioCandidateInstallations", + "Test-VisualStudioCppInstallation", + "Wait-VisualStudioCppInstallation", "Get-AnyVisualStudioInstallation", "setup.exe", "--installPath", @@ -259,6 +262,31 @@ def verify_local_packaging_script() -> None: 'Install-WithWinget -Id "Rustlang.Rustup" -AcceptedExitCodes @(0, -1978335189)' in rust_bootstrap, "Rustup bootstrap must treat WinGet's installed/no-update result as non-fatal", ) + visual_studio_detection = script.split("function Get-VisualStudioCandidateInstallations", 1)[1].split( + "function Get-VisualStudioInstallerPath", 1 + )[0] + for expected in ( + '"Microsoft.VisualStudio.Component.VC.Tools.x86.x64"', + "-all -products * -property installationPath", + '"VC\\Tools\\MSVC"', + '"bin\\$hostArchitecture\\x64\\cl.exe"', + '"bin\\$hostArchitecture\\x64\\link.exe"', + "[int]$Attempts = 12", + "[int]$DelaySeconds = 5", + "Start-Sleep -Seconds $DelaySeconds", + ): + require(expected in visual_studio_detection, f"Windows MSVC detection is missing {expected}") + require( + "| Select-Object -First 1" not in visual_studio_detection, + "vswhere must be allowed to finish before LASTEXITCODE is inspected", + ) + msvc_initialization = script.split("function Initialize-MsvcBuildEnvironment", 1)[1].split( + "function Assert-StageWithinArtifacts", 1 + )[0] + require( + "$installation = Wait-VisualStudioCppInstallation" in msvc_initialization, + "MSVC initialization must tolerate bounded Visual Studio registration delay", + ) # WinGet can retain Rustlang.Rustup's installed registration after the # current user's rustup/cargo proxy payload has disappeared. A normal # `winget install` then returns the documented no-update code, so the From e84da090cc5aebbcba8360c41f715f4b88763a48 Mon Sep 17 00:00:00 2001 From: VincentAdamNemessis <126950990+VincentAdamNemessisX@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:22:18 +0800 Subject: [PATCH 6/6] feat(converter): add native release update checks (#37) * feat(converter): add native release update checks * fix(converter): avoid GitHub API rate-limit failures --- README.md | 7 + README.zh-CN.md | 5 + apps/mh3g-save-converter-macos/README.md | 17 ++ .../mh3g-save-converter-macos/README.zh-CN.md | 13 + .../ConverterPresentation/Localization.swift | 28 ++ .../UpdateChecking.swift | 268 ++++++++++++++++ .../ConversionWorkbenchView.swift | 18 +- .../GitHubUpdateChecker.swift | 201 ++++++++++++ .../Resources/Localizable.xcstrings | 14 + .../MH3GSaveConverterMac/SettingsView.swift | 58 ++++ .../UpdateReleaseView.swift | 94 ++++++ .../UpdateCheckingTests.swift | 114 +++++++ .../Infrastructure/ConverterCopy.cs | 45 +++ .../MainWindow.xaml | 8 +- .../MainWindow.xaml.cs | 179 ++++++++++- .../Models/UpdateModels.cs | 32 ++ apps/mh3g-save-converter-windows/README.md | 20 +- .../README.zh-CN.md | 15 +- .../Services/GitHubUpdateService.cs | 285 ++++++++++++++++++ .../Services/UpdateCheckPreferenceStore.cs | 102 +++++++ .../package-mh3g-save-converter-windows.ps1 | 11 +- ...rify-mh3g-save-converter-windows-source.py | 52 ++++ 22 files changed, 1575 insertions(+), 11 deletions(-) create mode 100644 apps/mh3g-save-converter-macos/Sources/ConverterPresentation/UpdateChecking.swift create mode 100644 apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/GitHubUpdateChecker.swift create mode 100644 apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/UpdateReleaseView.swift create mode 100644 apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/UpdateCheckingTests.swift create mode 100644 apps/mh3g-save-converter-windows/Models/UpdateModels.cs create mode 100644 apps/mh3g-save-converter-windows/Services/GitHubUpdateService.cs create mode 100644 apps/mh3g-save-converter-windows/Services/UpdateCheckPreferenceStore.cs diff --git a/README.md b/README.md index 91cf69c..dc5e316 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,13 @@ rollback themselves. The macOS window appears in the Dock and Cmd-Tab, follows the system language by default, and can switch between Simplified Chinese and English in Settings. +Both native workbenches expose **About & Updates**. The first launch on each +local calendar day makes at most one non-blocking request to the official +`MHToolkit/mh-save-sync` latest-release endpoint; users may also check +manually. An unavailable GitHub connection never blocks conversion. A newer +release dialog shows its version, publication date, release notes, and official +link. No save bytes or selected paths are sent with this request. + The workbenches provide four **guided but non-blocking** stages: input and inspection, optional shared data, Dry Run, then write or rollback. Completing one stage reveals the recommended next action (for example, inspect then diff --git a/README.zh-CN.md b/README.zh-CN.md index 4622ba6..6aec25d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -53,6 +53,11 @@ JSON 报告;不会在 UI 内重写字节转换、备份、manifest、模拟器 macOS 窗口会正常出现于 Dock 和 Cmd-Tab;默认跟随系统语言,也可在设置中切换简体中文或 English。 +两个原生工作台都提供“**关于与更新**”。每个本地自然日首次启动最多向 +`MHToolkit/mh-save-sync` 官方 latest-release 接口发起一次非阻塞请求,也可手动检查; +GitHub 不可访问时绝不会阻塞转换。发现新版时会显示版本、发布日期、Release 说明和官方 +链接。该请求不会上传任何存档字节或用户所选路径。 + 工作台提供四个**有引导、但不强制**的阶段:输入与检查、可选共享数据、Dry Run、写入或 回滚。完成一个阶段后会显示推荐的下一步操作(例如检查完成后配置可选数据),但玩家仍可 返回前一阶段,或跳过可选组件。该引导不会额外执行任何转换。 diff --git a/apps/mh3g-save-converter-macos/README.md b/apps/mh3g-save-converter-macos/README.md index 3b4d2f5..7871cd5 100644 --- a/apps/mh3g-save-converter-macos/README.md +++ b/apps/mh3g-save-converter-macos/README.md @@ -31,6 +31,23 @@ Normal conversion uses a single-file manifest; compatibility repair uses `.mh3g-compatibility-repair-.json` and `rollback-repair`. Quit Nemessix, Azahar, and Cemu before any write or rollback. +The optional housekeeper gallery/movie migration never creates or replaces a +shared `system` from the 3DS file alone. Select both the 3DS source `system` and +an existing initialized Cemu `system`; the converter unions only the known +gallery/movie flags and preserves every other Cemu byte, including data shared +by the other character slots. + +## Updates + +The About & Updates section resolves the latest stable tag through the official +`MHToolkit/mh-save-sync` GitHub release page and reads its official Atom release +feed. This path does not consume the shared anonymous GitHub API quota; the +Release API remains a fallback. The first launch on each local calendar day +makes at most one silent attempt. A blocked or unavailable GitHub connection +never blocks the window or any local conversion; manual checks show the error +and may be retried. When a newer release exists, the app shows its title, +publication date, release notes, and official release link. + ## Local verification ```bash diff --git a/apps/mh3g-save-converter-macos/README.zh-CN.md b/apps/mh3g-save-converter-macos/README.zh-CN.md index 883491f..2b5da83 100644 --- a/apps/mh3g-save-converter-macos/README.zh-CN.md +++ b/apps/mh3g-save-converter-macos/README.zh-CN.md @@ -23,6 +23,19 @@ sidecar 并展示 JSON 报告,不在 Swift 中重复实现存档转换。 使用 `.mh3g-compatibility-repair-.json` 并通过 `rollback-repair` 总体回滚。 执行写入或回滚前必须退出 Nemessix、Azahar 和 Cemu。 +可选的管家画廊/动画迁移不会只凭 3DS `system` 新建或整文件覆盖共享 +`system`。必须同时选择 3DS 源 `system` 和已存在、已初始化的 Cemu `system`; +转换器只合并已知画廊/动画标记,其他 Cemu 字节(包括其他角色槽位共享数据) +全部保留。 + +## 更新检查 + +“关于与更新”会通过 `MHToolkit/mh-save-sync` 官方 GitHub Release 页面确认最新 +正式版 tag,并读取官方 Atom Release feed。该主路径不消耗共享的 GitHub 匿名 +API 配额,Release API 仅作为备用。每个本地自然日首次启动最多静默尝试一次; +GitHub 无法访问时不会阻塞窗口,也不会影响任何本地转换。手动检查会展示错误并 +允许重试;发现新版本时会显示版本名、发布日期、Release 说明和官方发布链接。 + ## 本地验证 ```bash diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift index 87f545b..9f41529 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift @@ -292,6 +292,20 @@ public enum ConverterCopy { "WorkflowState.Failure": "操作失败", "Settings.BundledSidecar": "打包时使用内置 sidecar", "Settings.Development": "开发版本", + "Update.About": "关于与更新", + "Update.CurrentVersion": "当前版本", + "Update.Check": "检查更新", + "Update.Checking": "正在连接 GitHub…", + "Update.UpToDate": "已经是最新版(GitHub:%@)", + "Update.Available": "发现新版本 %@,请在弹窗中查看更新说明。", + "Update.Failed": "无法检查更新", + "Update.NetworkNote": "每天首次启动会静默检查一次 GitHub Release;网络不可用时不会阻塞或影响任何本地存档操作。也可以随时手动重试。", + "Update.AvailableTitle": "发现新版本", + "Update.VersionSummary": "当前 %@ · 最新 %@", + "Update.ReleaseNotes": "更新说明", + "Update.NoReleaseNotes": "该版本未提供更新说明。", + "Update.Later": "稍后", + "Update.OpenRelease": "打开发布页面", ] private static let english: [String: String] = [ @@ -482,6 +496,20 @@ public enum ConverterCopy { "WorkflowState.Failure": "Operation failed", "Settings.BundledSidecar": "Bundled sidecar when packaged", "Settings.Development": "development", + "Update.About": "About & Updates", + "Update.CurrentVersion": "Current version", + "Update.Check": "Check for Updates", + "Update.Checking": "Contacting GitHub…", + "Update.UpToDate": "You are up to date (GitHub: %@)", + "Update.Available": "Version %@ is available. Review its release notes in the update window.", + "Update.Failed": "Unable to check for updates", + "Update.NetworkNote": "The first launch each day silently checks GitHub Releases once. An unavailable network never blocks or changes any local save operation, and you can retry manually at any time.", + "Update.AvailableTitle": "Update Available", + "Update.VersionSummary": "Current %@ · Latest %@", + "Update.ReleaseNotes": "Release notes", + "Update.NoReleaseNotes": "No release notes were provided for this version.", + "Update.Later": "Later", + "Update.OpenRelease": "Open Release Page", ] /// The complete set of copy IDs rendered by the SwiftUI workbench. Tests diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/UpdateChecking.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/UpdateChecking.swift new file mode 100644 index 0000000..82ab9db --- /dev/null +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/UpdateChecking.swift @@ -0,0 +1,268 @@ +import Foundation + +public struct ConverterSemanticVersion: Comparable, Equatable, Sendable, CustomStringConvertible { + public let components: [Int] + + public init?(_ value: String) { + let normalized = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingPrefix("v") + .split(separator: "+", maxSplits: 1, omittingEmptySubsequences: true)[0] + .split(separator: "-", maxSplits: 1, omittingEmptySubsequences: true)[0] + let parts = normalized.split(separator: ".", omittingEmptySubsequences: false) + guard !parts.isEmpty, + parts.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }), + parts.allSatisfy({ Int($0) != nil }) + else { + return nil + } + components = parts.map { Int($0)! } + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + let count = max(lhs.components.count, rhs.components.count) + for index in 0.. Bool { + !(lhs < rhs) && !(rhs < lhs) + } + + public var description: String { + components.map(String.init).joined(separator: ".") + } +} + +public struct GitHubConverterRelease: Codable, Equatable, Identifiable, Sendable { + public let tagName: String + public let name: String? + public let body: String? + public let htmlURL: URL + public let publishedAt: String? + public let draft: Bool + public let prerelease: Bool + + public var id: String { tagName } + + public var isOfficialStableRelease: Bool { + !draft + && !prerelease + && htmlURL.scheme == "https" + && htmlURL.host?.lowercased() == "github.com" + && htmlURL.path.hasPrefix("/MHToolkit/mh-save-sync/releases/") + } + + public init( + tagName: String, + name: String?, + body: String?, + htmlURL: URL, + publishedAt: String?, + draft: Bool, + prerelease: Bool + ) { + self.tagName = tagName + self.name = name + self.body = body + self.htmlURL = htmlURL + self.publishedAt = publishedAt + self.draft = draft + self.prerelease = prerelease + } + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case name + case body + case htmlURL = "html_url" + case publishedAt = "published_at" + case draft + case prerelease + } +} + +public enum ConverterUpdateDecision: Equatable, Sendable { + case updateAvailable + case upToDate + case invalidVersion + + public static func decide(current: String, latest: String) -> Self { + guard let currentVersion = ConverterSemanticVersion(current), + let latestVersion = ConverterSemanticVersion(latest) + else { + return .invalidVersion + } + return currentVersion < latestVersion ? .updateAvailable : .upToDate + } +} + +public enum GitHubReleaseAtomFeed { + public static func stableRelease(from data: Data, expectedReleaseURL: URL) throws -> GitHubConverterRelease { + guard isOfficialTagURL(expectedReleaseURL), + let tagName = expectedReleaseURL.lastPathComponent.removingPercentEncoding, + ConverterSemanticVersion(tagName) != nil + else { + throw GitHubReleaseAtomFeedError.invalidReleaseURL + } + + let delegate = GitHubReleaseAtomParser() + let parser = XMLParser(data: data) + parser.delegate = delegate + guard parser.parse(), parser.parserError == nil else { + throw GitHubReleaseAtomFeedError.invalidXML + } + guard let entry = delegate.entries.first(where: { + $0.link?.absoluteString == expectedReleaseURL.absoluteString + }) else { + throw GitHubReleaseAtomFeedError.releaseNotFound + } + + let release = GitHubConverterRelease( + tagName: tagName, + name: entry.title.nilIfBlank, + body: plainText(fromHTML: entry.content).nilIfBlank, + htmlURL: expectedReleaseURL, + publishedAt: entry.updated.nilIfBlank, + draft: false, + prerelease: false + ) + guard release.isOfficialStableRelease else { + throw GitHubReleaseAtomFeedError.invalidReleaseURL + } + return release + } + + public static func isOfficialTagURL(_ url: URL) -> Bool { + url.scheme == "https" + && url.host?.lowercased() == "github.com" + && url.query == nil + && url.fragment == nil + && url.path.hasPrefix("/MHToolkit/mh-save-sync/releases/tag/") + && url.pathComponents.count == 6 + } + + private static func plainText(fromHTML html: String) -> String { + var text = html.replacingOccurrences( + of: #"(?i)]*)?>"#, + with: "• ", + options: .regularExpression + ) + text = text.replacingOccurrences( + of: #"(?i)|||||"#, + with: "\n", + options: .regularExpression + ) + text = text.replacingOccurrences(of: #"<[^>]+>"#, with: "", options: .regularExpression) + return text + .split(whereSeparator: \Character.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n") + .prefix(32_000) + .description + } +} + +private enum GitHubReleaseAtomFeedError: Error { + case invalidReleaseURL + case invalidXML + case releaseNotFound +} + +private final class GitHubReleaseAtomParser: NSObject, XMLParserDelegate { + struct Entry { + var title = "" + var updated = "" + var content = "" + var link: URL? + } + + private(set) var entries: [Entry] = [] + private var currentEntry: Entry? + private var capturedElement: String? + private var characters = "" + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:] + ) { + if elementName == "entry" { + currentEntry = Entry() + return + } + guard currentEntry != nil else { return } + if elementName == "link", + attributeDict["rel"] == "alternate", + let href = attributeDict["href"], + let url = URL(string: href) + { + currentEntry?.link = url + } else if elementName == "title" || elementName == "updated" || elementName == "content" { + capturedElement = elementName + characters = "" + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + guard capturedElement != nil else { return } + characters += string + } + + func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String? + ) { + if elementName == capturedElement { + switch elementName { + case "title": + currentEntry?.title = characters.trimmingCharacters(in: .whitespacesAndNewlines) + case "updated": + currentEntry?.updated = characters.trimmingCharacters(in: .whitespacesAndNewlines) + case "content": + currentEntry?.content = characters + default: + break + } + capturedElement = nil + characters = "" + } + if elementName == "entry", let currentEntry { + entries.append(currentEntry) + self.currentEntry = nil + } + } +} + +public enum DailyUpdateCheckGate { + public static func shouldCheck( + lastAttempt: Date?, + now: Date, + calendar: Calendar = .current + ) -> Bool { + guard let lastAttempt else { return true } + return !calendar.isDate(lastAttempt, inSameDayAs: now) + } +} + +private extension String { + var nilIfBlank: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func trimmingPrefix(_ prefix: Character) -> String { + first == prefix ? String(dropFirst()) : self + } +} diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ConversionWorkbenchView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ConversionWorkbenchView.swift index 63bb595..f9289ae 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ConversionWorkbenchView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ConversionWorkbenchView.swift @@ -5,6 +5,7 @@ struct ConversionWorkbenchView: View { @Binding var localeOverride: String @State private var selectedNavigation: ConverterNavigation? = .input @State private var workflow = ConversionWorkflow(executable: ConverterExecutableLocator.locate()) + @StateObject private var updateChecker = GitHubUpdateChecker() @Environment(\.accessibilityReduceMotion) private var reduceMotion private var language: ConverterLanguage { @@ -50,6 +51,16 @@ struct ConversionWorkbenchView: View { // Navigation is controlled by native split-view selection. The // content changes are brief and do not disable any other control. } + .task { + await updateChecker.checkAutomaticallyIfNeeded() + } + .sheet(item: $updateChecker.availableRelease) { release in + UpdateReleaseView( + release: release, + currentVersion: updateChecker.currentVersion, + language: language + ) + } } @ViewBuilder @@ -68,7 +79,12 @@ struct ConversionWorkbenchView: View { case .experimentalCEC: ExperimentalCECView(workflow: workflow, language: language) case .settings: - SettingsView(localeOverride: $localeOverride, workflow: workflow, language: language) + SettingsView( + localeOverride: $localeOverride, + workflow: workflow, + updateChecker: updateChecker, + language: language + ) } } } diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/GitHubUpdateChecker.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/GitHubUpdateChecker.swift new file mode 100644 index 0000000..253078f --- /dev/null +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/GitHubUpdateChecker.swift @@ -0,0 +1,201 @@ +import ConverterPresentation +import Foundation + +enum UpdateCheckStatus: Equatable { + case idle + case checking + case upToDate(String) + case updateAvailable(String) + case failed(String) +} + +@MainActor +final class GitHubUpdateChecker: ObservableObject { + static let releaseEndpoint = URL(string: "https://api.github.com/repos/MHToolkit/mh-save-sync/releases/latest")! + static let releaseWebEndpoint = URL(string: "https://github.com/MHToolkit/mh-save-sync/releases/latest")! + static let releaseAtomEndpoint = URL(string: "https://github.com/MHToolkit/mh-save-sync/releases.atom")! + static let lastAttemptDefaultsKey = "MH3GSaveConverter.LastUpdateCheckAttempt" + + @Published private(set) var status: UpdateCheckStatus = .idle + @Published var availableRelease: GitHubConverterRelease? + + let currentVersion: String + + private let session: URLSession + private let defaults: UserDefaults + + init( + currentVersion: String? = nil, + session: URLSession? = nil, + defaults: UserDefaults = .standard + ) { + self.currentVersion = currentVersion ?? Self.bundleVersion + self.defaults = defaults + if let session { + self.session = session + } else { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 8 + configuration.timeoutIntervalForResource = 12 + self.session = URLSession(configuration: configuration) + } + } + + var isChecking: Bool { + status == .checking + } + + func checkAutomaticallyIfNeeded(now: Date = .now, calendar: Calendar = .current) async { + guard !isChecking, + DailyUpdateCheckGate.shouldCheck( + lastAttempt: defaults.object(forKey: Self.lastAttemptDefaultsKey) as? Date, + now: now, + calendar: calendar + ) + else { + return + } + + // Persist before networking. A blocked or offline GitHub request must + // not be repeated on every launch during the same local calendar day. + defaults.set(now, forKey: Self.lastAttemptDefaultsKey) + await performCheck(manual: false) + } + + func checkManually() async { + guard !isChecking else { return } + await performCheck(manual: true) + } + + private func performCheck(manual: Bool) async { + status = .checking + do { + let release = try await fetchLatestRelease() + try validate(release: release) + switch ConverterUpdateDecision.decide(current: currentVersion, latest: release.tagName) { + case .updateAvailable: + availableRelease = release + status = .updateAvailable(release.tagName) + case .upToDate: + availableRelease = nil + status = .upToDate(release.tagName) + case .invalidVersion: + throw UpdateCheckError.invalidVersion + } + } catch { + availableRelease = nil + // Automatic checks stay silent and never obstruct the local save + // workflow. Manual checks retain a diagnostic for the About pane. + status = manual ? .failed(error.localizedDescription) : .idle + } + } + + private func fetchLatestRelease() async throws -> GitHubConverterRelease { + do { + return try await fetchLatestReleaseFromWebFeed() + } catch { + let webFailure = error.localizedDescription + do { + return try await fetchLatestReleaseFromAPI() + } catch { + throw UpdateCheckError.allSourcesUnavailable( + web: webFailure, + api: error.localizedDescription + ) + } + } + } + + private func fetchLatestReleaseFromWebFeed() async throws -> GitHubConverterRelease { + var latestRequest = configuredRequest(url: Self.releaseWebEndpoint, accept: "text/html") + // One byte is enough: URLSession follows GitHub's /releases/latest + // redirect, and the final response URL carries the stable tag. This + // avoids downloading the full release page. + latestRequest.setValue("bytes=0-0", forHTTPHeaderField: "Range") + let (redirectProbe, latestResponse) = try await session.data(for: latestRequest) + guard let latestHTTP = latestResponse as? HTTPURLResponse, + latestHTTP.statusCode == 200 || latestHTTP.statusCode == 206, + redirectProbe.count <= 2 * 1_024 * 1_024, + let stableReleaseURL = latestHTTP.url, + GitHubReleaseAtomFeed.isOfficialTagURL(stableReleaseURL) + else { + throw UpdateCheckError.invalidResponse(source: "GitHub release page") + } + + let atomRequest = configuredRequest(url: Self.releaseAtomEndpoint, accept: "application/atom+xml") + let (atomData, atomResponse) = try await session.data(for: atomRequest) + guard let atomHTTP = atomResponse as? HTTPURLResponse, atomHTTP.statusCode == 200 else { + throw UpdateCheckError.invalidResponse(source: "GitHub release feed") + } + guard atomData.count <= 2 * 1_024 * 1_024 else { + throw UpdateCheckError.responseTooLarge(source: "GitHub release feed") + } + return try GitHubReleaseAtomFeed.stableRelease( + from: atomData, + expectedReleaseURL: stableReleaseURL + ) + } + + private func fetchLatestReleaseFromAPI() async throws -> GitHubConverterRelease { + var request = configuredRequest(url: Self.releaseEndpoint, accept: "application/vnd.github+json") + request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let status = (response as? HTTPURLResponse)?.statusCode + throw UpdateCheckError.invalidResponse( + source: status.map { "GitHub Release API (HTTP \($0))" } ?? "GitHub Release API" + ) + } + guard data.count <= 2 * 1_024 * 1_024 else { + throw UpdateCheckError.responseTooLarge(source: "GitHub Release API") + } + let release = try JSONDecoder().decode(GitHubConverterRelease.self, from: data) + try validate(release: release) + return release + } + + private func configuredRequest(url: URL, accept: String) -> URLRequest { + var request = URLRequest(url: url) + request.timeoutInterval = 8 + request.setValue(accept, forHTTPHeaderField: "Accept") + request.setValue("MH3GSaveConverter/\(safeUserAgentVersion)", forHTTPHeaderField: "User-Agent") + return request + } + + private func validate(release: GitHubConverterRelease) throws { + guard release.isOfficialStableRelease else { + throw UpdateCheckError.invalidRelease + } + } + + private var safeUserAgentVersion: String { + ConverterSemanticVersion(currentVersion)?.description ?? "0.0.0" + } + + private static var bundleVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "development" + } +} + +private enum UpdateCheckError: LocalizedError { + case invalidResponse(source: String) + case responseTooLarge(source: String) + case invalidRelease + case invalidVersion + case allSourcesUnavailable(web: String, api: String) + + var errorDescription: String? { + switch self { + case let .invalidResponse(source): + "\(source) returned an unexpected response." + case let .responseTooLarge(source): + "\(source) response exceeded the accepted size." + case .invalidRelease: + "GitHub Release metadata did not match the official MHToolkit repository." + case .invalidVersion: + "The current or latest release version could not be compared safely." + case let .allSourcesUnavailable(web, api): + "GitHub release page/feed and API were unavailable. Web: \(web) API: \(api)" + } + } +} 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 1a06981..f9d4da8 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings @@ -54,6 +54,20 @@ "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" } } } }, + "Update.About" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "About & Updates" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "关于与更新" } } } }, + "Update.CurrentVersion" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Current version" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "当前版本" } } } }, + "Update.Check" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Check for Updates" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "检查更新" } } } }, + "Update.Checking" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Contacting GitHub…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在连接 GitHub…" } } } }, + "Update.UpToDate" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "You are up to date (GitHub: %@)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已经是最新版(GitHub:%@)" } } } }, + "Update.Available" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Version %@ is available. Review its release notes in the update window." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "发现新版本 %@,请在弹窗中查看更新说明。" } } } }, + "Update.Failed" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Unable to check for updates" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无法检查更新" } } } }, + "Update.NetworkNote" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The first launch each day silently checks GitHub Releases once. An unavailable network never blocks or changes any local save operation, and you can retry manually at any time." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "每天首次启动会静默检查一次 GitHub Release;网络不可用时不会阻塞或影响任何本地存档操作。也可以随时手动重试。" } } } }, + "Update.AvailableTitle" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Update Available" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "发现新版本" } } } }, + "Update.VersionSummary" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Current %@ · Latest %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "当前 %@ · 最新 %@" } } } }, + "Update.ReleaseNotes" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Release notes" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "更新说明" } } } }, + "Update.NoReleaseNotes" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "No release notes were provided for this version." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "该版本未提供更新说明。" } } } }, + "Update.Later" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Later" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "稍后" } } } }, + "Update.OpenRelease" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Open Release Page" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "打开发布页面" } } } }, "WorkflowState.ComponentSelection" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Inspection complete" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已完成检查" } } } }, "WorkflowState.DryRun" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run complete" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run 已完成" } } } }, "WorkflowState.Failure" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Operation failed" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "操作失败" } } } }, diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/SettingsView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/SettingsView.swift index c98b395..96c145a 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/SettingsView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/SettingsView.swift @@ -4,6 +4,7 @@ import ConverterPresentation struct SettingsView: View { @Binding var localeOverride: String @Bindable var workflow: ConversionWorkflow + @ObservedObject var updateChecker: GitHubUpdateChecker let language: ConverterLanguage var body: some View { @@ -37,8 +38,65 @@ struct SettingsView: View { value: ConverterCopy.text(workflow.state.localizationKey, language: language) ) } + + Section(ConverterCopy.text("Update.About", language: language)) { + LabeledContent(ConverterCopy.text("Update.CurrentVersion", language: language)) { + Text(updateChecker.currentVersion) + .font(.caption.monospaced()) + } + + HStack(spacing: 10) { + Button(ConverterCopy.text("Update.Check", language: language)) { + Task { await updateChecker.checkManually() } + } + .disabled(updateChecker.isChecking) + + if updateChecker.isChecking { + ProgressView() + .controlSize(.small) + Text(ConverterCopy.text("Update.Checking", language: language)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + updateStatus + + Text(ConverterCopy.text("Update.NetworkNote", language: language)) + .font(.caption) + .foregroundStyle(.secondary) + } } .formStyle(.grouped) } } + + @ViewBuilder + private var updateStatus: some View { + switch updateChecker.status { + case .idle, .checking: + EmptyView() + case let .upToDate(latest): + Label( + String(format: ConverterCopy.text("Update.UpToDate", language: language), latest), + systemImage: "checkmark.circle.fill" + ) + .foregroundStyle(.green) + case let .updateAvailable(latest): + Label( + String(format: ConverterCopy.text("Update.Available", language: language), latest), + systemImage: "arrow.down.circle.fill" + ) + .foregroundStyle(.tint) + case let .failed(detail): + VStack(alignment: .leading, spacing: 4) { + Label(ConverterCopy.text("Update.Failed", language: language), systemImage: "wifi.exclamationmark") + .foregroundStyle(.orange) + Text(detail) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } } diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/UpdateReleaseView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/UpdateReleaseView.swift new file mode 100644 index 0000000..e65439a --- /dev/null +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/UpdateReleaseView.swift @@ -0,0 +1,94 @@ +import AppKit +import ConverterPresentation +import SwiftUI + +struct UpdateReleaseView: View { + let release: GitHubConverterRelease + let currentVersion: String + let language: ConverterLanguage + + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top, spacing: 14) { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 34)) + .foregroundStyle(.tint) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text(ConverterCopy.text("Update.AvailableTitle", language: language)) + .font(.title2.weight(.semibold)) + Text(releaseTitle) + .font(.headline) + Text( + String( + format: ConverterCopy.text("Update.VersionSummary", language: language), + currentVersion, + release.tagName + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + if let published = formattedPublishedDate { + Text(published) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + Divider() + + Text(ConverterCopy.text("Update.ReleaseNotes", language: language)) + .font(.headline) + ScrollView { + Text(releaseNotes) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(minHeight: 150, maxHeight: 320) + + HStack { + Spacer() + Button(ConverterCopy.text("Update.Later", language: language)) { + dismiss() + } + Button(ConverterCopy.text("Update.OpenRelease", language: language)) { + NSWorkspace.shared.open(release.htmlURL) + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(24) + .frame(minWidth: 540, idealWidth: 600, minHeight: 390) + } + + private var formattedPublishedDate: String? { + guard let value = release.publishedAt, + let date = ISO8601DateFormatter().date(from: value) + else { + return nil + } + return date.formatted(date: .long, time: .omitted) + } + + private var releaseTitle: String { + guard let name = release.name?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty + else { + return release.tagName + } + return name + } + + private var releaseNotes: String { + guard let body = release.body?.trimmingCharacters(in: .whitespacesAndNewlines), + !body.isEmpty + else { + return ConverterCopy.text("Update.NoReleaseNotes", language: language) + } + return body + } +} diff --git a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/UpdateCheckingTests.swift b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/UpdateCheckingTests.swift new file mode 100644 index 0000000..92a7504 --- /dev/null +++ b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/UpdateCheckingTests.swift @@ -0,0 +1,114 @@ +import Foundation +import XCTest +@testable import ConverterPresentation + +final class UpdateCheckingTests: XCTestCase { + func testSemanticVersionAcceptsReleaseTagsAndComparesNumerically() throws { + let current = try XCTUnwrap(ConverterSemanticVersion("0.0.16")) + let latest = try XCTUnwrap(ConverterSemanticVersion("v0.0.17")) + let twoDigit = try XCTUnwrap(ConverterSemanticVersion("0.0.100")) + + XCTAssertLessThan(current, latest) + XCTAssertLessThan(latest, twoDigit) + XCTAssertEqual(ConverterSemanticVersion("1.2"), ConverterSemanticVersion("1.2.0")) + } + + func testMalformedVersionsFailClosed() { + XCTAssertNil(ConverterSemanticVersion("release-latest")) + XCTAssertNil(ConverterSemanticVersion("1..2")) + XCTAssertEqual( + ConverterUpdateDecision.decide(current: "development", latest: "v0.0.17"), + .invalidVersion + ) + } + + func testGitHubReleasePayloadDecodesExpectedFields() throws { + let payload = #"{"tag_name":"v0.0.17","name":"MH3G 0.0.17","body":"Fix notes","html_url":"https://github.com/MHToolkit/mh-save-sync/releases/tag/v0.0.17","published_at":"2026-08-10T00:00:00Z","draft":false,"prerelease":false}"#.data(using: .utf8)! + let release = try JSONDecoder().decode(GitHubConverterRelease.self, from: payload) + + XCTAssertEqual(release.tagName, "v0.0.17") + XCTAssertEqual(release.name, "MH3G 0.0.17") + XCTAssertEqual(release.body, "Fix notes") + XCTAssertFalse(release.draft) + XCTAssertFalse(release.prerelease) + XCTAssertTrue(release.isOfficialStableRelease) + } + + func testReleaseLinkMustBelongToTheOfficialRepository() throws { + let spoofed = GitHubConverterRelease( + tagName: "v9.9.9", + name: nil, + body: nil, + htmlURL: try XCTUnwrap(URL(string: "https://github.com.evil.example/MHToolkit/mh-save-sync/releases/tag/v9.9.9")), + publishedAt: nil, + draft: false, + prerelease: false + ) + + XCTAssertFalse(spoofed.isOfficialStableRelease) + } + + func testAtomFeedConvertsTheExpectedStableReleaseAndPlainTextNotes() throws { + let feed = #""" + + + + 2026-08-10T00:00:00Z + + MH3G 0.0.17 + <h2>Fixes</h2><ul><li>Rate-limit fallback</li></ul> + + + """#.data(using: .utf8)! + let expectedURL = try XCTUnwrap( + URL(string: "https://github.com/MHToolkit/mh-save-sync/releases/tag/v0.0.17") + ) + + let release = try GitHubReleaseAtomFeed.stableRelease( + from: feed, + expectedReleaseURL: expectedURL + ) + + XCTAssertEqual(release.tagName, "v0.0.17") + XCTAssertEqual(release.name, "MH3G 0.0.17") + XCTAssertEqual(release.body, "Fixes\n• Rate-limit fallback") + XCTAssertEqual(release.publishedAt, "2026-08-10T00:00:00Z") + XCTAssertTrue(release.isOfficialStableRelease) + } + + func testAtomFeedRequiresTheExactOfficialTagURLAndMatchingEntry() throws { + let feed = #""" + + + + v0.0.17 + + + """#.data(using: .utf8)! + let spoofed = try XCTUnwrap( + URL(string: "https://github.com.evil.example/MHToolkit/mh-save-sync/releases/tag/v0.0.17") + ) + let missing = try XCTUnwrap( + URL(string: "https://github.com/MHToolkit/mh-save-sync/releases/tag/v0.0.18") + ) + + XCTAssertThrowsError( + try GitHubReleaseAtomFeed.stableRelease(from: feed, expectedReleaseURL: spoofed) + ) + XCTAssertThrowsError( + try GitHubReleaseAtomFeed.stableRelease(from: feed, expectedReleaseURL: missing) + ) + } + + func testDailyGateRunsOnlyOncePerLocalCalendarDay() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(secondsFromGMT: 8 * 3600)) + let lastAttempt = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 8, day: 10, hour: 1))) + let sameDay = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 8, day: 10, hour: 23))) + let nextDay = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 8, day: 11, hour: 0))) + + XCTAssertFalse(DailyUpdateCheckGate.shouldCheck(lastAttempt: lastAttempt, now: sameDay, calendar: calendar)) + XCTAssertTrue(DailyUpdateCheckGate.shouldCheck(lastAttempt: lastAttempt, now: nextDay, calendar: calendar)) + XCTAssertTrue(DailyUpdateCheckGate.shouldCheck(lastAttempt: nil, now: sameDay, calendar: calendar)) + } +} diff --git a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs index 5b40212..3dd3a32 100644 --- a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs +++ b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs @@ -32,6 +32,21 @@ public void SetLanguage(AppLanguageOverride language) public string LanguageSystem => Text(nameof(LanguageSystem)); public string LanguageChinese => Text(nameof(LanguageChinese)); public string LanguageEnglish => Text(nameof(LanguageEnglish)); + public string AboutUpdates => Text(nameof(AboutUpdates)); + public string AboutDescription => Text(nameof(AboutDescription)); + public string CurrentVersion => Text(nameof(CurrentVersion)); + public string CheckForUpdates => Text(nameof(CheckForUpdates)); + public string CheckingForUpdates => Text(nameof(CheckingForUpdates)); + public string UpdateAvailableTitle => Text(nameof(UpdateAvailableTitle)); + public string UpdateAvailableSummary => Text(nameof(UpdateAvailableSummary)); + public string ReleaseNotes => Text(nameof(ReleaseNotes)); + public string NoReleaseNotes => Text(nameof(NoReleaseNotes)); + public string OpenReleasePage => Text(nameof(OpenReleasePage)); + public string UpToDateTitle => Text(nameof(UpToDateTitle)); + public string UpToDateMessage => Text(nameof(UpToDateMessage)); + public string UpdateCheckFailed => Text(nameof(UpdateCheckFailed)); + public string UpdateNetworkNote => Text(nameof(UpdateNetworkNote)); + public string Close => Text(nameof(Close)); public string StageInput => Text(nameof(StageInput)); public string StageInspect => Text(nameof(StageInspect)); public string StageDryRun => Text(nameof(StageDryRun)); @@ -183,6 +198,21 @@ private string Text(string key) [nameof(LanguageSystem)] = "System default", [nameof(LanguageChinese)] = "Simplified Chinese", [nameof(LanguageEnglish)] = "English", + [nameof(AboutUpdates)] = "About & Updates", + [nameof(AboutDescription)] = "MH3G Save Converter uses the bundled local Rust converter. Update checks read only the official MHToolkit GitHub Release metadata.", + [nameof(CurrentVersion)] = "Current version", + [nameof(CheckForUpdates)] = "Check for Updates", + [nameof(CheckingForUpdates)] = "Contacting GitHub…", + [nameof(UpdateAvailableTitle)] = "Update Available", + [nameof(UpdateAvailableSummary)] = "Current {0} · Latest {1}", + [nameof(ReleaseNotes)] = "Release notes", + [nameof(NoReleaseNotes)] = "No release notes were provided for this version.", + [nameof(OpenReleasePage)] = "Open Release Page", + [nameof(UpToDateTitle)] = "Up to date", + [nameof(UpToDateMessage)] = "Version {0} is the latest published release.", + [nameof(UpdateCheckFailed)] = "Unable to check for updates", + [nameof(UpdateNetworkNote)] = "The first launch each day checks GitHub Releases once. A blocked or unavailable network never blocks or changes local save operations; use this button to retry manually.", + [nameof(Close)] = "Close", [nameof(StageInput)] = "Input", [nameof(StageInspect)] = "Inspect", [nameof(StageDryRun)] = "Dry Run", @@ -336,6 +366,21 @@ private string Text(string key) [nameof(LanguageSystem)] = "跟随系统", [nameof(LanguageChinese)] = "简体中文", [nameof(LanguageEnglish)] = "English", + [nameof(AboutUpdates)] = "关于与更新", + [nameof(AboutDescription)] = "MH3G Save Converter 使用内置的本地 Rust 转换器。更新检查只读取 MHToolkit 官方 GitHub Release 元数据。", + [nameof(CurrentVersion)] = "当前版本", + [nameof(CheckForUpdates)] = "检查更新", + [nameof(CheckingForUpdates)] = "正在连接 GitHub…", + [nameof(UpdateAvailableTitle)] = "发现新版本", + [nameof(UpdateAvailableSummary)] = "当前 {0} · 最新 {1}", + [nameof(ReleaseNotes)] = "更新说明", + [nameof(NoReleaseNotes)] = "该版本未提供更新说明。", + [nameof(OpenReleasePage)] = "打开发布页面", + [nameof(UpToDateTitle)] = "已经是最新版", + [nameof(UpToDateMessage)] = "版本 {0} 是当前最新正式版。", + [nameof(UpdateCheckFailed)] = "无法检查更新", + [nameof(UpdateNetworkNote)] = "每天首次启动会检查一次 GitHub Release;网络受限或不可用时不会阻塞或改动任何本地存档,也可以在这里手动重试。", + [nameof(Close)] = "关闭", [nameof(StageInput)] = "输入", [nameof(StageInspect)] = "检查", [nameof(StageDryRun)] = "Dry Run", diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml b/apps/mh3g-save-converter-windows/MainWindow.xaml index 50ca378..a3ab643 100644 --- a/apps/mh3g-save-converter-windows/MainWindow.xaml +++ b/apps/mh3g-save-converter-windows/MainWindow.xaml @@ -6,7 +6,7 @@ xmlns:converter="using:MHToolkit.MH3GSaveConverter.Windows.Controls" xmlns:models="using:MHToolkit.MH3GSaveConverter.Windows.Models" Title="MH3G Save Converter"> - + @@ -34,6 +34,12 @@ AutomationProperties.LiveSetting="Polite" AutomationProperties.Name="{Binding StatusText}" /> + diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml.cs b/apps/mh3g-save-converter-windows/MainWindow.xaml.cs index 35ecb43..93b1f70 100644 --- a/apps/mh3g-save-converter-windows/MainWindow.xaml.cs +++ b/apps/mh3g-save-converter-windows/MainWindow.xaml.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Microsoft.UI.Composition.SystemBackdrops; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -5,15 +6,22 @@ using Windows.Graphics; using Windows.Storage.Pickers; using WinRT.Interop; +using MHToolkit.MH3GSaveConverter.Windows.Models; +using MHToolkit.MH3GSaveConverter.Windows.Services; using MHToolkit.MH3GSaveConverter.Windows.ViewModels; namespace MHToolkit.MH3GSaveConverter.Windows; public sealed partial class MainWindow : Window { + private readonly GitHubUpdateService _updateService = new(); + private readonly UpdateCheckPreferenceStore _updateCheckStore = new(); + private readonly SemaphoreSlim _dialogGate = new(1, 1); + private Task? _activeUpdateCheck; private bool _synchronizingLanguage; private bool _synchronizingConversionMode; private bool _synchronizingRepairVersion; + private bool _loadedOnce; public MainWindow() { @@ -28,6 +36,174 @@ public MainWindow() public MainViewModel ViewModel { get; } + private async void RootGrid_Loaded(object sender, RoutedEventArgs e) + { + if (_loadedOnce) + { + return; + } + _loadedOnce = true; + + if (!_updateCheckStore.ShouldCheckToday()) + { + return; + } + + // Record the attempt before networking so a blocked GitHub connection + // is not retried on every launch during the same local calendar day. + _updateCheckStore.MarkAttempt(); + await CheckForUpdatesAsync(manual: false); + } + + private async void About_Click(object sender, RoutedEventArgs e) + { + var content = new StackPanel { Spacing = 10, Width = 520 }; + content.Children.Add(new TextBlock + { + Text = ViewModel.Copy.AboutDescription, + TextWrapping = TextWrapping.Wrap, + }); + content.Children.Add(new TextBlock + { + Text = $"{ViewModel.Copy.CurrentVersion}: {_updateService.CurrentVersion}", + FontFamily = new FontFamily("Cascadia Mono"), + }); + content.Children.Add(new TextBlock + { + Text = ViewModel.Copy.UpdateNetworkNote, + TextWrapping = TextWrapping.Wrap, + Foreground = (Brush)Application.Current.Resources["ConverterSlateBrush"], + }); + + var dialog = new ContentDialog + { + Title = ViewModel.Copy.AboutUpdates, + Content = content, + PrimaryButtonText = ViewModel.Copy.CheckForUpdates, + CloseButtonText = ViewModel.Copy.Close, + DefaultButton = ContentDialogButton.Primary, + }; + if (await ShowDialogAsync(dialog) == ContentDialogResult.Primary) + { + await CheckForUpdatesAsync(manual: true); + } + } + + private async Task CheckForUpdatesAsync(bool manual) + { + try + { + var activeCheck = _activeUpdateCheck; + if (activeCheck is null || activeCheck.IsCompleted) + { + activeCheck = _updateService.CheckAsync(); + _activeUpdateCheck = activeCheck; + } + var result = await activeCheck; + if (result.IsUpdateAvailable) + { + await ShowUpdateAvailableAsync(result); + } + else if (manual) + { + await ShowMessageAsync( + ViewModel.Copy.UpToDateTitle, + string.Format(ViewModel.Copy.UpToDateMessage, result.Release.TagName)); + } + } + catch (Exception exception) + { + // Automatic checks are advisory and silent. They never delay the + // local workflow beyond their own timeout or surface a startup + // blocker when GitHub is unavailable in the user's network. + if (manual) + { + await ShowMessageAsync( + ViewModel.Copy.UpdateCheckFailed, + $"{ViewModel.Copy.UpdateNetworkNote}\n\n{exception.Message}"); + } + } + } + + private async Task ShowUpdateAvailableAsync(UpdateCheckResult result) + { + var release = result.Release; + var content = new StackPanel { Spacing = 10, Width = 560 }; + content.Children.Add(new TextBlock + { + Text = string.Format( + ViewModel.Copy.UpdateAvailableSummary, + result.CurrentVersion, + release.TagName), + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + }); + if (release.PublishedAt is { } publishedAt) + { + content.Children.Add(new TextBlock + { + Text = publishedAt.ToLocalTime().ToString("D"), + Foreground = (Brush)Application.Current.Resources["ConverterSlateBrush"], + }); + } + content.Children.Add(new TextBlock + { + Text = ViewModel.Copy.ReleaseNotes, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + }); + content.Children.Add(new ScrollViewer + { + MaxHeight = 320, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Content = new TextBlock + { + Text = string.IsNullOrWhiteSpace(release.Body) ? ViewModel.Copy.NoReleaseNotes : release.Body, + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true, + }, + }); + + var dialog = new ContentDialog + { + Title = string.IsNullOrWhiteSpace(release.Name) + ? ViewModel.Copy.UpdateAvailableTitle + : release.Name, + Content = content, + PrimaryButtonText = ViewModel.Copy.OpenReleasePage, + CloseButtonText = ViewModel.Copy.Close, + DefaultButton = ContentDialogButton.Primary, + }; + if (await ShowDialogAsync(dialog) == ContentDialogResult.Primary) + { + Process.Start(new ProcessStartInfo(release.HtmlUrl) { UseShellExecute = true }); + } + } + + private async Task ShowMessageAsync(string title, string message) + { + var dialog = new ContentDialog + { + Title = title, + Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, + CloseButtonText = ViewModel.Copy.Close, + DefaultButton = ContentDialogButton.Close, + }; + await ShowDialogAsync(dialog); + } + + private async Task ShowDialogAsync(ContentDialog dialog) + { + await _dialogGate.WaitAsync(); + try + { + dialog.XamlRoot = RootGrid.XamlRoot; + return await dialog.ShowAsync(); + } + finally + { + _dialogGate.Release(); + } + } + private void ConfigureWindowMaterial() { AppWindow.Resize(new SizeInt32(1240, 900)); @@ -468,14 +644,13 @@ private async Task ConfirmAsync(string title, string message) { var dialog = new ContentDialog { - XamlRoot = RootGrid.XamlRoot, Title = title, Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, PrimaryButtonText = ViewModel.Copy.Continue, CloseButtonText = ViewModel.Copy.Cancel, DefaultButton = ContentDialogButton.Close, }; - return await dialog.ShowAsync() == ContentDialogResult.Primary; + return await ShowDialogAsync(dialog) == ContentDialogResult.Primary; } private async Task RunSafelyAsync(Func operation) diff --git a/apps/mh3g-save-converter-windows/Models/UpdateModels.cs b/apps/mh3g-save-converter-windows/Models/UpdateModels.cs new file mode 100644 index 0000000..2e3ac0f --- /dev/null +++ b/apps/mh3g-save-converter-windows/Models/UpdateModels.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace MHToolkit.MH3GSaveConverter.Windows.Models; + +public sealed class GitHubReleaseInfo +{ + [JsonPropertyName("tag_name")] + public string TagName { get; init; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("body")] + public string? Body { get; init; } + + [JsonPropertyName("html_url")] + public string HtmlUrl { get; init; } = string.Empty; + + [JsonPropertyName("published_at")] + public DateTimeOffset? PublishedAt { get; init; } + + [JsonPropertyName("draft")] + public bool Draft { get; init; } + + [JsonPropertyName("prerelease")] + public bool Prerelease { get; init; } +} + +public sealed record UpdateCheckResult( + string CurrentVersion, + GitHubReleaseInfo Release, + bool IsUpdateAvailable); diff --git a/apps/mh3g-save-converter-windows/README.md b/apps/mh3g-save-converter-windows/README.md index e70ed71..ccd89ca 100644 --- a/apps/mh3g-save-converter-windows/README.md +++ b/apps/mh3g-save-converter-windows/README.md @@ -30,7 +30,10 @@ code. Dry Run's aggregate source_record_set_sha256 and target_sha256_before are bound to its write, so a changed mailbox or cache fails closed. It never turns on merely because a player selected the primary slot. -- `system` and normal ExtData staging/install remain explicit transactions. +- Shared `system` and normal ExtData staging/install remain explicit + transactions. Gallery/movie migration requires both the 3DS source `system` + and an existing initialized Cemu `system`; only the known flags are unioned, + while all other target bytes and other-slot shared data are retained. The Windows backend installs complete ExtData groups through `ReplaceFileW`, manifest-bound backups, and a durable recovery journal. The UI never guesses a Cemu MLC directory or silently installs a group. Compatibility repair @@ -42,6 +45,21 @@ Quit Nemessix, Azahar, and Cemu before any write or rollback. See the root [Chinese CLI contract](../../docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md) for the exact source files and transaction scope. +## Updates + +The About & Updates dialog resolves the latest stable tag through the official +`MHToolkit/mh-save-sync` GitHub release page and reads its official Atom release +feed. This path does not consume the shared anonymous GitHub API quota; the +Release API remains a fallback. The first launch on each local calendar day +makes at most one silent attempt. A blocked or unavailable GitHub connection +never blocks the window or changes a local save; manual checks display the +failure and can be retried. A newer release dialog includes the release title, +publication date, notes, and official link. + +The package script passes the Rust converter version into both WinUI publish +forms, so the folder, portable EXE, and installer compare the same real version +instead of the .NET default assembly version. + ## Release formats The one-command package build produces three Windows x64 formats from the same diff --git a/apps/mh3g-save-converter-windows/README.zh-CN.md b/apps/mh3g-save-converter-windows/README.zh-CN.md index 7a5a34f..71f08f2 100644 --- a/apps/mh3g-save-converter-windows/README.zh-CN.md +++ b/apps/mh3g-save-converter-windows/README.zh-CN.md @@ -26,7 +26,9 @@ 回滚链路。写入会绑定紧接着的 Dry Run 返回的聚合 source_record_set_sha256 与 target_sha256_before,邮箱或缓存变化时会 失败关闭。选择核心槽位不会自动打开 CEC。 -- `system` 与普通 ExtData 暂存/安装仍是显式事务。Windows 后端使用 +- 共享 `system` 与普通 ExtData 暂存/安装仍是显式事务。画廊/动画迁移必须同时 + 提供 3DS 源 `system` 和已存在、已初始化的 Cemu `system`;只合并已知标记, + 其余目标字节和其他角色槽位共享数据全部保留。Windows 后端使用 `ReplaceFileW`、manifest 绑定备份和持久恢复日志安装完整 ExtData 组件组; 界面不会猜测 Cemu MLC 目录或静默安装。兼容修复模式只字段级更新仍保持旧版 转换结果的 `user#` 与公会名片字段,当前 `quest1` 至 `quest4` 保持原字节。 @@ -36,6 +38,17 @@ [中文 CLI 文件契约](../../docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md) 与 [English CLI contract](../../docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md)。 +## 更新检查 + +“关于与更新”会通过 `MHToolkit/mh-save-sync` 官方 GitHub Release 页面确认最新 +正式版 tag,并读取官方 Atom Release feed。该主路径不消耗共享的 GitHub 匿名 +API 配额,Release API 仅作为备用。每个本地自然日首次启动最多静默尝试一次; +GitHub 无法访问时不会阻塞窗口或改动本地存档,手动检查会展示失败信息并允许 +重试。发现新版本时,弹窗会显示版本名、发布日期、更新说明与官方链接。 + +打包脚本会把 Rust 转换器版本同时注入 WinUI 文件夹版和单文件便携版,因此 ZIP、 +便携 EXE 和安装器比较的是同一个真实版本,不会误用 .NET 默认程序集版本。 + ## 发布格式 一键打包会使用同一份原生 WinUI 应用与 Rust sidecar 生成三种 Windows x64 形式: diff --git a/apps/mh3g-save-converter-windows/Services/GitHubUpdateService.cs b/apps/mh3g-save-converter-windows/Services/GitHubUpdateService.cs new file mode 100644 index 0000000..9369b4f --- /dev/null +++ b/apps/mh3g-save-converter-windows/Services/GitHubUpdateService.cs @@ -0,0 +1,285 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; +using MHToolkit.MH3GSaveConverter.Windows.Models; + +namespace MHToolkit.MH3GSaveConverter.Windows.Services; + +public sealed class GitHubUpdateService +{ + private static readonly Uri LatestReleaseEndpoint = new( + "https://api.github.com/repos/MHToolkit/mh-save-sync/releases/latest"); + private static readonly Uri LatestReleaseWebEndpoint = new( + "https://github.com/MHToolkit/mh-save-sync/releases/latest"); + private static readonly Uri ReleaseAtomEndpoint = new( + "https://github.com/MHToolkit/mh-save-sync/releases.atom"); + + private readonly HttpClient _httpClient; + + public GitHubUpdateService(HttpClient? httpClient = null, string? currentVersion = null) + { + _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(8) }; + CurrentVersion = currentVersion ?? ResolveCurrentVersion(); + } + + public string CurrentVersion { get; } + + public async Task CheckAsync(CancellationToken cancellationToken = default) + { + GitHubReleaseInfo release; + try + { + release = await FetchFromWebFeedAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception webFailure) when (!cancellationToken.IsCancellationRequested) + { + try + { + release = await FetchFromApiAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception apiFailure) when (!cancellationToken.IsCancellationRequested) + { + throw new InvalidDataException( + $"GitHub release page/feed and Release API were unavailable. Web: {webFailure.Message} API: {apiFailure.Message}", + new AggregateException(webFailure, apiFailure)); + } + } + + return CreateResult(release); + } + + private async Task FetchFromWebFeedAsync(CancellationToken cancellationToken) + { + using var latestRequest = CreateRequest(HttpMethod.Get, LatestReleaseWebEndpoint, "text/html"); + // HttpClient follows GitHub's stable /releases/latest redirect. One + // byte is enough to retain the final tag URL without downloading the + // full HTML release page. + latestRequest.Headers.Range = new RangeHeaderValue(0, 0); + using var latestResponse = await _httpClient.SendAsync( + latestRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + Uri? stableReleaseUri; + if (latestResponse.StatusCode is HttpStatusCode.MovedPermanently + or HttpStatusCode.Found + or HttpStatusCode.SeeOther + or HttpStatusCode.TemporaryRedirect + or HttpStatusCode.PermanentRedirect) + { + stableReleaseUri = latestResponse.Headers.Location is { } location + ? new Uri(LatestReleaseWebEndpoint, location) + : null; + } + else + { + latestResponse.EnsureSuccessStatusCode(); + stableReleaseUri = latestResponse.RequestMessage?.RequestUri; + } + + if (stableReleaseUri is null || !IsOfficialReleaseTagUri(stableReleaseUri)) + { + throw new InvalidDataException("GitHub latest-release redirect did not resolve to the official repository tag."); + } + + using var feedRequest = CreateRequest(HttpMethod.Get, ReleaseAtomEndpoint, "application/atom+xml"); + using var feedResponse = await _httpClient.SendAsync(feedRequest, cancellationToken).ConfigureAwait(false); + feedResponse.EnsureSuccessStatusCode(); + var payload = await ReadBoundedContentAsync( + feedResponse, + "GitHub release feed", + cancellationToken).ConfigureAwait(false); + return ParseAtomRelease(payload, stableReleaseUri); + } + + private async Task FetchFromApiAsync(CancellationToken cancellationToken) + { + using var request = CreateRequest( + HttpMethod.Get, + LatestReleaseEndpoint, + "application/vnd.github+json"); + request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var payload = await ReadBoundedContentAsync( + response, + "GitHub Release API", + cancellationToken).ConfigureAwait(false); + + var release = JsonSerializer.Deserialize(payload) + ?? throw new InvalidDataException("GitHub Release API returned an empty payload."); + ValidateRelease(release); + return release; + } + + private HttpRequestMessage CreateRequest(HttpMethod method, Uri endpoint, string accept) + { + var request = new HttpRequestMessage(method, endpoint); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept)); + request.Headers.UserAgent.ParseAdd($"MH3GSaveConverter/{SafeUserAgentVersion(CurrentVersion)}"); + return request; + } + + private static async Task ReadBoundedContentAsync( + HttpResponseMessage response, + string source, + CancellationToken cancellationToken) + { + if (response.Content.Headers.ContentLength is > 2 * 1024 * 1024) + { + throw new InvalidDataException($"{source} response exceeded the accepted size."); + } + + var payload = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + if (payload.Length > 2 * 1024 * 1024) + { + throw new InvalidDataException($"{source} response exceeded the accepted size."); + } + return payload; + } + + private static GitHubReleaseInfo ParseAtomRelease(byte[] payload, Uri stableReleaseUri) + { + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + MaxCharactersInDocument = 2 * 1024 * 1024, + }; + using var input = new MemoryStream(payload, writable: false); + using var reader = XmlReader.Create(input, settings); + var document = XDocument.Load(reader, LoadOptions.None); + XNamespace atom = "http://www.w3.org/2005/Atom"; + var entry = document.Root? + .Elements(atom + "entry") + .FirstOrDefault(candidate => + { + var href = candidate + .Elements(atom + "link") + .FirstOrDefault(link => string.Equals( + (string?)link.Attribute("rel"), + "alternate", + StringComparison.Ordinal))? + .Attribute("href")? + .Value; + return Uri.TryCreate(href, UriKind.Absolute, out var entryUri) + && string.Equals( + entryUri.AbsoluteUri, + stableReleaseUri.AbsoluteUri, + StringComparison.Ordinal); + }) + ?? throw new InvalidDataException("The stable GitHub release was not present in the official release feed."); + + var tagName = Uri.UnescapeDataString(stableReleaseUri.Segments[^1]); + DateTimeOffset? publishedAt = null; + if (DateTimeOffset.TryParse( + entry.Element(atom + "updated")?.Value, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsedPublishedAt)) + { + publishedAt = parsedPublishedAt; + } + + var release = new GitHubReleaseInfo + { + TagName = tagName, + Name = entry.Element(atom + "title")?.Value.Trim(), + Body = PlainTextFromHtml(entry.Element(atom + "content")?.Value ?? string.Empty), + HtmlUrl = stableReleaseUri.AbsoluteUri, + PublishedAt = publishedAt, + Draft = false, + Prerelease = false, + }; + ValidateRelease(release); + return release; + } + + private static string PlainTextFromHtml(string html) + { + var text = Regex.Replace(html, @"(?i)]*)?>", "• "); + text = Regex.Replace( + text, + @"(?i)|||||", + "\n"); + text = Regex.Replace(text, @"<[^>]+>", string.Empty); + text = WebUtility.HtmlDecode(text); + var normalized = string.Join( + Environment.NewLine, + text.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); + return normalized.Length <= 32_000 ? normalized : normalized[..32_000]; + } + + private UpdateCheckResult CreateResult(GitHubReleaseInfo release) + { + ValidateRelease(release); + + if (!TryParseVersion(CurrentVersion, out var current) + || !TryParseVersion(release.TagName, out var latest)) + { + throw new InvalidDataException("The current or latest release version could not be compared safely."); + } + + return new UpdateCheckResult(CurrentVersion, release, latest > current); + } + + private static bool IsOfficialReleaseTagUri(Uri releaseUri) + { + return releaseUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && releaseUri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase) + && string.IsNullOrEmpty(releaseUri.Query) + && string.IsNullOrEmpty(releaseUri.Fragment) + && releaseUri.AbsolutePath.StartsWith( + "/MHToolkit/mh-save-sync/releases/tag/", + StringComparison.Ordinal) + && releaseUri.Segments.Length == 6 + && TryParseVersion(Uri.UnescapeDataString(releaseUri.Segments[^1]), out _); + } + + internal static bool TryParseVersion(string value, out Version version) + { + var normalized = value.Trim().TrimStart('v', 'V').Split('+', 2)[0].Split('-', 2)[0]; + if (Version.TryParse(normalized, out var parsed)) + { + version = parsed; + return true; + } + version = new Version(0, 0); + return false; + } + + private static void ValidateRelease(GitHubReleaseInfo release) + { + if (release.Draft + || release.Prerelease + || !Uri.TryCreate(release.HtmlUrl, UriKind.Absolute, out var releaseUri) + || !releaseUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || !releaseUri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase) + || !releaseUri.AbsolutePath.StartsWith("/MHToolkit/mh-save-sync/releases/", StringComparison.Ordinal)) + { + throw new InvalidDataException("GitHub Release metadata did not match the official MHToolkit repository."); + } + } + + private static string ResolveCurrentVersion() + { + var assembly = Assembly.GetEntryAssembly() ?? typeof(GitHubUpdateService).Assembly; + var informational = assembly.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(informational)) + { + return informational.Split('+', 2)[0]; + } + return assembly.GetName().Version?.ToString(3) ?? "development"; + } + + private static string SafeUserAgentVersion(string version) + { + return TryParseVersion(version, out var parsed) ? parsed.ToString() : "0.0.0"; + } +} diff --git a/apps/mh3g-save-converter-windows/Services/UpdateCheckPreferenceStore.cs b/apps/mh3g-save-converter-windows/Services/UpdateCheckPreferenceStore.cs new file mode 100644 index 0000000..93202b3 --- /dev/null +++ b/apps/mh3g-save-converter-windows/Services/UpdateCheckPreferenceStore.cs @@ -0,0 +1,102 @@ +using System.Globalization; +using System.Text.Json; + +namespace MHToolkit.MH3GSaveConverter.Windows.Services; + +public sealed class UpdateCheckPreferenceStore +{ + private const string StateFileName = "update-check.json"; + private readonly string _statePath; + private bool _attemptedInProcess; + + public UpdateCheckPreferenceStore(string? settingsDirectory = null) + { + var directory = settingsDirectory + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MHToolkit", + "MH3GSaveConverter"); + _statePath = Path.Combine(directory, StateFileName); + } + + public bool ShouldCheckToday(DateTimeOffset? now = null) + { + if (_attemptedInProcess) + { + return false; + } + + var localDate = (now ?? DateTimeOffset.Now).LocalDateTime.Date; + try + { + if (!File.Exists(_statePath)) + { + return true; + } + var stored = JsonSerializer.Deserialize(File.ReadAllText(_statePath)); + return !DateTime.TryParseExact( + stored?.LastAttemptLocalDate, + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var lastAttempt) + || lastAttempt.Date != localDate; + } + catch (IOException) + { + return true; + } + catch (UnauthorizedAccessException) + { + return true; + } + catch (JsonException) + { + return true; + } + } + + public void MarkAttempt(DateTimeOffset? now = null) + { + _attemptedInProcess = true; + var directory = Path.GetDirectoryName(_statePath)!; + var temporaryPath = Path.Combine(directory, $".{StateFileName}.{Guid.NewGuid():N}.tmp"); + try + { + Directory.CreateDirectory(directory); + var state = new StoredState + { + LastAttemptLocalDate = (now ?? DateTimeOffset.Now).LocalDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + }; + File.WriteAllBytes(temporaryPath, JsonSerializer.SerializeToUtf8Bytes(state)); + File.Move(temporaryPath, _statePath, overwrite: true); + } + catch (IOException) + { + // The in-process gate still prevents repeated launch checks. A + // read-only profile may retry on the next application launch. + } + catch (UnauthorizedAccessException) + { + // Update checks are advisory and never block the converter. + } + finally + { + try + { + File.Delete(temporaryPath); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } + + private sealed class StoredState + { + public string? LastAttemptLocalDate { get; init; } + } +} diff --git a/scripts/package-mh3g-save-converter-windows.ps1 b/scripts/package-mh3g-save-converter-windows.ps1 index 94e0943..af8d1a7 100644 --- a/scripts/package-mh3g-save-converter-windows.ps1 +++ b/scripts/package-mh3g-save-converter-windows.ps1 @@ -882,7 +882,8 @@ function Publish-PortableExecutable { [Parameter(Mandatory = $true)][string]$ProjectPath, [Parameter(Mandatory = $true)][string]$NativeSidecar, [Parameter(Mandatory = $true)][string]$PortableStageDirectory, - [Parameter(Mandatory = $true)][string]$PortableOutput + [Parameter(Mandatory = $true)][string]$PortableOutput, + [Parameter(Mandatory = $true)][string]$Version ) # The UI invokes the Rust converter from AppContext.BaseDirectory\tools. @@ -908,7 +909,7 @@ function Publish-PortableExecutable { Invoke-External -FilePath $Dotnet -Arguments @( "publish", $ProjectPath, "-c", "Release", "-r", "win-x64", "--self-contained", "true", "--no-restore", "-p:Platform=x64", "-p:WindowsAppSDKSelfContained=true", "-p:PublishSingleFile=true", - "-p:IncludeAllContentForSelfExtract=true", "-p:PublishReadyToRun=false", "-o", $PortableStageDirectory + "-p:IncludeAllContentForSelfExtract=true", "-p:PublishReadyToRun=false", "-p:Version=$Version", "-o", $PortableStageDirectory ) -Description "WinUI portable single-file dotnet publish" $publishedPortable = Join-Path $PortableStageDirectory "MH3GSaveConverter.exe" @@ -1173,6 +1174,7 @@ try { } Assert-NativeConverterSidecar -FilePath $sidecar Invoke-External -FilePath $sidecar -Arguments @("--help") -Description "release sidecar smoke" + $converterVersion = Get-ConverterVersion # Recheck directly before recursive deletion in case an existing stage # component was replaced by a junction/symlink after initial preflight. @@ -1180,7 +1182,7 @@ try { Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue Invoke-External -FilePath $dotnet -Arguments @( "publish", $project, "-c", "Release", "-r", "win-x64", "--self-contained", "true", "--no-restore", - "-p:Platform=x64", "-p:WindowsAppSDKSelfContained=true", "-o", $stage + "-p:Platform=x64", "-p:WindowsAppSDKSelfContained=true", "-p:Version=$converterVersion", "-o", $stage ) -Description "WinUI dotnet publish" $gui = Join-Path $stage "MH3GSaveConverter.exe" @@ -1212,10 +1214,9 @@ try { # that sidecar as extracted app content; the installer wraps the folder # form so its tools\ path remains intact. Remove-Item -LiteralPath $portableExecutable, $portableChecksum, $installerExecutable, $installerChecksum -Force -ErrorAction SilentlyContinue - Publish-PortableExecutable -Dotnet $dotnet -ProjectPath $project -NativeSidecar $sidecar -PortableStageDirectory $portableStage -PortableOutput $portableExecutable + Publish-PortableExecutable -Dotnet $dotnet -ProjectPath $project -NativeSidecar $sidecar -PortableStageDirectory $portableStage -PortableOutput $portableExecutable -Version $converterVersion Write-Sha256File -FilePath $portableExecutable -OutputPath $portableChecksum -DisplayName "MH3GSaveConverter-Portable-x64.exe" | Out-Null - $converterVersion = Get-ConverterVersion Build-InstallerExecutable -InstallerCompiler $innoSetup -InstallerDefinition $installerScript -SourceDirectory $stage -Version $converterVersion -InstallerOutput $installerExecutable Write-Sha256File -FilePath $installerExecutable -OutputPath $installerChecksum -DisplayName "MH3GSaveConverter-Setup-x64.exe" | Out-Null diff --git a/scripts/verify-mh3g-save-converter-windows-source.py b/scripts/verify-mh3g-save-converter-windows-source.py index f0402d4..7fd0a2c 100644 --- a/scripts/verify-mh3g-save-converter-windows-source.py +++ b/scripts/verify-mh3g-save-converter-windows-source.py @@ -147,6 +147,7 @@ def verify_local_packaging_script() -> None: "WindowsAppSDKSelfContained=true", "PublishSingleFile=true", "IncludeAllContentForSelfExtract=true", + "-p:Version=$converterVersion", "Publish-PortableExecutable", "Build-InstallerExecutable", "MH3GSaveConverter-Portable-x64.exe", @@ -228,6 +229,7 @@ def verify_local_packaging_script() -> None: for expected in ( "-p:PublishSingleFile=true", "-p:IncludeAllContentForSelfExtract=true", + "-p:Version=$Version", "mh3g-save-convert.exe", "finally", "Remove-Item -LiteralPath $embeddedSidecar", @@ -414,6 +416,41 @@ def main() -> int: require("startInfo.Arguments" not in bridge, "CLI bridge must not build a command-string argument list") require("cmd.exe" not in bridge and "powershell" not in bridge.lower(), "CLI bridge must not invoke a shell") + update_service = read("Services/GitHubUpdateService.cs") + for expected in ( + "https://api.github.com/repos/MHToolkit/mh-save-sync/releases/latest", + "https://github.com/MHToolkit/mh-save-sync/releases/latest", + "https://github.com/MHToolkit/mh-save-sync/releases.atom", + "application/vnd.github+json", + "application/atom+xml", + "X-GitHub-Api-Version", + "MH3GSaveConverter/", + "TimeSpan.FromSeconds(8)", + "MHToolkit/mh-save-sync/releases/", + "AssemblyInformationalVersionAttribute", + "FetchFromWebFeedAsync", + "FetchFromApiAsync", + "RangeHeaderValue(0, 0)", + "HttpCompletionOption.ResponseHeadersRead", + "XDocument.Load", + "DtdProcessing.Prohibit", + ): + require(expected in update_service, f"Windows update service is missing {expected}") + require( + update_service.index("FetchFromWebFeedAsync(cancellationToken)") + < update_service.index("FetchFromApiAsync(cancellationToken)"), + "Windows update checks must avoid anonymous API quotas by preferring the official release page/feed", + ) + update_store = read("Services/UpdateCheckPreferenceStore.cs") + for expected in ( + "update-check.json", + "ShouldCheckToday", + "MarkAttempt", + '"yyyy-MM-dd"', + "LocalApplicationData", + ): + require(expected in update_store, f"Windows daily update gate is missing {expected}") + launcher = (ROOT / "scripts" / "mh3g-windows-launcher.ps1").read_text(encoding="utf-8") require( 'Join-Path $PSScriptRoot "tools/mh3g-save-convert.exe"' in launcher, @@ -578,6 +615,16 @@ def main() -> int: require("private void GoToOptionalConfiguration_Click" in code_behind, "optional configuration CTA handler is missing") require("OptionalConfigurationAnchor.StartBringIntoView();" in code_behind, "optional configuration CTA must scroll to its controls") require("private void GoToPostWriteDestination_Click" in code_behind, "post-write destination handler is missing") + for expected in ( + "RootGrid_Loaded", + "About_Click", + "CheckForUpdatesAsync(manual: false)", + "_updateCheckStore.MarkAttempt();", + "ShowUpdateAvailableAsync", + "ProcessStartInfo(release.HtmlUrl)", + "UseShellExecute = true", + ): + require(expected in code_behind, f"WinUI update-check flow is missing {expected}") cec_write = workflow.split("public async Task WriteCecAsync()", 1)[1].split( "public async Task RollbackCecAsync()", 1 @@ -792,6 +839,11 @@ def main() -> int: "Optional ExtData", "可选 ExtData", "ExtDataInstallUnavailable", + "About & Updates", + "关于与更新", + "Check for Updates", + "检查更新", + "UpdateNetworkNote", ): require(expected in copy, f"localized copy is missing {expected}") require((APP / "README.zh-CN.md").is_file(), "Windows shell must include Chinese usage guidance")