Skip to content

A save editor/converter for the 3DS/Switch game Luigi's Mansion 2 (Dark Moon in the US)

Notifications You must be signed in to change notification settings

TollyH/LM2_SaveEditor

Repository files navigation

Luigi's Mansion 2 Save Editor

A save editor for the 3DS/Switch game Luigi's Mansion 2 (also known as Luigi's Mansion: Dark Moon in North American releases). Can also be used to convert 3DS save files to Switch save files, and vice versa.

Note

If you're using a physical 3DS/Switch system, you must have custom firmware and/or homebrew installed, as well as a save manager such as Checkpoint. Saves copied directly from the SD card will not work.

Caution

ALWAYS keep a backup of your save files before using a save editor. In the unlikely event that this editor causes you to lose your save data, I cannot be held responsible.

Table of Contents

Save File Documentation

This section of the README documents all of my findings related to how both versions of the game's save file works. You do not need to read this section to utilise the save editor, it is only for those curious as to the inner workings of the save file format. At some points I reference address offsets in the game's executable in case you wish to find relevant code/data yourself. For the 3DS, these addresses require an ELF dump of the European version's executable to utilise.

General Information

As with many games, Luigi's Mansion 2 save files use a proprietary binary format designed specifically for the game.

Each profile is stored in a separate file with the slot number being determined solely by the filename. This means that renaming a file is all you need to do to move a profile to another slot, the file contents itself does not differ at all. On the 3DS, the files are called profile1.sav, profile2.sav, and profile3.sav in the order you would expect. On the Switch, they are called profile1.txt, profile2.txt, and profile3.txt instead. Despite the file extension, the Switch save files are not plain text, and remain mostly unchanged from the 3DS ones.

As far as I can tell, the save file format does not differ at all between 3DS regions. This means, for example, you could copy a save file from the European version to the North American version and it would work identically, no modifications required. Even the so-called "Version CRC" does not change.

The save file is split into two sections which are loaded separately by the game. The first section is 0x1A (26) bytes long for both versions and stores the information that is shown when hovering over the profile on the title screen. Changing it does not affect anything in-game. The second section is 0xF1D (3869) bytes long on the 3DS and 0xF2A (3882) bytes long on the Switch. It stores all the information needed once loaded into a save profile. With the sole exception of total playtime, there is no data in the title screen section that cannot be determined using data already present in the in-game section.

If the title screen section of the save file is modified without changing the relevant parameters in the in-game section of the save file, it will be overwritten with the correct values according to the in-game section when the game is next saved.

All numeric values in the save file are stored using the little endian byte format, therefore in memory the single number 0xAABBCCDD would be represented with the bytes 0xDD, 0xCC, 0xBB, 0xAA in that order.

Save File Format

The following is a table of the locations of all the fields within the save file, as well as any additional notes I may have on each field. The in-game section byte offsets have both the overall file offset, as well as the offset relative to the start of the in-game section in brackets. Items marked as "N/A" in the 3DS column are new to the Switch version and are not present in the original.

