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
11 changes: 11 additions & 0 deletions crates/mh3g-save-convert/src/cec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1405,6 +1405,10 @@ mod tests {
let weapon_usage_field = slot_start + 0x12C;
let date_field = slot_start + 0x17A;
let record_field = slot_start + 0x7C0 + 32 * 10;
// Row 45 is intentionally beyond the sparse MEOW crown entries. A
// non-zero hunt count with no 3DS discovery bit must still become a
// displayable Wii U Hunter's Notes row inside a packed CEC card.
let late_record_field = slot_start + 0x7C0 + 45 * 10;
let mut record = vec![0_u8; CEMU_RECORD_SLOT_SIZE];
for equipment in 0_u8..5 {
let equipment_field = slot_start + 0x4C + usize::from(equipment) * 0x10;
Expand All @@ -1428,6 +1432,8 @@ mod tests {
record[date_field..date_field + 2].copy_from_slice(&[0xEA, 0x07]);
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]
.copy_from_slice(&[0x09, 0x00, 0x00, 0x00, 0x64, 0x00, 0x64, 0x00, 0x00, 0x00]);

let body_size = CEC_SOURCE_RECORD_PREFIX_SIZE + CEMU_RECORD_SLOT_SIZE;
let header_size = MESSAGE_HEADER_SIZE;
Expand All @@ -1448,6 +1454,7 @@ mod tests {
CEMU_HEADER_SIZE + CEMU_RECORD_AREA_OFFSET + weapon_usage_field;
let converted_date = CEMU_HEADER_SIZE + CEMU_RECORD_AREA_OFFSET + date_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;

for equipment in 0_u8..5 {
let equipment_field = slot_start + 0x4C + usize::from(equipment) * 0x10;
Expand Down Expand Up @@ -1488,6 +1495,10 @@ mod tests {
&conversion.bytes[converted_offset..converted_offset + 10],
&[0x00, 0x0F, 0x00, 0x10, 0x00, 0x64, 0x00, 0x65, 0xA0, 0x00]
);
assert_eq!(
&conversion.bytes[converted_late_offset..converted_late_offset + 10],
&[0x00, 0x09, 0x00, 0x00, 0x00, 0x64, 0x00, 0x64, 0x80, 0x00]
);
}

#[test]
Expand Down
73 changes: 59 additions & 14 deletions crates/mh3g-save-convert/src/converter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,20 +327,21 @@ mod tests {
}

#[test]
fn remaps_unobserved_shakalaka_scalar_fields_in_both_companion_records() {
fn remaps_shakalaka_scalars_without_swapping_mask_state_bytes() {
let mut source = vec![0_u8; THREE_DS_SIZE];
source[..JP_3DS_HEADER.len()].copy_from_slice(&JP_3DS_HEADER);

// Each Shakalaka record has three u32 header fields followed by a
// 0xDA-byte u16 scalar table. The compatibility operation list covered
// only a subset of this record, so a valid mask-mastery scalar deeper
// in either table could remain little-endian.
// Each Shakalaka record starts with endian-sensitive numeric fields,
// but its mask/mastery state at relative 0xDE is byte-packed and has
// the same byte order on 3DS and Wii U. Treating that tail as u16
// changes Cha-Cha's observed `01 09` state to `09 01`, which makes the
// Wii U dialogue path consume an invalid mastery value.
let cha_cha_scalar = 0x6F74;
let kayamba_scalar = 0x70C8;
let cha_cha_last_scalar = 0x7028;
let kayamba_last_scalar = 0x7170;
let cha_cha_opaque_byte = 0x702A;
let kayamba_opaque_byte = 0x7172;
let cha_cha_last_scalar = 0x7020;
let kayamba_last_scalar = 0x7168;
let cha_cha_mask_state = 0x7022;
let kayamba_mask_state = 0x716A;
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 @@ -351,8 +352,12 @@ mod tests {
source[JP_3DS_HEADER.len() + kayamba_last_scalar
..JP_3DS_HEADER.len() + kayamba_last_scalar + 2]
.copy_from_slice(&[0xEF, 0x01]);
source[JP_3DS_HEADER.len() + cha_cha_opaque_byte] = 0xA5;
source[JP_3DS_HEADER.len() + kayamba_opaque_byte] = 0x5A;
source[JP_3DS_HEADER.len() + cha_cha_mask_state
..JP_3DS_HEADER.len() + cha_cha_mask_state + 2]
.copy_from_slice(&[0x01, 0x09]);
source[JP_3DS_HEADER.len() + kayamba_mask_state
..JP_3DS_HEADER.len() + kayamba_mask_state + 2]
.copy_from_slice(&[0x02, 0x05]);

let output = convert_3ds_to_cemu(&source).unwrap();
let payload = &output[JP_CEMU_HEADER.len()..];
Expand All @@ -366,8 +371,14 @@ mod tests {
&payload[kayamba_last_scalar..kayamba_last_scalar + 2],
&[0x01, 0xEF]
);
assert_eq!(payload[cha_cha_opaque_byte], 0xA5);
assert_eq!(payload[kayamba_opaque_byte], 0x5A);
assert_eq!(
&payload[cha_cha_mask_state..cha_cha_mask_state + 2],
&[0x01, 0x09]
);
assert_eq!(
&payload[kayamba_mask_state..kayamba_mask_state + 2],
&[0x02, 0x05]
);
}

#[test]
Expand Down Expand Up @@ -417,7 +428,7 @@ mod tests {
);
assert_eq!(
hex::encode(Sha256::digest(&output[JP_CEMU_HEADER.len()..])),
"9fce84bec2ff99d998747078228941e5dec2f18198ed4931b82b8a30efc00efb"
"0cb299de141dfe9c47e15c7329536c534222ae4d719d947f3f7405c4cf368363"
);
}

Expand Down Expand Up @@ -544,6 +555,40 @@ mod tests {
);
}

#[test]
fn remaps_discovery_bits_for_every_received_card_monster_record() {
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()..];

// The personal hunter record maps its low discovery bit to the Wii U
// display bit (0x80). A received-card slot late in card1 catches the
// old sparse table: its kills were present, but the offline-hall
// partner's Hunter's Notes rendered the monster as `????`.
let last_card_row = 97 * 0xE00 + 0x7C0 + 38 * 10;
body[last_card_row..last_card_row + 10]
.copy_from_slice(&[0x0E, 0x00, 0x00, 0x00, 0x64, 0x00, 0x64, 0x00, 0x01, 0x00]);
// Some real received cards have a non-zero hunt count but no low
// discovery flag. The personal-record converter treats that count as
// sufficient evidence that the record is displayable; card records
// must use the same rule.
let last_card_unflagged_row = last_card_row + 10;
body[last_card_unflagged_row..last_card_unflagged_row + 10]
.copy_from_slice(&[0x09, 0x00, 0x03, 0x00, 0x64, 0x00, 0x64, 0x00, 0x00, 0x00]);

let output = convert_external_component_to_cemu_named(&source, "card1").unwrap();
let payload = &output[JP_CEMU_HEADER.len()..];

assert_eq!(
&payload[last_card_row..last_card_row + 10],
&[0x00, 0x0E, 0x00, 0x00, 0x00, 0x64, 0x00, 0x64, 0x80, 0x00]
);
assert_eq!(
&payload[last_card_unflagged_row..last_card_unflagged_row + 10],
&[0x00, 0x09, 0x00, 0x03, 0x00, 0x64, 0x00, 0x64, 0x80, 0x00]
);
}

#[test]
fn remaps_every_received_card_weapon_usage_counter_as_an_independent_u16() {
let mut source = vec![0_u8; JP_3DS_HEADER.len() + CARD_PAYLOAD_SIZE];
Expand Down
170 changes: 127 additions & 43 deletions crates/mh3g-save-convert/src/transforms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ 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
// has three u32 header fields followed by a contiguous 109-entry u16 scalar
// table. The trailing bytes are packed/opaque state and must remain
// byte-preserved.
// Cha-Cha and Kayamba have two adjacent, fixed-width companion records. Each
// starts with three u32 header fields and endian-sensitive u16 scalars. The
// tail beginning at relative 0xDE is byte-packed mask/mastery state: it has
// the same byte order on 3DS and Wii U and must remain byte-preserved.
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_U16_TABLE_END: usize = 0xE6;
const SHAKALAKA_MASK_STATE_START: usize = 0xDE;
const OFFLINE_HUNTER_EQUIPMENT_CACHE_START: usize = 0x75B0;
const OFFLINE_HUNTER_HEADER_START: usize = 0x75E0;
const OFFLINE_HUNTER_COUNT: usize = 6;
Expand Down Expand Up @@ -333,23 +333,82 @@ fn transform_crown(source: &[u8], target: &mut [u8], offset: usize) -> Result<()
Ok(())
}

/// Apply the single authoritative cross-platform Hunter's Notes visibility
/// mapping.
///
/// The three storage locations (the player's own profile, received guild-card
/// files, and CEC/offline-hall partner cards) have different physical offsets,
/// but they encode the same semantics: 3DS discovery/crown state at
/// `state_offset`, plus little-endian slay/capture counters. Wii U consumes
/// the converted state byte and only renders a monster name when bit `0x80` is
/// set. Keeping this rule here prevents those three import paths drifting.
fn apply_hunter_notes_display_state(
source: &[u8],
target: &mut [u8],
slay_offset: usize,
capture_offset: usize,
state_offset: usize,
) -> Result<(), ConversionError> {
validate_range(source, slay_offset, 2, "Hunter's Notes slay source")?;
validate_range(source, capture_offset, 2, "Hunter's Notes capture source")?;
transform_crown(source, target, state_offset)?;

let slays = u16::from_le_bytes(
source[slay_offset..slay_offset + 2]
.try_into()
.expect("validated Hunter's Notes slay range"),
);
let captures = u16::from_le_bytes(
source[capture_offset..capture_offset + 2]
.try_into()
.expect("validated Hunter's Notes capture range"),
);
if slays != 0 || captures != 0 {
target[state_offset] |= 0x80;
}

Ok(())
}

fn apply_guild_card_monster_log_corrections(
source: &[u8],
target: &mut [u8],
) -> Result<(), ConversionError> {
for slot in 0..GUILD_CARD_SLOT_COUNT {
let slot_start = slot * GUILD_CARD_SLOT_SIZE;
for row in 0..GUILD_CARD_MONSTER_LOG_COUNT {
let record_start =
slot_start + GUILD_CARD_MONSTER_LOG_START + row * GUILD_CARD_MONSTER_LOG_STRIDE;
// A monster-log row is four adjacent u16 values (slays, captures,
// maximum size, minimum size), followed by crown/discovery bytes.
// The static MEOW table treats a subset of these bytes as broader
// scalar spans. Reassert the confirmed field boundaries after it.
for relative in [0, 2, 4, 6] {
copy_reversed(source, target, record_start + relative, 2)?;
}
apply_guild_card_monster_log_slot_corrections(source, target, slot * GUILD_CARD_SLOT_SIZE)?;
}

Ok(())
}

/// Reapply the complete Hunter's Notes schema to one received-card slot.
///
/// Card files and experimental CEC records store the same 0xE00-byte card
/// shape. Keeping this correction slot-relative makes their monster names and
/// crown bits agree with the personal guild-card view.
fn apply_guild_card_monster_log_slot_corrections(
source: &[u8],
target: &mut [u8],
slot_start: usize,
) -> Result<(), ConversionError> {
for row in 0..GUILD_CARD_MONSTER_LOG_COUNT {
let record_start =
slot_start + GUILD_CARD_MONSTER_LOG_START + row * GUILD_CARD_MONSTER_LOG_STRIDE;
// A monster-log row is four adjacent u16 values (slays, captures,
// maximum size, minimum size), followed by crown/discovery bytes.
// The static MEOW table treats a subset of these bytes as broader
// scalar spans. Reassert the confirmed field boundaries after it.
for relative in [0, 2, 4, 6] {
copy_reversed(source, target, record_start + relative, 2)?;
}

apply_hunter_notes_display_state(
source,
target,
record_start,
record_start + 2,
record_start + 8,
)?;
}

Ok(())
Expand Down Expand Up @@ -577,15 +636,7 @@ pub fn apply_japanese_wiiu_guild_card_slot_corrections(
copy_reversed(source, target, offset, 4)?;
}

// The static table has irregular coverage of the 50-row monster log. The
// confirmed row schema is four independent u16 values plus crown bytes;
// reassert those field boundaries for every packed card slot.
for row in 0..GUILD_CARD_MONSTER_LOG_COUNT {
let record_start = GUILD_CARD_MONSTER_LOG_START + row * GUILD_CARD_MONSTER_LOG_STRIDE;
for relative in [0, 2, 4, 6] {
copy_reversed(source, target, record_start + relative, 2)?;
}
}
apply_guild_card_monster_log_slot_corrections(source, target, 0)?;

Ok(())
}
Expand Down Expand Up @@ -681,16 +732,14 @@ fn apply_confirmed_numeric_and_record_corrections(
copy_reversed(source, target, offset, 2)?;
}

let slay = u16::from_le_bytes(source[slay_offset..slay_offset + 2].try_into().unwrap());
let capture = u16::from_le_bytes(
source[capture_offset..capture_offset + 2]
.try_into()
.unwrap(),
);
let discovery_offset = 0x81B4 + index * 10 + 8;
if slay != 0 || capture != 0 || source[discovery_offset] & 0x01 != 0 {
target[discovery_offset] |= 0x80;
}
apply_hunter_notes_display_state(
source,
target,
slay_offset,
capture_offset,
discovery_offset,
)?;
}

let deviljho_linked_size_offset = 0x5984 + DEVILJHO_LINKED_SIZE_CACHE_ID * 4;
Expand Down Expand Up @@ -756,7 +805,7 @@ fn apply_shakalaka_companion_corrections(
for relative in (0..SHAKALAKA_U32_HEADER_SIZE).step_by(4) {
copy_reversed(source, target, record_start + relative, 4)?;
}
for relative in (SHAKALAKA_U32_HEADER_SIZE..SHAKALAKA_U16_TABLE_END).step_by(2) {
for relative in (SHAKALAKA_U32_HEADER_SIZE..SHAKALAKA_MASK_STATE_START).step_by(2) {
copy_reversed(source, target, record_start + relative, 2)?;
}
}
Expand Down Expand Up @@ -846,12 +895,9 @@ pub fn apply_japanese_wiiu_corrections(
target[offset] = source[offset];
}

// The compatibility operation list covers only a subset of the
// Cha-Cha/Kayamba scalar fields. The fixed companion schema is broader:
// all 109 u16 fields in each record need endian conversion, including a
// player's independently progressed mask-mastery values. Reassert the
// full bounded schema so valid values cannot remain in 3DS little-endian
// form.
// The compatibility operation list covers only a subset of the numeric
// Cha-Cha/Kayamba fields. Reassert the bounded numeric prefix, stopping
// before the platform-invariant byte-packed mask/mastery state.
apply_shakalaka_companion_corrections(source, target)?;

// These fields are read as big-endian values by the Wii U title. MEOW v5
Expand All @@ -874,8 +920,9 @@ mod tests {
};

use super::{
EQUIPMENT_BOX_START, EVENT_FLAG_START, QUEST_COMPLETION_START, SECOND_RGBA_OFFSET,
apply_arena_records, apply_endian_swaps, apply_japanese_wiiu_corrections,
EQUIPMENT_BOX_START, EVENT_FLAG_START, GUILD_CARD_SLOT_SIZE, MONSTER_IDS,
QUEST_COMPLETION_START, SECOND_RGBA_OFFSET, apply_arena_records, apply_endian_swaps,
apply_japanese_wiiu_corrections, apply_japanese_wiiu_guild_card_slot_corrections,
apply_monster_discovery,
};

Expand Down Expand Up @@ -1079,6 +1126,43 @@ mod tests {
assert_ne!(target[discovery_offset] & 0x80, 0);
}

#[test]
fn hunter_notes_state_mapping_is_shared_by_personal_and_received_cards() {
const MONSTER_INDEX: usize = 2;
const MONSTER_ID: usize = 0x2D;
const SOURCE_STATE: u8 = 0x0E;
const EXPECTED_WIIU_STATE: u8 = 0x68;

// The personal record stores counters and display state in separate
// tables; a received card stores them together in a ten-byte row.
// The physical offsets differ, but the crown/discovery mapping must
// be identical across the personal card and an offline-hall partner.
let mut personal_source = vec![0_u8; PAYLOAD_SIZE];
let personal_state_offset = 0x81B4 + MONSTER_INDEX * 10 + 8;
personal_source[personal_state_offset] = SOURCE_STATE;
let mut personal_target = personal_source.clone();
apply_japanese_wiiu_corrections(&personal_source, &mut personal_target).unwrap();

let mut received_source = vec![0_u8; GUILD_CARD_SLOT_SIZE];
let received_state_offset = 0x7C0 + MONSTER_INDEX * 10 + 8;
received_source[received_state_offset] = SOURCE_STATE;
let mut received_target = received_source.clone();
apply_japanese_wiiu_guild_card_slot_corrections(&received_source, &mut received_target)
.unwrap();

assert_eq!(
personal_target[personal_state_offset], EXPECTED_WIIU_STATE,
"personal guild card"
);
assert_eq!(
received_target[received_state_offset], EXPECTED_WIIU_STATE,
"received/black-slave guild card"
);
// Keep the test's selected personal monster tied to the record order
// rather than silently relying on an unrelated cache row.
assert_eq!(MONSTER_IDS[MONSTER_INDEX], MONSTER_ID);
}

#[test]
fn japanese_wiiu_corrections_swap_nonzero_physical_monster_sizes() {
// The Cemu hunter-record UI consumes this cache as big-endian u16
Expand Down
2 changes: 1 addition & 1 deletion tools/compatibility-wrapper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Only for the legacy `mh3g-save-convert-v0.0.3` converter core.** The core after PR #17 already performs these conversions natively. Do not layer this wrapper on a newer core, or the affected fields will be converted twice.

This directory preserves the tester-provided Windows hotfix build path. The v0.0.3 WinUI package calls `tools/mh3g-save-convert.exe`; this wrapper patches a temporary copy of the input for the missing companion-mask proficiency, guild-card arena, and CEC arena conversions, then delegates all remaining work to `mh3g-save-convert-core.exe`.
This directory preserves the tester-provided Windows hotfix build path. The v0.0.3 WinUI package calls `tools/mh3g-save-convert.exe`; this wrapper patches a temporary copy of the input for the missing companion numeric-prefix, guild-card arena, and CEC arena conversions while preserving byte-packed mask mastery state, then delegates all remaining work to `mh3g-save-convert-core.exe`.

## Build on Windows

Expand Down
Loading
Loading