Skip to content
Closed
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 40 additions & 29 deletions crates/mh3g-save-convert/src/compatibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -478,30 +480,25 @@ fn repair_fields(filename: &str) -> Result<Vec<FieldSpec>, 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" => {
Expand Down Expand Up @@ -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, &current, "user2", ConverterRevision::V0_0_5).unwrap();
merge_component(&source, &current, "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!(
&current[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!(&current[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);
}
Expand All @@ -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, &current, "user2", ConverterRevision::V0_0_5).unwrap();
merge_component(&source, &current, "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
}));
}
Expand Down
99 changes: 47 additions & 52 deletions crates/mh3g-save-convert/src/converter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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::<Vec<_>>();
let mut kayamba_packed = (0..0x62)
.map(|index| (index as u8).wrapping_mul(17).wrapping_add(9))
.collect::<Vec<_>>();
// 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!(
Expand All @@ -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
);
}

Expand Down
Loading
Loading