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):