diff options
author | Rafael Marçalo <public@rafaelmarcalo.xyz> | 2024-09-05 16:31:33 +0100 |
---|---|---|
committer | Rafael Marçalo <public@rafaelmarcalo.xyz> | 2024-09-05 16:31:33 +0100 |
commit | 8b67572ad7e1508341345dc46a2597e9fa170cbb (patch) | |
tree | 8f37c4d60ce0f07b9eaf30be34f39298da97b242 /src/com/pkrandom/ctr | |
parent | b65f4a80da28e7ec4de16c8b1abf906e8d7be2c5 (diff) |
Diffstat (limited to 'src/com/pkrandom/ctr')
-rw-r--r-- | src/com/pkrandom/ctr/AMX.java | 227 | ||||
-rw-r--r-- | src/com/pkrandom/ctr/BFLIM.java | 203 | ||||
-rw-r--r-- | src/com/pkrandom/ctr/GARCArchive.java | 388 | ||||
-rw-r--r-- | src/com/pkrandom/ctr/Mini.java | 102 | ||||
-rw-r--r-- | src/com/pkrandom/ctr/NCCH.java | 1024 | ||||
-rw-r--r-- | src/com/pkrandom/ctr/RomfsFile.java | 121 | ||||
-rw-r--r-- | src/com/pkrandom/ctr/SMDH.java | 118 |
7 files changed, 2183 insertions, 0 deletions
diff --git a/src/com/pkrandom/ctr/AMX.java b/src/com/pkrandom/ctr/AMX.java new file mode 100644 index 0000000..d99ba7f --- /dev/null +++ b/src/com/pkrandom/ctr/AMX.java @@ -0,0 +1,227 @@ +package com.pkrandom.ctr; + +/*----------------------------------------------------------------------------*/ +/*-- AMX.java - class for handling AMX script archives --*/ +/*-- --*/ +/*-- Contains code based on "pk3DS", copyright (C) Kaphotics --*/ +/*-- Contains code based on "pkNX", copyright (C) Kaphotics --*/ +/*-- Contains code based on "poketools", copyright (C) FireyFly --*/ +/*-- Additional contributions by the UPR-ZX team --*/ +/*-- --*/ +/*-- 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.exceptions.RandomizerIOException; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class AMX { + + public byte[] decData; + public int scriptOffset = 0; + + private int amxMagic = 0x0A0AF1E0; + private int amxMagicDebug = 0x0A0AF1EF; + private long mask = 0xFF; + + private int length; + + private int scriptInstrStart; + private int scriptMovementStart; + private int finalOffset; + private int allocatedMemory; + + private int compLength; + private int decompLength; + + private int ptrOffset; + private int ptrCount; + + private byte[] extraData; + + public AMX(byte[] data, int scriptNum) throws IOException { + int found = 0; + for (int i = 0; i < data.length - 3; i++) { + int val = FileFunctions.readFullInt(data,i); + if (val == amxMagic) { + if (found == scriptNum) { + int length = FileFunctions.readFullInt(data,i-4); + readHeaderAndDecompress(Arrays.copyOfRange(data,i-4,i-4+length)); + scriptOffset = i-4; + break; + } else { + found++; + } + } + } + } + + public AMX(byte[] encData) throws IOException { + readHeaderAndDecompress(encData); + } + + // Credit to the creators of pk3DS (Kaphotics et al) + private void readHeaderAndDecompress(byte[] encData) throws IOException { + length = FileFunctions.readFullInt(encData,0); + int magic = FileFunctions.readFullInt(encData,4); + if (magic != amxMagic) { + throw new IOException(); + } + + ptrOffset = FileFunctions.read2ByteInt(encData,8); + ptrCount = FileFunctions.read2ByteInt(encData,0xA); + + scriptInstrStart = FileFunctions.readFullInt(encData,0xC); + scriptMovementStart = FileFunctions.readFullInt(encData,0x10); + finalOffset = FileFunctions.readFullInt(encData,0x14); + allocatedMemory = FileFunctions.readFullInt(encData,0x18); + + compLength = length - scriptInstrStart; + byte[] compressedBytes = Arrays.copyOfRange(encData,scriptInstrStart,length); + decompLength = finalOffset - scriptInstrStart; + + decData = decompressBytes(compressedBytes, decompLength); + extraData = Arrays.copyOfRange(encData,0x1C,scriptInstrStart); + } + + // Credit to FireyFly + private byte[] decompressBytes(byte[] data, int length) { + byte[] code = new byte[length]; + int i = 0, j = 0, x = 0, f = 0; + while (i < code.length) { + int b = data[f++]; + int v = b & 0x7F; + if (++j == 1) { + x = ((((v >>> 6 == 0 ? 1 : 0) - 1 ) << 6) | v); + } else { + x = (x << 7) | (v & 0xFF); + } + if ((b & 0x80) != 0) continue; + code[i++] = (byte)(x & 0xFF); + code[i++] = (byte)((x >>> 8) & 0xFF); + code[i++] = (byte)((x >>> 16) & 0xFF); + code[i++] = (byte)((x >>> 24) & 0xFF); + j = 0; + } + return code; + } + + public byte[] getBytes() { + + ByteBuffer bbuf = ByteBuffer.allocate(length*2); + + bbuf.order(ByteOrder.LITTLE_ENDIAN); + + bbuf.putInt(length); + bbuf.putInt(amxMagic); + bbuf.putShort((short)ptrOffset); + bbuf.putShort((short)ptrCount); + bbuf.putInt(scriptInstrStart); + bbuf.putInt(scriptMovementStart); + bbuf.putInt(finalOffset); + bbuf.putInt(allocatedMemory); + bbuf.put(extraData); + bbuf.put(compressScript(decData)); + bbuf.flip(); + bbuf.putInt(bbuf.limit()); + + return Arrays.copyOfRange(bbuf.array(),0,bbuf.limit()); + } + + private byte[] compressScript(byte[] data) { + if (data == null || data.length % 4 != 0) { + return null; + } + ByteBuffer inBuf = ByteBuffer.wrap(data); + inBuf.order(ByteOrder.LITTLE_ENDIAN); + + ByteArrayOutputStream out = new ByteArrayOutputStream(compLength); + + try { + while (inBuf.position() < data.length) { + compressBytes(inBuf, out); + } + } catch (IOException e) { + throw new RandomizerIOException(e); + } + + return out.toByteArray(); + } + + // Modified version of the AMX script compression algorithm from pkNX + private void compressBytes(ByteBuffer inBuf, ByteArrayOutputStream out) throws IOException { + List<Byte> bytes = new ArrayList<>(); + int instructionTemp = inBuf.getInt(inBuf.position()); + long instruction = Integer.toUnsignedLong(instructionTemp); + boolean sign = (instruction & 0x80000000) > 0; + + // Signed (negative) values are handled opposite of unsigned (positive) values. + // Positive values are "done" when we've shifted the value down to zero, but + // we don't need to store the highest 1s in a signed value. We handle this by + // tracking the loop via a NOTed shadow copy of the instruction if it's signed. + int shadowTemp = sign ? ~instructionTemp : instructionTemp; + long shadow = Integer.toUnsignedLong(shadowTemp); + do + { + long least7 = instruction & 0b01111111; + byte byteVal = (byte)least7; + + if (bytes.size() > 0) + { + // Continuation bit on all but the lowest byte + byteVal |= 0x80; + } + + bytes.add(byteVal); + + instruction >>= 7; + shadow >>= 7; + } + while (shadow != 0); + + if (bytes.size() < 5) + { + // Ensure "sign bit" (bit just to the right of highest continuation bit) is + // correct. Add an extra empty continuation byte if we need to. Values can't + // be longer than 5 bytes, though. + + int signBit = sign ? 0x40 : 0x00; + + if ((bytes.get(bytes.size() - 1) & 0x40) != signBit) + bytes.add((byte)(sign ? 0xFF : 0x80)); + } + + // Reverse for endianess + for (int i = 0; i < bytes.size() / 2; i++) { + byte temp = bytes.get(i); + bytes.set(i, bytes.get(bytes.size() - i - 1)); + bytes.set(bytes.size() - i - 1, temp); + } + + byte[] ret = new byte[bytes.size()]; + for (int i = 0; i < ret.length; i++) { + ret[i] = bytes.get(i); + } + + inBuf.position(inBuf.position() + 4); + out.write(ret); + } +} diff --git a/src/com/pkrandom/ctr/BFLIM.java b/src/com/pkrandom/ctr/BFLIM.java new file mode 100644 index 0000000..5bbb71a --- /dev/null +++ b/src/com/pkrandom/ctr/BFLIM.java @@ -0,0 +1,203 @@ +package com.pkrandom.ctr; + +/*----------------------------------------------------------------------------*/ +/*-- BFLIM.java - class for reading/parsing BFLIM images. --*/ +/*-- Note that this class is optimized around handling Gen 7 --*/ +/*-- Pokemon icons, and won't work for all types of BFLIMs --*/ +/*-- --*/ +/*-- Code based on "Switch Toolbox", copyright (C) KillzXGaming --*/ +/*-- --*/ +/*-- Ported to Java by UPR-ZX Team under the terms of the GPL: --*/ +/*-- --*/ +/*-- 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 java.awt.image.BufferedImage; + +public class BFLIM { + + private int width; + private int height; + private byte[] imageData; + private Header header; + private Image image; + + public BFLIM(byte[] bflimBytes) { + if (bflimBytes.length < 0x28) { + throw new IllegalArgumentException("Invalid BFLIM: not long enough to contain a header"); + } + header = new Header(bflimBytes); + image = new Image(bflimBytes); + width = image.width; + height = image.height; + imageData = new byte[image.imageSize]; + System.arraycopy(bflimBytes, 0, imageData, 0, image.imageSize); + } + + @SuppressWarnings("SuspiciousNameCombination") + public BufferedImage getImage() { + // Swap width and height, because the image is rendered on its side + int swappedWidth = height; + int swappedHeight = width; + int[] decodedImageData = decodeBlock(imageData, swappedWidth, swappedHeight); + int[] colorData = convertToColorData(decodedImageData); + int[] correctedColorData = rearrangeImage(colorData, width, height); + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int color = correctedColorData[x + (y * this.width)]; + image.setRGB(x, y, color); + } + } + return image; + } + + private static int[] SwizzleLUT = { + 0, 1, 8, 9, 2, 3, 10, 11, + 16, 17, 24, 25, 18, 19, 26, 27, + 4, 5, 12, 13, 6, 7, 14, 15, + 20, 21, 28, 29, 22, 23, 30, 31, + 32, 33, 40, 41, 34, 35, 42, 43, + 48, 49, 56, 57, 50, 51, 58, 59, + 36, 37, 44, 45, 38, 39, 46, 47, + 52, 53, 60, 61, 54, 55, 62, 63 + }; + + private int[] decodeBlock(byte[] data, int width, int height) { + int[] output = new int[width * height * 4]; + int inputOffset = 0; + for (int ty = 0; ty < height; ty += 8) { + for (int tx = 0; tx < width; tx += 8) { + for (int px = 0; px < 64; px++) { + int x = SwizzleLUT[px] & 7; + int y = (SwizzleLUT[px] - x) >> 3; + int outputOffset = (tx + x + ((height - 1 - (ty + y)) * width)) * 4; + int value = FileFunctions.read2ByteInt(data, inputOffset); + if (image.format == 7) { + decodeRGBA5551(output, outputOffset, value); + } else if (image.format == 8) { + decodeRGBA4(output, outputOffset, value); + } else { + throw new IllegalArgumentException("Unsupported BFLIM: unsupported image format"); + } + inputOffset += 2; + } + } + } + return output; + } + + private int[] convertToColorData(int[] decodedImageData) { + int[] output = new int[decodedImageData.length / 4]; + for (int i = 0; i < decodedImageData.length; i += 4) { + int a = decodedImageData[i]; + int b = decodedImageData[i + 1]; + int g = decodedImageData[i + 2]; + int r = decodedImageData[i + 3]; + int color = (a << 24) | (b << 16) | (g << 8) | r; + output[i / 4] = color; + } + return output; + } + + private int[] rearrangeImage(int[] colorData, int width, int height) { + int[] output = new int[colorData.length]; + for (int destY = 0; destY < height; destY++) { + for (int destX = 0; destX < width; destX++) { + int srcX = height - destY - 1; + int srcY = width - destX - 1; + int srcIndex = srcX + (srcY * height); + int destIndex = destX + (destY * width); + output[destIndex] = colorData[srcIndex]; + } + } + return output; + } + + private static void decodeRGBA5551(int[] output, int outputOffset, int value) { + int R = ((value >> 1) & 0x1f) << 3; + int G = ((value >> 6) & 0x1f) << 3; + int B = ((value >> 11) & 0x1f) << 3; + int A = (value & 1) * 0xFF; + R = R | (R >> 5); + G = G | (G >> 5); + B = B | (B >> 5); + output[outputOffset] = A; + output[outputOffset + 1] = B; + output[outputOffset + 2] = G; + output[outputOffset + 3] = R; + } + + private static void decodeRGBA4(int[] output, int outputOffset, int value) { + int R = ((value >> 4) & 0xf); + int G = ((value >> 8) & 0xf); + int B = ((value >> 12) & 0xf); + int A = (value & 1) | (value << 4); + R = R | (R << 4); + G = G | (G << 4); + B = B | (B << 4); + output[outputOffset] = A; + output[outputOffset + 1] = B; + output[outputOffset + 2] = G; + output[outputOffset + 3] = R; + } + + private class Header { + public int version; + + public Header(byte[] bflimBytes) { + int headerOffset = bflimBytes.length - 0x28; + int signature = FileFunctions.readFullIntBigEndian(bflimBytes, headerOffset); + if (signature != 0x464C494D) { + throw new IllegalArgumentException("Invalid BFLIM: cannot find FLIM header"); + } + boolean bigEndian = FileFunctions.read2ByteInt(bflimBytes, headerOffset + 4) == 0xFFFE; + if (bigEndian) { + throw new IllegalArgumentException("Unsupported BFLIM: this is a big endian BFLIM"); + } + int headerSize = FileFunctions.read2ByteInt(bflimBytes, headerOffset + 6); + if (headerSize != 0x14) { + throw new IllegalArgumentException("Invalid BFLIM: header length does not equal 0x14"); + } + version = FileFunctions.readFullInt(bflimBytes, headerOffset + 8); + } + } + + private class Image { + public int size; + public short width; + public short height; + public short alignment; + public byte format; + public byte flags; + public int imageSize; + + public Image(byte[] bflimBytes) { + int imageHeaderOffset = bflimBytes.length - 0x14; + int signature = FileFunctions.readFullIntBigEndian(bflimBytes, imageHeaderOffset); + if (signature != 0x696D6167) { + throw new IllegalArgumentException("Invalid BFLIM: cannot find imag header"); + } + size = FileFunctions.readFullInt(bflimBytes, imageHeaderOffset + 4); + width = (short) FileFunctions.read2ByteInt(bflimBytes, imageHeaderOffset + 8); + height = (short) FileFunctions.read2ByteInt(bflimBytes, imageHeaderOffset + 10); + alignment = (short) FileFunctions.read2ByteInt(bflimBytes, imageHeaderOffset + 12); + format = bflimBytes[imageHeaderOffset + 14]; + flags = bflimBytes[imageHeaderOffset + 15]; + imageSize = FileFunctions.readFullInt(bflimBytes, imageHeaderOffset + 16); + } + } +} diff --git a/src/com/pkrandom/ctr/GARCArchive.java b/src/com/pkrandom/ctr/GARCArchive.java new file mode 100644 index 0000000..cd504c4 --- /dev/null +++ b/src/com/pkrandom/ctr/GARCArchive.java @@ -0,0 +1,388 @@ +package com.pkrandom.ctr; + +/*----------------------------------------------------------------------------*/ +/*-- GARCArchive.java - class for packing/unpacking GARC archives --*/ +/*-- --*/ +/*-- Code based on "pk3DS", copyright (C) Kaphotics --*/ +/*-- --*/ +/*-- Ported to Java by UPR-ZX Team under the terms of the GPL: --*/ +/*-- --*/ +/*-- 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 cuecompressors.BLZCoder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.*; + +public class GARCArchive { + + private final int VER_4 = 0x0400; + private final int VER_6 = 0x0600; + private int version; + private final int garcHeaderSize_4 = 0x1C; + private final int garcHeaderSize_6 = 0x24; + private final String garcMagic = "CRAG"; + private final String fatoMagic = "OTAF"; + private final String fatbMagic = "BTAF"; + private final String fimbMagic = "BMIF"; + private boolean skipDecompression = true; + + public List<Map<Integer,byte[]>> files = new ArrayList<>(); + private Map<Integer,Boolean> isCompressed = new TreeMap<>(); + private List<Boolean> compressThese = null; + + private GARCFrame garc; + private FATOFrame fato; + private FATBFrame fatb; + private FIMBFrame fimb; + + public GARCArchive() { + + } + + public GARCArchive(byte[] data, boolean skipDecompression) throws IOException { + this.skipDecompression = skipDecompression; + boolean success = readFrames(data); + if (!success) { + throw new IOException("Invalid GARC file"); + } + files = fimb.files; + } + + public GARCArchive(byte[] data, List<Boolean> compressedThese) throws IOException { + this.compressThese = compressedThese; + boolean success = readFrames(data); + if (!success) { + throw new IOException("Invalid GARC file"); + } + files = fimb.files; + } + + private boolean readFrames(byte[] data) { + if (data.length <= 0) { + System.out.println("Empty GARC"); + return false; + } + ByteBuffer bbuf = ByteBuffer.wrap(data); + bbuf.order(ByteOrder.LITTLE_ENDIAN); + // GARC + byte[] magicBuf = new byte[4]; + bbuf.get(magicBuf); + String magic = new String(magicBuf); + if (!magic.equals(garcMagic)) { + return false; + } + garc = new GARCFrame(); + garc.headerSize = bbuf.getInt(); + garc.endianness = bbuf.getShort(); + garc.version = bbuf.getShort(); + int frameCount = bbuf.getInt(); + if (frameCount != 4) { + return false; + } + garc.dataOffset = bbuf.getInt(); + garc.fileSize = bbuf.getInt(); + if (garc.version == VER_4) { + garc.contentLargestUnpadded = bbuf.getInt(); + garc.contentPadToNearest = 4; + version = 4; + } else if (garc.version == VER_6) { + garc.contentLargestPadded = bbuf.getInt(); + garc.contentLargestUnpadded = bbuf.getInt(); + garc.contentPadToNearest = bbuf.getInt(); + version = 6; + } else { + return false; + } + + // FATO + fato = new FATOFrame(); + bbuf.get(magicBuf); + magic = new String(magicBuf); + if (!magic.equals(fatoMagic)) { + return false; + } + fato.headerSize = bbuf.getInt(); + fato.entryCount = bbuf.getShort(); + fato.padding = bbuf.getShort(); + fato.entries = new int[fato.entryCount]; + for (int i = 0; i < fato.entryCount; i++) { + fato.entries[i] = bbuf.getInt(); + } + + // FATB + fatb = new FATBFrame(); + bbuf.get(magicBuf); + magic = new String(magicBuf); + if (!magic.equals(fatbMagic)) { + return false; + } + fatb.headerSize = bbuf.getInt(); + fatb.fileCount = bbuf.getInt(); + fatb.entries = new FATBEntry[fatb.fileCount]; + for (int i = 0; i < fatb.fileCount; i++) { + fatb.entries[i] = new FATBEntry(); + fatb.entries[i].vector = bbuf.getInt(); + fatb.entries[i].subEntries = new TreeMap<>(); + int bitVector = fatb.entries[i].vector; + int counter = 0; + for (int b = 0; b < 32; b++) { + boolean exists = (bitVector & 1) == 1; + bitVector >>>= 1; + if (!exists) continue; + FATBSubEntry subEntry = new FATBSubEntry(); + subEntry.start = bbuf.getInt(); + subEntry.end = bbuf.getInt(); + subEntry.length = bbuf.getInt(); + fatb.entries[i].subEntries.put(b,subEntry); + counter++; + } + fatb.entries[i].isFolder = counter > 1; + } + + // FIMB + fimb = new FIMBFrame(); + bbuf.get(magicBuf); + magic = new String(magicBuf); + if (!magic.equals(fimbMagic)) { + return false; + } + fimb.headerSize = bbuf.getInt(); + fimb.dataSize = bbuf.getInt(); + fimb.files = new ArrayList<>(); + for (int i = 0; i < fatb.fileCount; i++) { + FATBEntry entry = fatb.entries[i]; + Map<Integer,byte[]> files = new TreeMap<>(); + for (int k: entry.subEntries.keySet()) { + FATBSubEntry subEntry = entry.subEntries.get(k); + bbuf.position(garc.dataOffset + subEntry.start); + byte[] file = new byte[subEntry.length]; + boolean compressed = compressThese == null ? + bbuf.get(bbuf.position()) == 0x11 && !skipDecompression : + bbuf.get(bbuf.position()) == 0x11 && compressThese.get(i); + bbuf.get(file); + if (compressed) { + try { + files.put(k,new BLZCoder(null).BLZ_DecodePub(file,"GARC")); + isCompressed.put(i,true); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } else { + files.put(k,file); + isCompressed.put(i,false); + } + } + fimb.files.add(files); + } + return true; + } + + public void updateFiles(List<Map<Integer,byte[]>> files) { + fimb.files = files; + } + + public byte[] getBytes() throws IOException { + int garcHeaderSize = garc.version == VER_4 ? garcHeaderSize_4 : garcHeaderSize_6; + ByteBuffer garcBuf = ByteBuffer.allocate(garcHeaderSize); + garcBuf.order(ByteOrder.LITTLE_ENDIAN); + garcBuf.put(garcMagic.getBytes()); + garcBuf.putInt(garcHeaderSize); + garcBuf.putShort((short)0xFEFF); + garcBuf.putShort(version == 4 ? (short)VER_4 : (short)VER_6); + garcBuf.putInt(4); + + ByteBuffer fatoBuf = ByteBuffer.allocate(fato.headerSize); + fatoBuf.order(ByteOrder.LITTLE_ENDIAN); + fatoBuf.put(fatoMagic.getBytes()); + fatoBuf.putInt(fato.headerSize); + fatoBuf.putShort((short)fato.entryCount); + fatoBuf.putShort((short)fato.padding); + + ByteBuffer fatbBuf = ByteBuffer.allocate(fatb.headerSize); + fatbBuf.order(ByteOrder.LITTLE_ENDIAN); + fatbBuf.put(fatbMagic.getBytes()); + fatbBuf.putInt(fatb.headerSize); + fatbBuf.putInt(fatb.fileCount); + + ByteBuffer fimbHeaderBuf = ByteBuffer.allocate(fimb.headerSize); + fimbHeaderBuf.order(ByteOrder.LITTLE_ENDIAN); + fimbHeaderBuf.put(fimbMagic.getBytes()); + fimbHeaderBuf.putInt(fimb.headerSize); + + ByteArrayOutputStream fimbPayloadStream = new ByteArrayOutputStream(); // Unknown size, can't use ByteBuffer + + int fimbOffset = 0; + int largestSize = 0; + int largestPadded = 0; + for (int i = 0; i < fimb.files.size(); i++) { + Map<Integer,byte[]> directory = fimb.files.get(i); + int bitVector = 0; + int totalLength = 0; + for (int k: directory.keySet()) { + bitVector |= (1 << k); + byte[] file = directory.get(k); + if (isCompressed.get(i)) { + file = new BLZCoder(null).BLZ_EncodePub(file,false,false,"GARC"); + } + fimbPayloadStream.write(file); + totalLength += file.length; + } + + int paddingRequired = totalLength % garc.contentPadToNearest; + if (paddingRequired != 0) { + paddingRequired = garc.contentPadToNearest - paddingRequired; + } + + if (totalLength > largestSize) { + largestSize = totalLength; + } + if (totalLength + paddingRequired > largestPadded) { + largestPadded = totalLength + paddingRequired; + } + + for (int j = 0; j < paddingRequired; j++) { + fimbPayloadStream.write(fato.padding & 0xFF); + } + + fatoBuf.putInt(fatbBuf.position() - 12); + + fatbBuf.putInt(bitVector); + fatbBuf.putInt(fimbOffset); + fimbOffset = fimbPayloadStream.size(); + fatbBuf.putInt(fimbOffset); + fatbBuf.putInt(totalLength); + } + + int dataOffset = garcHeaderSize + fatoBuf.position() + fatbBuf.position() + fimb.headerSize; + garcBuf.putInt(dataOffset); + garcBuf.putInt(dataOffset + fimbOffset); + if (garc.version == VER_4) { + garcBuf.putInt(largestSize); + } else if (garc.version == VER_6) { + garcBuf.putInt(largestPadded); + garcBuf.putInt(largestSize); + garcBuf.putInt(garc.contentPadToNearest); + } + fimbHeaderBuf.putInt(fimbPayloadStream.size()); + + garcBuf.flip(); + fatoBuf.flip(); + fatbBuf.flip(); + fimbHeaderBuf.flip(); + + byte[] fullArray = new byte[garcBuf.limit() + fatoBuf.limit() + fatbBuf.limit() + fimbHeaderBuf.limit() + fimbPayloadStream.size()]; + System.arraycopy(garcBuf.array(), + 0, + fullArray, + 0, + garcBuf.limit()); + System.arraycopy(fatoBuf.array(), + 0, + fullArray, + garcBuf.limit(), + fatoBuf.limit()); + System.arraycopy(fatbBuf.array(), + 0, + fullArray, + garcBuf.limit()+fatoBuf.limit(), + fatbBuf.limit()); + System.arraycopy(fimbHeaderBuf.array(), + 0, + fullArray, + garcBuf.limit()+fatoBuf.limit()+fatbBuf.limit(), + fimbHeaderBuf.limit()); +// garcBuf.get(fullArray); +// fatoBuf.get(fullArray,garcBuf.limit(),fatoBuf.limit()); +// fatbBuf.get(fullArray,garcBuf.limit()+fatoBuf.limit(),fatbBuf.limit()); +// fimbHeaderBuf.get(fullArray,garcBuf.limit()+fatoBuf.limit()+fatbBuf.limit(),fimbHeaderBuf.limit()); + System.arraycopy(fimbPayloadStream.toByteArray(), + 0, + fullArray, + garcBuf.limit()+fatoBuf.limit()+fatbBuf.limit()+fimbHeaderBuf.limit(), + fimbPayloadStream.size()); + return fullArray; + } + + + + public byte[] getFile(int index) { + return fimb.files.get(index).get(0); + } + + public byte[] getFile(int index, int subIndex) { + return fimb.files.get(index).get(subIndex); + } + + public void setFile(int index, byte[] data) { + fimb.files.get(index).put(0,data); + } + + public Map<Integer,byte[]> getDirectory(int index) { + return fimb.files.get(index); + } + + private class GARCFrame { + int headerSize; + int endianness; + int version; + int dataOffset; + int fileSize; + + int contentLargestPadded; + int contentLargestUnpadded; + int contentPadToNearest; + } + + private class FATOFrame { + int headerSize; + int entryCount; + int padding; + + int[] entries; + } + + private class FATBFrame { + int headerSize; + int fileCount; + FATBEntry[] entries; + } + + private class FATBEntry { + int vector; + boolean isFolder; + Map<Integer,FATBSubEntry> subEntries; + } + + private class FATBSubEntry { + boolean exists; + int start; + int end; + int length; + int padding; + } + + private class FIMBFrame { + int headerSize; + int dataSize; + List<Map<Integer,byte[]>> files; + } +} diff --git a/src/com/pkrandom/ctr/Mini.java b/src/com/pkrandom/ctr/Mini.java new file mode 100644 index 0000000..82daa08 --- /dev/null +++ b/src/com/pkrandom/ctr/Mini.java @@ -0,0 +1,102 @@ +package com.pkrandom.ctr; + +/*----------------------------------------------------------------------------*/ +/*-- Mini.java - class for packing/unpacking Mini archives --*/ +/*-- --*/ +/*-- Code based on "pk3DS", copyright (C) Kaphotics --*/ +/*-- --*/ +/*-- Ported to Java by UPR-ZX Team under the terms of the GPL: --*/ +/*-- --*/ +/*-- 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 java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +public class Mini { + public static byte[] PackMini(byte[][] fileData, String identifier) throws IOException { + // Create new Binary with the relevant header bytes + byte[] data = new byte[4]; + data[0] = (byte) identifier.charAt(0); + data[1] = (byte) identifier.charAt(1); + ByteBuffer buf = ByteBuffer.allocate(2); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.putShort((short) fileData.length); + System.arraycopy(buf.array(), 0, data, 2, 2); + + int count = fileData.length; + int dataOffset = 4 + 4 + (count * 4); + + // Start the data filling + ByteArrayOutputStream dataOut = new ByteArrayOutputStream(); + ByteArrayOutputStream offsetMap = new ByteArrayOutputStream(); + // For each file... + for (int i = 0; i < count; i++) { + int fileOffset = dataOut.size() + dataOffset; + buf = ByteBuffer.allocate(4); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.putInt(fileOffset); + offsetMap.write(buf.array()); + dataOut.write(fileData[i]); + + // Pad with zeroes until len % 4 == 0 + while (dataOut.size() % 4 != 0) { + dataOut.write((byte) 0); + } + } + // Cap the file + buf = ByteBuffer.allocate(4); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.putInt(dataOut.size() + dataOffset); + offsetMap.write(buf.array()); + + ByteArrayOutputStream newPack = new ByteArrayOutputStream(); + ByteArrayOutputStream header = new ByteArrayOutputStream(); + header.write(data); + header.writeTo(newPack); + offsetMap.writeTo(newPack); + dataOut.writeTo(newPack); + return newPack.toByteArray(); + } + + public static byte[][] UnpackMini(byte[] fileData, String identifier) { + if (fileData == null || fileData.length < 4) { + return null; + } + + if (identifier.charAt(0) != fileData[0] || identifier.charAt(1) != fileData[1]) { + return null; + } + + int count = FileFunctions.read2ByteInt(fileData, 2); + int ctr = 4; + int start = FileFunctions.readFullInt(fileData, ctr); + ctr += 4; + byte[][] returnData = new byte[count][]; + for (int i = 0; i < count; i++) { + int end = FileFunctions.readFullInt(fileData, ctr); + ctr += 4; + int len = end - start; + byte[] data = new byte[len]; + System.arraycopy(fileData, start, data, 0, len); + returnData[i] = data; + start = end; + } + return returnData; + } +} 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; + } + } +} diff --git a/src/com/pkrandom/ctr/RomfsFile.java b/src/com/pkrandom/ctr/RomfsFile.java new file mode 100644 index 0000000..30e9f7c --- /dev/null +++ b/src/com/pkrandom/ctr/RomfsFile.java @@ -0,0 +1,121 @@ +package com.pkrandom.ctr; + +/*----------------------------------------------------------------------------*/ +/*-- RomfsFile.java - an entry in the romfs filesystem --*/ +/*-- --*/ +/*-- 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 java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.RandomAccessFile; + +public class RomfsFile { + + private NCCH parent; + public long offset; + public int size; + public String fullPath; + private Extracted status = Extracted.NOT; + private String extFilename; + public byte[] data; + public boolean fileChanged = false; + public long originalCRC; + + public RomfsFile(NCCH parent) { + this.parent = parent; + } + + public byte[] getContents() throws IOException { + if (this.status == Extracted.NOT) { + // extract file + parent.reopenROM(); + RandomAccessFile rom = parent.getBaseRom(); + byte[] buf = new byte[this.size]; + rom.seek(this.offset); + rom.readFully(buf); + originalCRC = FileFunctions.getCRC32(buf); + if (parent.isWritingEnabled()) { + // make a file + String tmpDir = parent.getTmpFolder(); + this.extFilename = fullPath.replaceAll("[^A-Za-z0-9_\\.]+", ""); + File tmpFile = new File(tmpDir + extFilename); + FileOutputStream fos = new FileOutputStream(tmpFile); + fos.write(buf); + fos.close(); + tmpFile.deleteOnExit(); + this.status = Extracted.TO_FILE; + this.data = null; + return buf; + } else { + this.status = Extracted.TO_RAM; + this.data = buf; + byte[] newcopy = new byte[buf.length]; + System.arraycopy(buf, 0, newcopy, 0, buf.length); + return newcopy; + } + } else if (this.status == Extracted.TO_RAM) { + byte[] newcopy = new byte[this.data.length]; + System.arraycopy(this.data, 0, newcopy, 0, this.data.length); + return newcopy; + } else { + String tmpDir = parent.getTmpFolder(); + return FileFunctions.readFileFullyIntoBuffer(tmpDir + this.extFilename); + } + } + + public void writeOverride(byte[] data) throws IOException { + if (status == Extracted.NOT) { + // temp extract + getContents(); + } + fileChanged = true; + size = data.length; + if (status == Extracted.TO_FILE) { + String tmpDir = parent.getTmpFolder(); + FileOutputStream fos = new FileOutputStream(new File(tmpDir + this.extFilename)); + fos.write(data); + fos.close(); + } else { + if (this.data.length == data.length) { + // copy new in + System.arraycopy(data, 0, this.data, 0, data.length); + } else { + // make new array + this.data = null; + this.data = new byte[data.length]; + System.arraycopy(data, 0, this.data, 0, data.length); + } + } + } + + // returns null if no override + public byte[] getOverrideContents() throws IOException { + if (status == Extracted.NOT) { + return null; + } + return getContents(); + } + + private enum Extracted { + NOT, TO_FILE, TO_RAM + } +} diff --git a/src/com/pkrandom/ctr/SMDH.java b/src/com/pkrandom/ctr/SMDH.java new file mode 100644 index 0000000..070809b --- /dev/null +++ b/src/com/pkrandom/ctr/SMDH.java @@ -0,0 +1,118 @@ +package com.pkrandom.ctr; + +/*----------------------------------------------------------------------------*/ +/*-- NCCH.java - a base class for dealing with 3DS SMDH (icon.bin) files. --*/ +/*-- --*/ +/*-- 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 java.nio.charset.StandardCharsets; + +public class SMDH { + + private byte[] data; + private String[] shortDescriptions = new String[12]; + private String[] longDescriptions = new String[12]; + private String[] publishers = new String[12]; + + private static final int smdh_magic = 0x48444D53; + private static final int length_of_title = 0x200; + private static final int short_description_length = 0x80; + private static final int long_description_length = 0x100; + private static final int publisher_length = 0x80; + + public SMDH(byte[] smdhData) { + data = smdhData; + if (this.isValid()) { + readDescriptionsAndPublishers(); + } + } + + public byte[] getBytes() { + return data; + } + + public void setAllDescriptions(String newDescription) { + byte[] newDescriptionBytes = newDescription.getBytes(StandardCharsets.UTF_16LE); + if (newDescriptionBytes.length <= short_description_length) { + for (int i = 0; i < 12; i++) { + shortDescriptions[i] = newDescription; + longDescriptions[i] = newDescription; + } + writeDescriptionsAndPublishers(); + } + } + + public void setAllPublishers(String newPublisher) { + byte[] newPublisherBytes = newPublisher.getBytes(StandardCharsets.UTF_16LE); + if (newPublisherBytes.length <= publisher_length) { + for (int i = 0; i < 12; i++) { + publishers[i] = newPublisher; + } + writeDescriptionsAndPublishers(); + } + } + + private boolean isValid() { + int magic = FileFunctions.readFullInt(data, 0x0); + return magic == smdh_magic; + } + + private void readDescriptionsAndPublishers() { + for (int i = 0; i < 12; i++) { + int shortDescriptionOffset = 0x08 + (length_of_title * i); + byte[] shortDescriptionBytes = new byte[short_description_length]; + System.arraycopy(data, shortDescriptionOffset, shortDescriptionBytes, 0, short_description_length); + shortDescriptions[i] = new String(shortDescriptionBytes, StandardCharsets.UTF_16LE).trim(); + + int longDescriptionOffset = 0x88 + (length_of_title * i); + byte[] longDescriptionBytes = new byte[long_description_length]; + System.arraycopy(data, longDescriptionOffset, longDescriptionBytes, 0, long_description_length); + longDescriptions[i] = new String(longDescriptionBytes, StandardCharsets.UTF_16LE).trim(); + + int publisherOffset = 0x188 + (length_of_title * i); + byte[] publisherBytes = new byte[publisher_length]; + System.arraycopy(data, publisherOffset, publisherBytes, 0, publisher_length); + publishers[i] = new String(publisherBytes, StandardCharsets.UTF_16LE).trim(); + } + } + + private void writeDescriptionsAndPublishers() { + for (int i = 0; i < 12; i++) { + byte[] emptyShortDescription = new byte[short_description_length]; + int shortDescriptionOffset = 0x08 + (length_of_title * i); + byte[] shortDescriptionBytes = shortDescriptions[i].getBytes(StandardCharsets.UTF_16LE); + System.arraycopy(emptyShortDescription, 0, data, shortDescriptionOffset, short_description_length); + System.arraycopy(shortDescriptionBytes, 0, data, shortDescriptionOffset, shortDescriptionBytes.length); + + byte[] emptyLongDescription = new byte[long_description_length]; + int longDescriptionOffset = 0x88 + (length_of_title * i); + byte[] longDescriptionBytes = longDescriptions[i].getBytes(StandardCharsets.UTF_16LE); + System.arraycopy(emptyLongDescription, 0, data, longDescriptionOffset, long_description_length); + System.arraycopy(longDescriptionBytes, 0, data, longDescriptionOffset, longDescriptionBytes.length); + + byte[] emptyPublisher = new byte[publisher_length]; + int publisherOffset = 0x188 + (length_of_title * i); + byte[] publisherBytes = publishers[i].getBytes(StandardCharsets.UTF_16LE); + System.arraycopy(emptyPublisher, 0, data, publisherOffset, publisher_length); + System.arraycopy(publisherBytes, 0, data, publisherOffset, publisherBytes.length); + } + } +} |