Byte Offset (3DS) Byte Offset (Switch) Field Name Data Type Notes
Title Screen Section
0x0000 - 0x0003 0x0000 - 0x0003 Data CRC UInt32 See CRC Checksums section below.
0x0004 - 0x0007 0x0004 - 0x0007 Version CRC UInt32 Always 0x7B, 0x0C, 0x27, 0x49 on both versions.
0x0008 0x0008 Furthest Cleared Mansion Byte 0-indexed, follows the same order as in-game, 0xFF if no mission has been cleared.
0x0009 0x0009 Furthest Cleared Mission Byte Uses the rightmost digit of the mission's index, see the mission indices section below.
0x000A 0x000A Highest Tower Floor Byte
0x000B - 0x000E 0x000B - 0x000E Total Treasure Acquired Int32
0x000F 0x000F Boos Captured Byte
0x0010 0x0010 Dark Moon Pieces Byte Even if no missions are complete, player will always start with 1 piece.
0x0011 0x0011 E. Gadd Medals Byte Referred to as "stars" internally.
0x0012 - 0x0019 0x0012 - 0x0019 Total Playtime Int64 Measured in seconds. Only value that cannot be determined with the in-game section.
In-Game Section
0x001A - 0x001D (0x0000 - 0x0003) 0x001A - 0x001D (0x0000 - 0x0003) Data CRC UInt32 See CRC Checksums section below.
0x001E - 0x0021 (0x0004 - 0x0007) 0x001E - 0x0021 (0x0004 - 0x0007) Version CRC UInt32 Always 0xAD, 0x03, 0x32, 0xD4 on the 3DS, 0xA5, 0xEB, 0xAA, 0x3D on the Switch.
N/A 0x0022 - 0x0025 (0x0008 - 0x000B) Save Game Version Number Int32 Currently always the number 3. Not present on the 3DS version.
0x0022 - 0x0821 (0x0008 - 0x0807) 0x0026 - 0x0825 (0x000C - 0x080B) Discovered NIS Unknown NIS most likely stands for "Non-Interactable Sequences" (i.e. real-time cutscenes). Has nearly no discernable effect on the game, and I haven't been able to determine its format, so it is best to leave it unmodified. Possibly stores a list of cutscenes that have been seen for the few circumstances where cutscenes play differently if you've seen them before. The only one I'm aware of that does this is Luigi attaching the Poltergust to the vault nozzle, however, so why this needs over 2KB (over half the entire save file!) I do not know.
0x0822 - 0x085D (0x0808 - 0x0843) 0x0826 - 0x0861 (0x080C - 0x0847) Mission Completion Boolean[60] 0 = mission not complete, 1 = mission complete. See section below on mission indices.
0x085E - 0x0899 (0x0844 - 0x087F) 0x0862 - 0x089D (0x0848 - 0x0883) Mission Locked Boolean[60] 0 = mission unlocked, 1 = mission locked. See section below on mission indices. Even though it never happens legitimately, you can unlock more than one mission after the last completed one. You can also unlock missions whilst still having prior ones locked and they will be made visible, though they will be inaccessible.
0x089A - 0x08D5 (0x0880 - 0x08BB) 0x089E - 0x08D9 (0x0884 - 0x08BF) Mission Grade Byte[60] 0 = Bronze, 1 = Silver, 2 = Gold. See section below on mission indices.
0x08D6 - 0x0911 (0x08BC - 0x08F7) 0x08DA - 0x0915 (0x08C0 - 0x08FB) Mission Prev Grade Byte[60] Possibly previous grade? Value doesn't seem to affect anything, keeping it the same as Mission Grade is a safe bet. See section below on mission indices.
0x0912 - 0x094D (0x08F8 - 0x0933) 0x0916 - 0x0951 (0x08FC - 0x0937) Mission Boo Captured Boolean[60] 0 = Boo not captured, 1 = Boo captured. See section below on mission indices. Setting this to 1 on levels that don't have a Boo will not increase the Boo counter in the vault, but it will increase the Boos shown toward the bonus level for each mansion. E-4 "Ambush Manoeuvre" (index 43) counts for 10 Boos instead of the usual 1 in the vault when set to 1. It still counts as 1 toward the Treacherous Mansion bonus mission, however.
0x094E - 0x0989 (0x0934 - 0x096F) 0x0952 - 0x098D (0x0938 - 0x0973) Mission Boo Notify State Byte[60] Seems to always be between 0 and 2. Changing it doesn't seem to do anything.
0x098A - 0x09C5 (0x0970 - 0x09AB) 0x098E - 0x09C9 (0x0974 - 0x09AF) Mission Notify State Byte[60] Seems to always be between 0 and 2. Changing it doesn't seem to do anything.
0x09C6 - 0x0AB5 (0x09AC - 0x0A9B) 0x09CA - 0x0AB9 (0x09B0 - 0x0A9F) Mission Clear Time Single[60] Stores the shortest completion time. Measured in seconds. See section below on mission indices. Despite being a float, the value stored is always a whole number.
0x0AB6 - 0x0B2D (0x0A9C - 0x0B13) 0x0ABA - 0x0B31 (0x0AA0 - 0x0B17) Mission Ghosts Captured UInt16[60] Stores the most ghosts captured. See section below on mission indices.
0x0B2E - 0x0BA5 (0x0B14 - 0x0B8B) 0x0B32 - 0x0BA9 (0x0B18 - 0x0B8F) Mission Damage Taken UInt16[60] Stores the least damage taken. See section below on mission indices.
0x0BA6 - 0x0C1D (0x0B8C - 0x0C03) 0x0BAA - 0x0C21 (0x0B90 - 0x0C07) Mission Treasure Collected UInt16[60] Stores the most treasure collected. See section below on mission indices.
0x0C1E - 0x0C3A (0x0C04 - 0x0C20) 0x0C22 - 0x0C3E (0x0C08 - 0x0C24) Num Basic Ghost Collected Byte[29] Caps at 99 (anything higher will be set back down). See the below section on ghost indices. Also 99 is nowhere near enough, this really could have done with being a 16-bit integer, which considering ghost weight did, I don't know why this didn't. And for that matter why did they leave half the byte unused capping at 99 not 255? There's more than enough space on the UI for 255 and beyond. Ugh.
0x0C3B - 0x0C74 (0x0C21 - 0x0C5A) 0x0C3F - 0x0C78 (0x0C25 - 0x0C5E) Max Basic Ghost Weight UInt16[29] See the below section on ghost indices.
0x0C75 - 0x0C91 (0x0C5B - 0x0C77) 0x0C79 - 0x0C95 (0x0C5F - 0x0C7B) Basic Ghost Notify State Byte[29] 2 to mark as new on the UI, 0 otherwise. See the below section on ghost indices.
0x0C92 - 0x0CAE (0x0C78 - 0x0C94) 0x0C96 - 0x0CB2 (0x0C7C - 0x0C98) Basic Ghost Notify Because Higher Weight Byte[29] No effect I could find.
0x0CAF (0x0C95) 0x0CB3 (0x0C99) Any Optional Boo Captured Boolean If this is 0 then E. Gadd will call Luigi after he catches a Boo explaining Boos to him. It will then be set to 1 and this will no longer occur.
0x0CB0 (0x0C96) 0x0CB4 (0x0C9A) Just Collected Chaser Boolean I'm pretty sure "Chaser" is the internal name for the Polterpup, though changing this doesn't seem to do anything.
0x0CB1 - 0x0D0A (0x0C97 - 0x0CF0) 0x0CB5 - 0x0D0E (0x0C9B - 0x0CF4) Ghost Weight Requirement Unknown[45] Not sure what this is for, I couldn't spot any differences after modifying. As will be explained in the section on ghost indices, this is related to the tower ghosts.
0x0D0B - 0x0D37 (0x0CF1 - 0x0D1D) 0x0D0F - 0x0D3B (0x0CF5 - 0x0D21) Ghost Collectable State Byte[45] 2 = caught at least once, 0 = uncaught. See the below section on ghost indices. Not sure why tower ghosts have this field when Evershade ghosts just use the number caught to determine if they've been caught before.
0x0D38 - 0x0D64 (0x0D1E - 0x0D4A) 0x0D3C - 0x0D68 (0x0D22 - 0x0D4E) Num Ghost Collected Byte[45] Same restrictions (and complaints) as "Num Basic Ghost Collected". See the below section on ghost indices.
0x0D65 - 0x0DBE (0x0D4B - 0x0DA4) 0x0D69 - 0x0DC2 (0x0D4F - 0x0DA8) Max Ghost Weight UInt16[45] See the below section on ghost indices.
0x0DBF - 0x0DEB (0x0DA5 - 0x0DD1) 0x0DC3 - 0x0DEF (0x0DA9 - 0x0DD5) Ghost Notify State Byte[45] 2 to mark as new on the UI, 0 otherwise. See the below section on ghost indices.
0x0DEC - 0x0E18 (0x0DD2 - 0x0DFE) 0x0DF0 - 0x0E1C (0x0DD6 - 0x0E02) Ghost Notify Because Higher Weight Byte[45] No effect I could find.
0x0E19 - 0x0E66 (0x0DFF - 0x0E4C) 0x0E1D - 0x0E6A (0x0E03 - 0x0E50) Gem Collected Boolean[78] 0 = gem not collected, 1 = gem collected. See the below section on gem indices.
0x0E67 - 0x0EB4 (0x0E4D - 0x0E9A) 0x0E6B - 0x0EB8 (0x0E51 - 0x0E9E) Gem Notify State Byte[78] 2 to mark as new on the UI, 0 otherwise. See the below section on gem indices.
0x0EB5 (0x0E9B) 0x0EB9 (0x0E9F) Has Poltergust Boolean Completely redundant as Luigi will always be given the Poltergust on levels where he should have it. Doesn't even affect A-1, as whether Luigi is given the Poltergust in A-1 is determined by whether you've completed the level before, not by this field. This does, however, remove the Poltergust from Luigi in the bunker if set to 0 (though it will reappear in animations where Luigi would normally have it).
0x0EB6 (0x0E9C) 0x0EBA (0x0EA0) Seen Initial Dual Scream Animation Boolean If this is 0, Luigi will play his initial "aha" animation from A-1 when answering an E. Gadd call. Afterwards this will be set to 1 and the animation will play as normal for future calls.
0x0EB7 (0x0E9D) 0x0EBB (0x0EA1) Has Mario Been Revealed in the Story Boolean If 0, Luigi will call out his usual voice clips when pressing a D-Pad button. If 1, Luigi will instead call for Mario. In normal gameplay, this is set to 1 once Mario has been revealed at the start of E-3 (not after finishing the game; a common misconception).
0x0EB8 (0x0E9E) 0x0EBC (0x0EA2) Last Mansion Played Byte Determines which mansion will be initially selected on the central screen in the bunker, though only if the main story has been completed. If it hasn't, the furthest unlocked mansion will be selected instead. 0-indexed, follows the same order as in-game.
0x0EB9 - 0x0EBC (0x0E9F - 0x0EA2) 0x0EBD - 0x0EC0 (0x0EA3 - 0x0EA6) Total Treasure Acquired Int32 Capped at 999999. Higher values will be set back down.
0x0EBD - 0x0EC0 (0x0EA3 - 0x0EA6) 0x0EC1 - 0x0EC4 (0x0EA7 - 0x0EAA) Treasure to Notify During Unloading Int32 Gets set to however much treasure you got in the last level you completed. Doesn't actually seem to affect anything though.
0x0EC1 - 0x0EC4 (0x0EA7 - 0x0EAA) 0x0EC5 - 0x0EC8 (0x0EAB - 0x0EAE) Total Ghost Weight Acquired Int32
0x0EC5 (0x0EAB) 0x0EC9 (0x0EAF) Darklight Upgrade Level Byte Starts at 1, goes up to 3. This value is prioritised over the total treasure collected, therefore setting to something lower after already being max upgraded will make it impossible to upgrade again without re-editing the save.
0x0EC6 (0x0EAC) 0x0ECA (0x0EB0) Darklight Notify State Byte No effect I could find.
0x0EC7 (0x0EAD) 0x0ECB (0x0EB1) Poltergust Upgrade Level Byte Starts at 1, goes up to 3. Does not include Super Poltergust. This value is prioritised over the total treasure collected, therefore setting to something lower after already being max upgraded will make it impossible to upgrade again without re-editing the save.
0x0EC8 (0x0EAE) 0x0ECC (0x0EB2) Poltergust Notify State Byte No effect I could find.
0x0EC9 (0x0EAF) 0x0ECD (0x0EB3) Has Super Poltergust Boolean 0 = not unlocked, 1 = unlocked. This value is prioritised over the total treasure collected, therefore setting to 0 after already being max upgraded will make it impossible to upgrade again without re-editing the save.
0x0ECA (0x0EB0) 0x0ECE (0x0EB4) Super Poltergust Notify State Byte No effect I could find.
0x0ECB (0x0EB1) 0x0ECF (0x0EB5) Has Seen Revive Bone PIP Boolean Most likely stores whether or not you've seen the Polterpup revive cutscene or not. I'm unsure what PIP stands for.
0x0ECC - 0x0F2B (0x0EB2 - 0x0F11) 0x0ED0 - 0x0F2F (0x0EB6 - 0x0F15) Best Tower Clear Time UInt16[48] Each possible configuration of tower has its own best time. See section on tower mode indices. Despite not being timed overall, endless towers are given (unused) spaces in this array.
0x0F2C - 0x0F2F (0x0F12 - 0x0F15) 0x0F30 - 0x0F33 (0x0F16 - 0x0F19) Endless Mode Highest Floor Reached Byte[4] Stored for each gameplay mode individually in this order: Hunter, Rush, Polterpup, Surprise.
0x0F30 (0x0F16) 0x0F34 (0x0F1A) Any Mode Highest Floor Reached Byte Likely stores the highest floor reached in any tower configuration.
0x0F31 - 0x0F34 (0x0F17 - 0x0F1A) 0x0F35 - 0x0F38 (0x0F1B - 0x0F1E) Endless Floors Unlocked Boolean[4] Whether or not endless mode has been unlocked for each tower gameplay mode. 0 = not unlocked, 1 = unlocked. In normal gameplay, this requires you to beat 25 floor mode on each respective gameplay mode on any difficulty setting. Stored for each gameplay mode individually in this order: Hunter, Rush, Polterpup, Surprise.
0x0F35 (0x0F1B) 0x0F39 (0x0F1F) Random Tower Unlocked Boolean Whether or not the surprise tower gameplay mode is unlocked. 0 = not unlocked, 1 = unlocked. In normal gameplay, this is unlocked after beating Hunter, Rush, and Polterpup modes on any difficulty and floor setting.
0x0F36 (0x0F1C) 0x0F3A (0x0F20) Tower Notify State Byte No effect I could find.
N/A 0xF3B (0xF21) Rumble Enabled Boolean "HD Rumble" setting. 1 = On, 0 = Off. Not present on the 3DS version.
N/A 0xF3C (0xF22) Gyroscope Enabled Boolean "Motion Sensor" setting. 1 = Enabled, 0 = Disabled. Not present on the 3DS version.
N/A 0xF3D (0xF23) R Stick Omnidirectional Boolean "Right Stick" setting. 1 = Omnidirectional, 0 = Horizontal-Only. Not present on the 3DS version.
N/A 0xF3E (0xF24) X Axis Inverted Boolean "X-Axis" setting. 1 = Inverted, 0 = Standard. Not present on the 3DS version.
N/A 0xF3F (0xF25) Y Axis Inverted Boolean "Y-Axis" setting. 1 = Inverted, 0 = Standard. Not present on the 3DS version.
N/A 0xF40 (0xF26) Gyroscope Sensitivity Byte "1st-Person Motion-Sensor Sensitivity" setting. 1 = Low, 2 = Standard, 3 = High. Not present on the 3DS version.
N/A 0xF41 (0xF27) First Person Stick Sensitivity Byte "1st-Person Stick Sensitivity" setting. 1 = Low, 2 = Standard, 3 = High. Not present on the 3DS version.
N/A 0xF42 (0xF28) Level Brightness Byte "TV Mode Brightness" setting. 0 = Dimmest, 4 = Brightest. Not present on the 3DS version.
N/A 0xF43 (0xF29) Hide Minimap Boolean "Minimap" setting. 0 = Show, 1 = Hide. Not present on the 3DS version.

