summaryrefslogtreecommitdiff
path: root/src/com/pkrandom/ctr/NCCH.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/pkrandom/ctr/NCCH.java')
-rw-r--r--src/com/pkrandom/ctr/NCCH.java1024
1 files changed, 1024 insertions, 0 deletions
diff --git a/src/com/pkrandom/ctr/NCCH.java b/src/com/pkrandom/ctr/NCCH.java
new file mode 100644
index 0000000..9a326a5
--- /dev/null
+++ b/src/com/pkrandom/ctr/NCCH.java
@@ -0,0 +1,1024 @@
+package com.pkrandom.ctr;
+
+/*----------------------------------------------------------------------------*/
+/*-- NCCH.java - a base class for dealing with 3DS NCCH ROM images. --*/
+/*-- --*/
+/*-- Part of "Universal Pokemon Randomizer ZX" by the UPR-ZX team --*/
+/*-- Pokemon and any associated names and the like are --*/
+/*-- trademark and (C) Nintendo 1996-2020. --*/
+/*-- --*/
+/*-- This program is free software: you can redistribute it and/or modify --*/
+/*-- it under the terms of the GNU General Public License as published by --*/
+/*-- the Free Software Foundation, either version 3 of the License, or --*/
+/*-- (at your option) any later version. --*/
+/*-- --*/
+/*-- This program is distributed in the hope that it will be useful, --*/
+/*-- but WITHOUT ANY WARRANTY; without even the implied warranty of --*/
+/*-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the --*/
+/*-- GNU General Public License for more details. --*/
+/*-- --*/
+/*-- You should have received a copy of the GNU General Public License --*/
+/*-- along with this program. If not, see <http://www.gnu.org/licenses/>. --*/
+/*----------------------------------------------------------------------------*/
+
+import com.pkrandom.FileFunctions;
+import com.pkrandom.SysConstants;
+import com.pkrandom.exceptions.CannotWriteToLocationException;
+import com.pkrandom.exceptions.EncryptedROMException;
+import com.pkrandom.exceptions.RandomizerIOException;
+import cuecompressors.BLZCoder;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.*;
+import java.util.*;
+
+public class NCCH {
+ private String romFilename;
+ private RandomAccessFile baseRom;
+ private long ncchStartingOffset;
+ private String productCode;
+ private String titleId;
+ private int version;
+ private long exefsOffset, romfsOffset, fileDataOffset;
+ private ExefsFileHeader codeFileHeader;
+ private SMDH smdh;
+ private List<ExefsFileHeader> extraExefsFiles;
+ private List<FileMetadata> fileMetadataList;
+ private Map<String, RomfsFile> romfsFiles;
+ private boolean romOpen;
+ private String tmpFolder;
+ private boolean writingEnabled;
+ private boolean codeCompressed, codeOpen, codeChanged;
+ private byte[] codeRamstored;
+
+ // Public so the base game can read it from the game update NCCH
+ public long originalCodeCRC, originalRomfsHeaderCRC;
+
+ private static final int media_unit_size = 0x200;
+ private static final int header_and_exheader_size = 0xA00;
+ private static final int ncsd_magic = 0x4E435344;
+ private static final int cia_header_size = 0x2020;
+ private static final int ncch_magic = 0x4E434348;
+ private static final int ncch_and_ncsd_magic_offset = 0x100;
+ private static final int exefs_header_size = 0x200;
+ private static final int romfs_header_size = 0x5C;
+ private static final int romfs_magic_1 = 0x49564643;
+ private static final int romfs_magic_2 = 0x00000100;
+ private static final int level3_header_size = 0x28;
+ private static final int metadata_unused = 0xFFFFFFFF;
+
+ public NCCH(String filename, String productCode, String titleId) throws IOException {
+ this.romFilename = filename;
+ this.baseRom = new RandomAccessFile(filename, "r");
+ this.ncchStartingOffset = NCCH.getCXIOffsetInFile(filename);
+ this.productCode = productCode;
+ this.titleId = titleId;
+ this.romOpen = true;
+
+ if (this.ncchStartingOffset != -1) {
+ this.version = this.readVersionFromFile();
+ }
+
+ // TMP folder?
+ String rawFilename = new File(filename).getName();
+ String dataFolder = "tmp_" + rawFilename.substring(0, rawFilename.lastIndexOf('.'));
+ // remove nonsensical chars
+ dataFolder = dataFolder.replaceAll("[^A-Za-z0-9_]+", "");
+ File tmpFolder = new File(SysConstants.ROOT_PATH + dataFolder);
+ tmpFolder.mkdirs();
+ if (tmpFolder.canWrite()) {
+ writingEnabled = true;
+ this.tmpFolder = SysConstants.ROOT_PATH + dataFolder + File.separator;
+ tmpFolder.deleteOnExit();
+ } else {
+ writingEnabled = false;
+ }
+
+ // The below code handles things "wrong" with regards to encrypted ROMs. We just
+ // blindly treat the ROM as decrypted and try to parse all of its data, when we
+ // *should* be looking at the header of the ROM to determine if the ROM is encrypted.
+ // Unfortunately, many people have poorly-decrypted ROMs that do not properly set
+ // the bytes on the NCCH header, so we can't assume that the header is telling the
+ // truth. If we read the whole ROM without crashing, then it's probably decrypted.
+ try {
+ readFileSystem();
+ } catch (Exception ex) {
+ if (!this.isDecrypted()) {
+ throw new EncryptedROMException(ex);
+ } else {
+ throw ex;
+ }
+ }
+ }
+
+ public void reopenROM() throws IOException {
+ if (!this.romOpen) {
+ baseRom = new RandomAccessFile(this.romFilename, "r");
+ romOpen = true;
+ }
+ }
+
+ public void closeROM() throws IOException {
+ if (this.romOpen && baseRom != null) {
+ baseRom.close();
+ baseRom = null;
+ romOpen = false;
+ }
+ }
+
+ private void readFileSystem() throws IOException {
+ exefsOffset = ncchStartingOffset + FileFunctions.readIntFromFile(baseRom, ncchStartingOffset + 0x1A0) * media_unit_size;
+ romfsOffset = ncchStartingOffset + FileFunctions.readIntFromFile(baseRom, ncchStartingOffset + 0x1B0) * media_unit_size;
+ baseRom.seek(ncchStartingOffset + 0x20D);
+ byte systemControlInfoFlags = baseRom.readByte();
+ codeCompressed = (systemControlInfoFlags & 0x01) != 0;
+ readExefs();
+ readRomfs();
+ }
+
+ private void readExefs() throws IOException {
+ System.out.println("NCCH: Reading exefs...");
+ byte[] exefsHeaderData = new byte[exefs_header_size];
+ baseRom.seek(exefsOffset);
+ baseRom.readFully(exefsHeaderData);
+
+ ExefsFileHeader[] fileHeaders = new ExefsFileHeader[10];
+ for (int i = 0; i < 10; i++) {
+ fileHeaders[i] = new ExefsFileHeader(exefsHeaderData, i * 0x10);
+ }
+
+ extraExefsFiles = new ArrayList<>();
+ for (ExefsFileHeader fileHeader : fileHeaders) {
+ if (fileHeader.isValid() && fileHeader.filename.equals(".code")) {
+ codeFileHeader = fileHeader;
+ } else if (fileHeader.isValid()) {
+ extraExefsFiles.add(fileHeader);
+ }
+
+ if (fileHeader.isValid() && fileHeader.filename.equals("icon")) {
+ byte[] smdhBytes = new byte[fileHeader.size];
+ baseRom.seek(exefsOffset + 0x200 + fileHeader.offset);
+ baseRom.readFully(smdhBytes);
+ smdh = new SMDH(smdhBytes);
+ }
+ }
+ System.out.println("NCCH: Done reading exefs");
+ }
+
+ private void readRomfs() throws IOException {
+ System.out.println("NCCH: Reading romfs...");
+ byte[] romfsHeaderData = new byte[romfs_header_size];
+ baseRom.seek(romfsOffset);
+ baseRom.readFully(romfsHeaderData);
+ originalRomfsHeaderCRC = FileFunctions.getCRC32(romfsHeaderData);
+ int magic1 = FileFunctions.readFullIntBigEndian(romfsHeaderData, 0x00);
+ int magic2 = FileFunctions.readFullIntBigEndian(romfsHeaderData, 0x04);
+ if (magic1 != romfs_magic_1 || magic2 != romfs_magic_2) {
+ System.err.println("NCCH: romfs does not contain magic values");
+ // Not a valid romfs
+ return;
+ }
+ int masterHashSize = FileFunctions.readFullInt(romfsHeaderData, 0x08);
+ int level3HashBlockSize = 1 << FileFunctions.readFullInt(romfsHeaderData, 0x4C);
+ long level3Offset = romfsOffset + alignLong(0x60 + masterHashSize, level3HashBlockSize);
+
+ byte[] level3HeaderData = new byte[level3_header_size];
+ baseRom.seek(level3Offset);
+ baseRom.readFully(level3HeaderData);
+ int headerLength = FileFunctions.readFullInt(level3HeaderData, 0x00);
+ if (headerLength != level3_header_size) {
+ // Not a valid romfs
+ System.err.println("NCCH: romfs does not have a proper level 3 header");
+ return;
+ }
+ int directoryMetadataOffset = FileFunctions.readFullInt(level3HeaderData, 0x0C);
+ int directoryMetadataLength = FileFunctions.readFullInt(level3HeaderData, 0x10);
+ int fileMetadataOffset = FileFunctions.readFullInt(level3HeaderData, 0x1c);
+ int fileMetadataLength = FileFunctions.readFullInt(level3HeaderData, 0x20);
+ int fileDataOffsetFromHeaderStart = FileFunctions.readFullInt(level3HeaderData, 0x24);
+ fileDataOffset = level3Offset + fileDataOffsetFromHeaderStart;
+
+ byte[] directoryMetadataBlock = new byte[directoryMetadataLength];
+ baseRom.seek(level3Offset + directoryMetadataOffset);
+ baseRom.readFully(directoryMetadataBlock);
+ byte[] fileMetadataBlock = new byte[fileMetadataLength];
+ baseRom.seek(level3Offset + fileMetadataOffset);
+ baseRom.readFully(fileMetadataBlock);
+ fileMetadataList = new ArrayList<>();
+ romfsFiles = new TreeMap<>();
+ visitDirectory(0, "", directoryMetadataBlock, fileMetadataBlock);
+ System.out.println("NCCH: Done reading romfs");
+ }
+
+ private void visitDirectory(int offset, String rootPath, byte[] directoryMetadataBlock, byte[] fileMetadataBlock) {
+ DirectoryMetadata metadata = new DirectoryMetadata(directoryMetadataBlock, offset);
+ String currentPath = rootPath;
+ if (!metadata.name.equals("")) {
+ currentPath = rootPath + metadata.name + "/";
+ }
+
+ if (metadata.firstFileOffset != metadata_unused) {
+ visitFile(metadata.firstFileOffset, currentPath, fileMetadataBlock);
+ }
+ if (metadata.firstChildDirectoryOffset != metadata_unused) {
+ visitDirectory(metadata.firstChildDirectoryOffset, currentPath, directoryMetadataBlock, fileMetadataBlock);
+ }
+ if (metadata.siblingDirectoryOffset != metadata_unused) {
+ visitDirectory(metadata.siblingDirectoryOffset, rootPath, directoryMetadataBlock, fileMetadataBlock);
+ }
+ }
+
+ private void visitFile(int offset, String rootPath, byte[] fileMetadataBlock) {
+ FileMetadata metadata = new FileMetadata(fileMetadataBlock, offset);
+ String currentPath = rootPath + metadata.name;
+ System.out.println("NCCH: Visiting file " + currentPath);
+ RomfsFile file = new RomfsFile(this);
+ file.offset = fileDataOffset + metadata.fileDataOffset;
+ file.size = (int) metadata.fileDataLength; // no Pokemon game has a file larger than unsigned int max
+ file.fullPath = currentPath;
+ metadata.file = file;
+ fileMetadataList.add(metadata);
+ romfsFiles.put(currentPath, file);
+ if (metadata.siblingFileOffset != metadata_unused) {
+ visitFile(metadata.siblingFileOffset, rootPath, fileMetadataBlock);
+ }
+ }
+
+ public void saveAsNCCH(String filename, String gameAcronym, long seed) throws IOException, NoSuchAlgorithmException {
+ this.reopenROM();
+
+ // Initialize new ROM
+ RandomAccessFile fNew = new RandomAccessFile(filename, "rw");
+
+ // Read the header and exheader and write it to the output ROM
+ byte[] header = new byte[header_and_exheader_size];
+ baseRom.seek(ncchStartingOffset);
+ baseRom.readFully(header);
+ fNew.write(header);
+
+ // Just in case they were set wrong in the original header, let's correctly set the
+ // bytes in the header to indicate the output ROM is decrypted
+ byte[] flags = new byte[8];
+ baseRom.seek(ncchStartingOffset + 0x188);
+ baseRom.readFully(flags);
+ flags[3] = 0;
+ flags[7] = 4;
+ fNew.seek(0x188);
+ fNew.write(flags);
+
+ // The logo is small enough (8KB) to just read the whole thing into memory. Write it to the new ROM directly
+ // after the header, then update the new ROM's logo offset
+ long logoOffset = ncchStartingOffset + FileFunctions.readIntFromFile(baseRom, ncchStartingOffset + 0x198) * media_unit_size;
+ long logoLength = FileFunctions.readIntFromFile(baseRom, ncchStartingOffset + 0x19C) * media_unit_size;
+ if (logoLength > 0) {
+ byte[] logo = new byte[(int) logoLength];
+ baseRom.seek(logoOffset);
+ baseRom.readFully(logo);
+ long newLogoOffset = header_and_exheader_size;
+ fNew.seek(newLogoOffset);
+ fNew.write(logo);
+ fNew.seek(0x198);
+ fNew.write((int) newLogoOffset / media_unit_size);
+ }
+
+ // The plain region is even smaller (1KB) so repeat the same process
+ long plainOffset = ncchStartingOffset + FileFunctions.readIntFromFile(baseRom, ncchStartingOffset + 0x190) * media_unit_size;
+ long plainLength = FileFunctions.readIntFromFile(baseRom, ncchStartingOffset + 0x194) * media_unit_size;
+ if (plainLength > 0) {
+ byte[] plain = new byte[(int) plainLength];
+ baseRom.seek(plainOffset);
+ baseRom.readFully(plain);
+ long newPlainOffset = header_and_exheader_size + logoLength;
+ fNew.seek(newPlainOffset);
+ fNew.write(plain);
+ fNew.seek(0x190);
+ fNew.write((int) newPlainOffset / media_unit_size);
+ }
+
+ // Update the SMDH so that Citra displays the seed in the title
+ smdh.setAllDescriptions(gameAcronym + " randomizer seed: " + seed);
+ smdh.setAllPublishers("Universal Pokemon Randomizer ZX");
+
+ // Now, reconstruct the exefs based on our new version of .code and our new SMDH
+ long newExefsOffset = header_and_exheader_size + logoLength + plainLength;
+ long newExefsLength = rebuildExefs(fNew, newExefsOffset);
+ fNew.seek(0x1A0);
+ fNew.write((int) newExefsOffset / media_unit_size);
+ fNew.seek(0x1A4);
+ fNew.write((int) newExefsLength / media_unit_size);
+
+ // Then, reconstruct the romfs
+ // TODO: Fix the yet-unsolved alignment issues in rebuildRomfs when you remove this align
+ long newRomfsOffset = alignLong(header_and_exheader_size + logoLength + plainLength + newExefsLength, 4096);
+ long newRomfsLength = rebuildRomfs(fNew, newRomfsOffset);
+ fNew.seek(0x1B0);
+ fNew.write((int) newRomfsOffset / media_unit_size);
+ fNew.seek(0x1B4);
+ fNew.write((int) newRomfsLength / media_unit_size);
+
+ // Lastly, reconstruct the superblock hashes
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ int exefsHashRegionSize = FileFunctions.readIntFromFile(baseRom, ncchStartingOffset + 0x1A8) * media_unit_size;
+ byte[] exefsDataToHash = new byte[exefsHashRegionSize];
+ fNew.seek(newExefsOffset);
+ fNew.readFully(exefsDataToHash);
+ byte[] exefsSuperblockHash = digest.digest(exefsDataToHash);
+ fNew.seek(0x1C0);
+ fNew.write(exefsSuperblockHash);
+ int romfsHashRegionSize = FileFunctions.readIntFromFile(baseRom, ncchStartingOffset + 0x1B8) * media_unit_size;
+ byte[] romfsDataToHash = new byte[romfsHashRegionSize];
+ fNew.seek(newRomfsOffset);
+ fNew.readFully(romfsDataToHash);
+ byte[] romfsSuperblockHash = digest.digest(romfsDataToHash);
+ fNew.seek(0x1E0);
+ fNew.write(romfsSuperblockHash);
+
+ // While totally optional, let's zero out the NCCH signature so that
+ // it's clear this isn't a properly-signed ROM
+ byte[] zeroedSignature = new byte[0x100];
+ fNew.seek(0x0);
+ fNew.write(zeroedSignature);
+ fNew.close();
+ }
+
+ private long rebuildExefs(RandomAccessFile fNew, long newExefsOffset) throws IOException, NoSuchAlgorithmException {
+ System.out.println("NCCH: Rebuilding exefs...");
+ byte[] code = getCode();
+ if (codeCompressed) {
+ code = new BLZCoder(null).BLZ_EncodePub(code, false, true, ".code");
+ }
+
+ // Create a new ExefsFileHeader for our updated .code
+ ExefsFileHeader newCodeHeader = new ExefsFileHeader();
+ newCodeHeader.filename = codeFileHeader.filename;
+ newCodeHeader.size = code.length;
+ newCodeHeader.offset = 0;
+
+ // For all the file headers, write them to the new ROM and store them in order for hashing later
+ ExefsFileHeader[] newHeaders = new ExefsFileHeader[10];
+ newHeaders[0] = newCodeHeader;
+ fNew.seek(newExefsOffset);
+ fNew.write(newCodeHeader.asBytes());
+ for (int i = 0; i < extraExefsFiles.size(); i++) {
+ ExefsFileHeader header = extraExefsFiles.get(i);
+ newHeaders[i + 1] = header;
+ fNew.write(header.asBytes());
+ }
+
+ // Write the file data, then hash the data and write the hashes in reverse order
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ long endingOffset = 0;
+ for (int i = 0; i < newHeaders.length; i++) {
+ ExefsFileHeader header = newHeaders[i];
+ if (header != null) {
+ byte[] data;
+ if (header.filename.equals(".code")) {
+ data = code;
+ } else if (header.filename.equals("icon")) {
+ data = smdh.getBytes();
+ } else {
+ long dataOffset = exefsOffset + 0x200 + header.offset;
+ data = new byte[header.size];
+ baseRom.seek(dataOffset);
+ baseRom.readFully(data);
+ }
+ fNew.seek(newExefsOffset + 0x200 + header.offset);
+ fNew.write(data);
+ byte[] hash = digest.digest(data);
+ fNew.seek(newExefsOffset + 0x200 - ((i + 1) * 0x20));
+ fNew.write(hash);
+ endingOffset = newExefsOffset + 0x200 + header.offset + header.size;
+ }
+ }
+
+ // Pad to media unit size
+ fNew.seek(endingOffset);
+ long exefsLength = endingOffset - newExefsOffset;
+ while (exefsLength % media_unit_size != 0) {
+ fNew.writeByte(0);
+ exefsLength++;
+ }
+
+ System.out.println("NCCH: Done rebuilding exefs");
+ return exefsLength;
+ }
+
+ private long rebuildRomfs(RandomAccessFile fNew, long newRomfsOffset) throws IOException, NoSuchAlgorithmException {
+ System.out.println("NCCH: Rebuilding romfs...");
+
+ // Start by copying the romfs header straight from the original ROM. We'll update the
+ // header as we continue to build the romfs
+ byte[] romfsHeaderData = new byte[romfs_header_size];
+ baseRom.seek(romfsOffset);
+ baseRom.readFully(romfsHeaderData);
+ fNew.seek(newRomfsOffset);
+ fNew.write(romfsHeaderData);
+
+ // Now find the level 3 (file data) offset, since the first thing we need to do is write the
+ // updated file data. We're assuming here that the master hash size is smaller than the level 3
+ // hash block size, which it almost certainly will because we're not adding large amounts of data
+ // to the romfs
+ int masterHashSize = FileFunctions.readFullInt(romfsHeaderData, 0x08);
+ int level3HashBlockSize = 1 << FileFunctions.readFullInt(romfsHeaderData, 0x4C);
+ long level3Offset = romfsOffset + alignLong(0x60 + masterHashSize, level3HashBlockSize);
+ long newLevel3Offset = newRomfsOffset + alignLong(0x60 + masterHashSize, level3HashBlockSize);
+
+ // Copy the level 3 header straight from the original ROM. Since we're not adding or
+ // removing any files, the File/Directory tables should have the same offsets and lengths
+ byte[] level3HeaderData = new byte[level3_header_size];
+ baseRom.seek(level3Offset);
+ baseRom.readFully(level3HeaderData);
+ fNew.seek(newLevel3Offset);
+ fNew.write(level3HeaderData);
+
+ // Write out both hash tables and the directory metadata table. Since we're not adding or removing
+ // any files/directories, we can just use what's in the base ROM for this.
+ int directoryHashTableOffset = FileFunctions.readFullInt(level3HeaderData, 0x04);
+ int directoryHashTableLength = FileFunctions.readFullInt(level3HeaderData, 0x08);
+ int directoryMetadataTableOffset = FileFunctions.readFullInt(level3HeaderData, 0x0C);
+ int directoryMetadataTableLength = FileFunctions.readFullInt(level3HeaderData, 0x10);
+ int fileHashTableOffset = FileFunctions.readFullInt(level3HeaderData, 0x14);
+ int fileHashTableLength = FileFunctions.readFullInt(level3HeaderData, 0x18);
+ byte[] directoryHashTable = new byte[directoryHashTableLength];
+ baseRom.seek(level3Offset + directoryHashTableOffset);
+ baseRom.readFully(directoryHashTable);
+ fNew.seek(newLevel3Offset + directoryHashTableOffset);
+ fNew.write(directoryHashTable);
+ byte[] directoryMetadataTable = new byte[directoryMetadataTableLength];
+ baseRom.seek(level3Offset + directoryMetadataTableOffset);
+ baseRom.readFully(directoryMetadataTable);
+ fNew.seek(newLevel3Offset + directoryMetadataTableOffset);
+ fNew.write(directoryMetadataTable);
+ byte[] fileHashTable = new byte[fileHashTableLength];
+ baseRom.seek(level3Offset + fileHashTableOffset);
+ baseRom.readFully(fileHashTable);
+ fNew.seek(newLevel3Offset + fileHashTableOffset);
+ fNew.write(fileHashTable);
+
+ // Now reconstruct the file metadata table. It may need to be changed if any file grew or shrunk
+ int fileMetadataTableOffset = FileFunctions.readFullInt(level3HeaderData, 0x1C);
+ int fileMetadataTableLength = FileFunctions.readFullInt(level3HeaderData, 0x20);
+ byte[] newFileMetadataTable = updateFileMetadataTable(fileMetadataTableLength);
+ fNew.seek(newLevel3Offset + fileMetadataTableOffset);
+ fNew.write(newFileMetadataTable);
+
+ // Using the new file metadata table, output the file data
+ int fileDataOffset = FileFunctions.readFullInt(level3HeaderData, 0x24);
+ long endOfFileDataOffset = 0;
+ for (FileMetadata metadata : fileMetadataList) {
+ System.out.println("NCCH: Writing file " + metadata.file.fullPath + " to romfs");
+ // Users have sent us bug reports with really bizarre errors here that seem to indicate
+ // broken metadata; do this in a try-catch solely so we can log the metadata if we fail
+ try {
+ byte[] fileData;
+ if (metadata.file.fileChanged) {
+ fileData = metadata.file.getOverrideContents();
+ } else {
+ fileData = new byte[metadata.file.size];
+ baseRom.seek(metadata.file.offset);
+ baseRom.readFully(fileData);
+ }
+ long currentDataOffset = newLevel3Offset + fileDataOffset + metadata.fileDataOffset;
+ fNew.seek(currentDataOffset);
+ fNew.write(fileData);
+ endOfFileDataOffset = currentDataOffset + fileData.length;
+ } catch (Exception e) {
+ String message = String.format("Error when building romfs: File: %s, offset: %s, size: %s",
+ metadata.file.fullPath, metadata.offset, metadata.file.size);
+ throw new RandomizerIOException(message, e);
+ }
+ }
+
+ // Now that level 3 (file data) is done, construct level 2 (hashes of file data)
+ // Note that in the ROM, level 1 comes *before* level 2, so we need to calculate
+ // level 1 length and offset as well.
+ long newLevel3EndingOffset = endOfFileDataOffset;
+ long newLevel3HashdataSize = newLevel3EndingOffset - newLevel3Offset;
+ long numberOfLevel3HashBlocks = alignLong(newLevel3HashdataSize, level3HashBlockSize) / level3HashBlockSize;
+ int level2HashBlockSize = 1 << FileFunctions.readFullInt(romfsHeaderData, 0x34);
+ long newLevel2HashdataSize = numberOfLevel3HashBlocks * 0x20;
+ long numberOfLevel2HashBlocks = alignLong(newLevel2HashdataSize, level2HashBlockSize) / level2HashBlockSize;
+ int level1HashBlockSize = 1 << FileFunctions.readFullInt(romfsHeaderData, 0x1C);
+ long newLevel1HashdataSize = numberOfLevel2HashBlocks * 0x20;
+ long newLevel1Offset = newLevel3Offset + alignLong(newLevel3HashdataSize, level3HashBlockSize);
+ long newLevel2Offset = newLevel1Offset + alignLong(newLevel1HashdataSize, level1HashBlockSize);
+ long newFileEndingOffset = alignLong(newLevel2Offset + newLevel2HashdataSize, level2HashBlockSize);
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] dataToHash = new byte[level3HashBlockSize];
+ for (long i = 0; i < numberOfLevel3HashBlocks; i++) {
+ fNew.seek(newLevel3Offset + (i * level3HashBlockSize));
+ fNew.readFully(dataToHash);
+ byte[] hash = digest.digest(dataToHash);
+ fNew.seek(newLevel2Offset + (i * 0x20));
+ fNew.write(hash);
+ }
+ while (fNew.getFilePointer() != newFileEndingOffset) {
+ fNew.writeByte(0);
+ }
+
+ // Now that level 2 (hashes of file data) is done, construct level 1 (hashes of
+ // hashes of file data) and the master hash/level 0 (hashes of level 1)
+ dataToHash = new byte[level2HashBlockSize];
+ for (long i = 0; i < numberOfLevel2HashBlocks; i++) {
+ fNew.seek(newLevel2Offset + (i * level2HashBlockSize));
+ fNew.readFully(dataToHash);
+ byte[] hash = digest.digest(dataToHash);
+ fNew.seek(newLevel1Offset + (i * 0x20));
+ fNew.write(hash);
+ }
+ long numberOfLevel1HashBlocks = alignLong(newLevel1HashdataSize, level1HashBlockSize) / level1HashBlockSize;
+ dataToHash = new byte[level1HashBlockSize];
+ for (long i = 0; i < numberOfLevel1HashBlocks; i++) {
+ fNew.seek(newLevel1Offset + (i * level1HashBlockSize));
+ fNew.readFully(dataToHash);
+ byte[] hash = digest.digest(dataToHash);
+ fNew.seek(newRomfsOffset + 0x60 + (i * 0x20));
+ fNew.write(hash);
+ }
+
+ // Lastly, update the header and return the size of the new romfs
+ long level1LogicalOffset = 0;
+ long level2LogicalOffset = alignLong(newLevel1HashdataSize, level1HashBlockSize);
+ long level3LogicalOffset = alignLong(level2LogicalOffset + newLevel2HashdataSize, level2HashBlockSize);
+ FileFunctions.writeFullInt(romfsHeaderData, 0x08, (int) numberOfLevel1HashBlocks * 0x20);
+ FileFunctions.writeFullLong(romfsHeaderData, 0x0C, level1LogicalOffset);
+ FileFunctions.writeFullLong(romfsHeaderData, 0x14, newLevel1HashdataSize);
+ FileFunctions.writeFullLong(romfsHeaderData, 0x24, level2LogicalOffset);
+ FileFunctions.writeFullLong(romfsHeaderData, 0x2C, newLevel2HashdataSize);
+ FileFunctions.writeFullLong(romfsHeaderData, 0x3C, level3LogicalOffset);
+ FileFunctions.writeFullLong(romfsHeaderData, 0x44, newLevel3HashdataSize);
+ fNew.seek(newRomfsOffset);
+ fNew.write(romfsHeaderData);
+ long currentLength = newFileEndingOffset - newRomfsOffset;
+ long newRomfsLength = alignLong(currentLength, media_unit_size);
+ fNew.seek(newFileEndingOffset);
+ while (fNew.getFilePointer() < newRomfsOffset + newRomfsLength) {
+ fNew.writeByte(0);
+ }
+
+ System.out.println("NCCH: Done rebuilding romfs");
+ return newRomfsLength;
+ }
+
+ private byte[] updateFileMetadataTable(int fileMetadataTableLength) {
+ fileMetadataList.sort((FileMetadata f1, FileMetadata f2) -> (int) (f1.fileDataOffset - f2.fileDataOffset));
+ byte[] fileMetadataTable = new byte[fileMetadataTableLength];
+ int currentTableOffset = 0;
+ long currentFileDataOffset = 0;
+ for (FileMetadata metadata : fileMetadataList) {
+ metadata.fileDataOffset = currentFileDataOffset;
+ if (metadata.file.fileChanged) {
+ metadata.fileDataLength = metadata.file.size;
+ }
+ byte[] metadataBytes = metadata.asBytes();
+ System.arraycopy(metadataBytes, 0, fileMetadataTable, currentTableOffset, metadataBytes.length);
+ currentTableOffset += metadataBytes.length;
+ currentFileDataOffset += metadata.fileDataLength;
+ }
+ return fileMetadataTable;
+ }
+
+ public void saveAsLayeredFS(String outputPath) throws IOException {
+ String layeredFSRootPath = outputPath + File.separator + titleId + File.separator;
+ File layeredFSRootDir = new File(layeredFSRootPath);
+ if (!layeredFSRootDir.exists()) {
+ layeredFSRootDir.mkdirs();
+ } else {
+ purgeDirectory(layeredFSRootDir);
+ }
+ String romfsRootPath = layeredFSRootPath + "romfs" + File.separator;
+ File romfsDir = new File(romfsRootPath);
+ if (!romfsDir.exists()) {
+ romfsDir.mkdirs();
+ }
+
+ if (codeChanged) {
+ byte[] code = getCode();
+ FileOutputStream fos = new FileOutputStream(new File(layeredFSRootPath + "code.bin"));
+ fos.write(code);
+ fos.close();
+ }
+
+ for (Map.Entry<String, RomfsFile> entry : romfsFiles.entrySet()) {
+ RomfsFile file = entry.getValue();
+ if (file.fileChanged) {
+ writeRomfsFileToLayeredFS(file, romfsRootPath);
+ }
+ }
+ }
+
+ private void purgeDirectory(File directory) {
+ for (File file : directory.listFiles()) {
+ if (file.isDirectory()) {
+ purgeDirectory(file);
+ }
+ file.delete();
+ }
+ }
+
+ private void writeRomfsFileToLayeredFS(RomfsFile file, String layeredFSRootPath) throws IOException {
+ String[] romfsPathComponents = file.fullPath.split("/");
+ StringBuffer buffer = new StringBuffer(layeredFSRootPath);
+ for (int i = 0; i < romfsPathComponents.length - 1; i++) {
+ buffer.append(romfsPathComponents[i]);
+ buffer.append(File.separator);
+ File currentDir = new File(buffer.toString());
+ if (!currentDir.exists()) {
+ currentDir.mkdirs();
+ }
+ }
+ buffer.append(romfsPathComponents[romfsPathComponents.length - 1]);
+ String romfsFilePath = buffer.toString();
+ FileOutputStream fos = new FileOutputStream(new File(romfsFilePath));
+ fos.write(file.getOverrideContents());
+ fos.close();
+ }
+
+ public boolean isDecrypted() throws IOException {
+ // This is the way you're *supposed* to tell if a ROM is decrypted. Specifically, this
+ // is checking the noCrypto flag on the NCCH bitflags.
+ long ncchFlagOffset = ncchStartingOffset + 0x188;
+ byte[] ncchFlags = new byte[8];
+ baseRom.seek(ncchFlagOffset);
+ baseRom.readFully(ncchFlags);
+ if ((ncchFlags[7] & 0x4) != 0) {
+ return true;
+ }
+
+ // However, some poorly-decrypted ROMs don't set this flag. So our heuristic for detecting
+ // if they're decrypted is to check whether the battle CRO exists, since all 3DS Pokemon
+ // games and updates have this file. If the game is *really* encrypted, then the odds of us
+ // successfully extracting this exact name from the metadata tables is like one in a billion.
+ return romfsFiles != null && (romfsFiles.containsKey("DllBattle.cro") || romfsFiles.containsKey("Battle.cro"));
+ }
+
+ // Retrieves a decompressed version of .code (the game's executable).
+ // The first time this is called, it will retrieve it straight from the
+ // exefs. Future calls will rely on a cached version to speed things up.
+ // If writing is enabled, it will cache the decompressed version to the
+ // tmpFolder; otherwise, it will store it in RAM.
+ public byte[] getCode() throws IOException {
+ if (!codeOpen) {
+ codeOpen = true;
+ byte[] code = new byte[codeFileHeader.size];
+
+ // File header offsets are from the start of the exefs but *exclude* the
+ // size of the exefs header, so we need to add it back ourselves.
+ baseRom.seek(exefsOffset + exefs_header_size + codeFileHeader.offset);
+ baseRom.readFully(code);
+ originalCodeCRC = FileFunctions.getCRC32(code);
+
+ if (codeCompressed) {
+ code = new BLZCoder(null).BLZ_DecodePub(code, ".code");
+ }
+
+ // Now actually make the copy or w/e
+ if (writingEnabled) {
+ File arm9file = new File(tmpFolder + ".code");
+ FileOutputStream fos = new FileOutputStream(arm9file);
+ fos.write(code);
+ fos.close();
+ arm9file.deleteOnExit();
+ this.codeRamstored = null;
+ return code;
+ } else {
+ this.codeRamstored = code;
+ byte[] newcopy = new byte[code.length];
+ System.arraycopy(code, 0, newcopy, 0, code.length);
+ return newcopy;
+ }
+ } else {
+ if (writingEnabled) {
+ return FileFunctions.readFileFullyIntoBuffer(tmpFolder + ".code");
+ } else {
+ byte[] newcopy = new byte[this.codeRamstored.length];
+ System.arraycopy(this.codeRamstored, 0, newcopy, 0, this.codeRamstored.length);
+ return newcopy;
+ }
+ }
+ }
+
+ public void writeCode(byte[] code) throws IOException {
+ if (!codeOpen) {
+ getCode();
+ }
+ codeChanged = true;
+ if (writingEnabled) {
+ FileOutputStream fos = new FileOutputStream(new File(tmpFolder + ".code"));
+ fos.write(code);
+ fos.close();
+ } else {
+ if (this.codeRamstored.length == code.length) {
+ // copy new in
+ System.arraycopy(code, 0, this.codeRamstored, 0, code.length);
+ } else {
+ // make new array
+ this.codeRamstored = null;
+ this.codeRamstored = new byte[code.length];
+ System.arraycopy(code, 0, this.codeRamstored, 0, code.length);
+ }
+ }
+ }
+
+ public boolean hasFile(String filename) {
+ return romfsFiles.containsKey(filename);
+ }
+
+ // returns null if file doesn't exist
+ public byte[] getFile(String filename) throws IOException {
+ if (romfsFiles.containsKey(filename)) {
+ return romfsFiles.get(filename).getContents();
+ } else {
+ return null;
+ }
+ }
+
+ public void writeFile(String filename, byte[] data) throws IOException {
+ if (romfsFiles.containsKey(filename)) {
+ romfsFiles.get(filename).writeOverride(data);
+ }
+ }
+
+ public void printRomDiagnostics(PrintStream logStream, NCCH gameUpdate) {
+ Path p = Paths.get(this.romFilename);
+ logStream.println("File name: " + p.getFileName().toString());
+ if (gameUpdate == null) {
+ logStream.println(".code: " + String.format("%08X", this.originalCodeCRC));
+ } else {
+ logStream.println(".code: " + String.format("%08X", gameUpdate.originalCodeCRC));
+ }
+ logStream.println("romfs header: " + String.format("%08X", this.originalRomfsHeaderCRC));
+ if (gameUpdate != null) {
+ logStream.println("romfs header (game update): " + String.format("%08X", gameUpdate.originalRomfsHeaderCRC));
+ }
+ List<String> fileList = new ArrayList<>();
+ Map<String, String> baseRomfsFileDiagnostics = this.getRomfsFilesDiagnostics();
+ Map<String, String> updateRomfsFileDiagnostics = new HashMap<>();
+ if (gameUpdate != null) {
+ updateRomfsFileDiagnostics = gameUpdate.getRomfsFilesDiagnostics();
+ }
+ for (Map.Entry<String, String> entry : updateRomfsFileDiagnostics.entrySet()) {
+ baseRomfsFileDiagnostics.remove(entry.getKey());
+ fileList.add(entry.getValue());
+ }
+ for (Map.Entry<String, String> entry : baseRomfsFileDiagnostics.entrySet()) {
+ fileList.add(entry.getValue());
+ }
+ Collections.sort(fileList);
+ for (String fileLog : fileList) {
+ logStream.println(fileLog);
+ }
+ }
+
+ public Map<String, String> getRomfsFilesDiagnostics() {
+ Map<String, String> fileDiagnostics = new HashMap<>();
+ for (Map.Entry<String, RomfsFile> entry : romfsFiles.entrySet()) {
+ if (entry.getValue().originalCRC != 0) {
+ fileDiagnostics.put(entry.getKey(), entry.getKey() + ": " + String.format("%08X", entry.getValue().originalCRC));
+ }
+ }
+ return fileDiagnostics;
+ }
+
+ public String getTmpFolder() {
+ return tmpFolder;
+ }
+
+ public RandomAccessFile getBaseRom() {
+ return baseRom;
+ }
+
+ public boolean isWritingEnabled() {
+ return writingEnabled;
+ }
+
+ public String getProductCode() {
+ return productCode;
+ }
+
+ public String getTitleId() {
+ return titleId;
+ }
+
+ public int getVersion() {
+ return version;
+ }
+
+ public static int alignInt(int num, int alignment) {
+ int mask = ~(alignment - 1);
+ return (num + (alignment - 1)) & mask;
+ }
+
+ public static long alignLong(long num, long alignment) {
+ long mask = ~(alignment - 1);
+ return (num + (alignment - 1)) & mask;
+ }
+
+ private int readVersionFromFile() {
+ try {
+ // Only CIAs can define a version in their TMD. If this is a different ROM type,
+ // just exit out early.
+ int magic = FileFunctions.readBigEndianIntFromFile(this.baseRom, ncch_and_ncsd_magic_offset);
+ if (magic == ncch_magic || magic == ncsd_magic) {
+ return 0;
+ }
+
+ // For CIAs, we need to read the title metadata (TMD) in order to retrieve the version.
+ // The TMD is after the certificate chain and ticket.
+ int certChainSize = FileFunctions.readIntFromFile(this.baseRom, 0x08);
+ int ticketSize = FileFunctions.readIntFromFile(this.baseRom, 0x0C);
+ long certChainOffset = NCCH.alignLong(cia_header_size, 64);
+ long ticketOffset = NCCH.alignLong(certChainOffset + certChainSize, 64);
+ long tmdOffset = NCCH.alignLong(ticketOffset + ticketSize, 64);
+
+ // At the start of the TMD is a signature whose length varies based on what type of signature it is.
+ int signatureType = FileFunctions.readBigEndianIntFromFile(this.baseRom, tmdOffset);
+ int signatureSize, paddingSize;
+ switch (signatureType) {
+ case 0x010003:
+ signatureSize = 0x200;
+ paddingSize = 0x3C;
+ break;
+ case 0x010004:
+ signatureSize = 0x100;
+ paddingSize = 0x3C;
+ break;
+ case 0x010005:
+ signatureSize = 0x3C;
+ paddingSize = 0x40;
+ break;
+ default:
+ signatureSize = -1;
+ paddingSize = -1;
+ break;
+ }
+ if (signatureSize == -1) {
+ // This shouldn't happen in practice, since all used and valid signature types are represented
+ // in the above switch. However, if we can't find the right signature type, then it's probably
+ // an invalid CIA anyway, so we're unlikely to get good version information out of it.
+ return 0;
+ }
+
+ // After the signature is the TMD header, which actually contains the version information.
+ long tmdHeaderOffset = tmdOffset + 4 + signatureSize + paddingSize;
+ return FileFunctions.read2ByteBigEndianIntFromFile(this.baseRom, tmdHeaderOffset + 0x9C);
+ } catch (IOException e) {
+ throw new RandomizerIOException(e);
+ }
+ }
+
+ // At the bare minimum, a 3DS game consists of what's known as a CXI file, which
+ // is just an NCCH that contains executable code. However, 3DS games are packaged
+ // in various containers that can hold other NCCH files like the game manual and
+ // firmware updates, among other things. This function's determines the location
+ // of the CXI regardless of the container.
+ public static long getCXIOffsetInFile(String filename) {
+ try {
+ RandomAccessFile rom = new RandomAccessFile(filename, "r");
+ int ciaHeaderSize = FileFunctions.readIntFromFile(rom, 0x00);
+ if (ciaHeaderSize == cia_header_size) {
+ // This *might* be a CIA; let's do our best effort to try to get
+ // a CXI out of this.
+ int certChainSize = FileFunctions.readIntFromFile(rom, 0x08);
+ int ticketSize = FileFunctions.readIntFromFile(rom, 0x0C);
+ int tmdFileSize = FileFunctions.readIntFromFile(rom, 0x10);
+
+ // If this is *really* a CIA, we'll find our CXI at the beginning of the
+ // content section, which is after the certificate chain, ticket, and TMD
+ long certChainOffset = NCCH.alignLong(ciaHeaderSize, 64);
+ long ticketOffset = NCCH.alignLong(certChainOffset + certChainSize, 64);
+ long tmdOffset = NCCH.alignLong(ticketOffset + ticketSize, 64);
+ long contentOffset = NCCH.alignLong(tmdOffset + tmdFileSize, 64);
+ int magic = FileFunctions.readBigEndianIntFromFile(rom, contentOffset + ncch_and_ncsd_magic_offset);
+ if (magic == ncch_magic) {
+ // This CIA's content contains a valid CXI!
+ return contentOffset;
+ }
+ }
+
+ // We don't put the following code in an else-block because there *might*
+ // exist a totally-valid CXI or CCI whose first four bytes just so
+ // *happen* to be the same as the first four bytes of a CIA file.
+ int magic = FileFunctions.readBigEndianIntFromFile(rom, ncch_and_ncsd_magic_offset);
+ rom.close();
+ if (magic == ncch_magic) {
+ // Magic is NCCH, so this just a straight-up NCCH/CXI; there is no container
+ // around the game data. Thus, the CXI offset is the beginning of the file.
+ return 0;
+ } else if (magic == ncsd_magic) {
+ // Magic is NCSD, so this is almost certainly a CCI. The CXI is always
+ // a fixed distance away from the start.
+ return 0x4000;
+ } else {
+ // This doesn't seem to be a valid 3DS file.
+ return -1;
+ }
+ } catch (IOException e) {
+ throw new RandomizerIOException(e);
+ }
+ }
+
+ private class ExefsFileHeader {
+ public String filename;
+ public int offset;
+ public int size;
+
+ public ExefsFileHeader() { }
+
+ public ExefsFileHeader(byte[] exefsHeaderData, int fileHeaderOffset) {
+ byte[] filenameBytes = new byte[0x8];
+ System.arraycopy(exefsHeaderData, fileHeaderOffset, filenameBytes, 0, 0x8);
+ this.filename = new String(filenameBytes, StandardCharsets.UTF_8).trim();
+ this.offset = FileFunctions.readFullInt(exefsHeaderData, fileHeaderOffset + 0x08);
+ this.size = FileFunctions.readFullInt(exefsHeaderData, fileHeaderOffset + 0x0C);
+ }
+
+ public boolean isValid() {
+ return this.filename != "" && this.size != 0;
+ }
+
+ public byte[] asBytes() {
+ byte[] output = new byte[0x10];
+ byte[] filenameBytes = this.filename.getBytes(StandardCharsets.UTF_8);
+ System.arraycopy(filenameBytes, 0, output, 0, filenameBytes.length);
+ FileFunctions.writeFullInt(output, 0x08, this.offset);
+ FileFunctions.writeFullInt(output, 0x0C, this.size);
+ return output;
+ }
+ }
+
+ private class DirectoryMetadata {
+ public int parentDirectoryOffset;
+ public int siblingDirectoryOffset;
+ public int firstChildDirectoryOffset;
+ public int firstFileOffset;
+ public int nextDirectoryInHashBucketOffset;
+ public int nameLength;
+ public String name;
+
+ public DirectoryMetadata(byte[] directoryMetadataBlock, int offset) {
+ parentDirectoryOffset = FileFunctions.readFullInt(directoryMetadataBlock, offset);
+ siblingDirectoryOffset = FileFunctions.readFullInt(directoryMetadataBlock, offset + 0x04);
+ firstChildDirectoryOffset = FileFunctions.readFullInt(directoryMetadataBlock, offset + 0x08);
+ firstFileOffset = FileFunctions.readFullInt(directoryMetadataBlock, offset + 0x0C);
+ nextDirectoryInHashBucketOffset = FileFunctions.readFullInt(directoryMetadataBlock, offset + 0x10);
+ nameLength = FileFunctions.readFullInt(directoryMetadataBlock, offset + 0x14);
+ name = "";
+ if (nameLength != metadata_unused) {
+ byte[] nameBytes = new byte[nameLength];
+ System.arraycopy(directoryMetadataBlock, offset + 0x18, nameBytes, 0, nameLength);
+ name = new String(nameBytes, StandardCharsets.UTF_16LE).trim();
+ }
+ }
+ }
+
+ private class FileMetadata {
+ public int offset;
+ public int parentDirectoryOffset;
+ public int siblingFileOffset;
+ public long fileDataOffset;
+ public long fileDataLength;
+ public int nextFileInHashBucketOffset;
+ public int nameLength;
+ public String name;
+ public RomfsFile file; // used only for rebuilding CXI
+
+ public FileMetadata(byte[] fileMetadataBlock, int offset) {
+ this.offset = offset;
+ parentDirectoryOffset = FileFunctions.readFullInt(fileMetadataBlock, offset);
+ siblingFileOffset = FileFunctions.readFullInt(fileMetadataBlock, offset + 0x04);
+ fileDataOffset = FileFunctions.readFullLong(fileMetadataBlock, offset + 0x08);
+ fileDataLength = FileFunctions.readFullLong(fileMetadataBlock, offset + 0x10);
+ nextFileInHashBucketOffset = FileFunctions.readFullInt(fileMetadataBlock, offset + 0x18);
+ nameLength = FileFunctions.readFullInt(fileMetadataBlock, offset + 0x1C);
+ name = "";
+ if (nameLength != metadata_unused) {
+ byte[] nameBytes = new byte[nameLength];
+ System.arraycopy(fileMetadataBlock, offset + 0x20, nameBytes, 0, nameLength);
+ name = new String(nameBytes, StandardCharsets.UTF_16LE).trim();
+ }
+ }
+
+ public byte[] asBytes() {
+ int metadataLength = 0x20;
+ if (nameLength != metadata_unused) {
+ metadataLength += alignInt(nameLength, 4);
+ }
+ byte[] output = new byte[metadataLength];
+ FileFunctions.writeFullInt(output, 0x00, this.parentDirectoryOffset);
+ FileFunctions.writeFullInt(output, 0x04, this.siblingFileOffset);
+ FileFunctions.writeFullLong(output, 0x08, this.fileDataOffset);
+ FileFunctions.writeFullLong(output, 0x10, this.fileDataLength);
+ FileFunctions.writeFullInt(output, 0x18, this.nextFileInHashBucketOffset);
+ FileFunctions.writeFullInt(output, 0x1C, this.nameLength);
+ if (!name.equals("")) {
+ byte[] nameBytes = name.getBytes(StandardCharsets.UTF_16LE);
+ System.arraycopy(nameBytes, 0, output, 0x20, nameBytes.length);
+ }
+ return output;
+ }
+ }
+}