Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion crates/mh3g-save-convert/src/cec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand All @@ -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;

Expand Down Expand Up @@ -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]
Expand Down
184 changes: 184 additions & 0 deletions crates/mh3g-save-convert/src/compatibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -451,6 +456,25 @@ fn repair_fields(filename: &str) -> Result<Vec<FieldSpec>, 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}"),
Expand Down Expand Up @@ -542,6 +566,29 @@ fn repair_fields(filename: &str) -> Result<Vec<FieldSpec>, 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}"),
Expand Down Expand Up @@ -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, &current, "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, &current, 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::<Vec<_>>();
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::<Vec<_>>();
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();
Expand Down
44 changes: 42 additions & 2 deletions crates/mh3g-save-convert/src/converter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -640,7 +640,7 @@ mod tests {
);
assert_eq!(
hex::encode(Sha256::digest(&output[JP_CEMU_HEADER.len()..])),
"0cb299de141dfe9c47e15c7329536c534222ae4d719d947f3f7405c4cf368363"
"0e652288ce1becc9c8141db230bdff49c41062d53520379b3e22d4ef03bb6d7f"
);
}

Expand Down Expand Up @@ -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];
Expand Down
Loading
Loading