The names and locations of these fields (though not their type or purpose) is fairly easy to locate due to two functions within the game's executable that list them.

The first one, used for the title screen data, is as follows (decompiled C code generated by Ghidra):

// 3DS - .text:0027fa94h (file offset: 0x0028fa94)
void FUN_0037fa94(int param_1,undefined *param_2,undefined4 param_3,int param_4)
{
  (*(code *)param_2)("mDataCRC",param_1,0,4,param_3);
  (*(code *)param_2)("mVersionCRC",param_1 + 4,4,4,param_3);
  (*(code *)param_2)("mFurthestClearedMansionIndex",param_1 + 8,8,1,param_3);
  (*(code *)param_2)("mFurthestClearedMissionIndex",param_1 + 9,9,1,param_3);
  (*(code *)param_2)("mTowerHighestFloorClimbed",param_1 + 10,10,1,param_3);
  (*(code *)param_2)("mTotalTreasureAcquired",param_1 + 0xc,0xb,4,param_3);
  (*(code *)param_2)("mTotalBoosCaptured",param_1 + 0x10,0xf,1,param_3);
  (*(code *)param_2)("mTotalDarkmoonPiecesAcquired",param_1 + 0x11,0x10,1,param_3);
  (*(code *)param_2)("mStars",param_1 + 0x12,0x11,1,param_3);
  (*(code *)param_2)("mTimePlayedInSeconds",param_1 + 0x18,0x12,8,param_3);
  if ((0 < param_4) && (param_4 < 0x1a)) {
    software_bkpt(0xa6);
    return;
  }
  return;
}

