/* This file is part of jpcsp. Jpcsp 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. Jpcsp 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 Jpcsp. If not, see <http://www.gnu.org/licenses/>. */ package jpcsp.format; import static jpcsp.util.Utilities.endianSwap32; import java.awt.Graphics2D; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; import java.util.HashMap; import java.util.Map; import javax.imageio.ImageIO; import jpcsp.GUI.UmdVideoPlayer; import jpcsp.format.rco.AnimFactory; import jpcsp.format.rco.LZR; import jpcsp.format.rco.ObjectFactory; import jpcsp.format.rco.RCOContext; import jpcsp.format.rco.RCOState; import jpcsp.format.rco.SoundFactory; import jpcsp.format.rco.object.BaseObject; import jpcsp.format.rco.object.ImageObject; import jpcsp.format.rco.vsmx.VSMX; import jpcsp.format.rco.vsmx.interpreter.VSMXBaseObject; import jpcsp.format.rco.vsmx.interpreter.VSMXInterpreter; import jpcsp.format.rco.vsmx.objects.Controller; import jpcsp.format.rco.vsmx.objects.GlobalVariables; import jpcsp.format.rco.vsmx.objects.MoviePlayer; import jpcsp.format.rco.vsmx.objects.Resource; import jpcsp.util.Utilities; import org.apache.log4j.Logger; public class RCO { public static final Logger log = Logger.getLogger("rco"); private static final boolean dumpImages = false; private static final int RCO_HEADER_SIZE = 164; private static final int RCO_MAGIC = 0x00505246; private static final int RCO_NULL_PTR = 0xFFFFFFFF; public static final int RCO_TABLE_MAIN = 1; public static final int RCO_TABLE_VSMX = 2; public static final int RCO_TABLE_TEXT = 3; public static final int RCO_TABLE_IMG = 4; public static final int RCO_TABLE_MODEL = 5; public static final int RCO_TABLE_SOUND = 6; public static final int RCO_TABLE_FONT = 7; public static final int RCO_TABLE_OBJ = 8; public static final int RCO_TABLE_ANIM = 9; public static final int RCO_DATA_COMPRESSION_NONE = 0; public static final int RCO_DATA_COMPRESSION_ZLIB = 1; public static final int RCO_DATA_COMPRESSION_RLZ = 2; private static final Charset textDataCharset = Charset.forName("UTF-16LE"); private byte[] buffer; private int offset; private boolean valid; private int pVSMXTable; private int pTextData; private int lTextData; private int pLabelData; private int lLabelData; private int pImgData; private int lImgData; private RCOEntry mainTable; private int[] compressedTextDataOffset; private Map<Integer, RCOEntry> entries; private Map<Integer, String> events; private Map<Integer, BufferedImage> images; private Map<Integer, BaseObject> objects; public class RCOEntry { private static final int RCO_ENTRY_SIZE = 40; public int type; // main table uses 0x01; may be used as a current entry depth value public int id; public int labelOffset; public String label; public int eHeadSize; public int entrySize; public int numSubEntries; public int nextEntryOffset; public int prevEntryOffset; public int parentTblOffset; public RCOEntry subEntries[]; public RCOEntry parent; public byte data[]; public BaseObject obj; public String[] texts; public VSMXBaseObject vsmxBaseObject; public void read() { int entryOffset = tell(); type = read8(); id = read8(); skip16(); labelOffset = read32(); eHeadSize = read32(); entrySize = read32(); numSubEntries = read32(); nextEntryOffset = read32(); prevEntryOffset = read32(); parentTblOffset = read32(); skip32(); skip32(); entries.put(entryOffset, this); if (parentTblOffset != 0) { parent = entries.get(entryOffset - parentTblOffset); } if (labelOffset != RCO_NULL_PTR) { label = readLabel(labelOffset); } if (log.isDebugEnabled()) { log.debug(String.format("RCO entry at offset 0x%X: %s", entryOffset, toString())); } switch (id) { case RCO_TABLE_MAIN: if (type != 1) { log.warn(String.format("Unknown RCO entry type 0x%X at offset 0x%X", type, entryOffset)); } break; case RCO_TABLE_VSMX: if (type == 1) { int offsetVSMX = read32(); int lengthVSMX = read32(); skip(offsetVSMX); data = readBytes(lengthVSMX); // 4-bytes alignment skip(Utilities.alignUp(lengthVSMX, 3) - lengthVSMX); } else { log.warn(String.format("Unknown RCO entry type 0x%X at offset 0x%X", type, entryOffset)); } break; case RCO_TABLE_IMG: case RCO_TABLE_MODEL: if (type == 1) { int format = read16(); int compression = read16(); int sizePacked = read32(); int offset = read32(); int sizeUnpacked; // this value doesn't exist if entry isn't compressed if (compression != RCO_DATA_COMPRESSION_NONE) { sizeUnpacked = read32(); } else { sizeUnpacked = sizePacked; } if (id == RCO_TABLE_IMG) { BufferedImage image = readImage(offset, sizePacked); if (image != null) { obj = new ImageObject(image); images.put(entryOffset, image); } } if (log.isDebugEnabled()) { log.debug(String.format("RCO entry %s: format=%d, compression=%d, sizePacked=0x%X, offset=0x%X, sizeUnpacked=0x%X", id == RCO_TABLE_IMG ? "IMG" : "MODEL", format, compression, sizePacked, offset, sizeUnpacked)); } } else if (type != 0) { log.warn(String.format("Unknown RCO entry type 0x%X at offset 0x%X", type, entryOffset)); } break; case RCO_TABLE_SOUND: if (type == 1) { int format = read16(); // 0x01 = VAG int channels = read16(); // 1 or 2 channels int sizeTotal = read32(); int offset = read32(); int[] channelSize = new int[channels]; int[] channelOffset = new int[channels]; // now pairs of size/offset for each channel if (log.isDebugEnabled()) { log.debug(String.format("RCO entry SOUND: format=%d, channels=%d, sizeTotal=0x%X, offset=0x%X", format, channels, sizeTotal, offset)); } for (int channel = 0; channel < channels; channel++) { channelSize[channel] = read32(); channelOffset[channel] = read32(); if (log.isDebugEnabled()) { log.debug(String.format("Channel %d: size=0x%X, offset=0x%X", channel, channelSize[channel], channelOffset[channel])); } } obj = SoundFactory.newSound(format, channels, channelSize, channelOffset); // there _must_ be two channels defined (no clear indication of size otherwise) if (channels < 2) { for (int i = channels; i < 2; i++) { int dummyChannelSize = read32(); int dummyChannelOffset = read32(); if (log.isTraceEnabled()) { log.trace(String.format("Dummy channel %d: size=0x%X, offset=0x%X", i, dummyChannelSize, dummyChannelOffset)); } } } } else if (type != 0) { log.warn(String.format("Unknown RCO entry type 0x%X at offset 0x%X", type, entryOffset)); } break; case RCO_TABLE_OBJ: if (type > 0) { obj = ObjectFactory.newObject(type); if (obj != null && entrySize == 0) { entrySize = obj.size() + RCO_ENTRY_SIZE; } if (entrySize > RCO_ENTRY_SIZE) { int dataLength = entrySize - RCO_ENTRY_SIZE; data = readBytes(dataLength); if (log.isTraceEnabled()) { log.trace(String.format("OBJ data at 0x%X: %s", entryOffset + RCO_ENTRY_SIZE, Utilities.getMemoryDump(data, 0, dataLength))); } if (obj != null) { RCOContext context = new RCOContext(data, 0, events, images, objects); obj.read(context); if (context.offset != dataLength) { log.warn(String.format("Incorrect length data for ANIM")); } objects.put(entryOffset, obj); if (log.isDebugEnabled()) { log.debug(String.format("OBJ: %s", obj)); } } } } break; case RCO_TABLE_ANIM: if (type > 0) { obj = AnimFactory.newAnim(type); if (obj != null && entrySize == 0) { entrySize = obj.size() + RCO_ENTRY_SIZE; } if (entrySize > RCO_ENTRY_SIZE) { int dataLength = entrySize - RCO_ENTRY_SIZE; data = readBytes(dataLength); if (log.isTraceEnabled()) { log.trace(String.format("ANIM data at 0x%X: %s", entryOffset + RCO_ENTRY_SIZE, Utilities.getMemoryDump(data, 0, dataLength))); } if (obj != null) { RCOContext context = new RCOContext(data, 0, events, images, objects); obj.read(context); if (context.offset != dataLength) { log.warn(String.format("Incorrect length data for ANIM")); } objects.put(entryOffset, obj); if (log.isDebugEnabled()) { log.debug(String.format("ANIM: %s", obj)); } } } } break; case RCO_TABLE_FONT: if (type == 1) { int format = read16(); int compression = read16(); int unknown1 = read32(); int unknown2 = read32(); if (log.isDebugEnabled()) { log.debug(String.format("RCO entry FONT: format=%d, compression=%d, unknown1=0x%X, unknown2=0x%X", format, compression, unknown1, unknown2)); } } else if (type != 0) { log.warn(String.format("Unknown RCO FONT entry type 0x%X at offset 0x%X", type, entryOffset)); } break; case RCO_TABLE_TEXT: if (type == 1) { int lang = read16(); int format = read16(); int numIndexes = read32(); if (log.isDebugEnabled()) { log.debug(String.format("RCO entry TEXT: lang=%d, format=%d, numIndexes=0x%X", lang, format, numIndexes)); } texts = new String[numIndexes]; for (int i = 0; i < numIndexes; i++) { int labelOffset = read32(); int length = read32(); int offset = read32(); texts[i] = readText(lang, offset, length); if (log.isDebugEnabled()) { log.debug(String.format("RCO entry TEXT Index#%d: labelOffset=%d, length=%d, offset=0x%X; '%s'", i, labelOffset, length, offset, texts[i])); } } } else if (type != 0) { log.warn(String.format("Unknown RCO TEXT entry type 0x%X at offset 0x%X", type, entryOffset)); } break; default: log.warn(String.format("Unknown RCO entry id 0x%X at offset 0x%X", id, entryOffset)); break; } if (numSubEntries > 0) { subEntries = new RCOEntry[numSubEntries]; for (int i = 0; i < numSubEntries; i++) { subEntries[i] = new RCOEntry(); subEntries[i].read(); } } } private String getIdName(int id) { String idNames[] = new String[] { null, "MAIN", "VSMX", "TEXT", "IMG", "MODEL", "SOUND", "FONT", "OBJ", "ANIM" }; if (id < 0 || id >= idNames.length || idNames[id] == null) { return String.format("0x%X", id); } return idNames[id]; } @Override public String toString() { return String.format("RCOEntry[type=0x%X, id=%s, labelOffset=0x%X('%s'), eHeadSize=0x%X, entrySize=0x%X, numSubEntries=%d, nextEntryOffset=0x%X, prevEntryOffset=0x%X, parentTblOffset=0x%X", type, getIdName(id), labelOffset, label != null ? label : "", eHeadSize, entrySize, numSubEntries, nextEntryOffset, prevEntryOffset, parentTblOffset); } } private int read8() { return buffer[offset++] & 0xFF; } private int read16() { return read8() | (read8() << 8); } private int read32() { return read16() | (read16() << 16); } private void skip(int n) { offset += n; } private void skip32() { skip(4); } private void skip16() { skip(2); } private void seek(int offset) { this.offset = offset; } private int tell() { return offset; } public RCO(byte[] buffer) { this.buffer = buffer; valid = read(); } public boolean isValid() { return valid; } private RCOEntry readRCOEntry() { RCOEntry entry = new RCOEntry(); entry.read(); return entry; } private RCOEntry readRCOEntry(int offset) { seek(offset); return readRCOEntry(); } private boolean isNull(int ptr) { return ptr == RCO_NULL_PTR; } private byte[] readBytes(int length) { if (length < 0) { return null; } byte[] bytes = new byte[length]; for (int i = 0; i < length; i++) { bytes[i] = (byte) read8(); } return bytes; } private byte[] readVSMX(int offset, StringBuilder name) { if (isNull(offset)) { return null; } RCOEntry entry = readRCOEntry(offset); name.append(entry.label); return entry.data; } private String readLabel(int labelOffset) { StringBuilder s = new StringBuilder(); int currentPosition = tell(); seek(pLabelData + labelOffset); for (int maxLength = lLabelData - labelOffset; maxLength > 0; maxLength--) { int b = read8(); if (b == 0) { break; } s.append((char) b); } seek(currentPosition); return s.toString(); } private String readText(int lang, int offset, int length) { if (offset == RCO_NULL_PTR) { return null; } int currentPosition = tell(); if (compressedTextDataOffset != null) { seek(compressedTextDataOffset[lang] + offset); } else { seek(pTextData + offset); } byte[] buffer = readBytes(length); seek(currentPosition); // Trailing null bytes? if (length >= 2 && buffer[length - 1] == (byte) 0 && buffer[length - 2] == (byte) 0) { // Remove trailing null bytes length -= 2; } return new String(buffer, 0, length, textDataCharset); } private BufferedImage readImage(int offset, int length) { int currentPosition = tell(); seek(pImgData + offset); byte[] buffer = readBytes(length); seek(currentPosition); InputStream imageInputStream = new ByteArrayInputStream(buffer); BufferedImage bufferedImage = null; try { bufferedImage = ImageIO.read(imageInputStream); imageInputStream.close(); // Add an alpha color channel if not available if (!bufferedImage.getColorModel().hasAlpha()) { BufferedImage bufferedImageWithAlpha = new BufferedImage(bufferedImage.getWidth(), bufferedImage.getHeight(), BufferedImage.TYPE_INT_ARGB); Graphics2D g = bufferedImageWithAlpha.createGraphics(); g.drawImage(bufferedImage, 0, 0, null); g.dispose(); bufferedImage = bufferedImageWithAlpha; } if (dumpImages) { ImageIO.write(bufferedImage, "png", new File(String.format("tmp/Image0x%X.png", offset))); } } catch (IOException e) { log.error(String.format("Error reading image from RCO at 0x%X, length=0x%X", offset, length), e); } return bufferedImage; } private String readString() { StringBuilder s = new StringBuilder(); while (true) { int b = read8(); if (b == 0) { break; } s.append((char) b); } return s.toString(); } private static byte[] append(byte[] a, byte[] b) { if (a == null || a.length == 0) { return b; } if (b == null || b.length == 0) { return a; } byte[] ab = new byte[a.length + b.length]; System.arraycopy(a, 0, ab, 0, a.length); System.arraycopy(b, 0, ab, a.length, b.length); return ab; } private static byte[] append(byte[] a, int length, byte[] b) { if (a == null || a.length == 0 || length <= 0) { return b; } if (b == null || b.length == 0) { return a; } length = Math.min(a.length, length); byte[] ab = new byte[length + b.length]; System.arraycopy(a, 0, ab, 0, length); System.arraycopy(b, 0, ab, length, b.length); return ab; } private static int[] extend(int[] a, int length) { if (a == null) { return new int[length]; } if (a.length >= length) { return a; } int[] b = new int[length]; System.arraycopy(a, 0, b, 0, a.length); return b; } /** * Read a RCO file. * See description of an RCO file structure in * https://github.com/kakaroto/RCOMage/blob/master/src/rcofile.h * * @return true RCO file is valid * false RCO file is invalid */ private boolean read() { int magic = endianSwap32(read32()); if (magic != RCO_MAGIC) { log.warn(String.format("Invalid RCO magic 0x%08X", magic)); return false; } int version = read32(); if (log.isDebugEnabled()) { log.debug(String.format("RCO version 0x%X", version)); } skip32(); // null int compression = read32(); int umdFlag = compression & 0x0F; int headerCompression = (compression & 0xF0) >> 4; if (log.isDebugEnabled()) { log.debug(String.format("umdFlag=0x%X, headerCompression=0x%X", umdFlag, headerCompression)); } int pMainTable = read32(); pVSMXTable = read32(); int pTextTable = read32(); int pSoundTable = read32(); int pModelTable = read32(); int pImgTable = read32(); skip32(); // pUnknown int pFontTable = read32(); int pObjTable = read32(); int pAnimTable = read32(); pTextData = read32(); lTextData = read32(); pLabelData = read32(); lLabelData = read32(); int pEventData = read32(); int lEventData = read32(); int pTextPtrs = read32(); int lTextPtrs = read32(); int pImgPtrs = read32(); int lImgPtrs = read32(); int pModelPtrs = read32(); int lModelPtrs = read32(); int pSoundPtrs = read32(); int lSoundPtrs = read32(); int pObjPtrs = read32(); int lObjPtrs = read32(); int pAnimPtrs = read32(); int lAnimPtrs = read32(); pImgData = read32(); lImgData = read32(); int pSoundData = read32(); int lSoundData = read32(); int pModelData = read32(); int lModelData = read32(); skip32(); // Always 0xFFFFFFFF skip32(); // Always 0xFFFFFFFF skip32(); // Always 0xFFFFFFFF if (log.isDebugEnabled()) { log.debug(String.format("pMainTable=0x%X, pVSMXTable=0x%X, pTextTable=0x%X, pSoundTable=0x%X, pModelTable=0x%X, pImgTable=0x%X, pFontTable=0x%X, pObjTable=0x%X, pAnimTable=0x%X", pMainTable, pVSMXTable, pTextTable, pSoundTable, pModelTable, pImgTable, pFontTable, pObjTable, pAnimTable)); log.debug(String.format("TextData=0x%X[0x%X], LabelData=0x%X[0x%X], EventData=0x%X[0x%X]", pTextData, lTextData, pLabelData, lLabelData, pEventData, lEventData)); log.debug(String.format("TextPtrs=0x%X[0x%X], ImgPtrs=0x%X[0x%X], ModelPtrs=0x%X[0x%X], SoundPtrs=0x%X[0x%X], ObjPtrs=0x%X[0x%X], AnimPtrs=0x%X[0x%X]", pTextPtrs, lTextPtrs, pImgPtrs, lImgPtrs, pModelPtrs, lModelPtrs, pSoundPtrs, lSoundPtrs, pObjPtrs, lObjPtrs, pAnimPtrs, lAnimPtrs)); log.debug(String.format("ImgData=0x%X[0x%X], SoundData=0x%X[0x%X], ModelData=0x%X[0x%X]", pImgData, lImgData, pSoundData, lSoundData, pModelData, lModelData)); } if (headerCompression != 0) { int lenPacked = read32(); int lenUnpacked = read32(); int lenLongestText = read32(); byte[] packedBuffer = readBytes(lenPacked); byte[] unpackedBuffer = new byte[lenUnpacked]; int result; if (headerCompression == RCO_DATA_COMPRESSION_RLZ) { result = LZR.decompress(unpackedBuffer, lenUnpacked, packedBuffer); } else { log.warn(String.format("Unimplemented compression %d", headerCompression)); result = -1; } if (log.isTraceEnabled()) { log.trace(String.format("Unpack header longestText=0x%X, result=0x%X: %s", lenLongestText, result, Utilities.getMemoryDump(unpackedBuffer, 0, lenUnpacked))); } if (pTextData != RCO_NULL_PTR && lTextData > 0) { seek(pTextData); int nextOffset; do { int textLang = read16(); skip16(); nextOffset = read32(); int textLenPacked = read32(); int textLenUnpacked = read32(); byte[] textPackedBuffer = readBytes(textLenPacked); byte[] textUnpackedBuffer = new byte[textLenUnpacked]; if (headerCompression == RCO_DATA_COMPRESSION_RLZ) { result = LZR.decompress(textUnpackedBuffer, textLenUnpacked, textPackedBuffer); } else { log.warn(String.format("Unimplemented compression %d", headerCompression)); result = -1; } if (log.isTraceEnabled()) { log.trace(String.format("Unpack text lang=%d, result=0x%X: %s", textLang, result, Utilities.getMemoryDump(textUnpackedBuffer, 0, textLenUnpacked))); } if (result >= 0) { compressedTextDataOffset = extend(compressedTextDataOffset, textLang + 1); compressedTextDataOffset[textLang] = unpackedBuffer.length + RCO_HEADER_SIZE; unpackedBuffer = append(unpackedBuffer, textUnpackedBuffer); } if (nextOffset == 0) { break; } skip(nextOffset - 16 - textLenPacked); } while (nextOffset != 0); } if (result >= 0) { buffer = append(buffer, RCO_HEADER_SIZE, unpackedBuffer); } } events = new HashMap<Integer, String>(); if (pEventData != RCO_NULL_PTR && lEventData > 0) { seek(pEventData); while (tell() < pEventData + lEventData) { int index = tell() - pEventData; String s = readString(); if (s != null && s.length() > 0) { events.put(index, s); } } } entries = new HashMap<Integer, RCO.RCOEntry>(); images = new HashMap<Integer, BufferedImage>(); objects = new HashMap<Integer, BaseObject>(); mainTable = readRCOEntry(pMainTable); if (log.isDebugEnabled()) { log.debug(String.format("mainTable: %s", mainTable)); } if (pObjPtrs != RCO_NULL_PTR) { seek(pObjPtrs); for (int i = 0; i < lObjPtrs; i += 4) { int objPtr = read32(); if (objPtr != 0 && !objects.containsKey(objPtr)) { log.warn(String.format("Object 0x%X not read", objPtr)); } } } if (pImgPtrs != RCO_NULL_PTR) { seek(pImgPtrs); for (int i = 0; i < lImgPtrs; i += 4) { int imgPtr = read32(); if (imgPtr != 0 && !images.containsKey(imgPtr)) { log.warn(String.format("Image 0x%X not read", imgPtr)); } } } RCOContext context = new RCOContext(null, 0, events, images, objects); for (BaseObject object : objects.values()) { object.init(context); } return true; } public RCOState execute(UmdVideoPlayer umdVideoPlayer, String resourceName) { RCOState state = null; if (pVSMXTable != RCO_NULL_PTR) { state = new RCOState(); state.interpreter = new VSMXInterpreter(); state.controller = Controller.create(state.interpreter, umdVideoPlayer, resourceName); state = execute(state, umdVideoPlayer, resourceName); state.controller.getObject().callCallback(state.interpreter, "onAutoPlay", null); } return state; } public RCOState execute(RCOState state, UmdVideoPlayer umdVideoPlayer, String resourceName) { if (pVSMXTable != RCO_NULL_PTR) { StringBuilder vsmxName = new StringBuilder(); VSMX vsmx = new VSMX(readVSMX(pVSMXTable, vsmxName), vsmxName.toString()); state.interpreter.setVSMX(vsmx); state.globalVariables = GlobalVariables.create(state.interpreter); state.globalVariables.setPropertyValue(Controller.objectName, state.controller); state.globalVariables.setPropertyValue(MoviePlayer.objectName, MoviePlayer.create(state.interpreter, umdVideoPlayer, state.controller)); state.globalVariables.setPropertyValue(Resource.objectName, Resource.create(state.interpreter, umdVideoPlayer.getRCODisplay(), state.controller, mainTable)); state.globalVariables.setPropertyValue(jpcsp.format.rco.vsmx.objects.Math.objectName, jpcsp.format.rco.vsmx.objects.Math.create(state.interpreter)); state.interpreter.run(state.globalVariables); } return state; } @Override public String toString() { return String.format("RCO valid=%b", valid); } }