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
|||"#,
+ 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")