// Switch - .text:00112ebch
void FUN_60112ebc(void)
{
  undefined auStack_40 [48];
  
  FUN_60112e0c((int)auStack_40);
  DAT_61704fec = DAT_61704fec + 0x1a;
  FUN_60600d0c(&DAT_61704ff0,(uint *)"mDataCRC",8);
  FUN_60600cb4(&DAT_61704ff0,4);
  FUN_60600d0c(&DAT_61704ff0,(uint *)"mVersionCRC",0xb);
  FUN_60600cb4(&DAT_61704ff0,4);
  FUN_60600d0c(&DAT_61704ff0,(uint *)"mFurthestClearedMansionIndex",0x1c);
  FUN_60600cb4(&DAT_61704ff0,1);
  FUN_60600d0c(&DAT_61704ff0,(uint *)"mFurthestClearedMissionIndex",0x1c);
  FUN_60600cb4(&DAT_61704ff0,1);
  FUN_60600d0c(&DAT_61704ff0,(uint *)"mTowerHighestFloorClimbed",0x19);
  FUN_60600cb4(&DAT_61704ff0,1);
  FUN_60600d0c(&DAT_61704ff0,(uint *)"mTotalTreasureAcquired",0x16);
  FUN_60600cb4(&DAT_61704ff0,4);
  FUN_60600d0c(&DAT_61704ff0,(uint *)"mTotalBoosCaptured",0x12);
  FUN_60600cb4(&DAT_61704ff0,1);
  FUN_60600d0c(&DAT_61704ff0,(uint *)"mTotalDarkmoonPiecesAcquired",0x1c);
  FUN_60600cb4(&DAT_61704ff0,1);
  FUN_60600d0c(&DAT_61704ff0,(uint *)"mStars",6);
  FUN_60600cb4(&DAT_61704ff0,1);
  FUN_60600d0c(&DAT_61704ff0,(uint *)"mTimePlayedInSeconds",0x14);
  FUN_60600cb4(&DAT_61704ff0,8);
  return;
}

The second one, used for in-game save data, is as follows:

