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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,15 @@ 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.

Slay and capture totals use a separate schema: 86 valid monster IDs
(`0x00..0x55`), each stored as a `u16` that changes from 3DS little-endian to
Wii U big-endian. Earlier releases omitted the three slay counters for Giggi,
Aptonoth, and Popo, causing Wii U to misread ordinary values as tens of
thousands and clamp the UI to `9999`. Current conversion reasserts all 86
entries in both count tables. Compatibility repair only updates count fields
that still match historical converter output, preserving later Wii U/Cemu
progress.

### Before you write: paths, inspection, and dry-run

The examples below run from this repository after Rust is installed. Define a
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ Windows 前置条件、行为和可选参数见

怪物是否出现在图鉴中只由 3DS 源存档的发现/大小冠状态字节决定,不能根据讨伐数或捕获数推断。当前转换对个人记录、收到的公会名片以及 CEC/离线集会所伙伴统一使用同一份“仅状态字节”映射,避免旧逻辑把“计数非零但尚未发现”的记录错误扩展成额外图鉴页。

怪物的讨伐数和捕获数是另一组独立数据:`0x00..0x55` 共 86 个有效怪物 ID,讨伐和捕获各有一张 86 项的 `u16` 表,从 3DS 小端转为 Wii U 大端。旧版遗漏了 Giggi、Aptonoth 和 Popo 的三项讨伐数,Wii U 会把普通数值误读为数万并在界面钳制为 `9999`;当前转换会重申两张完整计数表。修复已转换存档时,仅更新仍等于旧转换结果的计数字段,玩家在 Wii U/Cemu 后续产生的新计数会保留。

如果解压后的目录中直接包含 `user2`,使用 CLI 转换核心槽位时必须传入这个**文件**。在原生工作台中,选择该目录并选中 `user2` 时,只会解析其直接子文件 `user2`。如果目录中直接包含 `card1` 到 `quest4`,该目录只能作为 `convert-extras` 输入。如果解压结果外面还有一层包装目录,需要先进入这一层;上述预期文件必须是 CLI 路径或上述受限 GUI 选择的直接子项。

