diff --git a/Cargo.lock b/Cargo.lock index ed70530..bed6043 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1079,9 +1079,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", diff --git a/README.md b/README.md index 106db97..29ae0bd 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,18 @@ entries in both count tables. Compatibility repair only updates count fields that still match historical converter output, preserving later Wii U/Cemu progress. +### Hunter Life Diary + +The personal guild card, received guild cards, and CEC/offline-hall partner +cards share one fixed Hunter Life Diary record schema. Each table contains ten +records; its three `u16` and six `u32` event fields change from 3DS +little-endian to Wii U big-endian, while dates, packed descriptors, names, and +text retain their byte order. Earlier sparse conversion tables could leave a +later event parameter unchanged, making values such as `1` appear as +`16777216`. Current conversion applies one shared mapping to every record and +slot. Compatibility repair updates only fields that still match historical +converter output and preserves later Wii U events at whole-field granularity. + ### 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 574b3b1..ef80d46 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -154,6 +154,10 @@ Windows 前置条件、行为和可选参数见 怪物的讨伐数和捕获数是另一组独立数据:`0x00..0x55` 共 86 个有效怪物 ID,讨伐和捕获各有一张 86 项的 `u16` 表,从 3DS 小端转为 Wii U 大端。旧版遗漏了 Giggi、Aptonoth 和 Popo 的三项讨伐数,Wii U 会把普通数值误读为数万并在界面钳制为 `9999`;当前转换会重申两张完整计数表。修复已转换存档时,仅更新仍等于旧转换结果的计数字段,玩家在 Wii U/Cemu 后续产生的新计数会保留。 +### 猎人生活日记 + +个人公会名片、已收到的公会名片以及 CEC/离线集会所伙伴名片共用同一份固定的猎人生活日记结构。每张表有 10 条记录,其中 3 个 `u16` 和 6 个 `u32` 事件字段需要从 3DS 小端转换为 Wii U 大端;日期、打包描述、姓名和文本保持原字节顺序。旧版稀疏转换表可能漏掉后续记录中的非零参数,使 `1` 被显示为 `16777216`。当前转换会对所有记录和名片槽位应用同一个映射。兼容修复只更新仍等于旧转换结果的完整数值字段,并保留玩家后来在 Wii U/Cemu 产生的新日记事件。 + 如果解压后的目录中直接包含 `user2`,使用 CLI 转换核心槽位时必须传入这个**文件**。在原生工作台中,选择该目录并选中 `user2` 时,只会解析其直接子文件 `user2`。如果目录中直接包含 `card1` 到 `quest4`,该目录只能作为 `convert-extras` 输入。如果解压结果外面还有一层包装目录,需要先进入这一层;上述预期文件必须是 CLI 路径或上述受限 GUI 选择的直接子项。 ### 写入前:设置路径、检查和 dry-run diff --git a/crates/mh3g-save-convert/src/cec.rs b/crates/mh3g-save-convert/src/cec.rs index 38e481e..190d4e4 100644 --- a/crates/mh3g-save-convert/src/cec.rs +++ b/crates/mh3g-save-convert/src/cec.rs @@ -1400,10 +1400,14 @@ mod tests { fs::create_dir_all(&inbox).unwrap(); fs::write(inbox.join("BoxInfo_____"), [0_u8; BOX_INFO_SIZE]).unwrap(); - let slot_start = 0xE00; + // Use the third embedded card slot and its final diary record so the + // CEC path cannot accidentally pass by converting only the first + // sparse MEOW slot captured by the historical table. + let slot_start = 2 * 0xE00; let rank_field = slot_start + 0x14; let weapon_usage_field = slot_start + 0x12C; let date_field = slot_start + 0x17A; + let diary_field = slot_start + 0x178 + 9 * 0xA0; let record_field = slot_start + 0x7C0 + 32 * 10; // Row 45 is intentionally beyond the sparse MEOW crown entries. Its // non-zero hunt count must not synthesize a discovery flag that the @@ -1430,6 +1434,11 @@ mod tests { record[weapon_usage_field..weapon_usage_field + 8] .copy_from_slice(&[0x2B, 0x00, 0x1E, 0x00, 0x11, 0x00, 0x37, 0x00]); record[date_field..date_field + 2].copy_from_slice(&[0xEA, 0x07]); + record[diary_field..diary_field + 0x24].copy_from_slice(&[ + 0x1F, 0x07, 0xEA, 0x07, 0x34, 0x12, 0x78, 0x56, 0x91, 0x82, 0x73, 0x64, 0x56, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x28, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, + ]); record[record_field..record_field + 10] .copy_from_slice(&[0x0F, 0x00, 0x10, 0x00, 0x64, 0x00, 0x65, 0x00, 0x03, 0x00]); record[late_record_field..late_record_field + 10] @@ -1453,6 +1462,7 @@ mod tests { let converted_weapon_usage = CEMU_HEADER_SIZE + CEMU_RECORD_AREA_OFFSET + weapon_usage_field; let converted_date = CEMU_HEADER_SIZE + CEMU_RECORD_AREA_OFFSET + date_field; + let converted_diary = CEMU_HEADER_SIZE + CEMU_RECORD_AREA_OFFSET + diary_field; let converted_offset = CEMU_HEADER_SIZE + CEMU_RECORD_AREA_OFFSET + record_field; let converted_late_offset = CEMU_HEADER_SIZE + CEMU_RECORD_AREA_OFFSET + late_record_field; @@ -1491,6 +1501,14 @@ mod tests { &conversion.bytes[converted_date..converted_date + 2], &[0x07, 0xEA] ); + assert_eq!( + &conversion.bytes[converted_diary..converted_diary + 0x24], + &[ + 0x1F, 0x07, 0x07, 0xEA, 0x12, 0x34, 0x56, 0x78, 0x91, 0x82, 0x73, 0x64, 0x00, 0x00, + 0x00, 0x56, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x05, + ] + ); assert_eq!( &conversion.bytes[converted_offset..converted_offset + 10], &[0x00, 0x0F, 0x00, 0x10, 0x00, 0x64, 0x00, 0x65, 0xA0, 0x00] diff --git a/crates/mh3g-save-convert/src/compatibility.rs b/crates/mh3g-save-convert/src/compatibility.rs index e9797e1..89682e0 100644 --- a/crates/mh3g-save-convert/src/compatibility.rs +++ b/crates/mh3g-save-convert/src/compatibility.rs @@ -11,6 +11,11 @@ use crate::{ profile::{JP_CEMU_HEADER, SaveProfile, inspect_bytes, validate_slot_path}, revision::ConverterRevision, transaction::sha256_hex, + transforms::{ + GUILD_CARD_HUNTER_LIFE_DIARY_COUNT, GUILD_CARD_HUNTER_LIFE_DIARY_START, + HUNTER_LIFE_DIARY_RECORD_STRIDE, HUNTER_LIFE_DIARY_U16_OFFSETS, + HUNTER_LIFE_DIARY_U32_OFFSETS, USER_HUNTER_LIFE_DIARY_COUNT, USER_HUNTER_LIFE_DIARY_START, + }, }; const USER_ARENA_RECORD_START: usize = 0x83A8; @@ -451,6 +456,25 @@ fn repair_fields(filename: &str) -> Result, ConversionError> { let mut fields = Vec::new(); match filename { "user1" | "user2" | "user3" => { + for record in 0..USER_HUNTER_LIFE_DIARY_COUNT { + let start = header + + USER_HUNTER_LIFE_DIARY_START + + record * HUNTER_LIFE_DIARY_RECORD_STRIDE; + for relative in HUNTER_LIFE_DIARY_U16_OFFSETS { + fields.push(FieldSpec { + name: format!("personal-hunter-life-diary-{record}-u16-{relative:02x}"), + offset: start + relative, + width: 2, + }); + } + for relative in HUNTER_LIFE_DIARY_U32_OFFSETS { + fields.push(FieldSpec { + name: format!("personal-hunter-life-diary-{record}-u32-{relative:02x}"), + offset: start + relative, + width: 4, + }); + } + } for byte in 0..USER_MONSTER_GUIDE_PACKED_STATE_LEN { fields.push(FieldSpec { name: format!("monster-guide-packed-state-byte-{byte}"), @@ -542,6 +566,29 @@ fn repair_fields(filename: &str) -> Result, ConversionError> { "card1" | "card2" | "card3" => { for slot in 0..GUILD_CARD_SLOT_COUNT { let start = header + slot * GUILD_CARD_SLOT_SIZE; + for record in 0..GUILD_CARD_HUNTER_LIFE_DIARY_COUNT { + let diary_start = start + + GUILD_CARD_HUNTER_LIFE_DIARY_START + + record * HUNTER_LIFE_DIARY_RECORD_STRIDE; + for relative in HUNTER_LIFE_DIARY_U16_OFFSETS { + fields.push(FieldSpec { + name: format!( + "received-card-{slot}-hunter-life-diary-{record}-u16-{relative:02x}" + ), + offset: diary_start + relative, + width: 2, + }); + } + for relative in HUNTER_LIFE_DIARY_U32_OFFSETS { + fields.push(FieldSpec { + name: format!( + "received-card-{slot}-hunter-life-diary-{record}-u32-{relative:02x}" + ), + offset: diary_start + relative, + width: 4, + }); + } + } for record in 0..GUILD_CARD_ARENA_RECORD_COUNT { fields.push(FieldSpec { name: format!("received-card-{slot}-arena-{record}"), @@ -882,6 +929,143 @@ mod tests { ); } + #[test] + fn repairs_personal_hunter_life_diary_without_reverting_new_wiiu_events() { + let mut source = source(); + let source_record0 = JP_3DS_HEADER.len() + USER_HUNTER_LIFE_DIARY_START; + let source_record3 = source_record0 + 3 * HUNTER_LIFE_DIARY_RECORD_STRIDE; + source[source_record0 + 0x0C..source_record0 + 0x10].copy_from_slice(&86_u32.to_le_bytes()); + source[source_record3 + 0x1C..source_record3 + 0x20].copy_from_slice(&40_u32.to_le_bytes()); + let mut current = + convert_3ds_to_cemu_named_for_revision(&source, "user2", ConverterRevision::V0_0_6) + .unwrap(); + let record0 = JP_CEMU_HEADER.len() + USER_HUNTER_LIFE_DIARY_START; + let record3 = record0 + 3 * HUNTER_LIFE_DIARY_RECORD_STRIDE; + assert_eq!( + u32::from_be_bytes(current[record0 + 0x0C..record0 + 0x10].try_into().unwrap()), + 1_442_840_576 + ); + current[record3 + 0x1C..record3 + 0x20].copy_from_slice(&41_u32.to_be_bytes()); + + let merged = + merge_component(&source, ¤t, "user2", ConverterRevision::V0_0_6).unwrap(); + + assert_eq!( + &merged.bytes[record0 + 0x0C..record0 + 0x10], + &86_u32.to_be_bytes() + ); + assert_eq!( + &merged.bytes[record3 + 0x1C..record3 + 0x20], + &41_u32.to_be_bytes() + ); + assert!(merged.fields.iter().any(|field| { + field.name == "personal-hunter-life-diary-0-u32-0c" + && field.status == MergeFieldStatus::Repaired + })); + assert!(merged.fields.iter().any(|field| { + field.name == "personal-hunter-life-diary-3-u32-1c" + && field.status == MergeFieldStatus::PreservedConflict + })); + + let second = + merge_component(&source, &merged.bytes, "user2", ConverterRevision::V0_0_6).unwrap(); + assert_eq!(second.bytes, merged.bytes); + assert_eq!(second.repaired_fields, 0); + } + + #[test] + fn repairs_received_card_hunter_life_diary_for_all_card_components() { + let mut source = card_source(); + let source_record = JP_3DS_HEADER.len() + + 97 * GUILD_CARD_SLOT_SIZE + + GUILD_CARD_HUNTER_LIFE_DIARY_START + + 9 * HUNTER_LIFE_DIARY_RECORD_STRIDE; + source[source_record + 0x0C..source_record + 0x10].copy_from_slice(&40_u32.to_le_bytes()); + source[source_record + 0x10..source_record + 0x14].copy_from_slice(&1_u32.to_le_bytes()); + let target_record = JP_CEMU_HEADER.len() + + 97 * GUILD_CARD_SLOT_SIZE + + GUILD_CARD_HUNTER_LIFE_DIARY_START + + 9 * HUNTER_LIFE_DIARY_RECORD_STRIDE; + + for filename in ["card1", "card2", "card3"] { + let mut current = convert_external_component_to_cemu_named_for_revision( + &source, + filename, + ConverterRevision::V0_0_6, + ) + .unwrap(); + assert_eq!( + u32::from_be_bytes( + current[target_record + 0x0C..target_record + 0x10] + .try_into() + .unwrap() + ), + 671_088_640, + "{filename} historical diary value" + ); + current[target_record + 0x10..target_record + 0x14] + .copy_from_slice(&2_u32.to_be_bytes()); + + let merged = + merge_component(&source, ¤t, filename, ConverterRevision::V0_0_6).unwrap(); + assert_eq!( + &merged.bytes[target_record + 0x0C..target_record + 0x10], + &40_u32.to_be_bytes(), + "{filename} repaired diary value" + ); + assert_eq!( + &merged.bytes[target_record + 0x10..target_record + 0x14], + &2_u32.to_be_bytes(), + "{filename} later Wii U diary event" + ); + assert!(merged.fields.iter().any(|field| { + field.name == "received-card-97-hunter-life-diary-9-u32-0c" + && field.status == MergeFieldStatus::Repaired + })); + assert!(merged.fields.iter().any(|field| { + field.name == "received-card-97-hunter-life-diary-9-u32-10" + && field.status == MergeFieldStatus::PreservedConflict + })); + } + } + + #[test] + fn compatibility_diary_fields_follow_the_shared_schema_only() { + let user_fields = repair_fields("user1").unwrap(); + let personal = user_fields + .iter() + .filter(|field| field.name.starts_with("personal-hunter-life-diary-")) + .collect::>(); + assert_eq!( + personal.len(), + USER_HUNTER_LIFE_DIARY_COUNT + * (HUNTER_LIFE_DIARY_U16_OFFSETS.len() + HUNTER_LIFE_DIARY_U32_OFFSETS.len()) + ); + assert!( + personal + .iter() + .all(|field| field.width == 2 || field.width == 4) + ); + + let card_fields = repair_fields("card1").unwrap(); + let received = card_fields + .iter() + .filter(|field| field.name.contains("-hunter-life-diary-")) + .collect::>(); + assert_eq!( + received.len(), + GUILD_CARD_SLOT_COUNT + * GUILD_CARD_HUNTER_LIFE_DIARY_COUNT + * (HUNTER_LIFE_DIARY_U16_OFFSETS.len() + HUNTER_LIFE_DIARY_U32_OFFSETS.len()) + ); + assert!( + repair_fields("cardbox") + .unwrap() + .iter() + .all(|field| !field.name.contains("hunter-life-diary")) + ); + } + #[test] fn detects_each_unmodified_historical_core_output() { let source = source(); diff --git a/crates/mh3g-save-convert/src/converter.rs b/crates/mh3g-save-convert/src/converter.rs index f3e6ece..ab6120c 100644 --- a/crates/mh3g-save-convert/src/converter.rs +++ b/crates/mh3g-save-convert/src/converter.rs @@ -626,7 +626,7 @@ mod tests { } #[test] - fn transforms_card_payload_with_the_recovered_meow_mapping() { + fn transforms_card_payload_with_the_complete_current_mapping() { let source = synthetic_external_component(CARD_PAYLOAD_SIZE); let source_before = source.clone(); @@ -640,7 +640,7 @@ mod tests { ); assert_eq!( hex::encode(Sha256::digest(&output[JP_CEMU_HEADER.len()..])), - "0cb299de141dfe9c47e15c7329536c534222ae4d719d947f3f7405c4cf368363" + "0e652288ce1becc9c8141db230bdff49c41062d53520379b3e22d4ef03bb6d7f" ); } @@ -744,6 +744,46 @@ mod tests { ); } + #[test] + fn remaps_every_hunter_life_diary_field_in_the_last_received_card_slot() { + let mut source = vec![0_u8; JP_3DS_HEADER.len() + CARD_PAYLOAD_SIZE]; + source[..JP_3DS_HEADER.len()].copy_from_slice(&JP_3DS_HEADER); + let body = &mut source[JP_3DS_HEADER.len()..]; + let record = 97 * 0xE00 + 0x178 + 9 * 0xA0; + body[record..record + 0x2C].copy_from_slice(&[ + 0x1F, 0x07, // day/month: packed bytes + 0xEA, 0x07, // year: u16 LE + 0x34, 0x12, // event kind: u16 LE + 0x78, 0x56, // event/entity id: u16 LE + 0x91, 0x82, 0x73, 0x64, // packed descriptor + 0x56, 0x00, 0x00, 0x00, // HR parameter: 86 + 0x01, 0x00, 0x00, 0x00, // faint count: 1 + 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, + 0x00, // hunt count: 40 + 0x05, 0x00, 0x00, 0x00, b'Y', b'o', b'r', b'u', b'a', b's', b'k', b'i', + ]); + let source_before = source.clone(); + + let output = convert_external_component_to_cemu_named(&source, "card1").unwrap(); + let payload = &output[JP_CEMU_HEADER.len()..]; + + assert_eq!(source, source_before); + assert_eq!(&payload[record..record + 2], &[0x1F, 0x07]); + assert_eq!( + &payload[record + 2..record + 8], + &[0x07, 0xEA, 0x12, 0x34, 0x56, 0x78] + ); + assert_eq!(&payload[record + 8..record + 12], &[0x91, 0x82, 0x73, 0x64]); + assert_eq!( + &payload[record + 12..record + 36], + &[ + 0x00, 0x00, 0x00, 0x56, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, + 0x00, 0x03, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x05, + ] + ); + assert_eq!(&payload[record + 36..record + 44], b"Yoruaski"); + } + #[test] fn remaps_each_received_card_monster_record_as_four_independent_u16_values() { let mut source = vec![0_u8; JP_3DS_HEADER.len() + CARD_PAYLOAD_SIZE]; diff --git a/crates/mh3g-save-convert/src/transforms.rs b/crates/mh3g-save-convert/src/transforms.rs index d3981ad..40612b5 100644 --- a/crates/mh3g-save-convert/src/transforms.rs +++ b/crates/mh3g-save-convert/src/transforms.rs @@ -113,12 +113,27 @@ const OFFLINE_HUNTER_EQUIPMENT_STRIDE: usize = 8; const OFFLINE_HUNTER_TAIL_COLOR_OFFSETS: [usize; 2] = [0x28, 0x2C]; const OFFLINE_HUNTER_CANDIDATE_IDS_START: usize = 0x7848; const OFFLINE_HUNTER_CANDIDATE_ID_COUNT: usize = 3; +// The personal guild card and every received-card slot use the same ten-entry +// Hunter Life Diary record schema. Official 3DS -> Wii U transfer pairs agree +// on the field boundaries: day/month and the four packed bytes at +0x08 are +// byte-preserving, while the three u16 and six u32 values change only endian. +// Keeping this schema shared prevents the personal card, card list, and +// CEC/offline-partner views from drifting apart again. +pub(crate) const HUNTER_LIFE_DIARY_RECORD_STRIDE: usize = 0xA0; +pub(crate) const HUNTER_LIFE_DIARY_U16_OFFSETS: [usize; 3] = [0x02, 0x04, 0x06]; +pub(crate) const HUNTER_LIFE_DIARY_PACKED_OFFSET: usize = 0x08; +pub(crate) const HUNTER_LIFE_DIARY_PACKED_SIZE: usize = 4; +pub(crate) const HUNTER_LIFE_DIARY_U32_OFFSETS: [usize; 6] = [0x0C, 0x10, 0x14, 0x18, 0x1C, 0x20]; +pub(crate) const USER_HUNTER_LIFE_DIARY_START: usize = 0x7B6C; +pub(crate) const USER_HUNTER_LIFE_DIARY_COUNT: usize = 10; const CARD_BODY_SIZE: usize = 0x57_FFC; const CARDBOX_BODY_SIZE: usize = 0x2F_FFC; pub const GUILD_CARD_SLOT_SIZE: usize = 0xE00; // The native Wii U lookup scans exactly 0x62 card slots. Logical slot 98 and // the trailing body are summary/index metadata with a different record shape. const GUILD_CARD_SLOT_COUNT: usize = 0x62; +pub(crate) const GUILD_CARD_HUNTER_LIFE_DIARY_START: usize = 0x178; +pub(crate) const GUILD_CARD_HUNTER_LIFE_DIARY_COUNT: usize = 10; const GUILD_CARD_HR_OFFSET: usize = 0x14; const GUILD_CARD_EQUIPMENT_START: usize = 0x4C; const GUILD_CARD_EQUIPMENT_COUNT: usize = 5; @@ -431,6 +446,91 @@ fn apply_current_hunter_notes_display_state( transform_crown(source, target, state_offset) } +/// Reapply one Hunter Life Diary record with the official field boundaries. +/// +/// Historical MEOW tables only listed fields that happened to be non-zero in +/// one captured save. That left other u32 counters in 3DS little-endian order; +/// Wii U then rendered values such as one faint as 16,777,216. Every numeric +/// field is sourced from the immutable 3DS record here, so this correction is +/// safe after historical replay and cannot double-swap an already touched +/// field. +fn apply_hunter_life_diary_record_corrections( + source: &[u8], + target: &mut [u8], + record_start: usize, +) -> Result<(), ConversionError> { + validate_range( + source, + record_start, + HUNTER_LIFE_DIARY_RECORD_STRIDE, + "Hunter Life Diary source", + )?; + validate_range( + target, + record_start, + HUNTER_LIFE_DIARY_RECORD_STRIDE, + "Hunter Life Diary target", + )?; + + target[record_start..record_start + 2].copy_from_slice(&source[record_start..record_start + 2]); + for relative in HUNTER_LIFE_DIARY_U16_OFFSETS { + copy_reversed(source, target, record_start + relative, 2)?; + } + let packed_start = record_start + HUNTER_LIFE_DIARY_PACKED_OFFSET; + target[packed_start..packed_start + HUNTER_LIFE_DIARY_PACKED_SIZE] + .copy_from_slice(&source[packed_start..packed_start + HUNTER_LIFE_DIARY_PACKED_SIZE]); + for relative in HUNTER_LIFE_DIARY_U32_OFFSETS { + copy_reversed(source, target, record_start + relative, 4)?; + } + + Ok(()) +} + +fn apply_current_personal_hunter_life_diary_corrections( + source: &[u8], + target: &mut [u8], +) -> Result<(), ConversionError> { + for record in 0..USER_HUNTER_LIFE_DIARY_COUNT { + apply_hunter_life_diary_record_corrections( + source, + target, + USER_HUNTER_LIFE_DIARY_START + record * HUNTER_LIFE_DIARY_RECORD_STRIDE, + )?; + } + Ok(()) +} + +fn apply_current_guild_card_hunter_life_diary_slot_corrections( + source: &[u8], + target: &mut [u8], + slot_start: usize, +) -> Result<(), ConversionError> { + for record in 0..GUILD_CARD_HUNTER_LIFE_DIARY_COUNT { + apply_hunter_life_diary_record_corrections( + source, + target, + slot_start + + GUILD_CARD_HUNTER_LIFE_DIARY_START + + record * HUNTER_LIFE_DIARY_RECORD_STRIDE, + )?; + } + Ok(()) +} + +fn apply_current_guild_card_hunter_life_diary_corrections( + source: &[u8], + target: &mut [u8], +) -> Result<(), ConversionError> { + for slot in 0..GUILD_CARD_SLOT_COUNT { + apply_current_guild_card_hunter_life_diary_slot_corrections( + source, + target, + slot * GUILD_CARD_SLOT_SIZE, + )?; + } + Ok(()) +} + fn apply_guild_card_monster_log_corrections( source: &[u8], target: &mut [u8], @@ -674,6 +774,7 @@ pub fn apply_japanese_wiiu_guild_card_slot_corrections( target, ConverterRevision::LAST_HISTORICAL, )?; + apply_current_guild_card_hunter_life_diary_slot_corrections(source, target, 0)?; apply_current_guild_card_monster_log_slot_corrections(source, target, 0) } @@ -789,6 +890,7 @@ pub(crate) fn apply_current_japanese_wiiu_guild_card_corrections( ))); } if kind == GuildCardBodyKind::Card { + apply_current_guild_card_hunter_life_diary_corrections(source, target)?; apply_current_guild_card_monster_log_corrections(source, target)?; } Ok(()) @@ -1030,6 +1132,7 @@ fn apply_current_official_transfer_corrections( target: &mut [u8], ) -> Result<(), ConversionError> { apply_current_shakalaka_companion_corrections(source, target)?; + apply_current_personal_hunter_life_diary_corrections(source, target)?; target[MONSTER_GUIDE_PACKED_STATE_START ..MONSTER_GUIDE_PACKED_STATE_START + MONSTER_GUIDE_PACKED_STATE_LEN] @@ -1210,14 +1313,18 @@ mod tests { }; use super::{ - DEVILJHO_BOOK_ITEM_ID, EQUIPMENT_BOX_START, EVENT_FLAG_START, GUILD_CARD_SLOT_SIZE, - ITEM_ACQUIRED_BITSET_START, ITEM_ACQUIRED_BITSET_WORD_COUNT, + DEVILJHO_BOOK_ITEM_ID, EQUIPMENT_BOX_START, EVENT_FLAG_START, + GUILD_CARD_HUNTER_LIFE_DIARY_COUNT, GUILD_CARD_HUNTER_LIFE_DIARY_START, + GUILD_CARD_SLOT_SIZE, HUNTER_LIFE_DIARY_PACKED_OFFSET, HUNTER_LIFE_DIARY_PACKED_SIZE, + HUNTER_LIFE_DIARY_RECORD_STRIDE, HUNTER_LIFE_DIARY_U16_OFFSETS, + HUNTER_LIFE_DIARY_U32_OFFSETS, ITEM_ACQUIRED_BITSET_START, ITEM_ACQUIRED_BITSET_WORD_COUNT, ITEM_ACQUIRED_BITSET_WORD_SIZE, MONSTER_CAPTURE_COUNT_START, MONSTER_COUNT_ENTRY_COUNT, MONSTER_COUNT_STRIDE, MONSTER_GUIDE_PACKED_STATE_LEN, MONSTER_GUIDE_PACKED_STATE_START, MONSTER_IDS, MONSTER_SLAY_COUNT_START, 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, + SECOND_RGBA_OFFSET, USER_HUNTER_LIFE_DIARY_COUNT, USER_HUNTER_LIFE_DIARY_START, + 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; @@ -1749,6 +1856,207 @@ mod tests { assert_eq!(MONSTER_IDS[MONSTER_INDEX], MONSTER_ID); } + #[test] + fn hunter_life_diary_mapping_is_shared_by_personal_and_received_cards() { + let mut personal_source = vec![0_u8; PAYLOAD_SIZE]; + for record in 0..USER_HUNTER_LIFE_DIARY_COUNT { + let start = USER_HUNTER_LIFE_DIARY_START + record * HUNTER_LIFE_DIARY_RECORD_STRIDE; + personal_source[start..start + 2] + .copy_from_slice(&[0x11 + record as u8, 0x21 + record as u8]); + for (field, relative) in HUNTER_LIFE_DIARY_U16_OFFSETS.into_iter().enumerate() { + let value = 0x3100_u16 + (record * 0x10 + field) as u16; + personal_source[start + relative..start + relative + 2] + .copy_from_slice(&value.to_le_bytes()); + } + personal_source[start + HUNTER_LIFE_DIARY_PACKED_OFFSET + ..start + HUNTER_LIFE_DIARY_PACKED_OFFSET + HUNTER_LIFE_DIARY_PACKED_SIZE] + .copy_from_slice(&[0x91, 0x82, 0x73, 0x64]); + for (field, relative) in HUNTER_LIFE_DIARY_U32_OFFSETS.into_iter().enumerate() { + let value = 0x1020_3000_u32 + (record * 0x10 + field) as u32; + personal_source[start + relative..start + relative + 4] + .copy_from_slice(&value.to_le_bytes()); + } + personal_source[start + 0x24..start + 0x2C].copy_from_slice(b"PERSONAL"); + } + let personal_before = personal_source.clone(); + let mut personal_target = personal_source.clone(); + apply_japanese_wiiu_corrections(&personal_source, &mut personal_target).unwrap(); + assert_eq!(personal_source, personal_before); + + let mut received_source = vec![0_u8; GUILD_CARD_SLOT_SIZE]; + for record in 0..GUILD_CARD_HUNTER_LIFE_DIARY_COUNT { + let start = + GUILD_CARD_HUNTER_LIFE_DIARY_START + record * HUNTER_LIFE_DIARY_RECORD_STRIDE; + received_source[start..start + 2] + .copy_from_slice(&[0x41 + record as u8, 0x51 + record as u8]); + for (field, relative) in HUNTER_LIFE_DIARY_U16_OFFSETS.into_iter().enumerate() { + let value = 0x6100_u16 + (record * 0x10 + field) as u16; + received_source[start + relative..start + relative + 2] + .copy_from_slice(&value.to_le_bytes()); + } + received_source[start + HUNTER_LIFE_DIARY_PACKED_OFFSET + ..start + HUNTER_LIFE_DIARY_PACKED_OFFSET + HUNTER_LIFE_DIARY_PACKED_SIZE] + .copy_from_slice(&[0xA1, 0xB2, 0xC3, 0xD4]); + for (field, relative) in HUNTER_LIFE_DIARY_U32_OFFSETS.into_iter().enumerate() { + let value = 0x7080_9000_u32 + (record * 0x10 + field) as u32; + received_source[start + relative..start + relative + 4] + .copy_from_slice(&value.to_le_bytes()); + } + received_source[start + 0x24..start + 0x2C].copy_from_slice(b"RECEIVED"); + } + let received_before = received_source.clone(); + let mut received_target = received_source.clone(); + apply_japanese_wiiu_guild_card_slot_corrections(&received_source, &mut received_target) + .unwrap(); + assert_eq!(received_source, received_before); + + for record in 0..USER_HUNTER_LIFE_DIARY_COUNT { + let start = USER_HUNTER_LIFE_DIARY_START + record * HUNTER_LIFE_DIARY_RECORD_STRIDE; + assert_eq!( + &personal_target[start..start + 2], + &personal_source[start..start + 2] + ); + for relative in HUNTER_LIFE_DIARY_U16_OFFSETS { + let value = u16::from_le_bytes( + personal_source[start + relative..start + relative + 2] + .try_into() + .unwrap(), + ); + assert_eq!( + &personal_target[start + relative..start + relative + 2], + &value.to_be_bytes() + ); + } + assert_eq!( + &personal_target[start + HUNTER_LIFE_DIARY_PACKED_OFFSET + ..start + HUNTER_LIFE_DIARY_PACKED_OFFSET + HUNTER_LIFE_DIARY_PACKED_SIZE], + &personal_source[start + HUNTER_LIFE_DIARY_PACKED_OFFSET + ..start + HUNTER_LIFE_DIARY_PACKED_OFFSET + HUNTER_LIFE_DIARY_PACKED_SIZE] + ); + for relative in HUNTER_LIFE_DIARY_U32_OFFSETS { + let value = u32::from_le_bytes( + personal_source[start + relative..start + relative + 4] + .try_into() + .unwrap(), + ); + assert_eq!( + &personal_target[start + relative..start + relative + 4], + &value.to_be_bytes() + ); + } + assert_eq!( + &personal_target[start + 0x24..start + 0x2C], + &personal_source[start + 0x24..start + 0x2C] + ); + } + + for record in 0..GUILD_CARD_HUNTER_LIFE_DIARY_COUNT { + let start = + GUILD_CARD_HUNTER_LIFE_DIARY_START + record * HUNTER_LIFE_DIARY_RECORD_STRIDE; + assert_eq!( + &received_target[start..start + 2], + &received_source[start..start + 2] + ); + for relative in HUNTER_LIFE_DIARY_U16_OFFSETS { + let value = u16::from_le_bytes( + received_source[start + relative..start + relative + 2] + .try_into() + .unwrap(), + ); + assert_eq!( + &received_target[start + relative..start + relative + 2], + &value.to_be_bytes() + ); + } + assert_eq!( + &received_target[start + HUNTER_LIFE_DIARY_PACKED_OFFSET + ..start + HUNTER_LIFE_DIARY_PACKED_OFFSET + HUNTER_LIFE_DIARY_PACKED_SIZE], + &received_source[start + HUNTER_LIFE_DIARY_PACKED_OFFSET + ..start + HUNTER_LIFE_DIARY_PACKED_OFFSET + HUNTER_LIFE_DIARY_PACKED_SIZE] + ); + for relative in HUNTER_LIFE_DIARY_U32_OFFSETS { + let value = u32::from_le_bytes( + received_source[start + relative..start + relative + 4] + .try_into() + .unwrap(), + ); + assert_eq!( + &received_target[start + relative..start + relative + 4], + &value.to_be_bytes() + ); + } + assert_eq!( + &received_target[start + 0x24..start + 0x2C], + &received_source[start + 0x24..start + 0x2C] + ); + } + } + + #[test] + fn current_hunter_life_diary_corrections_fix_reported_large_values() { + let mut source = vec![0_u8; PAYLOAD_SIZE]; + let record0 = USER_HUNTER_LIFE_DIARY_START; + let record2 = USER_HUNTER_LIFE_DIARY_START + 2 * HUNTER_LIFE_DIARY_RECORD_STRIDE; + let record3 = USER_HUNTER_LIFE_DIARY_START + 3 * HUNTER_LIFE_DIARY_RECORD_STRIDE; + source[record0 + 0x0C..record0 + 0x10].copy_from_slice(&86_u32.to_le_bytes()); + source[record2 + 0x10..record2 + 0x14].copy_from_slice(&1_u32.to_le_bytes()); + source[record3 + 0x0C..record3 + 0x10].copy_from_slice(&85_u32.to_le_bytes()); + source[record3 + 0x1C..record3 + 0x20].copy_from_slice(&40_u32.to_le_bytes()); + 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(); + + assert_eq!( + u32::from_be_bytes( + historical[record0 + 0x0C..record0 + 0x10] + .try_into() + .unwrap() + ), + 1_442_840_576 + ); + assert_eq!( + u32::from_be_bytes( + historical[record2 + 0x10..record2 + 0x14] + .try_into() + .unwrap() + ), + 16_777_216 + ); + assert_eq!( + u32::from_be_bytes( + historical[record3 + 0x0C..record3 + 0x10] + .try_into() + .unwrap() + ), + 1_426_063_360 + ); + assert_eq!( + u32::from_be_bytes( + historical[record3 + 0x1C..record3 + 0x20] + .try_into() + .unwrap() + ), + 671_088_640 + ); + for (offset, expected) in [ + (record0 + 0x0C, 86), + (record2 + 0x10, 1), + (record3 + 0x0C, 85), + (record3 + 0x1C, 40), + ] { + assert_eq!( + u32::from_be_bytes(current[offset..offset + 4].try_into().unwrap()), + expected + ); + } + } + #[test] fn japanese_wiiu_corrections_swap_nonzero_physical_monster_sizes() { // The Cemu hunter-record UI consumes this cache as big-endian u16 diff --git a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md index 1f23415..b70a636 100644 --- a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md +++ b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md @@ -257,6 +257,13 @@ state-only mapping. Slay/capture counters are converted as numeric fields but never synthesize the Wii U visibility bit; paired official transfers prove that counter-bearing rows can remain undiscovered. +The same three views also share one Hunter Life Diary schema: ten records per +table, three endian-converted `u16` fields and six endian-converted `u32` +fields per record. Packed descriptors, names, and text remain byte-preserving. +Compatibility repair treats each numeric value as one complete field, so a +later Wii U edit is preserved rather than partially overwritten. `cardbox` +does not contain the full diary table. + There is no evidence-backed safe rule for selecting only one `card#` file for that result. Treat all four as one installation group. Conversely, CEC is not a prerequisite for these existing card/partner records; its raw received 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 eb83c4e..dc1a7c4 100644 --- a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md +++ b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md @@ -151,6 +151,8 @@ CEC 既不属于 `user#`,也不属于 `card*`,正常公会名片/离线伙 个人记录、收到的公会名片和 CEC 伙伴记录共用同一份“仅状态字节”怪物发现映射。讨伐/捕获计数会按数值字段转换,但绝不能据此合成 Wii U 可见标记;配对实机迁移已经证明,计数非零的记录仍可能保持未发现。 +这三个入口也共用同一份猎人生活日记结构:每张表 10 条记录,每条包含 3 个需要换序的 `u16` 字段和 6 个需要换序的 `u32` 字段;打包描述、姓名和文本保持原字节。兼容修复把每个数值作为一个完整字段处理,因此玩家后来在 Wii U 修改过的日记值会整体保留,不会只覆盖其中部分字节。`cardbox` 不含完整日记表。 + 目前没有证据支持“只选择一个 `card#` 文件也能安全得到该结果”的规则。应把全部四个名片文件视为同一个安装组。反过来,CEC 也不是这些已有名片/伙伴记录的前置依赖;其原始收件记录导入仍明确属于实验性功能。 ## 各命令的读写边界 diff --git a/docs/adr/0018-mh3g-hunter-life-diary-schema.md b/docs/adr/0018-mh3g-hunter-life-diary-schema.md new file mode 100644 index 0000000..32b75b8 --- /dev/null +++ b/docs/adr/0018-mh3g-hunter-life-diary-schema.md @@ -0,0 +1,74 @@ +# ADR 0018: Share the MH3G Hunter Life Diary schema across card views + +- Status: Accepted +- Date: 2026-08-18 +- Scope: Japanese MH3G 3DS to MH3G HD Wii U/Cemu conversion + +## Context + +The Hunter Life Diary is rendered from three user-facing entry points: the +player's personal guild card, received guild cards, and cards attached to +offline-hall partners. Releases through 0.0.22 relied on sparse MEOW operation +tables recovered from one reference save. Those tables converted only fields +that happened to be populated in that save. A populated event parameter in a +different diary row or received-card slot could therefore remain in 3DS +little-endian order and be read by Wii U as a very large big-endian value. + +The reported Yoruaski sample reproduces the failure exactly. A stored value of +`40` (`28 00 00 00`) was read as `0x28000000` (`671088640`), and a value of +`1` (`01 00 00 00`) was read as `16777216`. Two Hunter Rank event parameters +similarly produced the reported `1442840577` and `1426063361` display values. + +Four independently supplied official 3DS-to-Wii U transfer sets were compared +after removing their platform headers. The local evidence archive has SHA-256 +`0262a4c5353deca35fa1220d89c87414383bab1454509d06d14d6414f7827712`; +the player files remain local and are never committed. Every informative field +agrees on this fixed record schema: + +- personal table: payload `0x7B6C`, 10 records, stride `0xA0`; +- full received-card slot: relative `0x178`, 10 records, stride `0xA0`; +- relative `0x00..0x01`: packed day/month bytes, copied unchanged; +- relative `0x02`, `0x04`, `0x06`: independent `u16` values, LE to BE; +- relative `0x08..0x0B`: packed event descriptor, copied unchanged; +- relative `0x0C`, `0x10`, `0x14`, `0x18`, `0x1C`, `0x20`: independent + event parameters stored as `u32`, LE to BE; +- text, participant names, and the remaining tail are copied unchanged. + +`card1`, `card2`, and `card3` each contain 98 full `0xE00` slots. Experimental +CEC records embed three slots with the same shape. `cardbox` is a compact +index/summary format and does not contain this diary table. + +## Decision + +1. Keep the released 0.0.3-0.0.6 sparse replay unchanged for compatibility + detection. +2. Add one source-based Hunter Life Diary record correction that owns the + three `u16` and six `u32` field boundaries. +3. Apply that shared record correction to all 10 personal records, all 10 + records in every full received-card slot, and every CEC embedded slot. +4. Do not apply the schema to `cardbox`. +5. Add the same multibyte fields to compatibility repair. A field is repaired + only while the current Wii U bytes still equal the selected historical + converter output; a later Wii U change to any byte preserves the complete + field as a conflict. +6. Preserve packed descriptors, names, text, and unrelated Wii U progress. + +## Consequences + +- The three guild-card entry points use the same numeric mapping instead of + separate sparse offset coverage. +- Fresh conversion no longer produces powers-of-256 diary values. +- Existing converted `user#` and `card1`/`card2`/`card3` components can be + repaired without overwriting later Wii U diary events. +- Existing converted CEC caches are not part of `repair-converted`; CEC uses + the corrected schema when imported again from original 3DS inbox records. +- Deterministic tests cover all ten rows, the final received-card slot, the CEC + slot path, whole-field conflict preservation, and idempotent repair. + +## Verification boundary + +The four official transfer sets prove the file-level field mapping. Synthetic +tests prove all converter and compatibility paths without committing private +saves. Screenshot values are reproduced from the supplied source bytes, but a +future release artifact still requires normal Windows packaging and player +runtime acceptance before the UI behavior is called Runtime Verified.