// 3DS - .text:0027fe78h (file offset: 0x0028fe78)
void FUN_0037fe78(int param_1,undefined *param_2,undefined4 param_3,int param_4)
{
  bool bVar1;
  bool bVar2;
  
  (*(code *)param_2)("mDataCRC",param_1,0,4,param_3);
  (*(code *)param_2)("mVersionCRC",param_1 + 4,4,4,param_3);
  (*(code *)param_2)("mDiscoveredNIS",param_1 + 8,8,0x800,param_3);
  (*(code *)param_2)("mMansionMissionCompletion",param_1 + 0x808,0x808,0x3c,param_3);
  (*(code *)param_2)("mMansionMissionLocked",param_1 + 0x844,0x844,0x3c,param_3);
  (*(code *)param_2)("mMansionMissionGrade",param_1 + 0x880,0x880,0x3c,param_3);
  (*(code *)param_2)("mMansionMissionPrevGrade",param_1 + 0x8bc,0x8bc,0x3c,param_3);
  (*(code *)param_2)("mMansionMissionBooCaptured",param_1 + 0x8f8,0x8f8,0x3c,param_3);
  (*(code *)param_2)("mMansionMissionBooNotifyState",param_1 + 0x934,0x934,0x3c,param_3);
  (*(code *)param_2)("mMansionMissionNotifyState",param_1 + 0x970,0x970,0x3c,param_3);
  (*(code *)param_2)("mMansionMissionClearTime",param_1 + 0x9ac,0x9ac,0xf0,param_3);
  (*(code *)param_2)("mMansionMissionGhostsCaptured",param_1 + 0xa9c,0xa9c,0x78,param_3);
  (*(code *)param_2)("mMansionMissionDamageTaken",param_1 + 0xb14,0xb14,0x78,param_3);
  (*(code *)param_2)("mMansionMissionTreasureCollected",param_1 + 0xb8c,0xb8c,0x78,param_3);
  (*(code *)param_2)("mNumBasicGhostCollected",param_1 + 0xc04,0xc04,0x1d,param_3);
  (*(code *)param_2)("mMaxBasicGhostWeight",param_1 + 0xc22,0xc21,0x3a,param_3);
  (*(code *)param_2)("mBasicGhostNotifyState",param_1 + 0xc5c,0xc5b,0x1d,param_3);
  (*(code *)param_2)("mBasicGhostNotifyBecauseHigherWeight",param_1 + 0xc79,0xc78,0x1d,param_3);
  (*(code *)param_2)("mAnyOptionalBooCaptured",param_1 + 0xc96,0xc95,1,param_3);
  (*(code *)param_2)("mJustCollectedChaser",param_1 + 0xc97,0xc96,1,param_3);
  (*(code *)param_2)("mGhostWeightRequirement",param_1 + 0xc98,0xc97,0x5a,param_3);
  (*(code *)param_2)("mGhostCollectableState",param_1 + 0xcf2,0xcf1,0x2d,param_3);
  (*(code *)param_2)("mNumGhostCollected",param_1 + 0xd1f,0xd1e,0x2d,param_3);
  (*(code *)param_2)("mMaxGhostWeight",param_1 + 0xd4c,0xd4b,0x5a,param_3);
  (*(code *)param_2)("mGhostNotifyState",param_1 + 0xda6,0xda5,0x2d,param_3);
  (*(code *)param_2)("mGhostNotifyBecauseHigherWeight",param_1 + 0xdd3,0xdd2,0x2d,param_3);
  (*(code *)param_2)("mGemCollected",param_1 + 0xe00,0xdff,0x4e,param_3);
  (*(code *)param_2)("mGemNotifyState",param_1 + 0xe4e,0xe4d,0x4e,param_3);
  (*(code *)param_2)("mHasPoltergust",param_1 + 0xe9c,0xe9b,1,param_3);
  (*(code *)param_2)("mSeenInitialDSHorrorAnim",param_1 + 0xe9d,0xe9c,1,param_3);
  (*(code *)param_2)("mHasMarioBeenRevealedInTheStory",param_1 + 0xe9e,0xe9d,1,param_3);
  (*(code *)param_2)("mLastMansionPlayed",param_1 + 0xe9f,0xe9e,1,param_3);
  (*(code *)param_2)("mTotalTreasureAcquired",param_1 + 0xea0,0xe9f,4,param_3);
  (*(code *)param_2)("mTreasureToNotifyDuringUnloading",param_1 + 0xea4,0xea3,4,param_3);
  (*(code *)param_2)("mTotalGhostWeightAcquired",param_1 + 0xea8,0xea7,4,param_3);
  (*(code *)param_2)("mDarklightUpgradeLevel",param_1 + 0xeac,0xeab,1,param_3);
  (*(code *)param_2)("mDarklightUpgradeNotifyState",param_1 + 0xead,0xeac,1,param_3);
  (*(code *)param_2)("mPoltergustUpgradeLevel",param_1 + 0xeae,0xead,1,param_3);
  (*(code *)param_2)("mPoltergustUpgradeNotifyState",param_1 + 0xeaf,0xeae,1,param_3);
  (*(code *)param_2)("mHasSuperPoltergust",param_1 + 0xeb0,0xeaf,1,param_3);
  (*(code *)param_2)("mSuperPoltergustNotifyState",param_1 + 0xeb1,0xeb0,1,param_3);
  (*(code *)param_2)("mHasSeenReviveBonePIP",param_1 + 0xeb2,0xeb1,1,param_3);
  (*(code *)param_2)("mBestTerrorTowerClearTime",param_1 + 0xeb4,0xeb2,0x60,param_3);
  (*(code *)param_2)("mEndlessModeHighestFloorReached",param_1 + 0xf14,0xf12,4,param_3);
  (*(code *)param_2)("mAnyModeHighestFloorReached",param_1 + 0xf18,0xf16,1,param_3);
  (*(code *)param_2)("mEndlessFloorsUnlocked",param_1 + 0xf19,0xf17,4,param_3);
  (*(code *)param_2)("mRandomTowerUnlocked",param_1 + 0xf1d,0xf1b,1,param_3);
  (*(code *)param_2)("mTerrorTowerNotifyState",param_1 + 0xf1e,0xf1c,1,param_3);
  bVar1 = param_4 < 0;
  bVar2 = param_4 == 0;
  if (0 < param_4) {
    bVar1 = 0xf1d - param_4 < 0;
    bVar2 = param_4 == 0xf1d;
  }
  if (!bVar2 && bVar1 == (0 < param_4 && SBORROW4(0xf1d,param_4))) {
    software_bkpt(0xa6);
    return;
  }
  return;
}