### 写入前:设置路径、检查和 dry-run
Expand Down
135 changes: 135 additions & 0 deletions crates/mh3g-save-convert/src/compatibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ const GUILD_CARD_MONSTER_LOG_COUNT: usize = 50;
const GUILD_CARD_MONSTER_LOG_STRIDE: usize = 10;
const USER_MONSTER_GUIDE_PACKED_STATE_START: usize = 0x5760;
const USER_MONSTER_GUIDE_PACKED_STATE_LEN: usize = 0x1C;
const USER_MONSTER_SLAY_COUNT_START: usize = 0x5784;
const USER_MONSTER_CAPTURE_COUNT_START: usize = 0x5884;
const USER_MONSTER_COUNT_STRIDE: usize = 2;
// Only lanes whose historical 0.0.3-0.0.6 output differs from the current
// official-transfer correction belong in revision detection/repair. Adding the
// already-identical lanes would dilute revision scores without changing any
// bytes. Fresh conversion still normalizes all 0x56 valid monster IDs.
const USER_HISTORICAL_DIFFERENT_SLAY_COUNT_IDS: [usize; 4] = [26, 27, 28, 84];
const USER_HISTORICAL_DIFFERENT_CAPTURE_COUNT_IDS: [usize; 20] = [
21, 22, 23, 26, 27, 28, 29, 30, 31, 32, 33, 76, 77, 78, 79, 80, 81, 82, 83, 84,
];
const USER_ITEM_ACQUIRED_BITSET_START: usize = 0x65C4;
const USER_ITEM_ACQUIRED_BITSET_WORD_COUNT: usize = 48;
const USER_ITEM_ACQUIRED_BITSET_WORD_SIZE: usize = 4;
Expand Down Expand Up @@ -447,6 +458,24 @@ fn repair_fields(filename: &str) -> Result<Vec<FieldSpec>, ConversionError> {
width: 1,
});
}
for monster_id in USER_HISTORICAL_DIFFERENT_SLAY_COUNT_IDS {
fields.push(FieldSpec {
name: format!("monster-slay-count-{monster_id}"),
offset: header
+ USER_MONSTER_SLAY_COUNT_START
+ monster_id * USER_MONSTER_COUNT_STRIDE,
width: USER_MONSTER_COUNT_STRIDE,
});
}
for monster_id in USER_HISTORICAL_DIFFERENT_CAPTURE_COUNT_IDS {
fields.push(FieldSpec {
name: format!("monster-capture-count-{monster_id}"),
offset: header
+ USER_MONSTER_CAPTURE_COUNT_START
+ monster_id * USER_MONSTER_COUNT_STRIDE,
width: USER_MONSTER_COUNT_STRIDE,
});
}
for word in 0..USER_ITEM_ACQUIRED_BITSET_WORD_COUNT {
fields.push(FieldSpec {
name: format!("item-acquired-word-{word}"),
Expand Down Expand Up @@ -705,6 +734,16 @@ mod tests {
source[source_guide_state..source_guide_state + USER_MONSTER_GUIDE_PACKED_STATE_LEN]
.fill(0xFF);
source[source_guide_state + USER_MONSTER_GUIDE_PACKED_STATE_LEN - 1] = 0x00;
let source_giggi = JP_3DS_HEADER.len() + USER_MONSTER_SLAY_COUNT_START + 0x1A * 2;
let source_aptonoth = JP_3DS_HEADER.len() + USER_MONSTER_SLAY_COUNT_START + 0x1B * 2;
let source_popo = JP_3DS_HEADER.len() + USER_MONSTER_SLAY_COUNT_START + 0x1C * 2;
source[source_giggi..source_giggi + 2].copy_from_slice(&0x0584_u16.to_le_bytes());
source[source_aptonoth..source_aptonoth + 2].copy_from_slice(&0x00EC_u16.to_le_bytes());
source[source_popo..source_popo + 2].copy_from_slice(&0x015C_u16.to_le_bytes());
let source_capture_21 = JP_3DS_HEADER.len() + USER_MONSTER_CAPTURE_COUNT_START + 21 * 2;
let source_capture_22 = JP_3DS_HEADER.len() + USER_MONSTER_CAPTURE_COUNT_START + 22 * 2;
source[source_capture_21..source_capture_21 + 2].copy_from_slice(&0x1234_u16.to_le_bytes());
source[source_capture_22..source_capture_22 + 2].copy_from_slice(&0x2345_u16.to_le_bytes());
let source_item_word = JP_3DS_HEADER.len() + USER_ITEM_ACQUIRED_BITSET_START;
source[source_item_word..source_item_word + 4]
.copy_from_slice(&0x1234_5678_u32.to_le_bytes());
Expand All @@ -715,12 +754,19 @@ mod tests {
.unwrap();
let unrelated = JP_CEMU_HEADER.len() + 0x240;
current[unrelated] ^= 0x5A;
let popo = JP_CEMU_HEADER.len() + USER_MONSTER_SLAY_COUNT_START + 0x1C * 2;
current[popo..popo + 2].copy_from_slice(&0x015D_u16.to_be_bytes());
let capture_22 = JP_CEMU_HEADER.len() + USER_MONSTER_CAPTURE_COUNT_START + 22 * 2;
current[capture_22..capture_22 + 2].copy_from_slice(&0x2346_u16.to_be_bytes());

let merged =
merge_component(&source, &current, "user2", ConverterRevision::V0_0_6).unwrap();
let guide_state = JP_CEMU_HEADER.len() + USER_MONSTER_GUIDE_PACKED_STATE_START;
let item_word = JP_CEMU_HEADER.len() + USER_ITEM_ACQUIRED_BITSET_START;
let appearance = JP_CEMU_HEADER.len() + USER_APPEARANCE_RGBA_OFFSET;
let giggi = JP_CEMU_HEADER.len() + USER_MONSTER_SLAY_COUNT_START + 0x1A * 2;
let aptonoth = JP_CEMU_HEADER.len() + USER_MONSTER_SLAY_COUNT_START + 0x1B * 2;
let capture_21 = JP_CEMU_HEADER.len() + USER_MONSTER_CAPTURE_COUNT_START + 21 * 2;

assert_eq!(
&merged.bytes[guide_state..guide_state + USER_MONSTER_GUIDE_PACKED_STATE_LEN],
Expand All @@ -734,6 +780,25 @@ mod tests {
&merged.bytes[appearance..appearance + 4],
&[0xFA, 0xEF, 0xE6, 0xFF]
);
assert_eq!(&merged.bytes[giggi..giggi + 2], &0x0584_u16.to_be_bytes());
assert_eq!(
&merged.bytes[aptonoth..aptonoth + 2],
&0x00EC_u16.to_be_bytes()
);
assert_eq!(
&merged.bytes[popo..popo + 2],
&0x015D_u16.to_be_bytes(),
"later Wii U progress must win at the whole-field boundary"
);
assert_eq!(
&merged.bytes[capture_21..capture_21 + 2],
&0x1234_u16.to_be_bytes()
);
assert_eq!(
&merged.bytes[capture_22..capture_22 + 2],
&0x2346_u16.to_be_bytes(),
"later Wii U capture progress must win at the whole-field boundary"
);
assert_eq!(merged.bytes[unrelated], current[unrelated]);
assert!(merged.fields.iter().any(|field| {
field.name == "monster-guide-packed-state-byte-26"
Expand All @@ -745,6 +810,76 @@ mod tests {
assert!(merged.fields.iter().any(|field| {
field.name == "player-appearance-rgba" && field.status == MergeFieldStatus::Repaired
}));
assert!(merged.fields.iter().any(|field| {
field.name == "monster-slay-count-26" && field.status == MergeFieldStatus::Repaired
}));
assert!(merged.fields.iter().any(|field| {
field.name == "monster-slay-count-27" && field.status == MergeFieldStatus::Repaired
}));
assert!(merged.fields.iter().any(|field| {
field.name == "monster-slay-count-28"
&& field.status == MergeFieldStatus::PreservedConflict
}));
assert!(merged.fields.iter().any(|field| {
field.name == "monster-capture-count-21" && field.status == MergeFieldStatus::Repaired
}));
assert!(merged.fields.iter().any(|field| {
field.name == "monster-capture-count-22"
&& field.status == MergeFieldStatus::PreservedConflict
}));
}

#[test]
fn compatibility_count_fields_cover_exact_historical_delta_lanes() {
let mut source = source();
for monster_id in 0..0x56 {
let slay = JP_3DS_HEADER.len()
+ USER_MONSTER_SLAY_COUNT_START
+ monster_id * USER_MONSTER_COUNT_STRIDE;
let capture = JP_3DS_HEADER.len()
+ USER_MONSTER_CAPTURE_COUNT_START
+ monster_id * USER_MONSTER_COUNT_STRIDE;
source[slay..slay + 2].copy_from_slice(&(0xA500_u16 | monster_id as u16).to_le_bytes());
source[capture..capture + 2]
.copy_from_slice(&(0x5A00_u16 | monster_id as u16).to_le_bytes());
}

let historical =
convert_3ds_to_cemu_named_for_revision(&source, "user2", ConverterRevision::V0_0_6)
.unwrap();
let current = convert_3ds_to_cemu_named(&source, "user2").unwrap();
let differing_ids = |table_start: usize| {
(0..0x56)
.filter(|monster_id| {
let offset =
JP_CEMU_HEADER.len() + table_start + monster_id * USER_MONSTER_COUNT_STRIDE;
historical[offset..offset + 2] != current[offset..offset + 2]
})
.collect::<Vec<_>>()
};

assert_eq!(
differing_ids(USER_MONSTER_SLAY_COUNT_START),
USER_HISTORICAL_DIFFERENT_SLAY_COUNT_IDS
);
assert_eq!(
differing_ids(USER_MONSTER_CAPTURE_COUNT_START),
USER_HISTORICAL_DIFFERENT_CAPTURE_COUNT_IDS
);

let fields = repair_fields("user2").unwrap();
let count_fields = fields
.iter()
.filter(|field| {
field.name.starts_with("monster-slay-count-")
|| field.name.starts_with("monster-capture-count-")
})
.count();
assert_eq!(
count_fields,
USER_HISTORICAL_DIFFERENT_SLAY_COUNT_IDS.len()
+ USER_HISTORICAL_DIFFERENT_CAPTURE_COUNT_IDS.len()
);
}

#[test]
Expand Down
119 changes: 115 additions & 4 deletions crates/mh3g-save-convert/src/transforms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ const SECOND_RGBA_OFFSET: usize = 0x73E4;
// though both the acquired-book bit and the personal monster record are set.
const MONSTER_GUIDE_PACKED_STATE_START: usize = 0x5760;
const MONSTER_GUIDE_PACKED_STATE_LEN: usize = 0x1C;
// Both games expose one u16 slay counter and one u16 capture counter for each
// known monster ID 0x00..=0x55. The tables keep the same numeric values, but
// 3DS stores each lane little-endian and Wii U stores it big-endian. The
// historical MEOW table omitted the slay lanes for IDs 0x1A..=0x1C; Wii U then
// interpreted ordinary counts as values above 9999 and the UI saturated them
// to 9999.
const MONSTER_SLAY_COUNT_START: usize = 0x5784;
const MONSTER_CAPTURE_COUNT_START: usize = 0x5884;
const MONSTER_COUNT_ENTRY_COUNT: usize = 0x56;
const MONSTER_COUNT_STRIDE: usize = 2;
// The Wii U executable indexes this region as a 1536-bit item-acquisition
// bitset: item_id >> 5 selects one of 48 u32 words and item_id & 31 selects the
// bit inside that word. Five independently paired official transfers agree
Expand Down Expand Up @@ -867,8 +877,8 @@ fn apply_confirmed_numeric_and_record_corrections(
}

for (index, monster_id) in MONSTER_IDS.into_iter().enumerate() {
let slay_offset = 0x5784 + monster_id * 2;
let capture_offset = 0x5884 + monster_id * 2;
let slay_offset = MONSTER_SLAY_COUNT_START + monster_id * MONSTER_COUNT_STRIDE;
let capture_offset = MONSTER_CAPTURE_COUNT_START + monster_id * MONSTER_COUNT_STRIDE;
let size_offset = 0x5984 + monster_id * 4;
for offset in [slay_offset, capture_offset, size_offset, size_offset + 2] {
copy_reversed(source, target, offset, 2)?;
Expand Down Expand Up @@ -1028,6 +1038,22 @@ fn apply_current_official_transfer_corrections(
..MONSTER_GUIDE_PACKED_STATE_START + MONSTER_GUIDE_PACKED_STATE_LEN],
);

// Reassert the complete counter schema from the original source rather
// than extending the historical MEOW table. This keeps 0.0.3-0.0.6 replay
// byte-reproducible for compatibility detection while fixing every current
// slay/capture lane, including the three historically omitted small
// monsters (Giggi, Aptonoth, and Popo).
for monster_id in 0..MONSTER_COUNT_ENTRY_COUNT {
for table_start in [MONSTER_SLAY_COUNT_START, MONSTER_CAPTURE_COUNT_START] {
copy_reversed(
source,
target,
table_start + monster_id * MONSTER_COUNT_STRIDE,
MONSTER_COUNT_STRIDE,
)?;
}
}

for index in 0..MONSTER_IDS.len() {
apply_current_hunter_notes_display_state(source, target, 0x81B4 + index * 10 + 8)?;
}
Expand Down Expand Up @@ -1186,8 +1212,9 @@ 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,
ITEM_ACQUIRED_BITSET_WORD_SIZE, MONSTER_GUIDE_PACKED_STATE_LEN,
MONSTER_GUIDE_PACKED_STATE_START, MONSTER_IDS, PLAYER_APPEARANCE_PACKED_STYLE_OFFSET,
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,
Expand Down Expand Up @@ -1457,6 +1484,90 @@ mod tests {
);
}

#[test]
fn current_corrections_convert_every_monster_count_lane_to_big_endian() {
let mut source = vec![0_u8; PAYLOAD_SIZE];
source[MONSTER_GUIDE_PACKED_STATE_START
..MONSTER_GUIDE_PACKED_STATE_START + MONSTER_GUIDE_PACKED_STATE_LEN]
.copy_from_slice(&[
0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC, 0xFE, 0x5A, 0xA5, 0x11, 0x22, 0x33, 0x44,
0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xF0, 0x0F, 0xC3, 0x3C,
]);
for monster_id in 0..MONSTER_COUNT_ENTRY_COUNT {
let slays = 0x1100_u16 + monster_id as u16;
let captures = 0x2200_u16 + monster_id as u16;
let slay_offset = MONSTER_SLAY_COUNT_START + monster_id * MONSTER_COUNT_STRIDE;
let capture_offset = MONSTER_CAPTURE_COUNT_START + monster_id * MONSTER_COUNT_STRIDE;
source[slay_offset..slay_offset + 2].copy_from_slice(&slays.to_le_bytes());
source[capture_offset..capture_offset + 2].copy_from_slice(&captures.to_le_bytes());
}
let mut target = source.clone();

apply_japanese_wiiu_corrections(&source, &mut target).unwrap();

for monster_id in 0..MONSTER_COUNT_ENTRY_COUNT {
let slay_offset = MONSTER_SLAY_COUNT_START + monster_id * MONSTER_COUNT_STRIDE;
let capture_offset = MONSTER_CAPTURE_COUNT_START + monster_id * MONSTER_COUNT_STRIDE;
assert_eq!(
u16::from_be_bytes(target[slay_offset..slay_offset + 2].try_into().unwrap()),
0x1100_u16 + monster_id as u16,
"slay count for monster ID {monster_id:#04x}"
);
assert_eq!(
u16::from_be_bytes(
target[capture_offset..capture_offset + 2]
.try_into()
.unwrap()
),
0x2200_u16 + monster_id as u16,
"capture count for monster ID {monster_id:#04x}"
);
}
assert_eq!(
&target[MONSTER_GUIDE_PACKED_STATE_START
..MONSTER_GUIDE_PACKED_STATE_START + MONSTER_GUIDE_PACKED_STATE_LEN],
&source[MONSTER_GUIDE_PACKED_STATE_START
..MONSTER_GUIDE_PACKED_STATE_START + MONSTER_GUIDE_PACKED_STATE_LEN]
);
}

#[test]
fn current_corrections_fix_small_monster_counts_that_historically_saturated_to_9999() {
const CASES: [(usize, u16); 3] = [(0x1A, 0x0584), (0x1B, 0x00EC), (0x1C, 0x015C)];
let mut source = vec![0_u8; PAYLOAD_SIZE];
for (monster_id, value) in CASES {
let offset = MONSTER_SLAY_COUNT_START + monster_id * MONSTER_COUNT_STRIDE;
source[offset..offset + 2].copy_from_slice(&value.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();

for (monster_id, value) in CASES {
let offset = MONSTER_SLAY_COUNT_START + monster_id * MONSTER_COUNT_STRIDE;
assert_eq!(
&historical[offset..offset + 2],
&value.to_le_bytes(),
"historical replay for monster ID {monster_id:#04x}"
);
assert!(
u16::from_be_bytes(historical[offset..offset + 2].try_into().unwrap()) > 9999,
"historical Wii U interpretation should reproduce the saturated UI value"
);
assert_eq!(
&current[offset..offset + 2],
&value.to_be_bytes(),
"current conversion for monster ID {monster_id:#04x}"
);
}
}

#[test]
fn current_corrections_match_official_appearance_field_boundaries() {
let mut source = vec![0_u8; PAYLOAD_SIZE];
Expand Down
Loading
Loading