From 3d98df9a99b09b61e68ea0c8b86b4c36a91cd032 Mon Sep 17 00:00:00 2001 From: vincentadamnemessis Date: Wed, 12 Aug 2026 16:41:11 +0800 Subject: [PATCH 1/5] fix(mh3g): preserve companion mask mastery state --- README.md | 10 ++ README.zh-CN.md | 4 + crates/mh3g-save-convert/src/compatibility.rs | 69 +++++++------ crates/mh3g-save-convert/src/converter.rs | 99 +++++++++---------- crates/mh3g-save-convert/src/transforms.rs | 70 ++++++++++--- crates/mh3g-save-convert/tests/cli.rs | 23 +++-- .../adr/0015-mh3g-compatibility-save-merge.md | 19 +++- .../mh3g-compatibility-played-save-fixture.py | 5 +- tools/compatibility-wrapper/gen_wrapper.py | 2 +- tools/compatibility-wrapper/validate_patch.py | 2 +- 10 files changed, 188 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index e43a32d..2182490 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,16 @@ 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. +### Cha-Cha / Kayamba mask proficiency + +The companion mask/mastery area in `user#` is a packed byte block, not a table +of uniformly endian-swapped `u16` values. Current conversion preserves the +confirmed packed range exactly while converting only the numeric prefix. This +keeps the mask screen, companion status screen, and post-quest serializer on +the same representation. Compatibility repair can also recognize and repair +the historical two-byte swap at relative companion offset `0xE4` without +replacing unrelated Wii U progress. + ### Before you write: paths, inspection, and dry-run The examples below run from this repository after Rust is installed. Define a diff --git a/README.zh-CN.md b/README.zh-CN.md index 7df3b6f..483eb5b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -144,6 +144,10 @@ Windows 前置条件、行为和可选参数见 护石技能点在装备记录中是一个有符号单字节值。部分旧修改器却按无符号字节读取,因此合法的 `-5` 会显示成 `251`。转换器会保留该原始有符号字节,只转换确实存在平台字节序差异的字段。部分旧修改器在重写防具时还会重新构造防具头,却不保留其中的 RGB 染色字节;用这种修改器编辑防具可能让防具统一变黑,即使转换后的护石本身完全合法。不能通过钳制技能点或重写原本正确的装备记录来“修复”这类修改器问题。 +### 茶茶/卡扬巴面具熟练度 + +`user#` 中的随从面具/熟练度区域是按字节打包的数据块,并不是一张全部按 `u16` 交换字节序的表。当前转换只处理已经证实的数值前缀,完整保留已确认的打包区间,使面具界面、随从属性界面与任务结算回写使用同一种表示。兼容修复也可以识别并修复历史版本在随从相对偏移 `0xE4` 处造成的两字节误交换,同时保留无关的 Wii U 后续进度。 + 如果解压后的目录中直接包含 `user2`,使用 CLI 转换核心槽位时必须传入这个**文件**。在原生工作台中,选择该目录并选中 `user2` 时,只会解析其直接子文件 `user2`。如果目录中直接包含 `card1` 到 `quest4`,该目录只能作为 `convert-extras` 输入。如果解压结果外面还有一层包装目录,需要先进入这一层;上述预期文件必须是 CLI 路径或上述受限 GUI 选择的直接子项。 ### 写入前:设置路径、检查和 dry-run diff --git a/crates/mh3g-save-convert/src/compatibility.rs b/crates/mh3g-save-convert/src/compatibility.rs index 92048b1..f744ef6 100644 --- a/crates/mh3g-save-convert/src/compatibility.rs +++ b/crates/mh3g-save-convert/src/compatibility.rs @@ -19,9 +19,11 @@ const ARENA_RECORD_STRIDE: usize = 4; const SHAKALAKA_RECORD_START: usize = 0x6F44; const SHAKALAKA_RECORD_COUNT: usize = 2; const SHAKALAKA_RECORD_STRIDE: usize = 0x148; -const SHAKALAKA_U32_HEADER_SIZE: usize = 0x0C; +const SHAKALAKA_U32_PREFIX_SIZE: usize = 0x04; const SHAKALAKA_MASK_STATE_START: usize = 0xDE; -const SHAKALAKA_LAMP_MASK_MASTERY_START: usize = 0xE4; +const SHAKALAKA_MASK_STATE_END: usize = 0x140; +#[cfg(test)] +const HISTORICAL_SHAKALAKA_LAMP_SWAP_OFFSET: usize = 0xE4; const USER_MONSTER_LOG_START: usize = 0x81B4; const USER_MONSTER_LOG_COUNT: usize = 50; const USER_MONSTER_LOG_STRIDE: usize = 10; @@ -478,30 +480,25 @@ fn repair_fields(filename: &str) -> Result, ConversionError> { } for companion in 0..SHAKALAKA_RECORD_COUNT { let start = header + SHAKALAKA_RECORD_START + companion * SHAKALAKA_RECORD_STRIDE; - for field in 0..3 { + fields.push(FieldSpec { + name: format!("shakalaka-{companion}-u32-prefix"), + offset: start, + width: SHAKALAKA_U32_PREFIX_SIZE, + }); + for relative in (SHAKALAKA_U32_PREFIX_SIZE..SHAKALAKA_MASK_STATE_START).step_by(2) { fields.push(FieldSpec { - name: format!("shakalaka-{companion}-header-{field}"), - offset: start + field * 4, - width: 4, + name: format!("shakalaka-{companion}-scalar-{relative:03x}"), + offset: start + relative, + width: 2, }); } - for relative in (SHAKALAKA_U32_HEADER_SIZE..SHAKALAKA_MASK_STATE_START).step_by(2) { + for relative in (SHAKALAKA_MASK_STATE_START..SHAKALAKA_MASK_STATE_END).step_by(2) { fields.push(FieldSpec { - name: format!("shakalaka-{companion}-scalar-{relative:03x}"), + name: format!("shakalaka-{companion}-packed-mask-pair-{relative:03x}"), offset: start + relative, width: 2, }); } - fields.push(FieldSpec { - name: format!("shakalaka-{companion}-mask-state"), - offset: start + SHAKALAKA_MASK_STATE_START, - width: SHAKALAKA_LAMP_MASK_MASTERY_START - SHAKALAKA_MASK_STATE_START, - }); - fields.push(FieldSpec { - name: format!("shakalaka-{companion}-lamp-mask-mastery"), - offset: start + SHAKALAKA_LAMP_MASK_MASTERY_START, - width: 2, - }); } } "card1" | "card2" | "card3" => { @@ -619,23 +616,37 @@ mod tests { } #[test] - fn repairs_an_old_lamp_field_without_reverting_unrelated_wiiu_progress() { + fn repairs_the_historical_packed_mask_byte_swap_without_reverting_wiiu_progress() { let mut source = source(); + let source_record = JP_3DS_HEADER.len() + SHAKALAKA_RECORD_START; + source[source_record..source_record + 12].copy_from_slice(&[ + 0x19, 0xC2, 0x0A, 0x00, 0x2F, 0x13, 0x2F, 0x01, 0x2C, 0x01, 0x3E, 0x01, + ]); let payload_offset = - JP_3DS_HEADER.len() + SHAKALAKA_RECORD_START + SHAKALAKA_LAMP_MASK_MASTERY_START; + JP_3DS_HEADER.len() + SHAKALAKA_RECORD_START + HISTORICAL_SHAKALAKA_LAMP_SWAP_OFFSET; source[payload_offset..payload_offset + 2].copy_from_slice(&[0x1E, 0x00]); let mut current = - convert_3ds_to_cemu_named_for_revision(&source, "user2", ConverterRevision::V0_0_5) + 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_5).unwrap(); + merge_component(&source, ¤t, "user2", ConverterRevision::V0_0_6).unwrap(); let lamp = - JP_CEMU_HEADER.len() + SHAKALAKA_RECORD_START + SHAKALAKA_LAMP_MASK_MASTERY_START; + JP_CEMU_HEADER.len() + SHAKALAKA_RECORD_START + HISTORICAL_SHAKALAKA_LAMP_SWAP_OFFSET; + let record = JP_CEMU_HEADER.len() + SHAKALAKA_RECORD_START; - assert_eq!(&merged.bytes[lamp..lamp + 2], &[0x00, 0x1E]); + assert_eq!( + ¤t[record + 4..record + 12], + &[0x01, 0x2F, 0x13, 0x2F, 0x01, 0x3E, 0x01, 0x2C] + ); + assert_eq!( + &merged.bytes[record + 4..record + 12], + &[0x13, 0x2F, 0x01, 0x2F, 0x01, 0x2C, 0x01, 0x3E] + ); + assert_eq!(¤t[lamp..lamp + 2], &[0x00, 0x1E]); + assert_eq!(&merged.bytes[lamp..lamp + 2], &[0x1E, 0x00]); assert_eq!(merged.bytes[unrelated], current[unrelated]); assert!(merged.repaired_fields >= 1); } @@ -644,22 +655,22 @@ mod tests { fn preserves_a_whole_multibyte_field_when_wiiu_changed_only_one_byte() { let mut source = source(); let source_lamp = - JP_3DS_HEADER.len() + SHAKALAKA_RECORD_START + SHAKALAKA_LAMP_MASK_MASTERY_START; + JP_3DS_HEADER.len() + SHAKALAKA_RECORD_START + HISTORICAL_SHAKALAKA_LAMP_SWAP_OFFSET; source[source_lamp..source_lamp + 2].copy_from_slice(&[0x1E, 0x00]); let mut current = - convert_3ds_to_cemu_named_for_revision(&source, "user2", ConverterRevision::V0_0_5) + convert_3ds_to_cemu_named_for_revision(&source, "user2", ConverterRevision::V0_0_6) .unwrap(); let lamp = - JP_CEMU_HEADER.len() + SHAKALAKA_RECORD_START + SHAKALAKA_LAMP_MASK_MASTERY_START; + JP_CEMU_HEADER.len() + SHAKALAKA_RECORD_START + HISTORICAL_SHAKALAKA_LAMP_SWAP_OFFSET; current[lamp] = 0xAA; let observed = current[lamp..lamp + 2].to_vec(); let merged = - merge_component(&source, ¤t, "user2", ConverterRevision::V0_0_5).unwrap(); + merge_component(&source, ¤t, "user2", ConverterRevision::V0_0_6).unwrap(); assert_eq!(&merged.bytes[lamp..lamp + 2], observed.as_slice()); assert!(merged.fields.iter().any(|field| { - field.name == "shakalaka-0-lamp-mask-mastery" + field.name == "shakalaka-0-packed-mask-pair-0e4" && field.status == MergeFieldStatus::PreservedConflict })); } diff --git a/crates/mh3g-save-convert/src/converter.rs b/crates/mh3g-save-convert/src/converter.rs index e703188..3838d44 100644 --- a/crates/mh3g-save-convert/src/converter.rs +++ b/crates/mh3g-save-convert/src/converter.rs @@ -488,31 +488,32 @@ mod tests { } #[test] - fn remaps_shakalaka_scalars_without_swapping_mask_state_bytes() { + fn matches_official_shakalaka_field_boundaries_for_both_companions() { let mut source = vec![0_u8; THREE_DS_SIZE]; source[..JP_3DS_HEADER.len()].copy_from_slice(&JP_3DS_HEADER); - // Each Shakalaka record starts with endian-sensitive numeric fields. - // Its six-byte mask state at relative 0xDE and tail beginning at 0xE6 - // are byte-packed, while the intervening 0xE4 Lamp Mask mastery is a - // u16. Treating either packed region as u16 changes Cha-Cha's observed - // `01 09` state to `09 01` and makes the Wii U dialogue path consume - // invalid state; preserving the 0xE4 scalar leaves Lamp mastery at 0. + // Five paired official transfers establish one u32 prefix, u16 fields + // from relative 0x04 through 0xDE, and an entirely byte-packed + // mask/mastery block through relative 0x140. In particular, 0xE4 is + // not an isolated u16: swapping [0x18, 0x00] makes the companion status + // screen read zero and lets a quest completion write that zero back. + let cha_cha_prefix = 0x6F44; + let kayamba_prefix = 0x708C; + source[JP_3DS_HEADER.len() + cha_cha_prefix..JP_3DS_HEADER.len() + cha_cha_prefix + 12] + .copy_from_slice(&[ + 0x19, 0xC2, 0x0A, 0x00, 0x2F, 0x13, 0x2F, 0x01, 0x2C, 0x01, 0x3E, 0x01, + ]); + source[JP_3DS_HEADER.len() + kayamba_prefix..JP_3DS_HEADER.len() + kayamba_prefix + 12] + .copy_from_slice(&[ + 0x4C, 0xC6, 0x01, 0x00, 0x56, 0x02, 0x12, 0x00, 0xAA, 0x01, 0x00, 0x00, + ]); + let cha_cha_scalar = 0x6F74; let kayamba_scalar = 0x70C8; let cha_cha_last_scalar = 0x7020; let kayamba_last_scalar = 0x7168; let cha_cha_mask_state = 0x7022; let kayamba_mask_state = 0x716A; - // The six bytes at relative 0xDE are packed mask state, but the - // following relative 0xE4 field is the Lamp Mask mastery scalar. It - // remains little-endian on 3DS and must be written big-endian for the - // Wii U title. Keeping the whole tail byte-preserved loses this one - // scalar and makes the equipped Lamp Mask display mastery 0. - let cha_cha_lamp_mask_mastery = 0x7028; - let kayamba_lamp_mask_mastery = 0x7170; - let cha_cha_tail_state = 0x702A; - let kayamba_tail_state = 0x7172; source[JP_3DS_HEADER.len() + cha_cha_scalar..JP_3DS_HEADER.len() + cha_cha_scalar + 2] .copy_from_slice(&[0x12, 0x34]); source[JP_3DS_HEADER.len() + kayamba_scalar..JP_3DS_HEADER.len() + kayamba_scalar + 2] @@ -523,27 +524,37 @@ mod tests { source[JP_3DS_HEADER.len() + kayamba_last_scalar ..JP_3DS_HEADER.len() + kayamba_last_scalar + 2] .copy_from_slice(&[0xEF, 0x01]); + let mut cha_cha_packed = (0..0x62) + .map(|index| (index as u8).wrapping_mul(13).wrapping_add(5)) + .collect::>(); + let mut kayamba_packed = (0..0x62) + .map(|index| (index as u8).wrapping_mul(17).wrapping_add(9)) + .collect::>(); + // Exact non-palindromic bytes observed at relative 0xE4 in the paired + // Longwei sample. The official Wii U result keeps them unchanged. + cha_cha_packed[0xE4 - 0xDE..0xE6 - 0xDE].copy_from_slice(&[0x18, 0x00]); + kayamba_packed[0xE4 - 0xDE..0xE6 - 0xDE].copy_from_slice(&[0x08, 0x00]); source[JP_3DS_HEADER.len() + cha_cha_mask_state - ..JP_3DS_HEADER.len() + cha_cha_mask_state + 6] - .copy_from_slice(&[0x01, 0x09, 0x02, 0x05, 0x03, 0x07]); + ..JP_3DS_HEADER.len() + cha_cha_mask_state + cha_cha_packed.len()] + .copy_from_slice(&cha_cha_packed); source[JP_3DS_HEADER.len() + kayamba_mask_state - ..JP_3DS_HEADER.len() + kayamba_mask_state + 6] - .copy_from_slice(&[0x02, 0x05, 0x03, 0x07, 0x04, 0x09]); - source[JP_3DS_HEADER.len() + cha_cha_lamp_mask_mastery - ..JP_3DS_HEADER.len() + cha_cha_lamp_mask_mastery + 2] - .copy_from_slice(&[0x1E, 0x00]); - source[JP_3DS_HEADER.len() + kayamba_lamp_mask_mastery - ..JP_3DS_HEADER.len() + kayamba_lamp_mask_mastery + 2] - .copy_from_slice(&[0x08, 0x00]); - source[JP_3DS_HEADER.len() + cha_cha_tail_state - ..JP_3DS_HEADER.len() + cha_cha_tail_state + 2] - .copy_from_slice(&[0x0B, 0x00]); - source[JP_3DS_HEADER.len() + kayamba_tail_state - ..JP_3DS_HEADER.len() + kayamba_tail_state + 2] - .copy_from_slice(&[0x09, 0x0B]); + ..JP_3DS_HEADER.len() + kayamba_mask_state + kayamba_packed.len()] + .copy_from_slice(&kayamba_packed); let output = convert_3ds_to_cemu(&source).unwrap(); let payload = &output[JP_CEMU_HEADER.len()..]; + assert_eq!( + &payload[cha_cha_prefix..cha_cha_prefix + 12], + &[ + 0x00, 0x0A, 0xC2, 0x19, 0x13, 0x2F, 0x01, 0x2F, 0x01, 0x2C, 0x01, 0x3E + ] + ); + assert_eq!( + &payload[kayamba_prefix..kayamba_prefix + 12], + &[ + 0x00, 0x01, 0xC6, 0x4C, 0x02, 0x56, 0x00, 0x12, 0x01, 0xAA, 0x00, 0x00 + ] + ); assert_eq!(&payload[cha_cha_scalar..cha_cha_scalar + 2], &[0x34, 0x12]); assert_eq!(&payload[kayamba_scalar..kayamba_scalar + 2], &[0xCD, 0xAB]); assert_eq!( @@ -555,28 +566,12 @@ mod tests { &[0x01, 0xEF] ); assert_eq!( - &payload[cha_cha_mask_state..cha_cha_mask_state + 6], - &[0x01, 0x09, 0x02, 0x05, 0x03, 0x07] - ); - assert_eq!( - &payload[kayamba_mask_state..kayamba_mask_state + 6], - &[0x02, 0x05, 0x03, 0x07, 0x04, 0x09] - ); - assert_eq!( - &payload[cha_cha_lamp_mask_mastery..cha_cha_lamp_mask_mastery + 2], - &[0x00, 0x1E] - ); - assert_eq!( - &payload[kayamba_lamp_mask_mastery..kayamba_lamp_mask_mastery + 2], - &[0x00, 0x08] - ); - assert_eq!( - &payload[cha_cha_tail_state..cha_cha_tail_state + 2], - &[0x0B, 0x00] + &payload[cha_cha_mask_state..cha_cha_mask_state + cha_cha_packed.len()], + cha_cha_packed ); assert_eq!( - &payload[kayamba_tail_state..kayamba_tail_state + 2], - &[0x09, 0x0B] + &payload[kayamba_mask_state..kayamba_mask_state + kayamba_packed.len()], + kayamba_packed ); } diff --git a/crates/mh3g-save-convert/src/transforms.rs b/crates/mh3g-save-convert/src/transforms.rs index 8ec4f35..6a4f8dc 100644 --- a/crates/mh3g-save-convert/src/transforms.rs +++ b/crates/mh3g-save-convert/src/transforms.rs @@ -58,17 +58,25 @@ const FARM_FELYNE_SLOTS_START: usize = 0x6144; const HUNTING_FLEET_SHIP_COUNT_START: usize = 0x5BC6; const HUNTING_FLEET_SHIP_COUNT_END: usize = HUNTING_FLEET_SHIP_COUNT_START + 2; const HUNTING_FLEET_DISPATCH_RECORD_START: usize = 0x5D18; -// Cha-Cha and Kayamba have two adjacent, fixed-width companion records. Each -// starts with three u32 header fields and endian-sensitive u16 scalars. The -// six bytes at relative 0xDE are packed mask state and must retain their byte -// order. The immediately following relative 0xE4 Lamp Mask mastery is still a -// u16 scalar, then the byte-packed tail resumes at 0xE6. +// Cha-Cha and Kayamba have two adjacent, fixed-width companion records. Five +// independently paired 3DS -> Wii U transfers agree on the field boundaries +// through relative 0x140: one u32 prefix, u16 scalars through 0xDE, then a +// byte-packed mask/mastery block which must retain its exact byte order. +// +// Releases through 0.0.18 inherited two narrower schema assumptions from the +// recovered transfer table: offsets 0x04..0x0B were treated as two u32 values, +// and relative 0xE4 was treated as an isolated u16. Both assumptions disagree +// with every paired transfer. In particular, swapping the packed bytes at +// 0xE4 puts zero in the field read by the companion status screen; a quest +// completion can then serialize that zero back over the mask record. const SHAKALAKA_RECORD_START: usize = 0x6F44; const SHAKALAKA_RECORD_COUNT: usize = 2; const SHAKALAKA_RECORD_STRIDE: usize = 0x148; -const SHAKALAKA_U32_HEADER_SIZE: usize = 0x0C; +const HISTORICAL_SHAKALAKA_U32_HEADER_SIZE: usize = 0x0C; +const SHAKALAKA_U32_PREFIX_SIZE: usize = 0x04; const SHAKALAKA_MASK_STATE_START: usize = 0xDE; -const SHAKALAKA_LAMP_MASK_MASTERY_START: usize = 0xE4; +const HISTORICAL_SHAKALAKA_LAMP_SWAP_OFFSET: usize = 0xE4; +const SHAKALAKA_MASK_STATE_END: usize = 0x140; const OFFLINE_HUNTER_EQUIPMENT_CACHE_START: usize = 0x75B0; const OFFLINE_HUNTER_HEADER_START: usize = 0x75E0; const OFFLINE_HUNTER_COUNT: usize = 6; @@ -865,7 +873,7 @@ fn apply_confirmed_numeric_and_record_corrections( Ok(()) } -fn apply_shakalaka_companion_corrections( +fn apply_historical_shakalaka_companion_corrections( source: &[u8], target: &mut [u8], revision: ConverterRevision, @@ -873,22 +881,22 @@ fn apply_shakalaka_companion_corrections( for companion in 0..SHAKALAKA_RECORD_COUNT { let record_start = SHAKALAKA_RECORD_START + companion * SHAKALAKA_RECORD_STRIDE; - for relative in (0..SHAKALAKA_U32_HEADER_SIZE).step_by(4) { + for relative in (0..HISTORICAL_SHAKALAKA_U32_HEADER_SIZE).step_by(4) { copy_reversed(source, target, record_start + relative, 4)?; } let scalar_end = if revision == ConverterRevision::V0_0_4 { - SHAKALAKA_LAMP_MASK_MASTERY_START + 2 + HISTORICAL_SHAKALAKA_LAMP_SWAP_OFFSET + 2 } else { SHAKALAKA_MASK_STATE_START }; - for relative in (SHAKALAKA_U32_HEADER_SIZE..scalar_end).step_by(2) { + for relative in (HISTORICAL_SHAKALAKA_U32_HEADER_SIZE..scalar_end).step_by(2) { copy_reversed(source, target, record_start + relative, 2)?; } if revision >= ConverterRevision::V0_0_6 { copy_reversed( source, target, - record_start + SHAKALAKA_LAMP_MASK_MASTERY_START, + record_start + HISTORICAL_SHAKALAKA_LAMP_SWAP_OFFSET, 2, )?; } @@ -897,6 +905,33 @@ fn apply_shakalaka_companion_corrections( Ok(()) } +/// Reassert the companion schema proven by paired official transfers. +/// +/// This is intentionally layered after the historical converter replay. The +/// historical function above must stay byte-reproducible so compatibility +/// repair can still recognize 0.0.3-0.0.6 output, while current conversion +/// must use the corrected field boundaries. +fn apply_current_shakalaka_companion_corrections( + source: &[u8], + target: &mut [u8], +) -> Result<(), ConversionError> { + for companion in 0..SHAKALAKA_RECORD_COUNT { + let record_start = SHAKALAKA_RECORD_START + companion * SHAKALAKA_RECORD_STRIDE; + + copy_reversed(source, target, record_start, SHAKALAKA_U32_PREFIX_SIZE)?; + for relative in (SHAKALAKA_U32_PREFIX_SIZE..SHAKALAKA_MASK_STATE_START).step_by(2) { + copy_reversed(source, target, record_start + relative, 2)?; + } + target[record_start + SHAKALAKA_MASK_STATE_START..record_start + SHAKALAKA_MASK_STATE_END] + .copy_from_slice( + &source[record_start + SHAKALAKA_MASK_STATE_START + ..record_start + SHAKALAKA_MASK_STATE_END], + ); + } + + Ok(()) +} + /// Apply corrections proven by official-transfer pairs after the last /// historically reproducible 0.0.6 conversion semantics. /// @@ -907,6 +942,8 @@ fn apply_current_official_transfer_corrections( source: &[u8], target: &mut [u8], ) -> Result<(), ConversionError> { + apply_current_shakalaka_companion_corrections(source, target)?; + for record in 0..MONSTER_GUIDE_RECORD_COUNT { copy_reversed( source, @@ -1031,11 +1068,12 @@ pub(crate) fn apply_japanese_wiiu_corrections_for_revision( target[offset] = source[offset]; } - // The compatibility operation list covers only a subset of the numeric - // Cha-Cha/Kayamba fields. Reassert the bounded numeric prefix and the - // isolated Lamp Mask mastery scalar while preserving the packed state. + // Replay the exact historical Cha-Cha/Kayamba behavior here. Current + // conversion corrects its field boundaries only in + // `apply_current_official_transfer_corrections`, after the historical + // result has been kept available for compatibility detection. if revision >= ConverterRevision::V0_0_4 { - apply_shakalaka_companion_corrections(source, target, revision)?; + apply_historical_shakalaka_companion_corrections(source, target, revision)?; } // These fields are read as big-endian values by the Wii U title. MEOW v5 diff --git a/crates/mh3g-save-convert/tests/cli.rs b/crates/mh3g-save-convert/tests/cli.rs index 3300770..8651923 100644 --- a/crates/mh3g-save-convert/tests/cli.rs +++ b/crates/mh3g-save-convert/tests/cli.rs @@ -298,7 +298,7 @@ fn keys(value: &Value) -> BTreeSet { } #[test] -fn repair_converted_dry_run_then_write_repairs_only_an_old_lamp_field() { +fn repair_converted_repairs_the_historical_packed_mask_byte_swap() { #[cfg(target_os = "macos")] let _guard = PROCESS_GUARD.lock().unwrap(); let temp = tempfile::tempdir().unwrap(); @@ -317,7 +317,7 @@ fn repair_converted_dry_run_then_write_repairs_only_an_old_lamp_field() { let mut current = convert_3ds_to_cemu_named(&source, "user2").unwrap(); let lamp = JP_CEMU_HEADER.len() + 0x6F44 + 0xE4; - current[lamp..lamp + 2].copy_from_slice(&source[source_lamp..source_lamp + 2]); + current[lamp..lamp + 2].copy_from_slice(&[0x00, 0x1E]); let unrelated = JP_CEMU_HEADER.len() + 0x240; current[unrelated] ^= 0x5A; let unrelated_after = current[unrelated]; @@ -330,7 +330,7 @@ fn repair_converted_dry_run_then_write_repairs_only_an_old_lamp_field() { "--current".into(), current_path.to_string_lossy().into_owned(), "--from-version".into(), - "0.0.5".into(), + "0.0.6".into(), "--dry-run".into(), ]); assert_eq!(dry["status"], "dry-run"); @@ -343,7 +343,7 @@ fn repair_converted_dry_run_then_write_repairs_only_an_old_lamp_field() { "--current".into(), current_path.to_string_lossy().into_owned(), "--from-version".into(), - "0.0.5".into(), + "0.0.6".into(), "--write".into(), "--expected-source-set-sha256".into(), dry["source_set_sha256"].as_str().unwrap().to_owned(), @@ -354,7 +354,7 @@ fn repair_converted_dry_run_then_write_repairs_only_an_old_lamp_field() { ]); assert_eq!(written["status"], "written"); let installed = fs::read(¤t_path).unwrap(); - assert_eq!(&installed[lamp..lamp + 2], &[0x00, 0x1E]); + assert_eq!(&installed[lamp..lamp + 2], &[0x1E, 0x00]); assert_eq!(installed[unrelated], unrelated_after); assert!(written["manifests"].as_array().unwrap().len() == 1); let compatibility_manifest = written["compatibility_manifest"] @@ -386,7 +386,7 @@ fn repair_converted_can_read_current_and_write_a_separate_output() { let mut current = convert_3ds_to_cemu_named(&source, "user2").unwrap(); let lamp = JP_CEMU_HEADER.len() + 0x6F44 + 0xE4; - current[lamp..lamp + 2].copy_from_slice(&source[source_lamp..source_lamp + 2]); + current[lamp..lamp + 2].copy_from_slice(&[0x00, 0x1E]); let unrelated = JP_CEMU_HEADER.len() + 0x240; current[unrelated] ^= 0x5A; let current_before = current.clone(); @@ -400,7 +400,7 @@ fn repair_converted_can_read_current_and_write_a_separate_output() { "--output".into(), output_path.to_string_lossy().into_owned(), "--from-version".into(), - "0.0.5".into(), + "0.0.6".into(), "--dry-run".into(), ]); assert_eq!(dry["status"], "dry-run"); @@ -417,7 +417,7 @@ fn repair_converted_can_read_current_and_write_a_separate_output() { "--output".into(), output_path.to_string_lossy().into_owned(), "--from-version".into(), - "0.0.5".into(), + "0.0.6".into(), "--write".into(), "--expected-source-set-sha256".into(), dry["source_set_sha256"].as_str().unwrap().to_owned(), @@ -431,7 +431,7 @@ fn repair_converted_can_read_current_and_write_a_separate_output() { assert_eq!(written["status"], "written"); assert_eq!(fs::read(¤t_path).unwrap(), current_before); let installed = fs::read(&output_path).unwrap(); - assert_eq!(&installed[lamp..lamp + 2], &[0x00, 0x1E]); + assert_eq!(&installed[lamp..lamp + 2], &[0x1E, 0x00]); assert_eq!(installed[unrelated], current_before[unrelated]); let compatibility_manifest = written["compatibility_manifest"] @@ -645,7 +645,7 @@ fn repair_converted_write_rejects_a_current_save_changed_after_dry_run() { let mut current = convert_3ds_to_cemu_named(&source, "user2").unwrap(); let lamp = JP_CEMU_HEADER.len() + 0x6F44 + 0xE4; let source_lamp = JP_3DS_HEADER.len() + 0x6F44 + 0xE4; - current[lamp..lamp + 2].copy_from_slice(&source[source_lamp..source_lamp + 2]); + current[lamp..lamp + 2].copy_from_slice(&[source[source_lamp + 1], source[source_lamp]]); fs::write(¤t_path, ¤t).unwrap(); let dry = run_json(&[ @@ -695,8 +695,7 @@ fn repair_converted_preserves_the_played_directory_and_rolls_back_every_change() let current_path = current_dir.join("user2"); let mut current_slot = convert_3ds_to_cemu_named(&source_slot, "user2").unwrap(); let current_lamp = JP_CEMU_HEADER.len() + 0x6F44 + 0xE4; - current_slot[current_lamp..current_lamp + 2] - .copy_from_slice(&source_slot[source_lamp..source_lamp + 2]); + current_slot[current_lamp..current_lamp + 2].copy_from_slice(&[0x00, 0x1E]); fs::write(¤t_path, ¤t_slot).unwrap(); let current_slot_before = current_slot.clone(); diff --git a/docs/adr/0015-mh3g-compatibility-save-merge.md b/docs/adr/0015-mh3g-compatibility-save-merge.md index cc0e0b6..99de949 100644 --- a/docs/adr/0015-mh3g-compatibility-save-merge.md +++ b/docs/adr/0015-mh3g-compatibility-save-merge.md @@ -14,7 +14,7 @@ MH3G HD Wii U/Cemu saves, but each release added narrowly scoped corrections: | --- | --- | | 0.0.3 | Full personal/received-card arena tables and the complete numeric prefix of both Shakalaka records | | 0.0.4 | Packed Shakalaka mask-state preservation and the shared Hunter's Notes visibility rule for received cards and offline-hall partners | -| 0.0.5 | The isolated Lamp Mask mastery `u16` at relative Shakalaka offset `0xE4` | +| 0.0.5 | Historical 0.0.6 behavior that swapped relative Shakalaka offset `0xE4` as an isolated `u16` (retained only for exact old-output replay; later disproved by paired official transfers) | | 0.0.6 | Current conversion baseline | Some players converted with an older release and then continued playing on @@ -28,6 +28,23 @@ version. A byte pattern can support a version hypothesis, but ordinary gameplay can modify the same fields and erase that evidence. Therefore exact automatic version detection cannot be guaranteed for every played save. +### Shakalaka mask-state erratum + +Five paired 3DS -> Wii U transfers agree byte-for-byte on both Cha-Cha and +Kayamba records through relative offset `0x140`. The confirmed schema is one +big-endian-converted `u32` prefix, `u16` scalars from relative `0x04` through +`0xDE`, and a platform-invariant packed mask/mastery block from `0xDE` through +`0x140`. Relative offset `0xE4` belongs to that packed block; it is not an +isolated `u16`. + +The historical 0.0.6 transform swapped the two bytes at `0xE4`. For common +source values such as `18 00`, that produced `00 18`, making the companion +status path observe a zero byte. A quest completion could then serialize the +zero back into the mask state. Current conversion restores the whole packed +block from the 3DS source unchanged. Compatibility repair still replays the +old transform for version detection, then repairs only matching historical +fields and preserves later Wii U conflicts. + ## Decision Version 0.0.7 adds a separate **compatibility merge** operation. It does not diff --git a/scripts/mh3g-compatibility-played-save-fixture.py b/scripts/mh3g-compatibility-played-save-fixture.py index a2f3fd3..ad80d43 100755 --- a/scripts/mh3g-compatibility-played-save-fixture.py +++ b/scripts/mh3g-compatibility-played-save-fixture.py @@ -68,8 +68,7 @@ SHAKALAKA_RECORD_START = 0x6F44 SHAKALAKA_RECORD_COUNT = 2 SHAKALAKA_RECORD_STRIDE = 0x148 -SHAKALAKA_MASK_STATE_START = 0xDE -SHAKALAKA_LAMP_MASK_MASTERY_END = 0xE6 +SHAKALAKA_CONFIRMED_SCHEMA_END = 0x140 CARD_MONSTER_LOG_START = 0x7C0 CARD_MONSTER_LOG_COUNT = 50 CARD_MONSTER_LOG_STRIDE = 10 @@ -125,7 +124,7 @@ def compatibility_repair_offsets(name: str) -> set[int]: + SHAKALAKA_RECORD_START + companion * SHAKALAKA_RECORD_STRIDE ) - offsets.update(range(start, start + SHAKALAKA_LAMP_MASK_MASTERY_END)) + offsets.update(range(start, start + SHAKALAKA_CONFIRMED_SCHEMA_END)) elif name in {"card1", "card2", "card3"}: for slot in range(CARD_SLOT_COUNT): start = CEMU_HEADER_SIZE + slot * CARD_SLOT_SIZE diff --git a/tools/compatibility-wrapper/gen_wrapper.py b/tools/compatibility-wrapper/gen_wrapper.py index 86c32df..f6a5572 100644 --- a/tools/compatibility-wrapper/gen_wrapper.py +++ b/tools/compatibility-wrapper/gen_wrapper.py @@ -6,7 +6,7 @@ native_markers = ( 'const SHAKALAKA_MASK_STATE_START: usize = 0xDE;', 'const GUILD_CARD_ARENA_RECORD_COUNT: usize = 110;', - 'fn apply_shakalaka_companion_corrections(', + 'fn apply_current_shakalaka_companion_corrections(', 'fn apply_guild_card_arena_corrections(', ) if all(marker in native_transforms for marker in native_markers): diff --git a/tools/compatibility-wrapper/validate_patch.py b/tools/compatibility-wrapper/validate_patch.py index 5755d36..257972a 100644 --- a/tools/compatibility-wrapper/validate_patch.py +++ b/tools/compatibility-wrapper/validate_patch.py @@ -6,7 +6,7 @@ native_markers = ( 'const SHAKALAKA_MASK_STATE_START: usize = 0xDE;', 'const GUILD_CARD_ARENA_RECORD_COUNT: usize = 110;', - 'fn apply_shakalaka_companion_corrections(', + 'fn apply_current_shakalaka_companion_corrections(', 'fn apply_guild_card_arena_corrections(', ) if all(marker in native_transforms for marker in native_markers): From 8a2d17600c4ca73628c702a981eb1df603bd3489 Mon Sep 17 00:00:00 2001 From: vincentadamnemessis Date: Sun, 16 Aug 2026 18:35:26 +0800 Subject: [PATCH 2/5] fix(mh3g): align hunter notes and repair workflow --- README.md | 25 +++- README.zh-CN.md | 6 +- .../ConverterPresentation/Localization.swift | 4 +- .../Resources/Localizable.xcstrings | 2 +- .../Infrastructure/ConverterCopy.cs | 4 +- .../MainWindow.xaml | 8 +- .../MainWindow.xaml.cs | 23 ++- .../ViewModels/MainViewModel.cs | 41 +++++- crates/mh3g-save-convert/src/cec.rs | 8 +- crates/mh3g-save-convert/src/converter.rs | 37 +++-- crates/mh3g-save-convert/src/transforms.rs | 137 ++++++++++++++++-- crates/mh3g-save-convert/tests/cli.rs | 13 +- docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md | 25 +++- docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md | 4 +- .../0016-mh3g-shared-system-gallery-merge.md | 34 +++-- .../adr/0017-mh3g-official-transfer-parity.md | 96 ++++++++++++ ...mh3g-save-converter-windows-ui-quality.ps1 | 25 ++++ ...ave-converter-windows-ui-negative-gates.py | 21 ++- ...rify-mh3g-save-converter-windows-source.py | 39 +++++ ...mh3g-save-converter-windows-ui-evidence.py | 8 + ...-mh3g-save-converter-windows-ui-quality.py | 13 ++ 21 files changed, 495 insertions(+), 78 deletions(-) create mode 100644 docs/adr/0017-mh3g-official-transfer-parity.md diff --git a/README.md b/README.md index 2182490..bb4119d 100644 --- a/README.md +++ b/README.md @@ -200,12 +200,17 @@ 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. `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. +the housekeeper's gallery/movie unlock history. The physical save layout +exposes one title-wide `system` rather than separate `system1`, `system2`, and +`system3` files. Current evidence cannot attribute individual flags inside it +to `user1`, `user2`, or `user3`, and it 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 currently mapped 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. This conservative +transaction is not a claim that every `system` bit has official-transfer or +game-runtime parity. ### Legacy Wii U save-editor caveat @@ -228,6 +233,14 @@ the same representation. Compatibility repair can also recognize and repair the historical two-byte swap at relative companion offset `0xE4` without replacing unrelated Wii U progress. +### Monster guide discovery + +Monster discovery is controlled by the source 3DS discovery/crown state byte, +not inferred from slay or capture counters. Current conversion applies the +same state-only bit mapping to the personal record, received guild cards, and +CEC/offline-hall partners. This prevents historical conversions from turning +hidden rows with non-zero counters into extra monster-guide pages. + ### Before you write: paths, inspection, and dry-run The examples below run from this repository after Rust is installed. Define a diff --git a/README.zh-CN.md b/README.zh-CN.md index 483eb5b..0536ebf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -138,7 +138,7 @@ Windows 前置条件、行为和可选参数见 OutBox__/... <- 本机猎人的发出广播 ``` -如果没有提供 3DS `system` 文件,单独转换核心 `user#` 无法迁移管家处可查看的画廊/动画解锁记录。`system` 由三个角色槽位共同使用,还包含与当前角色无关的设置。因此 `convert-system` 必须同时提供 3DS 源文件和已初始化、已存在的 Cemu 目标文件。它只会对已确认的画廊/动画标记范围(Cemu 文件偏移 `0x68..0x77`)逐位取并集,其他目标字节全部保留;若目标不存在则拒绝执行,不会整文件覆盖共享数据。 +如果没有提供 3DS `system` 文件,单独转换核心 `user#` 无法迁移管家处可查看的画廊/动画解锁记录。物理存档布局只有一个标题级共享 `system`,不存在独立的 `system1`、`system2`、`system3` 文件;现有证据还不能把其中每个具体标记可靠归因到 `user1`、`user2` 或 `user3`,而且它还包含与当前角色无关的设置。因此 `convert-system` 必须同时提供 3DS 源文件和已初始化、已存在的 Cemu 目标文件。它只会对当前已映射的画廊/动画标记范围(Cemu 文件偏移 `0x68..0x77`)逐位取并集,其他目标字节全部保留;若目标不存在则拒绝执行,不会整文件覆盖共享数据。这个保守事务并不代表 `system` 的每一个标记都已经与官方迁移或游戏运行态完全对齐。 ### 旧版 Wii U 存档修改器注意事项 @@ -148,6 +148,10 @@ Windows 前置条件、行为和可选参数见 `user#` 中的随从面具/熟练度区域是按字节打包的数据块,并不是一张全部按 `u16` 交换字节序的表。当前转换只处理已经证实的数值前缀,完整保留已确认的打包区间,使面具界面、随从属性界面与任务结算回写使用同一种表示。兼容修复也可以识别并修复历史版本在随从相对偏移 `0xE4` 处造成的两字节误交换,同时保留无关的 Wii U 后续进度。 +### 怪物图鉴发现状态 + +怪物是否出现在图鉴中只由 3DS 源存档的发现/大小冠状态字节决定,不能根据讨伐数或捕获数推断。当前转换对个人记录、收到的公会名片以及 CEC/离线集会所伙伴统一使用同一份“仅状态字节”映射,避免旧逻辑把“计数非零但尚未发现”的记录错误扩展成额外图鉴页。 + 如果解压后的目录中直接包含 `user2`,使用 CLI 转换核心槽位时必须传入这个**文件**。在原生工作台中,选择该目录并选中 `user2` 时,只会解析其直接子文件 `user2`。如果目录中直接包含 `card1` 到 `quest4`,该目录只能作为 `convert-extras` 输入。如果解压结果外面还有一层包装目录,需要先进入这一层;上述预期文件必须是 CLI 路径或上述受限 GUI 选择的直接子项。 ### 写入前:设置路径、检查和 dry-run diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift index e375f63..8ba6c5c 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift @@ -204,7 +204,7 @@ public enum ConverterCopy { "Components.QuestsDetail": "迁移任务数据。", "Components.SystemSource": "3DS system", "Components.SystemTarget": "Cemu system", - "Components.SystemWarning": "system 由三个角色槽位共享。必须同时提供 3DS 源文件和已初始化的 Cemu 目标;转换器只合并管家画廊/动画标记,其他 Cemu 设置与跨槽位数据保持不变。", + "Components.SystemWarning": "物理存档只有一个共享 system,不存在独立的 system1、system2、system3;目前无法把具体画廊/动画标记可靠归因到某个角色槽。必须同时提供 3DS 源文件和已初始化的 Cemu 目标;转换器只修改狭窄的已映射范围,其他 Cemu 字节保持不变。精确官方迁移/运行态一致性仍未验证。", "Components.SystemFooter": "目标必须是已存在且格式有效的 Cemu system。若不提供这一对文件,则不会迁移管家画廊/动画记录。", "Components.SystemSourceMessage": "请选择一个 3DS system 文件。", "Components.SystemTargetMessage": "请选择一个 Cemu system 文件。", @@ -417,7 +417,7 @@ public enum ConverterCopy { "Components.QuestsDetail": "Migrate quest data.", "Components.SystemSource": "3DS system", "Components.SystemTarget": "Cemu system", - "Components.SystemWarning": "System is shared by all three character slots. Supply both the 3DS source and an initialized Cemu target; only housekeeper gallery/movie flags are merged while every other Cemu setting and shared-slot byte is retained.", + "Components.SystemWarning": "The physical save has one shared system file rather than separate system1, system2, and system3 files. Individual gallery/movie flags cannot currently be attributed reliably to a character slot. Supply both the 3DS source and an initialized Cemu target; the converter changes only its narrow mapped range and preserves every other Cemu byte. Exact official/runtime parity remains unverified.", "Components.SystemFooter": "The target must be an existing valid Cemu system. If this pair is omitted, housekeeper gallery/movie history is not migrated.", "Components.SystemSourceMessage": "Choose exactly one 3DS system file.", "Components.SystemTargetMessage": "Choose exactly one Cemu system file.", diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings index b7d35a3..4107b6e 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings @@ -17,7 +17,7 @@ "Repair.Version.Hint" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Use automatic detection if the version is unknown. If several historical versions match, Dry Run will require an explicit selection before it can authorize a write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "不知道版本时先自动检测;若多个历史版本都匹配,Dry Run 会要求你明确选择后再运行。" } } } }, "Repair.Version.Required" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Several versions match this save. Select the converter version originally used, then run Dry Run again." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "检测到多个可能版本。请选择当时使用的转换器版本,然后重新运行 Dry Run。" } } } }, "Repair.PreviewSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Repair preview SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "修复预览 SHA-256" } } } }, - "Components.SystemWarning" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "System is shared by all three character slots. Supply both the 3DS source and an initialized Cemu target; only housekeeper gallery/movie flags are merged while every other Cemu setting and shared-slot byte is retained." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "system 由三个角色槽位共享。必须同时提供 3DS 源文件和已初始化的 Cemu 目标;转换器只合并管家画廊/动画标记,其他 Cemu 设置与跨槽位数据保持不变。" } } } }, + "Components.SystemWarning" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The physical save has one shared system file rather than separate system1, system2, and system3 files. Individual gallery/movie flags cannot currently be attributed reliably to a character slot. Supply both the 3DS source and an initialized Cemu target; the converter changes only its narrow mapped range and preserves every other Cemu byte. Exact official/runtime parity remains unverified." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "物理存档只有一个共享 system,不存在独立的 system1、system2、system3;目前无法把具体画廊/动画标记可靠归因到某个角色槽。必须同时提供 3DS 源文件和已初始化的 Cemu 目标;转换器只修改狭窄的已映射范围,其他 Cemu 字节保持不变。精确官方迁移/运行态一致性仍未验证。" } } } }, "Guide.ComponentsReady" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Optional data is set. Next, run a read-only Dry Run to review what this conversion will write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "可选数据已确认。接下来运行只读 Dry Run,确认本次转换会写入什么。" } } } }, "Guide.CoreDryRunCompleteWithOptionals" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The core-slot Dry Run passed. Continue to the write page; selected system or ExtData data still needs its own Dry Run and write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "核心角色 Dry Run 已通过。接下来进入写入页;已选 system 或 ExtData 仍须分别 Dry Run 和写入。" } } } }, "Guide.DryRunComplete" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run passed. No save has been modified; continue when you are ready to confirm the write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run 已通过。确认写入前不会修改任何存档。" } } } }, diff --git a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs index c299c13..162a436 100644 --- a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs +++ b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs @@ -401,7 +401,7 @@ private string Text(string key) [nameof(SharedSystem)] = "Shared system", [nameof(SystemSource)] = "3DS system file", [nameof(SystemTarget)] = "Existing initialized Cemu system file", - [nameof(SystemMergeWarning)] = "System is shared by all three character slots and may contain settings or data for the other slots. Supply both the 3DS source and the existing Cemu target. The converter merges only housekeeper gallery/movie flags and preserves every other Cemu byte.", + [nameof(SystemMergeWarning)] = "The title has one shared system file, not separate user1, user2, and user3 files. Individual gallery/movie flags cannot currently be attributed to a character slot. Supply both the 3DS source and existing Cemu target; the converter changes only its narrow mapped range and preserves every other Cemu byte. Exact official/runtime parity remains unverified.", [nameof(RunSystemDryRun)] = "Run system Dry Run", [nameof(WriteSystem)] = "Merge gallery/movie flags", [nameof(SystemManifest)] = "System manifest", @@ -611,7 +611,7 @@ private string Text(string key) [nameof(SharedSystem)] = "共享 system", [nameof(SystemSource)] = "3DS system 文件", [nameof(SystemTarget)] = "已存在且已初始化的 Cemu system 文件", - [nameof(SystemMergeWarning)] = "system 由三个角色槽位共享,可能同时保存其他槽位数据或设置。必须同时提供 3DS 源 system 和现有 Cemu 目标 system;转换器只合并管家画廊/动画标记,其他 Cemu 字节全部保持不变。", + [nameof(SystemMergeWarning)] = "整个游戏标题只有一个共享 system 文件,并不存在独立的 user1、user2、user3 system;当前也无法把具体画廊/动画标记可靠归因到某个角色槽。必须同时提供 3DS 源 system 和现有 Cemu 目标 system;转换器只修改狭窄的已映射范围,其他 Cemu 字节全部保持不变。精确官方迁移/运行态一致性仍未验证。", [nameof(RunSystemDryRun)] = "运行 system Dry Run", [nameof(WriteSystem)] = "合并画廊/动画标记", [nameof(SystemManifest)] = "system manifest", diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml b/apps/mh3g-save-converter-windows/MainWindow.xaml index eb523e2..c8910f6 100644 --- a/apps/mh3g-save-converter-windows/MainWindow.xaml +++ b/apps/mh3g-save-converter-windows/MainWindow.xaml @@ -238,7 +238,13 @@ - + + + + + + +