// Switch - .text:001140c0h
uint FUN_601140c0(uint param_1)
{
  undefined auStack_f48 [3884];
  uint local_1c;
  
  local_1c = 0;
  FUN_601133dc((int)auStack_f48);
  if (-1 < (int)param_1) {
    FUN_60600d0c(&local_1c,(uint *)"mDataCRC",8);
    FUN_60600cb4(&local_1c,4);
    FUN_60600d0c(&local_1c,(uint *)"mVersionCRC",0xb);
    FUN_60600cb4(&local_1c,4);
    if (param_1 != 0) {
      FUN_60600d0c(&local_1c,(uint *)"mSaveGameVersionNumber",0x16);
      FUN_60600cb4(&local_1c,4);
    }
    FUN_60600d0c(&local_1c,(uint *)"mDiscoveredNIS",0xe);
    FUN_60600cb4(&local_1c,0x800);
    FUN_60600d0c(&local_1c,(uint *)"mMansionMissionCompletion",0x19);
    FUN_60600cb4(&local_1c,0x3c);
    FUN_60600d0c(&local_1c,(uint *)"mMansionMissionLocked",0x15);
    FUN_60600cb4(&local_1c,0x3c);
    FUN_60600d0c(&local_1c,(uint *)"mMansionMissionGrade",0x14);
    FUN_60600cb4(&local_1c,0x3c);
    FUN_60600d0c(&local_1c,(uint *)"mMansionMissionPrevGrade",0x18);
    FUN_60600cb4(&local_1c,0x3c);
    FUN_60600d0c(&local_1c,(uint *)"mMansionMissionBooCaptured",0x1a);
    FUN_60600cb4(&local_1c,0x3c);
    FUN_60600d0c(&local_1c,(uint *)"mMansionMissionBooNotifyState",0x1d);
    FUN_60600cb4(&local_1c,0x3c);
    FUN_60600d0c(&local_1c,(uint *)"mMansionMissionNotifyState",0x1a);
    FUN_60600cb4(&local_1c,0x3c);
    FUN_60600d0c(&local_1c,(uint *)"mMansionMissionClearTime",0x18);
    FUN_60600cb4(&local_1c,0xf0);
    FUN_60600d0c(&local_1c,(uint *)"mMansionMissionGhostsCaptured",0x1d);
    FUN_60600cb4(&local_1c,0x78);
    FUN_60600d0c(&local_1c,(uint *)"mMansionMissionDamageTaken",0x1a);
    FUN_60600cb4(&local_1c,0x78);
    FUN_60600d0c(&local_1c,(uint *)"mMansionMissionTreasureCollected",0x20);
    FUN_60600cb4(&local_1c,0x78);
    FUN_60600d0c(&local_1c,(uint *)"mNumBasicGhostCollected",0x17);
    FUN_60600cb4(&local_1c,0x1d);
    FUN_60600d0c(&local_1c,(uint *)"mMaxBasicGhostWeight",0x14);
    FUN_60600cb4(&local_1c,0x3a);
    FUN_60600d0c(&local_1c,(uint *)"mBasicGhostNotifyState",0x16);
    FUN_60600cb4(&local_1c,0x1d);
    FUN_60600d0c(&local_1c,(uint *)"mBasicGhostNotifyBecauseHigherWeight",0x24);
    FUN_60600cb4(&local_1c,0x1d);
    FUN_60600d0c(&local_1c,(uint *)"mAnyOptionalBooCaptured",0x17);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mJustCollectedChaser",0x14);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mGhostWeightRequirement",0x17);
    FUN_60600cb4(&local_1c,0x5a);
    FUN_60600d0c(&local_1c,(uint *)"mGhostCollectableState",0x16);
    FUN_60600cb4(&local_1c,0x2d);
    FUN_60600d0c(&local_1c,(uint *)"mNumGhostCollected",0x12);
    FUN_60600cb4(&local_1c,0x2d);
    FUN_60600d0c(&local_1c,(uint *)"mMaxGhostWeight",0xf);
    FUN_60600cb4(&local_1c,0x5a);
    FUN_60600d0c(&local_1c,(uint *)"mGhostNotifyState",0x11);
    FUN_60600cb4(&local_1c,0x2d);
    FUN_60600d0c(&local_1c,(uint *)"mGhostNotifyBecauseHigherWeight",0x1f);
    FUN_60600cb4(&local_1c,0x2d);
    FUN_60600d0c(&local_1c,(uint *)"mGemCollected",0xd);
    FUN_60600cb4(&local_1c,0x4e);
    FUN_60600d0c(&local_1c,(uint *)"mGemNotifyState",0xf);
    FUN_60600cb4(&local_1c,0x4e);
    FUN_60600d0c(&local_1c,(uint *)"mHasPoltergust",0xe);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mSeenInitialDSHorrorAnim",0x18);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mHasMarioBeenRevealedInTheStory",0x1f);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mLastMansionPlayed",0x12);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mTotalTreasureAcquired",0x16);
    FUN_60600cb4(&local_1c,4);
    FUN_60600d0c(&local_1c,(uint *)"mTreasureToNotifyDuringUnloading",0x20);
    FUN_60600cb4(&local_1c,4);
    FUN_60600d0c(&local_1c,(uint *)"mTotalGhostWeightAcquired",0x19);
    FUN_60600cb4(&local_1c,4);
    FUN_60600d0c(&local_1c,(uint *)"mDarklightUpgradeLevel",0x16);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mDarklightUpgradeNotifyState",0x1c);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mPoltergustUpgradeLevel",0x17);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mPoltergustUpgradeNotifyState",0x1d);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mHasSuperPoltergust",0x13);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mSuperPoltergustNotifyState",0x1b);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mHasSeenReviveBonePIP",0x15);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mBestTerrorTowerClearTime",0x19);
    FUN_60600cb4(&local_1c,0x60);
    FUN_60600d0c(&local_1c,(uint *)"mEndlessModeHighestFloorReached",0x1f);
    FUN_60600cb4(&local_1c,4);
    FUN_60600d0c(&local_1c,(uint *)"mAnyModeHighestFloorReached",0x1b);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mEndlessFloorsUnlocked",0x16);
    FUN_60600cb4(&local_1c,4);
    FUN_60600d0c(&local_1c,(uint *)"mRandomTowerUnlocked",0x14);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mTerrorTowerNotifyState",0x17);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mRumbleEnabled",0xe);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mGyroscopeEnabled",0x11);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mRStickOmnidirectional",0x16);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mXAxisInverted",0xe);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mYAxisInverted",0xe);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mGyroscopeSensitivity",0x15);
    FUN_60600cb4(&local_1c,1);
    FUN_60600d0c(&local_1c,(uint *)"mFirstPersonStickSensitivity",0x1c);
    FUN_60600cb4(&local_1c,1);
    if (1 < param_1) {
      FUN_60600d0c(&local_1c,(uint *)"mLevelBrightness",0x10);
      FUN_60600cb4(&local_1c,1);
      if (param_1 != 2) {
        FUN_60600d0c(&local_1c,(uint *)"mHideMinimap",0xc);
        FUN_60600cb4(&local_1c,1);
      }
    }
  }
  FUN_60600cb4(&local_1c,2);
  return local_1c;
}

