diff --git a/README.md b/README.md
index e43a32d..bb4119d 100644
--- a/README.md
+++ b/README.md
@@ -200,12 +200,17 @@ extra wrapper directory, enter it first; the expected filenames must be
immediate children of the CLI path or the narrow GUI selection described above.
If the 3DS `system` file is omitted, a core `user#` conversion cannot migrate
-the housekeeper's gallery/movie unlock history. `system` is shared across all
-three character slots and also contains settings unrelated to the selected
-slot. Therefore `convert-system` requires both the 3DS source and an existing,
-initialized Cemu target. It bitwise-unions only the verified gallery/movie
-flag range (Cemu file offsets `0x68..0x77`) and preserves every other target
-byte. It refuses a new/missing target instead of replacing all shared data.
+the housekeeper's gallery/movie unlock history. The physical save layout
+exposes one title-wide `system` rather than separate `system1`, `system2`, and
+`system3` files. Current evidence cannot attribute individual flags inside it
+to `user1`, `user2`, or `user3`, and it also contains settings unrelated to the
+selected slot. Therefore `convert-system` requires both the 3DS source and an
+existing, initialized Cemu target. It
+bitwise-unions only the currently mapped gallery/movie flag range (Cemu file
+offsets `0x68..0x77`) and preserves every other target byte. It refuses a
+new/missing target instead of replacing all shared data. This conservative
+transaction is not a claim that every `system` bit has official-transfer or
+game-runtime parity.
### Legacy Wii U save-editor caveat
@@ -218,6 +223,24 @@ 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.
+
+### Monster guide discovery
+
+Monster discovery is controlled by the source 3DS discovery/crown state byte,
+not inferred from slay or capture counters. Current conversion applies the
+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.
+
### 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 7df3b6f..0536ebf 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -138,12 +138,20 @@ Windows 前置条件、行为和可选参数见
OutBox__/... <- 本机猎人的发出广播
```
-如果没有提供 3DS `system` 文件,单独转换核心 `user#` 无法迁移管家处可查看的画廊/动画解锁记录。`system` 由三个角色槽位共同使用,还包含与当前角色无关的设置。因此 `convert-system` 必须同时提供 3DS 源文件和已初始化、已存在的 Cemu 目标文件。它只会对已确认的画廊/动画标记范围(Cemu 文件偏移 `0x68..0x77`)逐位取并集,其他目标字节全部保留;若目标不存在则拒绝执行,不会整文件覆盖共享数据。
+如果没有提供 3DS `system` 文件,单独转换核心 `user#` 无法迁移管家处可查看的画廊/动画解锁记录。物理存档布局只有一个标题级共享 `system`,不存在独立的 `system1`、`system2`、`system3` 文件;现有证据还不能把其中每个具体标记可靠归因到 `user1`、`user2` 或 `user3`,而且它还包含与当前角色无关的设置。因此 `convert-system` 必须同时提供 3DS 源文件和已初始化、已存在的 Cemu 目标文件。它只会对当前已映射的画廊/动画标记范围(Cemu 文件偏移 `0x68..0x77`)逐位取并集,其他目标字节全部保留;若目标不存在则拒绝执行,不会整文件覆盖共享数据。这个保守事务并不代表 `system` 的每一个标记都已经与官方迁移或游戏运行态完全对齐。
### 旧版 Wii U 存档修改器注意事项
护石技能点在装备记录中是一个有符号单字节值。部分旧修改器却按无符号字节读取,因此合法的 `-5` 会显示成 `251`。转换器会保留该原始有符号字节,只转换确实存在平台字节序差异的字段。部分旧修改器在重写防具时还会重新构造防具头,却不保留其中的 RGB 染色字节;用这种修改器编辑防具可能让防具统一变黑,即使转换后的护石本身完全合法。不能通过钳制技能点或重写原本正确的装备记录来“修复”这类修改器问题。
+### 茶茶/卡扬巴面具熟练度
+
+`user#` 中的随从面具/熟练度区域是按字节打包的数据块,并不是一张全部按 `u16` 交换字节序的表。当前转换只处理已经证实的数值前缀,完整保留已确认的打包区间,使面具界面、随从属性界面与任务结算回写使用同一种表示。兼容修复也可以识别并修复历史版本在随从相对偏移 `0xE4` 处造成的两字节误交换,同时保留无关的 Wii U 后续进度。
+
+### 怪物图鉴发现状态
+
+怪物是否出现在图鉴中只由 3DS 源存档的发现/大小冠状态字节决定,不能根据讨伐数或捕获数推断。当前转换对个人记录、收到的公会名片以及 CEC/离线集会所伙伴统一使用同一份“仅状态字节”映射,避免旧逻辑把“计数非零但尚未发现”的记录错误扩展成额外图鉴页。
+
如果解压后的目录中直接包含 `user2`,使用 CLI 转换核心槽位时必须传入这个**文件**。在原生工作台中,选择该目录并选中 `user2` 时,只会解析其直接子文件 `user2`。如果目录中直接包含 `card1` 到 `quest4`,该目录只能作为 `convert-extras` 输入。如果解压结果外面还有一层包装目录,需要先进入这一层;上述预期文件必须是 CLI 路径或上述受限 GUI 选择的直接子项。
### 写入前:设置路径、检查和 dry-run
diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift
index e375f63..8ba6c5c 100644
--- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift
+++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift
@@ -204,7 +204,7 @@ public enum ConverterCopy {
"Components.QuestsDetail": "迁移任务数据。",
"Components.SystemSource": "3DS system",
"Components.SystemTarget": "Cemu system",
- "Components.SystemWarning": "system 由三个角色槽位共享。必须同时提供 3DS 源文件和已初始化的 Cemu 目标;转换器只合并管家画廊/动画标记,其他 Cemu 设置与跨槽位数据保持不变。",
+ "Components.SystemWarning": "物理存档只有一个共享 system,不存在独立的 system1、system2、system3;目前无法把具体画廊/动画标记可靠归因到某个角色槽。必须同时提供 3DS 源文件和已初始化的 Cemu 目标;转换器只修改狭窄的已映射范围,其他 Cemu 字节保持不变。精确官方迁移/运行态一致性仍未验证。",
"Components.SystemFooter": "目标必须是已存在且格式有效的 Cemu system。若不提供这一对文件,则不会迁移管家画廊/动画记录。",
"Components.SystemSourceMessage": "请选择一个 3DS system 文件。",
"Components.SystemTargetMessage": "请选择一个 Cemu system 文件。",
@@ -417,7 +417,7 @@ public enum ConverterCopy {
"Components.QuestsDetail": "Migrate quest data.",
"Components.SystemSource": "3DS system",
"Components.SystemTarget": "Cemu system",
- "Components.SystemWarning": "System is shared by all three character slots. Supply both the 3DS source and an initialized Cemu target; only housekeeper gallery/movie flags are merged while every other Cemu setting and shared-slot byte is retained.",
+ "Components.SystemWarning": "The physical save has one shared system file rather than separate system1, system2, and system3 files. Individual gallery/movie flags cannot currently be attributed reliably to a character slot. Supply both the 3DS source and an initialized Cemu target; the converter changes only its narrow mapped range and preserves every other Cemu byte. Exact official/runtime parity remains unverified.",
"Components.SystemFooter": "The target must be an existing valid Cemu system. If this pair is omitted, housekeeper gallery/movie history is not migrated.",
"Components.SystemSourceMessage": "Choose exactly one 3DS system file.",
"Components.SystemTargetMessage": "Choose exactly one Cemu system file.",
diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings
index b7d35a3..4107b6e 100644
--- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings
+++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings
@@ -17,7 +17,7 @@
"Repair.Version.Hint" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Use automatic detection if the version is unknown. If several historical versions match, Dry Run will require an explicit selection before it can authorize a write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "不知道版本时先自动检测;若多个历史版本都匹配,Dry Run 会要求你明确选择后再运行。" } } } },
"Repair.Version.Required" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Several versions match this save. Select the converter version originally used, then run Dry Run again." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "检测到多个可能版本。请选择当时使用的转换器版本,然后重新运行 Dry Run。" } } } },
"Repair.PreviewSHA256" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Repair preview SHA-256" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "修复预览 SHA-256" } } } },
- "Components.SystemWarning" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "System is shared by all three character slots. Supply both the 3DS source and an initialized Cemu target; only housekeeper gallery/movie flags are merged while every other Cemu setting and shared-slot byte is retained." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "system 由三个角色槽位共享。必须同时提供 3DS 源文件和已初始化的 Cemu 目标;转换器只合并管家画廊/动画标记,其他 Cemu 设置与跨槽位数据保持不变。" } } } },
+ "Components.SystemWarning" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The physical save has one shared system file rather than separate system1, system2, and system3 files. Individual gallery/movie flags cannot currently be attributed reliably to a character slot. Supply both the 3DS source and an initialized Cemu target; the converter changes only its narrow mapped range and preserves every other Cemu byte. Exact official/runtime parity remains unverified." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "物理存档只有一个共享 system,不存在独立的 system1、system2、system3;目前无法把具体画廊/动画标记可靠归因到某个角色槽。必须同时提供 3DS 源文件和已初始化的 Cemu 目标;转换器只修改狭窄的已映射范围,其他 Cemu 字节保持不变。精确官方迁移/运行态一致性仍未验证。" } } } },
"Guide.ComponentsReady" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Optional data is set. Next, run a read-only Dry Run to review what this conversion will write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "可选数据已确认。接下来运行只读 Dry Run,确认本次转换会写入什么。" } } } },
"Guide.CoreDryRunCompleteWithOptionals" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The core-slot Dry Run passed. Continue to the write page; selected system or ExtData data still needs its own Dry Run and write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "核心角色 Dry Run 已通过。接下来进入写入页;已选 system 或 ExtData 仍须分别 Dry Run 和写入。" } } } },
"Guide.DryRunComplete" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run passed. No save has been modified; continue when you are ready to confirm the write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "Dry Run 已通过。确认写入前不会修改任何存档。" } } } },
diff --git a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs
index c299c13..162a436 100644
--- a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs
+++ b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs
@@ -401,7 +401,7 @@ private string Text(string key)
[nameof(SharedSystem)] = "Shared system",
[nameof(SystemSource)] = "3DS system file",
[nameof(SystemTarget)] = "Existing initialized Cemu system file",
- [nameof(SystemMergeWarning)] = "System is shared by all three character slots and may contain settings or data for the other slots. Supply both the 3DS source and the existing Cemu target. The converter merges only housekeeper gallery/movie flags and preserves every other Cemu byte.",
+ [nameof(SystemMergeWarning)] = "The title has one shared system file, not separate user1, user2, and user3 files. Individual gallery/movie flags cannot currently be attributed to a character slot. Supply both the 3DS source and existing Cemu target; the converter changes only its narrow mapped range and preserves every other Cemu byte. Exact official/runtime parity remains unverified.",
[nameof(RunSystemDryRun)] = "Run system Dry Run",
[nameof(WriteSystem)] = "Merge gallery/movie flags",
[nameof(SystemManifest)] = "System manifest",
@@ -611,7 +611,7 @@ private string Text(string key)
[nameof(SharedSystem)] = "共享 system",
[nameof(SystemSource)] = "3DS system 文件",
[nameof(SystemTarget)] = "已存在且已初始化的 Cemu system 文件",
- [nameof(SystemMergeWarning)] = "system 由三个角色槽位共享,可能同时保存其他槽位数据或设置。必须同时提供 3DS 源 system 和现有 Cemu 目标 system;转换器只合并管家画廊/动画标记,其他 Cemu 字节全部保持不变。",
+ [nameof(SystemMergeWarning)] = "整个游戏标题只有一个共享 system 文件,并不存在独立的 user1、user2、user3 system;当前也无法把具体画廊/动画标记可靠归因到某个角色槽。必须同时提供 3DS 源 system 和现有 Cemu 目标 system;转换器只修改狭窄的已映射范围,其他 Cemu 字节全部保持不变。精确官方迁移/运行态一致性仍未验证。",
[nameof(RunSystemDryRun)] = "运行 system Dry Run",
[nameof(WriteSystem)] = "合并画廊/动画标记",
[nameof(SystemManifest)] = "system manifest",
diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml b/apps/mh3g-save-converter-windows/MainWindow.xaml
index eb523e2..78e95fe 100644
--- a/apps/mh3g-save-converter-windows/MainWindow.xaml
+++ b/apps/mh3g-save-converter-windows/MainWindow.xaml
@@ -238,7 +238,13 @@
-
+
+
+
+
+
+
+
diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml.cs b/apps/mh3g-save-converter-windows/MainWindow.xaml.cs
index 8eb8341..7b20525 100644
--- a/apps/mh3g-save-converter-windows/MainWindow.xaml.cs
+++ b/apps/mh3g-save-converter-windows/MainWindow.xaml.cs
@@ -499,18 +499,31 @@ private void RaiseFixtureLiveRegion()
private void SkipOptional_Click(object sender, RoutedEventArgs e)
{
- if (ViewModel.CommitRepairOptionalScope(skip: true))
- {
- ShowConvertStep(ConvertStep.DryRun);
- }
+ ContinueFromOptional(skip: true);
}
private void ContinueToDryRun_Click(object sender, RoutedEventArgs e)
{
- if (ViewModel.CommitRepairOptionalScope(skip: false))
+ ContinueFromOptional(skip: false);
+ }
+
+ private void ContinueFromOptional(bool skip)
+ {
+ if (!ViewModel.CommitRepairOptionalScope(skip))
+ {
+ return;
+ }
+
+ if (ViewModel.CanRunCoreDryRun)
{
ShowConvertStep(ConvertStep.DryRun);
+ return;
}
+
+ // Defensive recovery: never expose the Dry Run page with a disabled
+ // action after an unexpected state mismatch. The input page carries
+ // the actionable inspection gate.
+ ShowConvertStep(ConvertStep.Input);
}
private void StartConversion_Click(object sender, RoutedEventArgs e)
diff --git a/apps/mh3g-save-converter-windows/ViewModels/MainViewModel.cs b/apps/mh3g-save-converter-windows/ViewModels/MainViewModel.cs
index df5f825..c6f5b57 100644
--- a/apps/mh3g-save-converter-windows/ViewModels/MainViewModel.cs
+++ b/apps/mh3g-save-converter-windows/ViewModels/MainViewModel.cs
@@ -153,6 +153,7 @@ public void ApplySyntheticFixture(string fixtureId)
break;
case "dry-run.ready":
StatusText = Copy.Inspected;
+ LatestReport = "{\"fixture\":true,\"operation\":\"repair-converted\",\"status\":\"dry-run\"}";
break;
case "dry-run.blocked":
_sourceInspected = false;
@@ -210,7 +211,9 @@ private bool SyntheticFixtureCanWrite() => _syntheticFixtureId is
public bool HasHistory => History.Count > 0;
public Visibility HistoryEmptyVisibility => HasHistory ? Visibility.Collapsed : Visibility.Visible;
public Visibility HistoryListVisibility => HasHistory ? Visibility.Visible : Visibility.Collapsed;
- public bool CanContinueOptional => true;
+ public bool CanContinueOptional => !IsRepairMode
+ || !IncludeGuildCards
+ || SavePathResolver.TryResolveExtDataUserDirectory(ExtrasSourceDirectory, out _, out _);
public Visibility OptionalMissingVisibility => SelectedOptionalDataIsConfigured ? Visibility.Collapsed : Visibility.Visible;
public Visibility OptionalSkippedVisibility => _syntheticFixtureId == "components.optional-skipped"
? Visibility.Visible
@@ -294,7 +297,9 @@ public void SetConversionMode(string? tag)
///
/// Commits the optional guild-card choice into the repair transaction
/// scope. Draft checkbox/path edits do not silently change an authorized
- /// core write; every effective scope change invalidates core inspection.
+ /// core write. An effective scope change revokes only the prior Dry Run
+ /// authorization; inspected core files remain valid because their paths
+ /// did not change.
///
public bool CommitRepairOptionalScope(bool skip)
{
@@ -312,7 +317,7 @@ public bool CommitRepairOptionalScope(bool skip)
if (!string.Equals(_repairGuildCardSource, next, StringComparison.OrdinalIgnoreCase))
{
_repairGuildCardSource = next;
- InvalidateCoreAuthorization();
+ InvalidateCoreWriteAuthorizationPreservingInspection();
OnPropertyChanged(nameof(HasCommittedRepairGuildCards));
}
return true;
@@ -714,6 +719,7 @@ private set
{
OnPropertyChanged(nameof(HasLatestReport));
OnPropertyChanged(nameof(LatestReportEmptyVisibility));
+ OnPropertyChanged(nameof(LatestReportVisibility));
}
}
}
@@ -732,6 +738,7 @@ private set
public bool HasLatestReport => !string.IsNullOrWhiteSpace(LatestReport);
public Visibility LatestReportEmptyVisibility => HasLatestReport ? Visibility.Collapsed : Visibility.Visible;
+ public Visibility LatestReportVisibility => HasLatestReport ? Visibility.Visible : Visibility.Collapsed;
public bool HasLatestError => !string.IsNullOrWhiteSpace(LatestError);
public bool ShowPostInspectGuidance => _workflowGuidance == WorkflowGuidance.CoreInspected;
public bool ShowPostDryRunGuidance => _workflowGuidance == WorkflowGuidance.CoreDryRunAuthorized;
@@ -1971,6 +1978,34 @@ or WorkflowGuidance.CoreDryRunAuthorized
RaiseCoreActionAvailability();
}
+ ///
+ /// Revoke a Dry Run/write authorization after the committed repair
+ /// ExtData scope changes without discarding the already inspected core
+ /// files. The next Dry Run re-hashes the full effective scope and write
+ /// remains fail-closed against those new hashes.
+ ///
+ private void InvalidateCoreWriteAuthorizationPreservingInspection()
+ {
+ _coreAuthorization = null;
+ _repairAuthorization = null;
+ IsRepairRevisionSelectionRequired = false;
+ RepairDetectionSummary = string.Empty;
+
+ var inspectionComplete = _sourceInspected
+ && _targetInspected
+ && (!IsRepairMode || _currentInspected && _inspectedCurrent?.Exists == true);
+ if (!inspectionComplete)
+ {
+ InvalidateCoreAuthorization();
+ return;
+ }
+
+ Stage = WorkflowStage.Inspected;
+ StatusText = Copy.Inspected;
+ SetWorkflowGuidance(WorkflowGuidance.CoreInspected);
+ RaiseCoreActionAvailability();
+ }
+
private void InvalidateSystemAuthorization()
{
_systemAuthorization = null;
diff --git a/crates/mh3g-save-convert/src/cec.rs b/crates/mh3g-save-convert/src/cec.rs
index 04f8c3a..38e481e 100644
--- a/crates/mh3g-save-convert/src/cec.rs
+++ b/crates/mh3g-save-convert/src/cec.rs
@@ -1405,9 +1405,9 @@ 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.
+ // Row 45 is intentionally beyond the sparse MEOW crown entries. Its
+ // non-zero hunt count must not synthesize a discovery flag that the
+ // 3DS card did not contain.
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 {
@@ -1497,7 +1497,7 @@ mod tests {
);
assert_eq!(
&conversion.bytes[converted_late_offset..converted_late_offset + 10],
- &[0x00, 0x09, 0x00, 0x00, 0x00, 0x64, 0x00, 0x64, 0x80, 0x00]
+ &[0x00, 0x09, 0x00, 0x00, 0x00, 0x64, 0x00, 0x64, 0x00, 0x00]
);
}
diff --git a/crates/mh3g-save-convert/src/compatibility.rs b/crates/mh3g-save-convert/src/compatibility.rs
index 92048b1..6cd3987 100644
--- a/crates/mh3g-save-convert/src/compatibility.rs
+++ b/crates/mh3g-save-convert/src/compatibility.rs
@@ -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;
@@ -32,9 +34,11 @@ const GUILD_CARD_ARENA_RECORD_COUNT: usize = 110;
const GUILD_CARD_MONSTER_LOG_START: usize = 0x7C0;
const GUILD_CARD_MONSTER_LOG_COUNT: usize = 50;
const GUILD_CARD_MONSTER_LOG_STRIDE: usize = 10;
-const USER_MONSTER_GUIDE_RECORD_START: usize = 0x65C4;
-const USER_MONSTER_GUIDE_RECORD_COUNT: usize = 48;
-const USER_MONSTER_GUIDE_RECORD_STRIDE: usize = 4;
+const USER_MONSTER_GUIDE_PACKED_STATE_START: usize = 0x5760;
+const USER_MONSTER_GUIDE_PACKED_STATE_LEN: usize = 0x1C;
+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;
const USER_APPEARANCE_SCALAR_OFFSETS: [usize; 3] = [0x73B8, 0x73BC, 0x73C8];
const USER_APPEARANCE_PACKED_STYLE_OFFSET: usize = 0x73D0;
const USER_APPEARANCE_RGBA_OFFSET: usize = 0x73D8;
@@ -436,13 +440,20 @@ fn repair_fields(filename: &str) -> Result, ConversionError> {
let mut fields = Vec::new();
match filename {
"user1" | "user2" | "user3" => {
- for record in 0..USER_MONSTER_GUIDE_RECORD_COUNT {
+ for byte in 0..USER_MONSTER_GUIDE_PACKED_STATE_LEN {
+ fields.push(FieldSpec {
+ name: format!("monster-guide-packed-state-byte-{byte}"),
+ offset: header + USER_MONSTER_GUIDE_PACKED_STATE_START + byte,
+ width: 1,
+ });
+ }
+ for word in 0..USER_ITEM_ACQUIRED_BITSET_WORD_COUNT {
fields.push(FieldSpec {
- name: format!("monster-guide-record-{record}"),
+ name: format!("item-acquired-word-{word}"),
offset: header
- + USER_MONSTER_GUIDE_RECORD_START
- + record * USER_MONSTER_GUIDE_RECORD_STRIDE,
- width: USER_MONSTER_GUIDE_RECORD_STRIDE,
+ + USER_ITEM_ACQUIRED_BITSET_START
+ + word * USER_ITEM_ACQUIRED_BITSET_WORD_SIZE,
+ width: USER_ITEM_ACQUIRED_BITSET_WORD_SIZE,
});
}
for (index, offset) in USER_APPEARANCE_SCALAR_OFFSETS.into_iter().enumerate() {
@@ -478,30 +489,25 @@ fn repair_fields(filename: &str) -> Result, 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" => {
@@ -619,23 +625,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, ¤t, "user2", ConverterRevision::V0_0_5).unwrap();
+ merge_component(&source, ¤t, "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!(
+ ¤t[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!(¤t[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);
}
@@ -644,22 +664,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, ¤t, "user2", ConverterRevision::V0_0_5).unwrap();
+ merge_component(&source, ¤t, "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
}));
}
@@ -681,8 +701,13 @@ mod tests {
#[test]
fn repairs_new_official_parity_fields_without_reverting_wiiu_progress() {
let mut source = source();
- let source_guide = JP_3DS_HEADER.len() + USER_MONSTER_GUIDE_RECORD_START;
- source[source_guide..source_guide + 4].copy_from_slice(&0x1234_5678_u32.to_le_bytes());
+ let source_guide_state = JP_3DS_HEADER.len() + USER_MONSTER_GUIDE_PACKED_STATE_START;
+ 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_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());
let source_appearance = JP_3DS_HEADER.len() + USER_APPEARANCE_RGBA_OFFSET;
source[source_appearance..source_appearance + 4].copy_from_slice(&[0xFF, 0xE6, 0xEF, 0xFA]);
let mut current =
@@ -693,11 +718,16 @@ mod tests {
let merged =
merge_component(&source, ¤t, "user2", ConverterRevision::V0_0_6).unwrap();
- let guide = JP_CEMU_HEADER.len() + USER_MONSTER_GUIDE_RECORD_START;
+ 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;
assert_eq!(
- &merged.bytes[guide..guide + 4],
+ &merged.bytes[guide_state..guide_state + USER_MONSTER_GUIDE_PACKED_STATE_LEN],
+ &source[source_guide_state..source_guide_state + USER_MONSTER_GUIDE_PACKED_STATE_LEN]
+ );
+ assert_eq!(
+ &merged.bytes[item_word..item_word + 4],
&0x1234_5678_u32.to_be_bytes()
);
assert_eq!(
@@ -706,7 +736,11 @@ mod tests {
);
assert_eq!(merged.bytes[unrelated], current[unrelated]);
assert!(merged.fields.iter().any(|field| {
- field.name == "monster-guide-record-0" && field.status == MergeFieldStatus::Repaired
+ field.name == "monster-guide-packed-state-byte-26"
+ && field.status == MergeFieldStatus::Repaired
+ }));
+ assert!(merged.fields.iter().any(|field| {
+ field.name == "item-acquired-word-0" && field.status == MergeFieldStatus::Repaired
}));
assert!(merged.fields.iter().any(|field| {
field.name == "player-appearance-rgba" && field.status == MergeFieldStatus::Repaired
diff --git a/crates/mh3g-save-convert/src/converter.rs b/crates/mh3g-save-convert/src/converter.rs
index e703188..f3e6ece 100644
--- a/crates/mh3g-save-convert/src/converter.rs
+++ b/crates/mh3g-save-convert/src/converter.rs
@@ -6,8 +6,8 @@ use crate::{
},
revision::ConverterRevision,
transforms::{
- GuildCardBodyKind, apply_japanese_wiiu_corrections,
- apply_japanese_wiiu_corrections_for_revision,
+ GuildCardBodyKind, apply_current_japanese_wiiu_guild_card_corrections,
+ apply_japanese_wiiu_corrections, apply_japanese_wiiu_corrections_for_revision,
apply_japanese_wiiu_guild_card_corrections_for_revision,
},
};
@@ -75,11 +75,29 @@ pub fn convert_external_component_to_cemu_named(
source: &[u8],
filename: &str,
) -> Result, ConversionError> {
- convert_external_component_to_cemu_named_for_revision(
+ let mut output = convert_external_component_to_cemu_named_for_revision(
source,
filename,
ConverterRevision::LAST_HISTORICAL,
- )
+ )?;
+
+ // Historical replay remains byte-stable for compatibility detection.
+ // Current conversion layers official-transfer corrections over that
+ // output, just as the core user# path does.
+ let kind = match filename {
+ "card1" | "card2" | "card3" => Some(GuildCardBodyKind::Card),
+ "cardbox" => Some(GuildCardBodyKind::Cardbox),
+ "quest1" | "quest2" | "quest3" | "quest4" => None,
+ _ => unreachable!("revision conversion validated the component"),
+ };
+ if let Some(kind) = kind {
+ let source_payload = &source[JP_3DS_HEADER.len()..];
+ let target_payload =
+ &mut output[build_jp_cemu_header(filename, source_payload.len())?.len()..];
+ apply_current_japanese_wiiu_guild_card_corrections(kind, source_payload, target_payload)?;
+ }
+
+ Ok(output)
}
pub(crate) fn convert_external_component_to_cemu_named_for_revision(
@@ -488,31 +506,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]
@@ -523,27 +542,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::>();
+ let mut kayamba_packed = (0..0x62)
+ .map(|index| (index as u8).wrapping_mul(17).wrapping_add(9))
+ .collect::>();
+ // 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!(
@@ -555,28 +584,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
);
}
@@ -755,7 +768,7 @@ mod tests {
}
#[test]
- fn remaps_discovery_bits_for_every_received_card_monster_record() {
+ fn remaps_source_owned_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()..];
@@ -767,10 +780,9 @@ mod tests {
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.
+ // Official transfers keep a row hidden when its source discovery flag
+ // is clear, even if the received card contains non-zero hunt counts.
+ // Inferring visibility from counters creates phantom guide pages.
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]);
@@ -784,7 +796,7 @@ mod tests {
);
assert_eq!(
&payload[last_card_unflagged_row..last_card_unflagged_row + 10],
- &[0x00, 0x09, 0x00, 0x03, 0x00, 0x64, 0x00, 0x64, 0x80, 0x00]
+ &[0x00, 0x09, 0x00, 0x03, 0x00, 0x64, 0x00, 0x64, 0x00, 0x00]
);
}
diff --git a/crates/mh3g-save-convert/src/transforms.rs b/crates/mh3g-save-convert/src/transforms.rs
index 8ec4f35..3c8edae 100644
--- a/crates/mh3g-save-convert/src/transforms.rs
+++ b/crates/mh3g-save-convert/src/transforms.rs
@@ -25,13 +25,27 @@ const CURRENT_EQUIPMENT_START: usize = 31280;
const CURRENT_EQUIPMENT_COUNT: usize = 7;
const EQUIPMENT_STRIDE: usize = 16;
const SECOND_RGBA_OFFSET: usize = 0x73E4;
-// Five independently paired 3DS -> Wii U official-transfer saves agree that
-// this is a contiguous table of 48 endian-sensitive monster-guide records.
-// Leaving the little-endian words untouched makes unlocked guide details look
-// incomplete on Wii U even though the source slot contains them.
-const MONSTER_GUIDE_RECORD_START: usize = 0x65C4;
-const MONSTER_GUIDE_RECORD_COUNT: usize = 48;
-const MONSTER_GUIDE_RECORD_STRIDE: usize = 4;
+// The monster-list/book state is a byte-packed 28-byte array on both
+// platforms. Five paired official transfers preserve every byte verbatim.
+// Treating its tail as u16 lanes moves the final 0x00 byte from 0x577B to
+// 0x577A; in the Yoruaski sample that suppresses Deviljho from the list even
+// 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;
+// 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
+// that every word changes from 3DS little endian to Wii U big endian.
+//
+// This is not a table of 48 monster records. One user-visible consequence of
+// getting the word order wrong is losing item 0x4F8 (the Deviljho book), which
+// removes the ninth page from Hunter's Notes even though the 3DS source owns
+// the book.
+const ITEM_ACQUIRED_BITSET_START: usize = 0x65C4;
+const ITEM_ACQUIRED_BITSET_WORD_COUNT: usize = 48;
+const ITEM_ACQUIRED_BITSET_WORD_SIZE: usize = 4;
+#[cfg(test)]
+const DEVILJHO_BOOK_ITEM_ID: usize = 0x4F8;
// These appearance scalars and the adjacent RGBA value are serialized as
// little-endian four-byte values on 3DS and big-endian values on Wii U. The
// older static table covered only the scalar at 0x73C4 and the later RGBA
@@ -58,17 +72,25 @@ 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
-// starts with three u32 header fields and endian-sensitive u16 scalars. The
-// six bytes at relative 0xDE are packed mask state and must retain their byte
-// order. The immediately following relative 0xE4 Lamp Mask mastery is still a
-// u16 scalar, then the byte-packed tail resumes at 0xE6.
+// Cha-Cha and Kayamba have two adjacent, fixed-width companion records. Five
+// independently paired 3DS -> Wii U transfers agree on the field boundaries
+// through relative 0x140: one u32 prefix, u16 scalars through 0xDE, then a
+// byte-packed mask/mastery block which must retain its exact byte order.
+//
+// Releases through 0.0.18 inherited two narrower schema assumptions from the
+// recovered transfer table: offsets 0x04..0x0B were treated as two u32 values,
+// and relative 0xE4 was treated as an isolated u16. Both assumptions disagree
+// with every paired transfer. In particular, swapping the packed bytes at
+// 0xE4 puts zero in the field read by the companion status screen; a quest
+// completion can then serialize that zero back over the mask record.
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 HISTORICAL_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 HISTORICAL_SHAKALAKA_LAMP_SWAP_OFFSET: usize = 0xE4;
+const SHAKALAKA_MASK_STATE_END: usize = 0x140;
const OFFLINE_HUNTER_EQUIPMENT_CACHE_START: usize = 0x75B0;
const OFFLINE_HUNTER_HEADER_START: usize = 0x75E0;
const OFFLINE_HUNTER_COUNT: usize = 6;
@@ -350,16 +372,14 @@ fn transform_crown(source: &[u8], target: &mut [u8], offset: usize) -> Result<()
Ok(())
}
-/// Apply the single authoritative cross-platform Hunter's Notes visibility
-/// mapping.
+/// Reproduce the historical 0.0.5/0.0.6 Hunter's Notes visibility rule.
///
-/// 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(
+/// Released converters inferred discovery from non-zero hunt counters. Keep
+/// that behavior isolated here so compatibility repair can still recognize
+/// their byte output. Current conversion must not use it: paired official
+/// transfers prove that an undiscovered row remains hidden even when its
+/// counters are non-zero.
+fn apply_historical_hunter_notes_display_state(
source: &[u8],
target: &mut [u8],
slay_offset: usize,
@@ -387,6 +407,20 @@ fn apply_hunter_notes_display_state(
Ok(())
}
+/// Apply the authoritative current Hunter's Notes state mapping.
+///
+/// Personal records, received guild cards, and CEC/offline-hall partner cards
+/// all use the same source-owned discovery/crown byte. Official transfer pairs
+/// preserve that state independently of hunt counters, so visibility must be
+/// derived only from this byte.
+fn apply_current_hunter_notes_display_state(
+ source: &[u8],
+ target: &mut [u8],
+ state_offset: usize,
+) -> Result<(), ConversionError> {
+ transform_crown(source, target, state_offset)
+}
+
fn apply_guild_card_monster_log_corrections(
source: &[u8],
target: &mut [u8],
@@ -427,7 +461,7 @@ fn apply_guild_card_monster_log_slot_corrections(
}
if revision >= ConverterRevision::V0_0_5 {
- apply_hunter_notes_display_state(
+ apply_historical_hunter_notes_display_state(
source,
target,
record_start,
@@ -440,6 +474,33 @@ fn apply_guild_card_monster_log_slot_corrections(
Ok(())
}
+fn apply_current_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 state_offset =
+ slot_start + GUILD_CARD_MONSTER_LOG_START + row * GUILD_CARD_MONSTER_LOG_STRIDE + 8;
+ apply_current_hunter_notes_display_state(source, target, state_offset)?;
+ }
+ Ok(())
+}
+
+fn apply_current_guild_card_monster_log_corrections(
+ source: &[u8],
+ target: &mut [u8],
+) -> Result<(), ConversionError> {
+ for slot in 0..GUILD_CARD_SLOT_COUNT {
+ apply_current_guild_card_monster_log_slot_corrections(
+ source,
+ target,
+ slot * GUILD_CARD_SLOT_SIZE,
+ )?;
+ }
+ Ok(())
+}
+
fn apply_guild_card_arena_corrections(
source: &[u8],
target: &mut [u8],
@@ -602,7 +663,8 @@ pub fn apply_japanese_wiiu_guild_card_slot_corrections(
source,
target,
ConverterRevision::LAST_HISTORICAL,
- )
+ )?;
+ apply_current_guild_card_monster_log_slot_corrections(source, target, 0)
}
pub(crate) fn apply_japanese_wiiu_guild_card_slot_corrections_for_revision(
@@ -696,7 +758,30 @@ pub fn apply_japanese_wiiu_guild_card_corrections(
source,
target,
ConverterRevision::LAST_HISTORICAL,
- )
+ )?;
+ apply_current_japanese_wiiu_guild_card_corrections(kind, source, target)
+}
+
+pub(crate) fn apply_current_japanese_wiiu_guild_card_corrections(
+ kind: GuildCardBodyKind,
+ source: &[u8],
+ target: &mut [u8],
+) -> Result<(), ConversionError> {
+ let expected_size = match kind {
+ GuildCardBodyKind::Card => CARD_BODY_SIZE,
+ GuildCardBodyKind::Cardbox => CARDBOX_BODY_SIZE,
+ };
+ if source.len() != expected_size || target.len() != expected_size {
+ return Err(ConversionError::InvalidSave(format!(
+ "MH3G {kind:?} body must be {expected_size} bytes, got source {} and target {}",
+ source.len(),
+ target.len()
+ )));
+ }
+ if kind == GuildCardBodyKind::Card {
+ apply_current_guild_card_monster_log_corrections(source, target)?;
+ }
+ Ok(())
}
pub(crate) fn apply_japanese_wiiu_guild_card_corrections_for_revision(
@@ -791,7 +876,7 @@ fn apply_confirmed_numeric_and_record_corrections(
let discovery_offset = 0x81B4 + index * 10 + 8;
if revision >= ConverterRevision::V0_0_5 {
- apply_hunter_notes_display_state(
+ apply_historical_hunter_notes_display_state(
source,
target,
slay_offset,
@@ -865,7 +950,7 @@ fn apply_confirmed_numeric_and_record_corrections(
Ok(())
}
-fn apply_shakalaka_companion_corrections(
+fn apply_historical_shakalaka_companion_corrections(
source: &[u8],
target: &mut [u8],
revision: ConverterRevision,
@@ -873,22 +958,22 @@ fn apply_shakalaka_companion_corrections(
for companion in 0..SHAKALAKA_RECORD_COUNT {
let record_start = SHAKALAKA_RECORD_START + companion * SHAKALAKA_RECORD_STRIDE;
- for relative in (0..SHAKALAKA_U32_HEADER_SIZE).step_by(4) {
+ for relative in (0..HISTORICAL_SHAKALAKA_U32_HEADER_SIZE).step_by(4) {
copy_reversed(source, target, record_start + relative, 4)?;
}
let scalar_end = if revision == ConverterRevision::V0_0_4 {
- SHAKALAKA_LAMP_MASK_MASTERY_START + 2
+ HISTORICAL_SHAKALAKA_LAMP_SWAP_OFFSET + 2
} else {
SHAKALAKA_MASK_STATE_START
};
- for relative in (SHAKALAKA_U32_HEADER_SIZE..scalar_end).step_by(2) {
+ for relative in (HISTORICAL_SHAKALAKA_U32_HEADER_SIZE..scalar_end).step_by(2) {
copy_reversed(source, target, record_start + relative, 2)?;
}
if revision >= ConverterRevision::V0_0_6 {
copy_reversed(
source,
target,
- record_start + SHAKALAKA_LAMP_MASK_MASTERY_START,
+ record_start + HISTORICAL_SHAKALAKA_LAMP_SWAP_OFFSET,
2,
)?;
}
@@ -897,6 +982,33 @@ fn apply_shakalaka_companion_corrections(
Ok(())
}
+/// Reassert the companion schema proven by paired official transfers.
+///
+/// This is intentionally layered after the historical converter replay. The
+/// historical function above must stay byte-reproducible so compatibility
+/// repair can still recognize 0.0.3-0.0.6 output, while current conversion
+/// must use the corrected field boundaries.
+fn apply_current_shakalaka_companion_corrections(
+ source: &[u8],
+ target: &mut [u8],
+) -> Result<(), ConversionError> {
+ for companion in 0..SHAKALAKA_RECORD_COUNT {
+ let record_start = SHAKALAKA_RECORD_START + companion * SHAKALAKA_RECORD_STRIDE;
+
+ copy_reversed(source, target, record_start, SHAKALAKA_U32_PREFIX_SIZE)?;
+ for relative in (SHAKALAKA_U32_PREFIX_SIZE..SHAKALAKA_MASK_STATE_START).step_by(2) {
+ copy_reversed(source, target, record_start + relative, 2)?;
+ }
+ target[record_start + SHAKALAKA_MASK_STATE_START..record_start + SHAKALAKA_MASK_STATE_END]
+ .copy_from_slice(
+ &source[record_start + SHAKALAKA_MASK_STATE_START
+ ..record_start + SHAKALAKA_MASK_STATE_END],
+ );
+ }
+
+ Ok(())
+}
+
/// Apply corrections proven by official-transfer pairs after the last
/// historically reproducible 0.0.6 conversion semantics.
///
@@ -907,12 +1019,25 @@ fn apply_current_official_transfer_corrections(
source: &[u8],
target: &mut [u8],
) -> Result<(), ConversionError> {
- for record in 0..MONSTER_GUIDE_RECORD_COUNT {
+ apply_current_shakalaka_companion_corrections(source, target)?;
+
+ target[MONSTER_GUIDE_PACKED_STATE_START
+ ..MONSTER_GUIDE_PACKED_STATE_START + MONSTER_GUIDE_PACKED_STATE_LEN]
+ .copy_from_slice(
+ &source[MONSTER_GUIDE_PACKED_STATE_START
+ ..MONSTER_GUIDE_PACKED_STATE_START + MONSTER_GUIDE_PACKED_STATE_LEN],
+ );
+
+ for index in 0..MONSTER_IDS.len() {
+ apply_current_hunter_notes_display_state(source, target, 0x81B4 + index * 10 + 8)?;
+ }
+
+ for word in 0..ITEM_ACQUIRED_BITSET_WORD_COUNT {
copy_reversed(
source,
target,
- MONSTER_GUIDE_RECORD_START + record * MONSTER_GUIDE_RECORD_STRIDE,
- MONSTER_GUIDE_RECORD_STRIDE,
+ ITEM_ACQUIRED_BITSET_START + word * ITEM_ACQUIRED_BITSET_WORD_SIZE,
+ ITEM_ACQUIRED_BITSET_WORD_SIZE,
)?;
}
@@ -1031,11 +1156,12 @@ pub(crate) fn apply_japanese_wiiu_corrections_for_revision(
target[offset] = source[offset];
}
- // The compatibility operation list covers only a subset of the numeric
- // Cha-Cha/Kayamba fields. Reassert the bounded numeric prefix and the
- // isolated Lamp Mask mastery scalar while preserving the packed state.
+ // Replay the exact historical Cha-Cha/Kayamba behavior here. Current
+ // conversion corrects its field boundaries only in
+ // `apply_current_official_transfer_corrections`, after the historical
+ // result has been kept available for compatibility detection.
if revision >= ConverterRevision::V0_0_4 {
- apply_shakalaka_companion_corrections(source, target, revision)?;
+ apply_historical_shakalaka_companion_corrections(source, target, revision)?;
}
// These fields are read as big-endian values by the Wii U title. MEOW v5
@@ -1058,12 +1184,13 @@ mod tests {
};
use super::{
- EQUIPMENT_BOX_START, EVENT_FLAG_START, GUILD_CARD_SLOT_SIZE, MONSTER_GUIDE_RECORD_COUNT,
- MONSTER_GUIDE_RECORD_START, MONSTER_GUIDE_RECORD_STRIDE, MONSTER_IDS,
- 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,
+ 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,
+ 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,
apply_japanese_wiiu_guild_card_slot_corrections, apply_monster_discovery,
};
use crate::revision::ConverterRevision;
@@ -1226,16 +1353,16 @@ mod tests {
}
#[test]
- fn current_corrections_swap_every_official_monster_guide_record() {
+ fn current_corrections_swap_every_item_acquisition_word() {
let mut source = vec![0_u8; PAYLOAD_SIZE];
- for record in 0..MONSTER_GUIDE_RECORD_COUNT {
- let offset = MONSTER_GUIDE_RECORD_START + record * MONSTER_GUIDE_RECORD_STRIDE;
- let value = 0x1020_3000_u32 + record as u32;
+ for word in 0..ITEM_ACQUIRED_BITSET_WORD_COUNT {
+ let offset = ITEM_ACQUIRED_BITSET_START + word * ITEM_ACQUIRED_BITSET_WORD_SIZE;
+ let value = 0x1020_3000_u32 + word as u32;
source[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
- source[MONSTER_GUIDE_RECORD_START - 1] = 0x5A;
- source[MONSTER_GUIDE_RECORD_START
- + MONSTER_GUIDE_RECORD_COUNT * MONSTER_GUIDE_RECORD_STRIDE] = 0xA5;
+ source[ITEM_ACQUIRED_BITSET_START - 1] = 0x5A;
+ source[ITEM_ACQUIRED_BITSET_START
+ + ITEM_ACQUIRED_BITSET_WORD_COUNT * ITEM_ACQUIRED_BITSET_WORD_SIZE] = 0xA5;
let mut historical = source.clone();
apply_japanese_wiiu_corrections_for_revision(
@@ -1247,8 +1374,8 @@ mod tests {
let mut current = source.clone();
apply_japanese_wiiu_corrections(&source, &mut current).unwrap();
- for record in 0..MONSTER_GUIDE_RECORD_COUNT {
- let offset = MONSTER_GUIDE_RECORD_START + record * MONSTER_GUIDE_RECORD_STRIDE;
+ for word in 0..ITEM_ACQUIRED_BITSET_WORD_COUNT {
+ let offset = ITEM_ACQUIRED_BITSET_START + word * ITEM_ACQUIRED_BITSET_WORD_SIZE;
assert_eq!(
¤t[offset..offset + 4],
&source[offset..offset + 4]
@@ -1256,7 +1383,7 @@ mod tests {
.rev()
.copied()
.collect::>(),
- "monster-guide record {record}"
+ "item-acquisition word {word}"
);
assert_eq!(
&historical[offset..offset + 4],
@@ -1265,14 +1392,68 @@ mod tests {
);
}
assert_eq!(
- current[MONSTER_GUIDE_RECORD_START - 1],
- historical[MONSTER_GUIDE_RECORD_START - 1]
+ current[ITEM_ACQUIRED_BITSET_START - 1],
+ historical[ITEM_ACQUIRED_BITSET_START - 1]
);
assert_eq!(
- current[MONSTER_GUIDE_RECORD_START
- + MONSTER_GUIDE_RECORD_COUNT * MONSTER_GUIDE_RECORD_STRIDE],
- historical[MONSTER_GUIDE_RECORD_START
- + MONSTER_GUIDE_RECORD_COUNT * MONSTER_GUIDE_RECORD_STRIDE]
+ current[ITEM_ACQUIRED_BITSET_START
+ + ITEM_ACQUIRED_BITSET_WORD_COUNT * ITEM_ACQUIRED_BITSET_WORD_SIZE],
+ historical[ITEM_ACQUIRED_BITSET_START
+ + ITEM_ACQUIRED_BITSET_WORD_COUNT * ITEM_ACQUIRED_BITSET_WORD_SIZE]
+ );
+ }
+
+ #[test]
+ fn current_corrections_preserve_deviljho_book_unlock_bit() {
+ let mut source = vec![0_u8; PAYLOAD_SIZE];
+ let word_index = DEVILJHO_BOOK_ITEM_ID >> 5;
+ let bit_index = DEVILJHO_BOOK_ITEM_ID & 31;
+ let offset = ITEM_ACQUIRED_BITSET_START + word_index * ITEM_ACQUIRED_BITSET_WORD_SIZE;
+
+ // Observed in the Yoruaski 3DS source: this word owns the Deviljho
+ // book (bit 24) and the preceding book (bit 23), while the dummy item
+ // at bit 15 remains absent.
+ let source_word = 0xFFFF_7FFE_u32;
+ source[offset..offset + 4].copy_from_slice(&source_word.to_le_bytes());
+
+ let mut current = source.clone();
+ apply_japanese_wiiu_corrections(&source, &mut current).unwrap();
+ let current_word = u32::from_be_bytes(current[offset..offset + 4].try_into().unwrap());
+
+ assert_eq!(¤t[offset..offset + 4], &[0xFF, 0xFF, 0x7F, 0xFE]);
+ assert_eq!(current_word, source_word);
+ assert_ne!(current_word & (1 << bit_index), 0, "Deviljho book");
+ assert_ne!(current_word & (1 << (bit_index - 1)), 0, "preceding book");
+ assert_eq!(current_word & (1 << 15), 0, "dummy item");
+ }
+
+ #[test]
+ fn current_corrections_preserve_monster_guide_packed_state_bytes() {
+ let mut source = vec![0_u8; PAYLOAD_SIZE];
+ source[MONSTER_GUIDE_PACKED_STATE_START
+ ..MONSTER_GUIDE_PACKED_STATE_START + MONSTER_GUIDE_PACKED_STATE_LEN]
+ .fill(0xFF);
+ source[MONSTER_GUIDE_PACKED_STATE_START] = 0x02;
+ source[MONSTER_GUIDE_PACKED_STATE_START + 1] = 0x00;
+ source[MONSTER_GUIDE_PACKED_STATE_START + MONSTER_GUIDE_PACKED_STATE_LEN - 1] = 0x00;
+
+ 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();
+
+ let range = MONSTER_GUIDE_PACKED_STATE_START
+ ..MONSTER_GUIDE_PACKED_STATE_START + MONSTER_GUIDE_PACKED_STATE_LEN;
+ assert_eq!(¤t[range.clone()], &source[range]);
+ assert_eq!(
+ &historical[MONSTER_GUIDE_PACKED_STATE_START + MONSTER_GUIDE_PACKED_STATE_LEN - 4
+ ..MONSTER_GUIDE_PACKED_STATE_START + MONSTER_GUIDE_PACKED_STATE_LEN],
+ &[0xFF, 0xFF, 0x00, 0xFF]
);
}
@@ -1349,6 +1530,7 @@ mod tests {
source[capture_offset..capture_offset + 2].copy_from_slice(&2_u16.to_le_bytes());
source[size_offset..size_offset + 2].copy_from_slice(&100_u16.to_le_bytes());
source[size_offset + 2..size_offset + 4].copy_from_slice(&112_u16.to_le_bytes());
+ source[discovery_offset] = 0x01;
let mut target = source.clone();
apply_japanese_wiiu_corrections(&source, &mut target).unwrap();
@@ -1376,6 +1558,49 @@ mod tests {
assert_ne!(target[discovery_offset] & 0x80, 0);
}
+ #[test]
+ fn current_hunter_notes_do_not_infer_discovery_from_hunt_counts() {
+ const MONSTER_INDEX: usize = 2;
+ const MONSTER_ID: usize = 0x2D;
+
+ let mut source = vec![0_u8; PAYLOAD_SIZE];
+ let slay_offset = 0x5784 + MONSTER_ID * 2;
+ let capture_offset = 0x5884 + MONSTER_ID * 2;
+ let discovery_offset = 0x81B4 + MONSTER_INDEX * 10 + 8;
+ source[slay_offset..slay_offset + 2].copy_from_slice(&9_u16.to_le_bytes());
+ source[capture_offset..capture_offset + 2].copy_from_slice(&3_u16.to_le_bytes());
+
+ let mut target = source.clone();
+ apply_japanese_wiiu_corrections(&source, &mut target).unwrap();
+
+ assert_eq!(target[discovery_offset], 0x00);
+ assert_eq!(MONSTER_IDS[MONSTER_INDEX], MONSTER_ID);
+ }
+
+ #[test]
+ fn historical_hunter_notes_replay_keeps_released_counter_inference() {
+ const MONSTER_INDEX: usize = 2;
+ const MONSTER_ID: usize = 0x2D;
+
+ let mut source = vec![0_u8; PAYLOAD_SIZE];
+ let slay_offset = 0x5784 + MONSTER_ID * 2;
+ let discovery_offset = 0x81B4 + MONSTER_INDEX * 10 + 8;
+ source[slay_offset..slay_offset + 2].copy_from_slice(&1_u16.to_le_bytes());
+
+ let mut historical = source.clone();
+ apply_japanese_wiiu_corrections_for_revision(
+ &source,
+ &mut historical,
+ ConverterRevision::V0_0_6,
+ )
+ .unwrap();
+ assert_eq!(historical[discovery_offset], 0x80);
+
+ let mut current = source.clone();
+ apply_japanese_wiiu_corrections(&source, &mut current).unwrap();
+ assert_eq!(current[discovery_offset], 0x00);
+ }
+
#[test]
fn hunter_notes_state_mapping_is_shared_by_personal_and_received_cards() {
const MONSTER_INDEX: usize = 2;
diff --git a/crates/mh3g-save-convert/tests/cli.rs b/crates/mh3g-save-convert/tests/cli.rs
index 3300770..04aedab 100644
--- a/crates/mh3g-save-convert/tests/cli.rs
+++ b/crates/mh3g-save-convert/tests/cli.rs
@@ -298,7 +298,7 @@ fn keys(value: &Value) -> BTreeSet {
}
#[test]
-fn repair_converted_dry_run_then_write_repairs_only_an_old_lamp_field() {
+fn repair_converted_repairs_the_historical_packed_mask_byte_swap() {
#[cfg(target_os = "macos")]
let _guard = PROCESS_GUARD.lock().unwrap();
let temp = tempfile::tempdir().unwrap();
@@ -317,7 +317,7 @@ fn repair_converted_dry_run_then_write_repairs_only_an_old_lamp_field() {
let mut current = convert_3ds_to_cemu_named(&source, "user2").unwrap();
let lamp = JP_CEMU_HEADER.len() + 0x6F44 + 0xE4;
- current[lamp..lamp + 2].copy_from_slice(&source[source_lamp..source_lamp + 2]);
+ current[lamp..lamp + 2].copy_from_slice(&[0x00, 0x1E]);
let unrelated = JP_CEMU_HEADER.len() + 0x240;
current[unrelated] ^= 0x5A;
let unrelated_after = current[unrelated];
@@ -330,7 +330,7 @@ fn repair_converted_dry_run_then_write_repairs_only_an_old_lamp_field() {
"--current".into(),
current_path.to_string_lossy().into_owned(),
"--from-version".into(),
- "0.0.5".into(),
+ "0.0.6".into(),
"--dry-run".into(),
]);
assert_eq!(dry["status"], "dry-run");
@@ -343,7 +343,7 @@ fn repair_converted_dry_run_then_write_repairs_only_an_old_lamp_field() {
"--current".into(),
current_path.to_string_lossy().into_owned(),
"--from-version".into(),
- "0.0.5".into(),
+ "0.0.6".into(),
"--write".into(),
"--expected-source-set-sha256".into(),
dry["source_set_sha256"].as_str().unwrap().to_owned(),
@@ -354,7 +354,7 @@ fn repair_converted_dry_run_then_write_repairs_only_an_old_lamp_field() {
]);
assert_eq!(written["status"], "written");
let installed = fs::read(¤t_path).unwrap();
- assert_eq!(&installed[lamp..lamp + 2], &[0x00, 0x1E]);
+ assert_eq!(&installed[lamp..lamp + 2], &[0x1E, 0x00]);
assert_eq!(installed[unrelated], unrelated_after);
assert!(written["manifests"].as_array().unwrap().len() == 1);
let compatibility_manifest = written["compatibility_manifest"]
@@ -386,7 +386,7 @@ fn repair_converted_can_read_current_and_write_a_separate_output() {
let mut current = convert_3ds_to_cemu_named(&source, "user2").unwrap();
let lamp = JP_CEMU_HEADER.len() + 0x6F44 + 0xE4;
- current[lamp..lamp + 2].copy_from_slice(&source[source_lamp..source_lamp + 2]);
+ current[lamp..lamp + 2].copy_from_slice(&[0x00, 0x1E]);
let unrelated = JP_CEMU_HEADER.len() + 0x240;
current[unrelated] ^= 0x5A;
let current_before = current.clone();
@@ -400,7 +400,7 @@ fn repair_converted_can_read_current_and_write_a_separate_output() {
"--output".into(),
output_path.to_string_lossy().into_owned(),
"--from-version".into(),
- "0.0.5".into(),
+ "0.0.6".into(),
"--dry-run".into(),
]);
assert_eq!(dry["status"], "dry-run");
@@ -417,7 +417,7 @@ fn repair_converted_can_read_current_and_write_a_separate_output() {
"--output".into(),
output_path.to_string_lossy().into_owned(),
"--from-version".into(),
- "0.0.5".into(),
+ "0.0.6".into(),
"--write".into(),
"--expected-source-set-sha256".into(),
dry["source_set_sha256"].as_str().unwrap().to_owned(),
@@ -431,7 +431,7 @@ fn repair_converted_can_read_current_and_write_a_separate_output() {
assert_eq!(written["status"], "written");
assert_eq!(fs::read(¤t_path).unwrap(), current_before);
let installed = fs::read(&output_path).unwrap();
- assert_eq!(&installed[lamp..lamp + 2], &[0x00, 0x1E]);
+ assert_eq!(&installed[lamp..lamp + 2], &[0x1E, 0x00]);
assert_eq!(installed[unrelated], current_before[unrelated]);
let compatibility_manifest = written["compatibility_manifest"]
@@ -613,7 +613,7 @@ fn repair_converted_writes_guild_cards_to_the_separate_output_only() {
);
assert_eq!(
fs::read(output_dir.join("card1")).unwrap()[JP_CEMU_HEADER.len() + 0x7C0 + 8],
- 0x80
+ 0x00
);
let compatibility_manifest = written["compatibility_manifest"].as_str().unwrap();
@@ -645,7 +645,7 @@ fn repair_converted_write_rejects_a_current_save_changed_after_dry_run() {
let mut current = convert_3ds_to_cemu_named(&source, "user2").unwrap();
let lamp = JP_CEMU_HEADER.len() + 0x6F44 + 0xE4;
let source_lamp = JP_3DS_HEADER.len() + 0x6F44 + 0xE4;
- current[lamp..lamp + 2].copy_from_slice(&source[source_lamp..source_lamp + 2]);
+ current[lamp..lamp + 2].copy_from_slice(&[source[source_lamp + 1], source[source_lamp]]);
fs::write(¤t_path, ¤t).unwrap();
let dry = run_json(&[
@@ -695,15 +695,14 @@ fn repair_converted_preserves_the_played_directory_and_rolls_back_every_change()
let current_path = current_dir.join("user2");
let mut current_slot = convert_3ds_to_cemu_named(&source_slot, "user2").unwrap();
let current_lamp = JP_CEMU_HEADER.len() + 0x6F44 + 0xE4;
- current_slot[current_lamp..current_lamp + 2]
- .copy_from_slice(&source_slot[source_lamp..source_lamp + 2]);
+ current_slot[current_lamp..current_lamp + 2].copy_from_slice(&[0x00, 0x1E]);
fs::write(¤t_path, ¤t_slot).unwrap();
let current_slot_before = current_slot.clone();
let extdata = extras_fixture(&temp);
let card1_path = extdata.join("card1");
let mut card1_source = fs::read(&card1_path).unwrap();
- let card_row = JP_3DS_HEADER.len() + 0x7C0;
+ let card_row = JP_3DS_HEADER.len() + 0x7C0 + 45 * 10;
card1_source[card_row..card_row + 2].copy_from_slice(&[0x01, 0x00]);
card1_source[card_row + 8] = 0;
fs::write(&card1_path, &card1_source).unwrap();
@@ -715,8 +714,9 @@ fn repair_converted_preserves_the_played_directory_and_rolls_back_every_change()
let mut current_bytes =
convert_external_component_to_cemu_named(&source_bytes, component).unwrap();
if component == "card1" {
- // Recreate the pre-0.0.5 display-state result.
- current_bytes[JP_CEMU_HEADER.len() + 0x7C0 + 8] = 0;
+ // Recreate the released 0.0.5/0.0.6 inference bug: hunt counts
+ // forced an undiscovered source row to become visible.
+ current_bytes[JP_CEMU_HEADER.len() + 0x7C0 + 45 * 10 + 8] = 0x80;
} else if component == "cardbox" {
// Model a later Wii U compact-card update outside the repair map.
current_bytes[JP_CEMU_HEADER.len() + 1976..JP_CEMU_HEADER.len() + 1978]
@@ -794,8 +794,8 @@ fn repair_converted_preserves_the_played_directory_and_rolls_back_every_change()
]);
assert_eq!(written["status"], "written");
assert_eq!(
- fs::read(current_dir.join("card1")).unwrap()[JP_CEMU_HEADER.len() + 0x7C0 + 8],
- 0x80
+ fs::read(current_dir.join("card1")).unwrap()[JP_CEMU_HEADER.len() + 0x7C0 + 45 * 10 + 8],
+ 0x00
);
for (component, before) in &preserved_before {
assert_eq!(
diff --git a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md
index dbff05d..1f23415 100644
--- a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md
+++ b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.md
@@ -141,14 +141,18 @@ second.
| `system` | `0x3000` | `system` | `0x3024` | `convert-system` |
Provide it only when the migration explicitly includes the housekeeper
-gallery/movie history. `system` is shared across all three character slots and
-also holds settings that are not owned by the selected slot. The command must
-therefore receive both a 3DS source and an existing initialized Cemu target. It
-recognizes the `0x3000` 3DS and `0x3024` Cemu profiles, bitwise-unions only the
-verified gallery/movie flag range (Cemu file offsets `0x68..0x77`), and
-preserves the Cemu header and every other target byte. A missing or malformed
-Cemu baseline is rejected. Omitting this transaction leaves Cemu `system`
-untouched, so a core-slot conversion alone cannot fill missing gallery entries.
+gallery/movie history. Each title/account exposes one physical `system` rather
+than separate per-slot system files. Current evidence cannot attribute every
+flag inside it to `user1`, `user2`, or `user3`, and it also holds settings that
+are not owned by the selected slot. The command must therefore receive both a
+3DS source and an existing initialized Cemu target. It recognizes the `0x3000`
+3DS and `0x3024` Cemu profiles, bitwise-unions only the currently mapped
+gallery/movie flag range (Cemu file offsets `0x68..0x77`), and preserves the
+Cemu header and every other target byte. A missing or malformed Cemu baseline
+is rejected. Omitting this transaction leaves Cemu `system` untouched, so a
+core-slot conversion alone cannot fill missing gallery entries. This narrow,
+synthetic-tested merge is not evidence that every `system` bit is officially
+transferred or runtime-verified.
### Optional Shared Extdata
@@ -248,6 +252,11 @@ slots. Therefore a migration intended to retain already received cards and
their offline-hall partners must retain both sides: the chosen `user#` and all
four card components.
+Personal, received-card, and CEC partner monster discovery uses one shared
+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.
+
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 6fed30d..eb83c4e 100644
--- a/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md
+++ b/docs/MH3G_3DS_TO_CEMU_FILE_CONTRACT.zh-CN.md
@@ -88,7 +88,7 @@ Dry Run 会把全部选中组件汇总成一个顶层版本判断,可能报告
| --- | ---: | --- | ---: | --- |
| `system` | `0x3000` | `system` | `0x3024` | `convert-system` |
-只有迁移明确包含管家画廊/动画记录时才提供它。`system` 由三个角色槽位共享,还包含不属于当前角色的设置,因此命令必须同时接收 3DS 源文件和已存在、已初始化的 Cemu 目标。它会识别 `0x3000` 的 3DS profile 与 `0x3024` 的 Cemu profile,只对已确认的画廊/动画标记范围(Cemu 文件偏移 `0x68..0x77`)逐位取并集,并保留 Cemu 头和其他全部目标字节。目标不存在或格式错误时会拒绝执行。不运行该事务会保持 Cemu `system` 不变,因此只转换核心槽位不能补齐缺失的画廊记录。
+只有迁移明确包含管家画廊/动画记录时才提供它。每个游戏标题/账号在物理布局上只有一个 `system`,不存在独立的 `system1`、`system2`、`system3`;现有证据还不能把其中每个具体标记可靠归因到 `user1`、`user2` 或 `user3`,而且它还包含不属于当前角色的设置,因此命令必须同时接收 3DS 源文件和已存在、已初始化的 Cemu 目标。它会识别 `0x3000` 的 3DS profile 与 `0x3024` 的 Cemu profile,只对当前已映射的画廊/动画标记范围(Cemu 文件偏移 `0x68..0x77`)逐位取并集,并保留 Cemu 头和其他全部目标字节。目标不存在或格式错误时会拒绝执行。不运行该事务会保持 Cemu `system` 不变,因此只转换核心槽位不能补齐缺失的画廊记录。这个范围受合成测试保护,但不能据此宣称 `system` 的每一个标记都已被官方迁移或游戏运行态验证。
### 可选的共享 ExtData
@@ -149,6 +149,8 @@ CEC 既不属于 `user#`,也不属于 `card*`,正常公会名片/离线伙
`user#` 保存六条离线猎人名单/缓存记录和候选 anchor。转换器会转换其平台相关字段。公会名片组件保存对应名片 body。回归测试证明 `user#` 中保留的 8 字节 anchor 与转换后名片槽位中的 anchor 匹配。因此,要保留已经收到的名片及其离线集会所伙伴,迁移必须同时保留两侧:选中的 `user#` 和全部四个名片组件。
+个人记录、收到的公会名片和 CEC 伙伴记录共用同一份“仅状态字节”怪物发现映射。讨伐/捕获计数会按数值字段转换,但绝不能据此合成 Wii U 可见标记;配对实机迁移已经证明,计数非零的记录仍可能保持未发现。
+
目前没有证据支持“只选择一个 `card#` 文件也能安全得到该结果”的规则。应把全部四个名片文件视为同一个安装组。反过来,CEC 也不是这些已有名片/伙伴记录的前置依赖;其原始收件记录导入仍明确属于实验性功能。
## 各命令的读写边界
diff --git a/docs/adr/0015-mh3g-compatibility-save-merge.md b/docs/adr/0015-mh3g-compatibility-save-merge.md
index cc0e0b6..99de949 100644
--- a/docs/adr/0015-mh3g-compatibility-save-merge.md
+++ b/docs/adr/0015-mh3g-compatibility-save-merge.md
@@ -14,7 +14,7 @@ MH3G HD Wii U/Cemu saves, but each release added narrowly scoped corrections:
| --- | --- |
| 0.0.3 | Full personal/received-card arena tables and the complete numeric prefix of both Shakalaka records |
| 0.0.4 | Packed Shakalaka mask-state preservation and the shared Hunter's Notes visibility rule for received cards and offline-hall partners |
-| 0.0.5 | The isolated Lamp Mask mastery `u16` at relative Shakalaka offset `0xE4` |
+| 0.0.5 | Historical 0.0.6 behavior that swapped relative Shakalaka offset `0xE4` as an isolated `u16` (retained only for exact old-output replay; later disproved by paired official transfers) |
| 0.0.6 | Current conversion baseline |
Some players converted with an older release and then continued playing on
@@ -28,6 +28,23 @@ version. A byte pattern can support a version hypothesis, but ordinary gameplay
can modify the same fields and erase that evidence. Therefore exact automatic
version detection cannot be guaranteed for every played save.
+### Shakalaka mask-state erratum
+
+Five paired 3DS -> Wii U transfers agree byte-for-byte on both Cha-Cha and
+Kayamba records through relative offset `0x140`. The confirmed schema is one
+big-endian-converted `u32` prefix, `u16` scalars from relative `0x04` through
+`0xDE`, and a platform-invariant packed mask/mastery block from `0xDE` through
+`0x140`. Relative offset `0xE4` belongs to that packed block; it is not an
+isolated `u16`.
+
+The historical 0.0.6 transform swapped the two bytes at `0xE4`. For common
+source values such as `18 00`, that produced `00 18`, making the companion
+status path observe a zero byte. A quest completion could then serialize the
+zero back into the mask state. Current conversion restores the whole packed
+block from the 3DS source unchanged. Compatibility repair still replays the
+old transform for version detection, then repairs only matching historical
+fields and preserves later Wii U conflicts.
+
## Decision
Version 0.0.7 adds a separate **compatibility merge** operation. It does not
diff --git a/docs/adr/0016-mh3g-shared-system-gallery-merge.md b/docs/adr/0016-mh3g-shared-system-gallery-merge.md
index 119a732..b0bbc62 100644
--- a/docs/adr/0016-mh3g-shared-system-gallery-merge.md
+++ b/docs/adr/0016-mh3g-shared-system-gallery-merge.md
@@ -1,15 +1,18 @@
# ADR 0016: Merge MH3G shared-system gallery flags conservatively
-- Status: Accepted
+- Status: Accepted; evidence boundary amended by ADR 0017
- Date: 2026-08-10
- Scope: Japanese MH3G 3DS `system` to MH3G HD Wii U/Cemu `system`
## Context
-MH3G stores the housekeeper gallery/movie unlock history in the separate
-`system` component rather than in `user1`, `user2`, or `user3`. The same
-`system` is shared by all three character slots and also contains settings and
-records unrelated to the slot currently being migrated.
+MH3G stores candidate housekeeper gallery/movie state in the separate `system`
+component rather than in the converted `user1`, `user2`, or `user3` core
+component. The physical layout exposes one `system` beside all three character
+slots, rather than separate per-slot system files, and it also contains settings
+and records unrelated to the slot currently being migrated. Current evidence
+does not prove whether individual bits internally encode slot-specific
+semantics.
Converter versions through 0.0.16 exposed `convert-system` as a complete 3DS
payload conversion followed by replacement of the Cemu target. Although that
@@ -22,12 +25,14 @@ The Japanese files have distinct validated containers:
- 3DS `system`: `0x3000` bytes, `JpThreeDsSystem`;
- Wii U/Cemu `system`: `0x3024` bytes, `JpCemuSystem`.
-Community file-level research identifies the gallery unlock booleans at Cemu
-file offsets `0x68..0x77` and reports that gallery state is shared between
-profiles. Local comparison of an independently supplied 3DS/Wii U transfer
-pair places the corresponding non-zero flag words in the same logical payload
-range. These are file-format observations, not game-runtime proof for every
-possible `system` field.
+Community file-level research identifies candidate gallery unlock booleans at
+Cemu file offsets `0x68..0x77` and reports that gallery state is shared between
+profiles. Later paired official-transfer audit confirms the title-wide
+physical `system` ownership but does not prove a one-to-one bit mapping for
+this range: the single strict source/target `system` pair does not equal a raw
+endian conversion or union. Therefore this remains a conservative,
+synthetic-tested opt-in mapping rather than complete official-transfer or
+game-runtime proof.
## Decision
@@ -52,7 +57,8 @@ unit tests and format analysis, but it is no longer the public
## Consequences
-- Existing Wii U gallery unlocks are retained while 3DS unlocks are added.
+- Existing Wii U bits in the mapped range are retained while mapped 3DS bits
+ are added.
- Other-slot settings and unknown shared records are not overwritten.
- A missing, malformed, or wrongly selected Cemu `system` fails closed.
- A user must start MH3G HD once to initialize a target before migrating
@@ -75,5 +81,5 @@ Every write retains the previous Cemu bytes in the standard
Deterministic tests prove profile recognition, endian-aware flag union,
preservation outside `0x68..0x77`, target/hash refusal, transaction backup, and
manifest behavior. Supplied transfer files are used only as local, uncommitted
-comparison evidence. Game UI/runtime behavior remains unverified until a user
-opens the gallery on Cemu or Wii U.
+comparison evidence. The precise flag semantics and game UI/runtime behavior
+remain unverified until controlled single-unlock before/after captures exist.
diff --git a/docs/adr/0017-mh3g-official-transfer-parity.md b/docs/adr/0017-mh3g-official-transfer-parity.md
new file mode 100644
index 0000000..1415c0c
--- /dev/null
+++ b/docs/adr/0017-mh3g-official-transfer-parity.md
@@ -0,0 +1,129 @@
+# ADR 0017: Use paired official transfers for MH3G field boundaries
+
+- Status: Accepted
+- Date: 2026-08-16
+- Scope: Japanese MH3G 3DS to MH3G HD Wii U/Cemu conversion
+
+## Context
+
+Several fields that look numeric are actually packed byte state. Broad endian
+swaps can therefore produce plausible levels while corrupting the value later
+consumed by the game. Likewise, hunt counters are not proof that a monster is
+marked discovered in Hunter's Notes.
+
+Five independently supplied `user#` pairs were compared byte-for-byte after
+removing their platform container headers. They came from one local evidence
+archive whose SHA-256 is
+`0262a4c5353deca35fa1220d89c87414383bab1454509d06d14d6414f7827712`.
+The archive and player saves remain local evidence and are never committed.
+
+The paired files agree on these boundaries:
+
+- each Cha-Cha/Kayamba record starts at payload `0x6F44`, with stride `0x148`;
+- relative `0x000..0x003` is one endian-converted `u32`;
+- relative `0x004..0x0DD` is a sequence of endian-converted `u16` lanes;
+- relative `0x0DE..0x13F` is a byte-packed mask/mastery block and is copied
+ unchanged;
+- each personal monster state is at `0x81B4 + row * 10 + 8`;
+- received-card and CEC card states use the same semantic mapping at their own
+ slot-relative offsets;
+- discovery/crown state is a source-owned bit permutation. Non-zero slay or
+ capture counts do not synthesize discovery.
+
+Static inspection of the Wii U executable and its Japanese item-name resource
+also corrects an earlier schema label. Payload `0x65C4..0x6683` is not 48
+monster records; it is 48 endian-sensitive `u32` words forming a 1536-bit
+item-acquisition bitset. The Wii U accessor selects `item_id >> 5` and then
+tests `item_id & 31`. The Deviljho book is item `0x4F8`, so it is word 39,
+bit 24. In the Yoruaski source, that word is `0xFFFF7FFE`: bytes
+`FE 7F FF FF` on 3DS must become `FF FF 7F FE` on Wii U.
+
+All five official-transfer pairs agree byte-for-byte on the complete 192-byte
+bitset after this LE-to-BE word conversion. Releases through 0.0.16 left these
+words untouched, which made Wii U read the Yoruaski sample as `0xFE7FFFFF` and
+cleared the Deviljho-book bit. Current conversion (introduced in 0.0.17)
+preserves the bit required by the ninth Hunter's Notes page. No item-ID
+remapping or adjacent-field rewrite is supported by the evidence; native game
+acceptance must still confirm that the intended output file and slot were
+actually loaded.
+
+The same five pairs also expose a second, independent monster-list condition.
+Payload `0x5760..0x577B` is a 28-byte packed state array and is copied verbatim
+by the official transfer. Historical conversion treated part of its tail as
+`u16` lanes. For Yoruaski this changed source tail `FF FF FF 00` into
+`FF FF 00 FF`, clearing byte `0x577A`. The acquired Deviljho-book bit and the
+personal Deviljho record can therefore both be correct while the runtime list
+still omits Deviljho. Current conversion restores the complete byte-packed
+array; compatibility repair handles it one byte at a time so later Wii U
+progress in another byte is not reverted.
+
+## Decision
+
+Current conversion layers the official-transfer corrections after the closed
+0.0.3-0.0.6 historical replay:
+
+1. copy the confirmed Shakalaka packed block without byte swapping;
+2. derive personal, received-card, and CEC Hunter's Notes visibility only from
+ the source state byte;
+3. keep one shared state-only mapping helper for all three storage locations;
+4. retain the former hunt-counter inference only inside historical replay so
+ compatibility detection remains byte-reproducible;
+5. include every corrected state byte and packed mask pair in compatibility
+ repair's field list, preserving later Wii U edits outside those fields;
+6. convert all 48 item-acquisition words independently and include them in
+ compatibility repair under their actual item-bitset semantics;
+7. preserve the 28-byte packed monster-list state verbatim and repair its
+ historical output at byte granularity.
+
+## Consequences
+
+- Lamp Mask mastery no longer becomes zero merely because its two packed bytes
+ were reversed.
+- Hidden source monsters no longer create extra guide pages after conversion.
+- The Deviljho book and other acquired-item unlocks no longer disappear merely
+ because their 32-bit word was interpreted in the wrong byte order.
+- Received cards and offline-hall partners cannot drift from the personal
+ Hunter's Notes mapping.
+- Existing 0.0.5/0.0.6 saves remain detectable and can be repaired without
+ overwriting unrelated post-conversion progress.
+
+## Shared `system` boundary
+
+The physical save layout contains one title-wide `system` beside all three
+`user#` slots, not separate per-slot system files. This proves physical file
+ownership, but it does not prove whether individual bits internally encode
+slot-specific semantics. Gallery/movie state must therefore not be presented
+as reliably attributable to a character slot. The current conservative merge
+remains a separate transaction that requires both a 3DS source and an
+initialized Cemu target.
+
+This official-pair audit does **not** upgrade the exact gallery bit mapping to
+runtime-verified status: the archive contains only one strict source/target
+`system` pair, and its observed bytes do not by themselves prove a general
+bit remap. A controlled single-unlock before/after capture is still required
+before broadening the mapped range or claiming complete official parity.
+
+## Residual parity backlog
+
+The paired-file comparison is intentionally not being presented as whole-file
+parity. After the mask and Hunter's Notes corrections, each of the five core
+slots still differs from its paired Wii U file by 200 to 274 payload bytes.
+The remaining high-confidence clusters include fleet/roster fields, companion
+record prefixes, offline-hunter records, and mixed record tables whose complete
+schemas are not yet proven.
+
+Received-card comparison also found independent, repeatable gaps outside the
+Hunter's Notes state byte: compact-equipment tail scalars, four slot-local
+`u32` fields, summary records after the formerly assumed 33-record boundary,
+and three fixed-width `cardbox` values. `quest1` through `quest4` matched their
+paired files byte-for-byte. These residuals need their own compatibility field
+specifications and regression fixtures; they are not silently folded into this
+symptom-focused change.
+
+## Verification boundary
+
+Synthetic tests cover current conversion, historical replay, compatibility
+repair, received-card conversion, and CEC conversion. Local scripts compare
+the corrected regions against the five uncommitted official pairs. Game UI
+behavior and the exact semantics of every byte inside the packed mask block
+remain runtime acceptance items.
diff --git a/scripts/capture-mh3g-save-converter-windows-ui-quality.ps1 b/scripts/capture-mh3g-save-converter-windows-ui-quality.ps1
index e768f2f..a92ed3e 100644
--- a/scripts/capture-mh3g-save-converter-windows-ui-quality.ps1
+++ b/scripts/capture-mh3g-save-converter-windows-ui-quality.ps1
@@ -58,6 +58,18 @@ foreach ($fixture in $fixtures) {
$windowPattern = $root.GetCurrentPattern([System.Windows.Automation.WindowPattern]::Pattern)
$windowPattern.SetWindowVisualState([System.Windows.Automation.WindowVisualState]::Normal)
+ if ($fixture -eq "dry-run.ready") {
+ $detailsCondition = [System.Windows.Automation.PropertyCondition]::new(
+ [System.Windows.Automation.AutomationElement]::AutomationIdProperty,
+ "mh3g.converter.windows.details.dryRun"
+ )
+ $details = $root.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $detailsCondition)
+ if ($null -eq $details) { throw "Missing Dry Run technical-details expander" }
+ $expandPattern = $details.GetCurrentPattern([System.Windows.Automation.ExpandCollapsePattern]::Pattern)
+ $expandPattern.Expand()
+ Start-Sleep -Milliseconds 200
+ }
+
$bounds = $root.Current.BoundingRectangle
if ([Math]::Abs($bounds.Width - $width) -gt 16 -or [Math]::Abs($bounds.Height - $height) -gt 16) {
throw "Window did not reach requested $WindowSize for $fixture: $($bounds.Width)x$($bounds.Height)"
@@ -73,9 +85,16 @@ foreach ($fixture in $fixtures) {
[System.Windows.Automation.TreeScope]::Descendants,
[System.Windows.Automation.Condition]::TrueCondition
) | ForEach-Object {
+ $value = $null
+ try {
+ $valuePattern = $_.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
+ $value = $valuePattern.Current.Value
+ }
+ catch { }
[ordered]@{
id = $_.Current.AutomationId
name = $_.Current.Name
+ value = $value
type = $_.Current.ControlType.ProgrammaticName
enabled = $_.Current.IsEnabled
focusable = $_.Current.IsKeyboardFocusable
@@ -85,6 +104,12 @@ foreach ($fixture in $fixtures) {
$ids = @($nodes | ForEach-Object { $_.id } | Where-Object { $_ })
if (($ids | Select-Object -Unique).Count -ne $ids.Count) { throw "Duplicate AutomationId in $fixture" }
foreach ($id in $requiredIds) { if ($ids -notcontains $id) { throw "Missing $id in $fixture" } }
+ if ($fixture -eq "dry-run.ready") {
+ $report = $nodes | Where-Object { $_.id -eq "mh3g.converter.windows.details.dryRun.report" }
+ if ($null -eq $report -or $report.value -notmatch '"status":"dry-run"') {
+ throw "Expanded Dry Run technical details did not expose the synthetic report"
+ }
+ }
$uiaPath = Join-Path $OutputDirectory "$fixture-$WindowSize-$Motion-uia.json"
$nodes | ConvertTo-Json -Depth 5 | Set-Content -Encoding UTF8 $uiaPath
diff --git a/scripts/mh3g-compatibility-played-save-fixture.py b/scripts/mh3g-compatibility-played-save-fixture.py
index a2f3fd3..ad80d43 100755
--- a/scripts/mh3g-compatibility-played-save-fixture.py
+++ b/scripts/mh3g-compatibility-played-save-fixture.py
@@ -68,8 +68,7 @@
SHAKALAKA_RECORD_START = 0x6F44
SHAKALAKA_RECORD_COUNT = 2
SHAKALAKA_RECORD_STRIDE = 0x148
-SHAKALAKA_MASK_STATE_START = 0xDE
-SHAKALAKA_LAMP_MASK_MASTERY_END = 0xE6
+SHAKALAKA_CONFIRMED_SCHEMA_END = 0x140
CARD_MONSTER_LOG_START = 0x7C0
CARD_MONSTER_LOG_COUNT = 50
CARD_MONSTER_LOG_STRIDE = 10
@@ -125,7 +124,7 @@ def compatibility_repair_offsets(name: str) -> set[int]:
+ SHAKALAKA_RECORD_START
+ companion * SHAKALAKA_RECORD_STRIDE
)
- offsets.update(range(start, start + SHAKALAKA_LAMP_MASK_MASTERY_END))
+ offsets.update(range(start, start + SHAKALAKA_CONFIRMED_SCHEMA_END))
elif name in {"card1", "card2", "card3"}:
for slot in range(CARD_SLOT_COUNT):
start = CEMU_HEADER_SIZE + slot * CARD_SLOT_SIZE
diff --git a/scripts/test-mh3g-save-converter-windows-ui-negative-gates.py b/scripts/test-mh3g-save-converter-windows-ui-negative-gates.py
index a9fd51a..378e0eb 100644
--- a/scripts/test-mh3g-save-converter-windows-ui-negative-gates.py
+++ b/scripts/test-mh3g-save-converter-windows-ui-negative-gates.py
@@ -19,8 +19,27 @@ def run(cmd, cwd):
result = run([sys.executable, "scripts/verify-mh3g-save-converter-windows-ui-quality.py"], temp)
if result.returncode == 0: raise SystemExit("missing primary action ID mutation was not rejected")
+ xaml.write_text(original.replace('Text="{Binding Copy.ResultEmpty}"', "", 1), encoding="utf-8")
+ result = run([sys.executable, "scripts/verify-mh3g-save-converter-windows-source.py"], temp)
+ if result.returncode == 0: raise SystemExit("blank technical-details mutation was not rejected")
+ xaml.write_text(original, encoding="utf-8")
+
+ view_model = temp / "apps/mh3g-save-converter-windows/ViewModels/MainViewModel.cs"
+ original_view_model = view_model.read_text(encoding="utf-8")
+ view_model.write_text(
+ original_view_model.replace(
+ "InvalidateCoreWriteAuthorizationPreservingInspection();",
+ "InvalidateCoreAuthorization();",
+ 1,
+ ),
+ encoding="utf-8",
+ )
+ result = run([sys.executable, "scripts/verify-mh3g-save-converter-windows-source.py"], temp)
+ if result.returncode == 0: raise SystemExit("repair optional-state reset mutation was not rejected")
+ view_model.write_text(original_view_model, encoding="utf-8")
+
contract = temp / ".ui-os/design/FROZEN_CONTRACT.md"
- contract.write_text(contract.read_text(encoding="utf-8").replace("920×600", "", 1), encoding="utf-8")
+ contract.write_text(contract.read_text(encoding="utf-8").replace("920×600", ""), encoding="utf-8")
result = run([sys.executable, "scripts/verify-mh3g-save-converter-windows-ui-quality.py"], temp)
if result.returncode == 0: raise SystemExit("minimum-window contract mutation was not rejected")
diff --git a/scripts/verify-mh3g-save-converter-windows-source.py b/scripts/verify-mh3g-save-converter-windows-source.py
index 8ed17ae..963cd11 100644
--- a/scripts/verify-mh3g-save-converter-windows-source.py
+++ b/scripts/verify-mh3g-save-converter-windows-source.py
@@ -606,6 +606,27 @@ def main() -> int:
require("_extrasInstallCompleted = false;" in extras_rollback, "ExtData rollback must clear completion")
require("CommitRepairOptionalScope" in workflow and "_repairGuildCardSource" in workflow,
"repair optional scope must be explicitly committed and authorization-bound")
+ repair_optional_commit = workflow.split("public bool CommitRepairOptionalScope(bool skip)", 1)[1].split(
+ "public string SourcePath", 1
+ )[0]
+ require(
+ "InvalidateCoreWriteAuthorizationPreservingInspection();" in repair_optional_commit
+ and "InvalidateCoreAuthorization();" not in repair_optional_commit,
+ "committing repair ExtData must revoke write authorization without discarding core inspection",
+ )
+ preserve_inspection = workflow.split(
+ "private void InvalidateCoreWriteAuthorizationPreservingInspection()", 1
+ )[1].split("private void InvalidateSystemAuthorization()", 1)[0]
+ for expected in (
+ "_coreAuthorization = null;",
+ "_repairAuthorization = null;",
+ "_sourceInspected",
+ "_currentInspected",
+ "_targetInspected",
+ "Stage = WorkflowStage.Inspected;",
+ "SetWorkflowGuidance(WorkflowGuidance.CoreInspected);",
+ ):
+ require(expected in preserve_inspection, f"repair optional transition is missing {expected}")
window = read("MainWindow.xaml")
require(
@@ -631,6 +652,15 @@ def main() -> int:
require('x:Name="OptionalConfigurationAnchor"' in window, "optional configuration requires a stable destination")
require('AutomationProperties.AutomationId="mh3g.converter.windows.action.confirmWrite"' in window,
"write surface must expose a stable primary action id")
+ for expected in (
+ 'AutomationProperties.AutomationId="mh3g.converter.windows.details.dryRun"',
+ 'AutomationProperties.AutomationId="mh3g.converter.windows.details.dryRun.empty"',
+ 'AutomationProperties.AutomationId="mh3g.converter.windows.details.dryRun.report"',
+ 'Text="{Binding Copy.ResultEmpty}"',
+ 'Text="{Binding LatestReport, Mode=OneWay}"',
+ 'Visibility="{Binding LatestReportVisibility}"',
+ ):
+ require(expected in window, f"Dry Run technical details must have a deterministic non-blank state: {expected}")
code_behind = read("MainWindow.xaml.cs")
require(
"RootGrid.DataContext = ViewModel;" in code_behind
@@ -654,6 +684,15 @@ def main() -> int:
"ViewModel.CurrentPath = CurrentPathBox.Text;",
):
require(expected in code_behind, f"independent Wii U reference picker is missing {expected}")
+ optional_transition = code_behind.split("private void ContinueFromOptional(bool skip)", 1)[1].split(
+ "private void StartConversion_Click", 1
+ )[0]
+ require(
+ "ViewModel.CanRunCoreDryRun" in optional_transition
+ and "ShowConvertStep(ConvertStep.DryRun);" in optional_transition
+ and "ShowConvertStep(ConvertStep.Input);" in optional_transition,
+ "optional navigation must gate Dry Run and recover to the actionable input step",
+ )
write_core = public_method_body(workflow, "WriteCoreAsync")
require(
"var repairArguments = new List" in write_core
diff --git a/scripts/verify-mh3g-save-converter-windows-ui-evidence.py b/scripts/verify-mh3g-save-converter-windows-ui-evidence.py
index eb5d0d0..36e5962 100644
--- a/scripts/verify-mh3g-save-converter-windows-ui-evidence.py
+++ b/scripts/verify-mh3g-save-converter-windows-ui-evidence.py
@@ -27,4 +27,12 @@
uia = args.evidence_dir / f"{fixture}-{size}-{motion}-uia.json"
if not png.is_file() or png.stat().st_size < 1000: raise SystemExit(f"missing screenshot {png.name}")
if not uia.is_file(): raise SystemExit(f"missing UIA tree {uia.name}")
+ if fixture == "dry-run.ready":
+ nodes = json.loads(uia.read_text(encoding="utf-8-sig"))
+ report = next(
+ (node for node in nodes if node.get("id") == "mh3g.converter.windows.details.dryRun.report"),
+ None,
+ )
+ if report is None or '"status":"dry-run"' not in (report.get("value") or ""):
+ raise SystemExit(f"expanded Dry Run technical report is missing from {uia.name}")
print("Native Windows UI evidence is complete and artifact/commit bound.")
diff --git a/scripts/verify-mh3g-save-converter-windows-ui-quality.py b/scripts/verify-mh3g-save-converter-windows-ui-quality.py
index 96c530e..4f5c942 100644
--- a/scripts/verify-mh3g-save-converter-windows-ui-quality.py
+++ b/scripts/verify-mh3g-save-converter-windows-ui-quality.py
@@ -52,6 +52,9 @@ def require(condition: bool, message: str) -> None:
"mh3g.converter.windows.path.source",
"mh3g.converter.windows.path.current",
"mh3g.converter.windows.path.output",
+ "mh3g.converter.windows.details.dryRun",
+ "mh3g.converter.windows.details.dryRun.empty",
+ "mh3g.converter.windows.details.dryRun.report",
}
ids = re.findall(r'AutomationProperties\.AutomationId="([^"]+)"', xaml)
require(required_ids.issubset(set(ids)), f"missing IDs: {sorted(required_ids - set(ids))}")
@@ -87,6 +90,16 @@ def require(condition: bool, message: str) -> None:
require("SelectedOptionalDataIsConfigured" not in can_write, "optional config still gates core CTA")
require("CommitRepairOptionalScope" in vm and "_repairGuildCardSource" in vm,
"repair guild-card scope is not explicit/authorization-bound")
+repair_commit = vm.split("public bool CommitRepairOptionalScope(bool skip)", 1)[1].split(
+ "public string SourcePath", 1
+)[0]
+require("InvalidateCoreWriteAuthorizationPreservingInspection();" in repair_commit,
+ "repair optional transition must preserve completed core inspection")
+require("InvalidateCoreAuthorization();" not in repair_commit,
+ "repair optional transition still clears completed core inspection")
+require('Text="{Binding Copy.ResultEmpty}"' in xaml
+ and 'Text="{Binding LatestReport, Mode=OneWay}"' in xaml,
+ "Dry Run technical details need both an empty state and a read-only report")
require("_extrasInstallCompleted = true;" in vm and "_extrasInstallCompleted = false;" in vm,
"ExtData independent completion lifecycle is incomplete")
diff --git a/tools/compatibility-wrapper/gen_wrapper.py b/tools/compatibility-wrapper/gen_wrapper.py
index 86c32df..f6a5572 100644
--- a/tools/compatibility-wrapper/gen_wrapper.py
+++ b/tools/compatibility-wrapper/gen_wrapper.py
@@ -6,7 +6,7 @@
native_markers = (
'const SHAKALAKA_MASK_STATE_START: usize = 0xDE;',
'const GUILD_CARD_ARENA_RECORD_COUNT: usize = 110;',
- 'fn apply_shakalaka_companion_corrections(',
+ 'fn apply_current_shakalaka_companion_corrections(',
'fn apply_guild_card_arena_corrections(',
)
if all(marker in native_transforms for marker in native_markers):
diff --git a/tools/compatibility-wrapper/validate_patch.py b/tools/compatibility-wrapper/validate_patch.py
index 5755d36..257972a 100644
--- a/tools/compatibility-wrapper/validate_patch.py
+++ b/tools/compatibility-wrapper/validate_patch.py
@@ -6,7 +6,7 @@
native_markers = (
'const SHAKALAKA_MASK_STATE_START: usize = 0xDE;',
'const GUILD_CARD_ARENA_RECORD_COUNT: usize = 110;',
- 'fn apply_shakalaka_companion_corrections(',
+ 'fn apply_current_shakalaka_companion_corrections(',
'fn apply_guild_card_arena_corrections(',
)
if all(marker in native_transforms for marker in native_markers):