I'm not 100% sure on what these functions are actually for, though they appear to be a part of invoking many things related to save data, from clearing and copying to allocated memory, to calculating checksums. I'm also not sure why the names of the fields themselves were included, though I am glad they were as it saved a lot of work figuring out the boundaries of data in the save file.

Mission Indices

The following is a list of every mission in the game along with it's associated index in the mission related save file arrays. Each of these arrays is 60 elements long (10 elements for each mansion, including King Boo's Illusion), despite there only being 34 missions in the game and each mansion having at most 7 missions. Modifying the values of unused missions in any of the fields doesn't do anything.

Gloomy Manor

  • A-1: 0
  • A-2: 1
  • A-3: 2
  • A-4: 3
  • A-5: 4
  • A-Boss: 5
  • A-Bonus: 7

Haunted Towers

  • B-1: 10
  • B-2: 11
  • B-3: 12
  • B-4: 13
  • B-5: 14
  • B-Boss: 15
  • B-Bonus: 18

Old Clockworks

  • C-1: 20
  • C-2: 21
  • C-3: 22
  • C-4: 23
  • C-5: 24
  • C-Boss: 25
  • C-Bonus: 27

Secret Mine

  • D-1: 30
  • D-2: 31
  • D-3: 32
  • D-Boss: 35
  • D-Bonus: 38

Treacherous Mansion

  • E-1: 40
  • E-2: 41
  • E-3: 42
  • E-4: 43
  • E-5: 44
  • E-Boss: 46
  • E-Bonus: 49

King Boo's Illusion

  • King Boo: 50

Ghost Indices

The following are lists of every ghost in the game along with their associated index in the ghost save file arrays. In the save file field names, "basic ghosts" refer to the Evershade Valley ghosts, and "ghosts" (with no adjective) refer to the tower ghosts. A few indices are left unused, however changing the values in these locations doesn't do anything. These values are still 0-indexed, the 0 index is just left unused.

Basic (Evershade Valley) Ghosts

  • Greenie: 12
  • Slammer: 1
  • Hider: 9
  • Sneaker: 24
  • Creeper: 6

  • Sister Melinda: 26
  • Sister Belinda: 27
  • Sister Herlinda: 28
  • Gobber: 7
  • Boffin: 15

  • Strong Greenie: 13
  • Strong Slammer: 2
  • Strong Hider: 10
  • Strong Sneaker: 25
  • Strong Gobber: 8

  • Grouchy Possessor: 19
  • Harsh Possessor: 20
  • Overset Possessor: 21
  • Scornful Possessor: 22
  • Tough Possessor: 23

  • Boffin Elder: 16
  • Strong Boffin: 17
  • Gold Greenie: 14

  • Polterpup (story): 3

The following ghosts only appear in the tower and are in the tower section of the vault's ghost container, however information pertaining to them is stored in the Evershade Valley ghost arrays

  • Polterpups (tower): 4
  • Big Polterpups: 5
  • The Brain: 18

(Tower) Ghosts

  • Bomb Brothers: 40
  • Scarab Nabber: 41
  • Terrible Teleporter: 42
  • Primordial Goo: 43
  • Creeper Creator: 44

  • Fright Knight: 0
  • Snug Thug: 1
  • Sleek Sneaker: 2
  • Tether Jacket: 3
  • Spectral Sloth: 4

  • Blue Pimpernel: 5
  • Sunflower: 6
  • Pink Zinnia: 7
  • Violet: 8
  • Daisy: 9

  • Melon-choly: 10
  • Aweberry: 11
  • Scorn: 12
  • Fright Egg: 13
  • Terrorange: 14

  • Spooky Spook: 15
  • Scars: 16
  • Skoul: 17
  • Jack-goo'-lantern: 18
  • Blimp Reaper: 19

  • Dreadonfly: 20
  • Shadybird: 21
  • Terrorfly: 22
  • Blobberfly: 23
  • Grumble Bee: 24

  • Horrorca: 25
  • Clown Fishy: 26
  • Shriek Shark: 27
  • Pondguin: 28
  • Snapper: 29

  • Bad-minton: 30
  • American Footbrawl: 31
  • Tennis Menace: 32
  • Goolf: 33
  • Ball Hog: 34

  • Maligator: 35
  • Banegal: 36
  • Zebrawl: 37
  • Leoprank: 38
  • Full Moo: 39

Gem Indices

The gem related save file arrays store every mansions' 13 gems in the same order that the mansions appear in-game. Within each mansion, however, the gems are stored in a different order to how they appear in the vault.

In the order that they appear in the vault, left-to-right, top-to-bottom, the indices of each gem is in the following, consistent order: 7, 11, 0, 3, 2, 6, 10, 8, 4, 1, 9, 12, 5.

Here is an image showing this order: Numbered order of gems in the vault

As each mansion's gems are stored one after the other with no space in-between, simply add 13 for each mansion you wish to go ahead in the array, for example +0 for Gloomy Manor, +13 for Haunted Towers, +26 for Old Clockworks, etc.

Note that King Boo's Illusion is also given 13 spaces (the same as the other mansions) in the arrays despite not having any gems. Modifying any of these values does nothing.

Gem Indices for Each Mansion

This is a list of each mansions gem indices with the relevant offset already added should you need it:

  • Gloomy Manor: 7, 11, 0, 3, 2, 6, 10, 8, 4, 1, 9, 12, 5
  • Haunted Towers: 20, 24, 13, 16, 15, 19, 23, 21, 17, 14, 22, 25, 18
  • Old Clockworks: 33, 37, 26, 29, 28, 32, 36, 34, 30, 27, 35, 38, 31
  • Secret Mine: 46, 50, 39, 42, 41, 45, 49, 47, 43, 40, 48, 51, 44
  • Treacherous Mansion: 59, 63, 52, 55, 54, 58, 62, 60, 56, 53, 61, 64, 57
  • King Boo's Illusion (Unused): 72, 76, 65, 68, 67, 71, 75, 73, 69, 66, 74, 77, 70

Tower Mode Indices

This is a list of all the possible tower configurations and their respective index in the best tower clear time array.

  • Hunter/5/Normal: 0
  • Hunter/5/Hard: 1
  • Hunter/5/Expert: 2

  • Hunter/10/Normal: 3
  • Hunter/10/Hard: 4
  • Hunter/10/Expert: 5

  • Hunter/25/Normal: 6
  • Hunter/25/Hard: 7
  • Hunter/25/Expert: 8

  • Hunter/Endless/Normal (Unused): 9
  • Hunter/Endless/Hard (Unused): 10
  • Hunter/Endless/Expert (Unused): 11

  • Rush/5/Normal: 12
  • Rush/5/Hard: 13
  • Rush/5/Expert: 14

  • Rush/10/Normal: 15
  • Rush/10/Hard: 16
  • Rush/10/Expert: 17

  • Rush/25/Normal: 18
  • Rush/25/Hard: 19
  • Rush/25/Expert: 20

  • Rush/Endless/Normal (Unused): 21
  • Rush/Endless/Hard (Unused): 22
  • Rush/Endless/Expert (Unused): 23

  • Polterpup/5/Normal: 24
  • Polterpup/5/Hard: 25
  • Polterpup/5/Expert: 26

  • Polterpup/10/Normal: 27
  • Polterpup/10/Hard: 28
  • Polterpup/10/Expert: 29

  • Polterpup/25/Normal: 30
  • Polterpup/25/Hard: 31
  • Polterpup/25/Expert: 32

  • Polterpup/Endless/Normal (Unused): 33
  • Polterpup/Endless/Hard (Unused): 34
  • Polterpup/Endless/Expert (Unused): 35

  • Surprise/5/Normal: 36
  • Surprise/5/Hard: 37
  • Surprise/5/Expert: 38

  • Surprise/10/Normal: 39
  • Surprise/10/Hard: 40
  • Surprise/10/Expert: 41

  • Surprise/25/Normal: 42
  • Surprise/25/Hard: 43
  • Surprise/25/Expert: 44

  • Surprise/Endless/Normal (Unused): 45
  • Surprise/Endless/Hard (Unused): 46
  • Surprise/Endless/Expert (Unused): 47

CRC Checksums

The save file contains four checksums, two for each section. Each checksum is 32-bits (4 bytes), and like every other numerical value, is stored in little endian order.

Each section has one "Data CRC" and one "Version CRC". The Version CRC is constant across all save files: 0x7B, 0x0C, 0x27, 0x49 for the title screen section on both versions, 0xAD, 0x03, 0x32, 0xD4 for the in-game section on the 3DS version, and 0xA5, 0xEB, 0xAA, 0x3D for the in-game section on the Switch version. The Data CRC is more interesting and is the part of the reverse engineering that by far took the longest to figure out. It is computed using all of the data stored within the relevant section of the save file (including the Version CRCs) and is used to detect corruption/modification.

If any of these four checksums fail to match the expected values, the game will refuse to load the save file. If it is the title screen section that is corrupt, then the profile will be labelled "Corrupted" on the title screen before even selecting it. If it is the in-game section that is corrupted, the message saying as such will not be displayed until you attempt to load the corrupted save file.

The game does not fully compute the Data CRC on-the-fly, instead it utilises a 1024-byte long lookup table stored within the game executable in order to calculate the checksum of the save file.

The game's CRC generation algorithm looks something like this (decompiled C code generated by Ghidra, generic names replaced with more descriptive ones by myself):

// 3DS - .text:002b4b98h (file offset: 0x002c4b98)
void GenerateCRC(uint *destination, uint *source, uint byteCount)
{
  uint toProcess;
  uint crcPart;
  
  for (; ((uint)source & 3) != 0; source = (uint *)((int)source + 1)) {
    if (byteCount == 0) goto no_more_bytes;
    byteCount = byteCount - 1;
    *destination = *(uint *)(&CRCTable + ((uint)*(byte *)source ^ *destination & 0xff) * 4) ^
                   *destination >> 8;
  }
  for (; 3 < byteCount; byteCount = byteCount - 4) {
    toProcess = *source;
    crcPart = *(uint *)(&CRCTable + (toProcess & 0xff ^ *destination & 0xff) * 4) ^
              *destination >> 8;
    *destination = crcPart;
    crcPart = *(uint *)(&CRCTable + (toProcess >> 8 & 0xff ^ crcPart & 0xff) * 4) ^ crcPart >> 8;
    *destination = crcPart;
    crcPart = *(uint *)(&CRCTable + (toProcess >> 0x10 & 0xff ^ crcPart & 0xff) * 4) ^ crcPart >> 8;
    *destination = crcPart;
    *destination = *(uint *)(&CRCTable + (toProcess >> 0x18 ^ crcPart & 0xff) * 4) ^ crcPart >> 8;
    source = source + 1;
  }
no_more_bytes:
  for (; byteCount != 0; byteCount = byteCount - 1) {
    *destination = *(uint *)(&CRCTable + ((uint)*(byte *)source ^ *destination & 0xff) * 4) ^
                   *destination >> 8;
    source = (uint *)((int)source + 1);
  }
  return;
}

However this algorithm is longer than it needs to be, and the entire algorithm can be shortened whilst retaining identical behaviour. This is my re-implementation in C#:

public static uint CalculateChecksum(Span<byte> source, uint initial = 0)
{
    uint crc = initial;
    foreach (byte toProcess in source)
    {
        int lookupIndex = (int)((toProcess ^ (crc & 0xff)) * 4);
        crc = LookupCRCTable(lookupIndex) ^ (crc >> 8);
    }
    return crc;
}

private static uint LookupCRCTable(int lookupIndex)
{
    return BinaryPrimitives.ReadUInt32LittleEndian(crcTable.AsSpan()[lookupIndex..(lookupIndex + 4)]);
}

(The first two for loops have been completely omitted, as simply running the final one alone will produce the same result).

The full lookup table can be found in SaveTools/CRC.cs if you're curious, though it is just one long list of bytes. If you want to find it in an original dumped binary, it can be found at .data:00022904h (file offset 0x0082b904) for the 3DS, or at .rodata.1:804b7h (file offset 0x00f4a8c0) for the Switch.

The CRC algorithm and lookup table are the same in both the 3DS and Switch versions, though the generated checksums will be different due to the Switch version having additional fields in the save file.