From d3e3cbd017f8022381519d8da372ea5293e59e7c Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Tue, 15 Sep 2026 22:34:18 -0400 Subject: [PATCH] Add RCO and VSMX parsers (menu foundation) - Lzr: RLZ decompressor ported from JPCSP (for compressed RCO data). - RcoFile: parses RCO header, entry tree (labels, VSMX blob, image/ sound/font/object/animation tables, text), and event name table. - RcoEntrySizes: fixed binary sizes of the 26 object and 11 animation record types, needed to locate entries with entrySize=0. - Vsmx: parses VSMX bytecode (code groups + string tables) and disassembles to human-readable opcodes; VsmxCode holds the opcode table. - CLI 'rco' command dumps events, entry tree, and VSMX disassembly. - Validated against Batman Begins EN/FR menus: full scene graph (pages/planes/buttons/groups) and 5585-bytecode scripts decoded. --- .../main/java/dev/quoije/umd/cli/Main.java | 61 ++- .../src/main/java/dev/quoije/umd/rco/Lzr.java | 222 ++++++++++ .../dev/quoije/umd/rco/RcoEntrySizes.java | 74 ++++ .../main/java/dev/quoije/umd/rco/RcoFile.java | 379 ++++++++++++++++++ .../java/dev/quoije/umd/rco/vsmx/Vsmx.java | 180 +++++++++ .../dev/quoije/umd/rco/vsmx/VsmxCode.java | 161 ++++++++ 6 files changed, 1076 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/dev/quoije/umd/rco/Lzr.java create mode 100644 core/src/main/java/dev/quoije/umd/rco/RcoEntrySizes.java create mode 100644 core/src/main/java/dev/quoije/umd/rco/RcoFile.java create mode 100644 core/src/main/java/dev/quoije/umd/rco/vsmx/Vsmx.java create mode 100644 core/src/main/java/dev/quoije/umd/rco/vsmx/VsmxCode.java diff --git a/cli/src/main/java/dev/quoije/umd/cli/Main.java b/cli/src/main/java/dev/quoije/umd/cli/Main.java index 8c756ce..aa9463d 100644 --- a/cli/src/main/java/dev/quoije/umd/cli/Main.java +++ b/cli/src/main/java/dev/quoije/umd/cli/Main.java @@ -9,18 +9,21 @@ import dev.quoije.umd.mps.AudioFramer; import dev.quoije.umd.mps.MpsDemuxer; import dev.quoije.umd.player.PlaybackEngine; import dev.quoije.umd.playlist.Playlist; +import dev.quoije.umd.rco.RcoFile; +import dev.quoije.umd.rco.vsmx.Vsmx; import dev.quoije.umd.sfo.Sfo; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.util.List; import java.util.Map; /** Command-line front-end for the UMD player library. */ public final class Main { - private static final String USAGE = "usage: umdplayer dump \n umdplayer mps \n umdplayer decode \n umdplayer audio [count]\n umdplayer adecode [maxPackets]\n umdplayer play [seconds]"; + private static final String USAGE = "usage: umdplayer dump \n umdplayer mps \n umdplayer decode \n umdplayer audio [count]\n umdplayer adecode [maxPackets]\n umdplayer play [seconds]\n umdplayer rco "; private Main() { } @@ -40,6 +43,7 @@ public final class Main { case "audio" -> audio(requireArg(args, 1), requireArg(args, 2), args.length > 3 ? Integer.parseInt(args[3]) : 8); case "adecode" -> adecode(requireArg(args, 1), requireArg(args, 2), args.length > 3 ? Integer.parseInt(args[3]) : 500); case "play" -> play(requireArg(args, 1), requireArg(args, 2), args.length > 3 ? Double.parseDouble(args[3]) : 5.0); + case "rco" -> rco(requireArg(args, 1), requireArg(args, 2)); case "help", "--help", "-h" -> { System.out.println(USAGE); yield 0; @@ -384,6 +388,61 @@ public final class Main { return 0; } + private static int rco(String imagePath, String rcoPath) throws IOException { + try (UmdIsoReader iso = new UmdIsoReader(Path.of(imagePath))) { + byte[] data = readFile(iso, rcoPath); + if (data == null) { + System.err.println("not found: " + rcoPath); + return 1; + } + + RcoFile rco = new RcoFile(data); + System.out.printf("%s: valid=%b compression=%d%n", rcoPath, rco.isValid(), rco.compression()); + + System.out.printf("events (%d):%n", rco.events().size()); + for (Map.Entry event : rco.events().entrySet()) { + System.out.printf(" [%3d] %s%n", event.getKey(), event.getValue()); + } + + if (rco.mainTable() != null) { + System.out.println("entries:"); + printRcoEntry(rco.mainTable(), 0); + } + + byte[] vsmxData = rco.vsmxData(); + if (vsmxData != null) { + Vsmx vsmx = new Vsmx(vsmxData, "menu"); + System.out.printf("vsmx: %d code groups, %d texts, %d props, %d names%n", + vsmx.codes().length, vsmx.texts().length, vsmx.properties().length, vsmx.names().length); + List lines = vsmx.disassemble(); + int shown = Math.min(lines.size(), 100); + for (int i = 0; i < shown; i++) { + System.out.println(" " + lines.get(i)); + } + if (lines.size() > shown) { + System.out.printf(" ... (%d more lines)%n", lines.size() - shown); + } + } + } + return 0; + } + + private static void printRcoEntry(RcoFile.Entry entry, int depth) { + String indent = " ".repeat(depth); + System.out.printf("%s[%s] type=%d label=%s subEntries=%d size=%d%n", + indent, entry.tableName(), entry.type, + entry.label == null ? "" : "'" + entry.label + "'", + entry.numSubEntries, entry.entrySize); + if (entry.texts != null) { + for (int i = 0; i < entry.texts.length; i++) { + System.out.printf("%s text[%d] = '%s'%n", indent, i, entry.texts[i]); + } + } + for (RcoFile.Entry sub : entry.subEntries) { + printRcoEntry(sub, depth + 1); + } + } + private static String hexPrefix(byte[] data, int max) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < Math.min(data.length, max); i++) { diff --git a/core/src/main/java/dev/quoije/umd/rco/Lzr.java b/core/src/main/java/dev/quoije/umd/rco/Lzr.java new file mode 100644 index 0000000..21c7657 --- /dev/null +++ b/core/src/main/java/dev/quoije/umd/rco/Lzr.java @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.rco; + +import java.util.Arrays; + +/** + * LZR (RLZ) decompression, used for compressed data inside RCO files. + * + *

Ported from JPCSP's {@code jpcsp.format.rco.LZR}, which is in turn based + * on libLZR 0.11 by BenHur.

+ */ +public final class Lzr { + + private Lzr() { + } + + private static int u8(byte b) { + return b & 0xFF; + } + + private static long u32(int i) { + return i & 0xFFFFFFFFL; + } + + private static final class IntObject { + private int value; + + IntObject(int value) { + this.value = value; + } + + int get() { + return value; + } + + void set(int value) { + this.value = value; + } + + int incr() { + return value++; + } + + void sub(int sub) { + value -= sub; + } + } + + private static final class BoolObject { + private boolean value; + + boolean get() { + return value; + } + + void set(boolean value) { + this.value = value; + } + } + + private static void fillBuffer(IntObject testMask, IntObject mask, IntObject buffer, byte[] in, IntObject nextIn) { + if (testMask.get() >= 0 && testMask.get() <= 0x00FFFFFF) { + buffer.set((buffer.get() << 8) + u8(in[nextIn.incr()])); + mask.set(testMask.get() << 8); + } + } + + private static boolean nextBit(byte[] buf, int bufPtr1, IntObject number, IntObject testMask, IntObject mask, IntObject buffer, byte[] in, IntObject nextIn) { + fillBuffer(testMask, mask, buffer, in, nextIn); + int value = (mask.get() >>> 8) * u8(buf[bufPtr1]); + if (testMask != mask) { + testMask.set(value); + } + buf[bufPtr1] -= u8(buf[bufPtr1]) >> 3; + number.set(number.get() << 1); + if (u32(buffer.get()) < u32(value)) { + mask.set(value); + buf[bufPtr1] += 31; + number.incr(); + return true; + } + + buffer.sub(value); + mask.sub(value); + return false; + } + + private static int getNumber(int nBits, byte[] buf, int bufPtr, int inc, BoolObject flag, IntObject mask, IntObject buffer, byte[] in, IntObject nextIn) { + IntObject number = new IntObject(1); + if (nBits >= 3) { + nextBit(buf, bufPtr + 3 * inc, number, mask, mask, buffer, in, nextIn); + if (nBits >= 4) { + nextBit(buf, bufPtr + 3 * inc, number, mask, mask, buffer, in, nextIn); + if (nBits >= 5) { + fillBuffer(mask, mask, buffer, in, nextIn); + for (; nBits >= 5; nBits--) { + number.set(number.get() << 1); + mask.set(mask.get() >>> 1); + if (u32(buffer.get()) < u32(mask.get())) { + number.incr(); + } else { + buffer.sub(mask.get()); + } + } + } + } + } + flag.set(nextBit(buf, bufPtr, number, mask, mask, buffer, in, nextIn)); + if (nBits >= 1) { + nextBit(buf, bufPtr + inc, number, mask, mask, buffer, in, nextIn); + if (nBits >= 2) { + nextBit(buf, bufPtr + 2 * inc, number, mask, mask, buffer, in, nextIn); + } + } + return number.get(); + } + + /** Decompresses {@code in} into {@code out}. Returns bytes written, or -1. */ + public static int decompress(byte[] out, int outCapacity, byte[] in) { + int type = in[0]; + IntObject buffer = new IntObject((u8(in[1]) << 24) | (u8(in[2]) << 16) | (u8(in[3]) << 8) | u8(in[4])); + + IntObject nextIn = new IntObject(5); + int nextOut = 0; + int outEnd = outCapacity; + + if (type < 0) { + int seqEnd = nextOut + buffer.get(); + if (seqEnd > outEnd) { + return -1; + } + while (nextOut < seqEnd) { + out[nextOut++] = in[nextIn.incr()]; + } + return nextOut; + } + + byte[] buf = new byte[2800]; + Arrays.fill(buf, (byte) 0x80); + int bufOff = 0; + + IntObject mask = new IntObject(0xFFFFFFFF); + IntObject testMask = new IntObject(0); + int lastChar = 0; + + while (true) { + int bufPtr1 = bufOff + 2488; + if (!nextBit(buf, bufPtr1, new IntObject(0), mask, mask, buffer, in, nextIn)) { + if (bufOff > 0) { + bufOff--; + } + if (nextOut == outEnd) { + return -1; + } + bufPtr1 = (((((nextOut & 0x07) << 8) + lastChar) >> type) & 0x07) * 0xFF - 0x01; + IntObject j = new IntObject(1); + while (j.get() <= 0xFF) { + nextBit(buf, bufPtr1 + j.get(), j, mask, mask, buffer, in, nextIn); + } + out[nextOut++] = (byte) j.get(); + } else { + testMask.set(mask.get()); + int nBits = -1; + BoolObject flag = new BoolObject(); + do { + bufPtr1 += 8; + flag.set(nextBit(buf, bufPtr1, new IntObject(0), testMask, mask, buffer, in, nextIn)); + if (flag.get()) { + nBits++; + } + } while (flag.get() && nBits < 6); + + int bufPtr2 = nBits + 2033; + int j = 64; + int seqLen; + if (flag.get() || nBits >= 0) { + bufPtr1 = (nBits << 5) + (((nextOut << nBits) & 0x03) << 3) + bufOff + 2552; + seqLen = getNumber(nBits, buf, bufPtr1, 8, flag, mask, buffer, in, nextIn); + if (seqLen == 0xFF) { + return nextOut; + } + if (flag.get() || nBits > 0) { + bufPtr2 += 56; + j = 352; + } + } else { + seqLen = 1; + } + + IntObject i = new IntObject(1); + do { + nBits = (i.get() << 4) - j; + flag.set(nextBit(buf, bufPtr2 + (i.get() << 3), i, mask, mask, buffer, in, nextIn)); + } while (nBits < 0); + + int seqOff; + if (flag.get() || nBits > 0) { + if (!flag.get()) { + nBits -= 8; + } + seqOff = getNumber(nBits / 8, buf, nBits + 2344, 1, flag, mask, buffer, in, nextIn); + } else { + seqOff = 1; + } + + int nextSeq = nextOut - seqOff; + if (nextSeq < 0) { + return -1; + } + int seqEnd = nextOut + seqLen + 1; + if (seqEnd > outEnd) { + return -1; + } + bufOff = ((seqEnd + 1) & 0x01) + 0x06; + do { + out[nextOut++] = out[nextSeq++]; + } while (nextOut < seqEnd); + } + lastChar = u8(out[nextOut - 1]); + } + } +} diff --git a/core/src/main/java/dev/quoije/umd/rco/RcoEntrySizes.java b/core/src/main/java/dev/quoije/umd/rco/RcoEntrySizes.java new file mode 100644 index 0000000..72f6ed6 --- /dev/null +++ b/core/src/main/java/dev/quoije/umd/rco/RcoEntrySizes.java @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.rco; + +/** + * Fixed binary sizes of RCO object and animation records, used to locate + * entries whose {@code entrySize} field is zero. Sizes are the sum of each + * record's typed fields: scalar types (int/float/unknown) are 4 bytes and + * reference types (event/image/text/object/model/ref) are 8 bytes. + * + *

Ported from JPCSP's {@code ObjectFactory} / {@code AnimFactory} object + * model field layouts.

+ */ +public final class RcoEntrySizes { + + /** Object record sizes indexed by object type (1..26). */ + private static final int[] OBJECT = { + 0, // type 0 unused + 36, // 1 PageObject + 76, // 2 PlaneObject + 156, // 3 ButtonObject + 108, // 4 XMenuObject + 20, // 5 XMListObject + 124, // 6 XListObject + 96, // 7 ProgressObject + 116, // 8 ScrollObject + 172, // 9 MListObject + 24, // 10 MItemObject + 0, // 11 unknown + 24, // 12 XItemObject + 196, // 13 TextObject + 72, // 14 ModelObject + 144, // 15 SpinObject + 8, // 16 ActionObject + 160, // 17 ItemSpinObject + 64, // 18 GroupObject + 132, // 19 LListObject + 24, // 20 LItemObject + 168, // 21 EditObject + 176, // 22 ClockObject + 100, // 23 IListObject + 16, // 24 IItemObject + 88, // 25 IconObject + 132, // 26 UButtonObject + }; + + /** Animation record sizes indexed by animation type (1..11). */ + private static final int[] ANIM = { + 0, // type 0 unused + 0, // 1 Anim + 28, // 2 MoveToAnim + 32, // 3 RecolourAnim + 28, // 4 RotateAnim + 28, // 5 ResizeAnim + 20, // 6 FadeAnim + 4, // 7 DelayAnim + 8, // 8 FireEventAnim + 4, // 9 LockAnim + 4, // 10 UnlockAnim + 32, // 11 SlideOutAnim + }; + + private RcoEntrySizes() { + } + + /** Size in bytes of an object record of the given type, or 0 if unknown. */ + public static int objectSize(int type) { + return type >= 0 && type < OBJECT.length ? OBJECT[type] : 0; + } + + /** Size in bytes of an animation record of the given type, or 0 if unknown. */ + public static int animSize(int type) { + return type >= 0 && type < ANIM.length ? ANIM[type] : 0; + } +} diff --git a/core/src/main/java/dev/quoije/umd/rco/RcoFile.java b/core/src/main/java/dev/quoije/umd/rco/RcoFile.java new file mode 100644 index 0000000..58ed06f --- /dev/null +++ b/core/src/main/java/dev/quoije/umd/rco/RcoFile.java @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.rco; + +import dev.quoije.umd.rco.vsmx.Vsmx; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Parser for PSP RCO resource files (UMD video menus). + * + *

Parses the RCO header, the entry tree (labels, text, images, sounds, + * fonts, objects, animations, and the VSMX script), and the event name table. + * This port focuses on the structural data needed to drive the menu; full + * object/animation instantiation is layered on top in later stages.

+ */ +public final class RcoFile { + + public static final int RCO_MAGIC = 0x00505246; + public static final int RCO_NULL_PTR = 0xFFFFFFFF; + + public static final int TABLE_MAIN = 1; + public static final int TABLE_VSMX = 2; + public static final int TABLE_TEXT = 3; + public static final int TABLE_IMG = 4; + public static final int TABLE_MODEL = 5; + public static final int TABLE_SOUND = 6; + public static final int TABLE_FONT = 7; + public static final int TABLE_OBJ = 8; + public static final int TABLE_ANIM = 9; + + public static final int COMPRESSION_NONE = 0; + public static final int COMPRESSION_ZLIB = 1; + public static final int COMPRESSION_RLZ = 2; + + private static final int ENTRY_SIZE = 40; + + private static final int RCO_HEADER_SIZE = 164; + + /** One entry (node) in the RCO table tree. */ + public static final class Entry { + public int type; + public int id; + public int labelOffset; + public String label; + public int entrySize; + public int numSubEntries; + public int nextEntryOffset; + public int prevEntryOffset; + public Entry parent; + public final List subEntries = new ArrayList<>(); + public byte[] data; + public String[] texts; + + public String tableName() { + return RcoFile.tableName(id); + } + } + + private final boolean valid; + private int compression; + private final Entry mainTable; + private final byte[] vsmxData; + private final Map events = new LinkedHashMap<>(); + + private final byte[] buffer; + private int offset; + private int pTextData; + private int lTextData; + private int pLabelData; + private int lLabelData; + private int pMainTable; + private int pEventData; + private int lEventData; + private final Map entries = new HashMap<>(); + + public RcoFile(byte[] buffer) { + this.buffer = buffer; + + boolean ok; + int comp = COMPRESSION_NONE; + Entry main = null; + byte[] vsmx = null; + try { + readHeader(); + comp = compression; + readEvents(); + main = readEntry(pMainTable); + vsmx = entries.values().stream() + .filter(e -> e.id == TABLE_VSMX && e.data != null) + .map(e -> e.data) + .findFirst() + .orElse(null); + ok = true; + } catch (RuntimeException e) { + ok = false; + } + + this.valid = ok; + this.compression = comp; + this.mainTable = main; + this.vsmxData = vsmx; + } + + public boolean isValid() { + return valid; + } + + public int compression() { + return compression; + } + + public Entry mainTable() { + return mainTable; + } + + public byte[] vsmxData() { + return vsmxData; + } + + public Map events() { + return events; + } + + public static String tableName(int id) { + return switch (id) { + case TABLE_MAIN -> "MAIN"; + case TABLE_VSMX -> "VSMX"; + case TABLE_TEXT -> "TEXT"; + case TABLE_IMG -> "IMG"; + case TABLE_MODEL -> "MODEL"; + case TABLE_SOUND -> "SOUND"; + case TABLE_FONT -> "FONT"; + case TABLE_OBJ -> "OBJ"; + case TABLE_ANIM -> "ANIM"; + default -> "0x" + Integer.toHexString(id); + }; + } + + private void readHeader() { + int magic = Integer.reverseBytes(read32()); + if (magic != RCO_MAGIC) { + throw new IllegalStateException(String.format("Invalid RCO magic 0x%08X", magic)); + } + read32(); // version + skip(4); // null + compression = read32(); + pMainTable = read32(); + int pVSMXTable = read32(); + int pTextTable = read32(); + int pSoundTable = read32(); + int pModelTable = read32(); + int pImgTable = read32(); + skip(4); // pUnknown + int pFontTable = read32(); + int pObjTable = read32(); + int pAnimTable = read32(); + pTextData = read32(); + lTextData = read32(); + pLabelData = read32(); + lLabelData = read32(); + pEventData = read32(); + lEventData = read32(); + + // Remaining pointer/data tables (18 ints) plus 3 trailing 0xFFFFFFFF. + skip(21 * 4); + } + + private void readEvents() { + if (pEventData != RCO_NULL_PTR && lEventData > 0) { + seek(pEventData); + while (tell() < pEventData + lEventData) { + int index = tell() - pEventData; + String s = readString(); + if (s != null && !s.isEmpty()) { + events.put(index, s); + } + } + } + } + + private Entry readEntry(int entryOffset) { + if (entryOffset == RCO_NULL_PTR) { + return null; + } + if (entryOffset < 0 || entryOffset >= buffer.length) { + throw new IllegalStateException("entry offset out of range: " + entryOffset); + } + seek(entryOffset); + Entry entry = new Entry(); + entries.put(entryOffset, entry); + + entry.type = read8(); + entry.id = read8(); + skip(2); + entry.labelOffset = read32(); + int eHeadSize = read32(); + entry.entrySize = read32(); + entry.numSubEntries = read32(); + int nextEntryOffset = read32(); + int prevEntryOffset = read32(); + int parentTblOffset = read32(); + skip(8); + + entry.parent = entries.get(entryOffset - parentTblOffset); + if (entry.labelOffset != RCO_NULL_PTR) { + entry.label = readLabel(entry.labelOffset); + } + + switch (entry.id) { + case TABLE_VSMX -> { + if (entry.type == 1) { + int offsetVSMX = read32(); + int lengthVSMX = read32(); + skip(offsetVSMX); + entry.data = readBytes(lengthVSMX); + skip(alignUp(lengthVSMX, 4) - lengthVSMX); + } + } + case TABLE_IMG, TABLE_MODEL -> { + if (entry.type == 1) { + skip(2); // format + int comp = read16(); + int sizePacked = read32(); + skip(4); // offset + if (comp != COMPRESSION_NONE) { + skip(4); // sizeUnpacked + } + } + } + case TABLE_SOUND -> { + if (entry.type == 1) { + skip(2); // format + int channels = read16(); + skip(4); // sizeTotal + skip(4); // offset + skip(8 * Math.max(channels, 2)); // per-channel size/offset pairs + } + } + case TABLE_FONT -> { + if (entry.type == 1) { + skip(2); // format + skip(2); // compression + skip(4); // unknown1 + skip(4); // unknown2 + } + } + case TABLE_OBJ -> { + int dataLength = entryDataLength(entry, RcoEntrySizes.objectSize(entry.type)); + if (dataLength > 0) { + entry.data = readBytes(dataLength); + } + } + case TABLE_ANIM -> { + int dataLength = entryDataLength(entry, RcoEntrySizes.animSize(entry.type)); + if (dataLength > 0) { + entry.data = readBytes(dataLength); + } + } + case TABLE_TEXT -> { + if (entry.type == 1) { + int lang = read16(); + skip(2); // format + int numIndexes = read32(); + entry.texts = new String[numIndexes]; + for (int i = 0; i < numIndexes; i++) { + skip(4); // labelOffset + int length = read32(); + int textOffset = read32(); + entry.texts[i] = readText(textOffset, length); + } + } + } + default -> { + // unknown table id; leave data unparsed + } + } + + for (int i = 0; i < entry.numSubEntries; i++) { + Entry sub = readEntry(tell()); + if (sub != null) { + entry.subEntries.add(sub); + } + } + return entry; + } + + private String readLabel(int labelOffset) { + int current = tell(); + seek(pLabelData + labelOffset); + StringBuilder s = new StringBuilder(); + for (int max = lLabelData - labelOffset; max > 0; max--) { + int b = read8(); + if (b == 0) { + break; + } + s.append((char) b); + } + seek(current); + return s.toString(); + } + + private String readText(int textOffset, int length) { + if (textOffset == RCO_NULL_PTR || length <= 0) { + return ""; + } + int current = tell(); + seek(pTextData + textOffset); + byte[] bytes = readBytes(length); + seek(current); + + int len = length; + if (len >= 2 && bytes[len - 1] == 0 && bytes[len - 2] == 0) { + len -= 2; + } + return new String(bytes, 0, len, StandardCharsets.UTF_16LE); + } + + private String readString() { + StringBuilder s = new StringBuilder(); + while (true) { + int b = read8(); + if (b == 0) { + break; + } + s.append((char) b); + } + return s.toString(); + } + + private int entryDataLength(Entry entry, int computedSize) { + if (entry.entrySize > ENTRY_SIZE) { + return entry.entrySize - ENTRY_SIZE; + } + if (entry.entrySize == 0) { + return computedSize; + } + return 0; + } + + private int read8() { + return buffer[offset++] & 0xFF; + } + + private int read16() { + return read8() | (read8() << 8); + } + + private int read32() { + return read16() | (read16() << 16); + } + + private byte[] readBytes(int length) { + byte[] bytes = new byte[length]; + System.arraycopy(buffer, offset, bytes, 0, length); + offset += length; + return bytes; + } + + private void skip(int n) { + offset += n; + } + + private void seek(int offset) { + this.offset = offset; + } + + private int tell() { + return offset; + } + + private static int alignUp(int value, int alignment) { + return (value + alignment - 1) & ~(alignment - 1); + } +} diff --git a/core/src/main/java/dev/quoije/umd/rco/vsmx/Vsmx.java b/core/src/main/java/dev/quoije/umd/rco/vsmx/Vsmx.java new file mode 100644 index 0000000..5cd9aee --- /dev/null +++ b/core/src/main/java/dev/quoije/umd/rco/vsmx/Vsmx.java @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.rco.vsmx; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Parser for the VSMX bytecode blob embedded in an RCO file. + * + *

A VSMX file holds a compiled JavaScript-like script: a code section of + * 8-byte groups ({@code id} + {@code value}), plus string tables for text + * literals, property names, and variable names.

+ */ +public final class Vsmx { + + private static final int VSMX_SIGNATURE = 0x584D5356; // "VSMX" + private static final int VSMX_VERSION = 0x00010000; + + private static final Charset UTF16_LE = StandardCharsets.UTF_16LE; + private static final Charset ISO_8859_1 = StandardCharsets.ISO_8859_1; + + /** One 8-byte code group: an opcode id plus an operand value. */ + public record Group(int id, int value) { + public int opcode() { + return id & 0xFF; + } + + public float floatValue() { + return Float.intBitsToFloat(value); + } + } + + private final String name; + private final Group[] codes; + private final String[] texts; + private final String[] properties; + private final String[] names; + + public Vsmx(byte[] buffer, String name) { + this.name = name; + + ByteCursor c = new ByteCursor(buffer); + int sig = c.read32(); + int ver = c.read32(); + int codeOffset = c.read32(); + int codeLength = c.read32(); + int textOffset = c.read32(); + int textLength = c.read32(); + int textEntries = c.read32(); + int propOffset = c.read32(); + int propLength = c.read32(); + int propEntries = c.read32(); + int namesOffset = c.read32(); + int namesLength = c.read32(); + int namesEntries = c.read32(); + + if (sig != VSMX_SIGNATURE) { + throw new IllegalArgumentException(String.format("Invalid VSMX signature 0x%08X", sig)); + } + if (ver != VSMX_VERSION) { + throw new IllegalArgumentException(String.format("Invalid VSMX version 0x%08X", ver)); + } + + if (codeLength % 8 != 0) { + throw new IllegalArgumentException("VSMX code length not aligned to 8 bytes: " + codeLength); + } + + codes = new Group[codeLength / 8]; + c.seek(codeOffset); + for (int i = 0; i < codes.length; i++) { + codes[i] = new Group(c.read32(), c.read32()); + } + + texts = readStrings(buffer, textOffset, textLength, textEntries, UTF16_LE, 2); + properties = readStrings(buffer, propOffset, propLength, propEntries, UTF16_LE, 2); + names = readStrings(buffer, namesOffset, namesLength, namesEntries, ISO_8859_1, 1); + } + + public String name() { + return name; + } + + public Group[] codes() { + return codes; + } + + public String[] texts() { + return texts; + } + + public String[] properties() { + return properties; + } + + public String[] names() { + return names; + } + + /** Disassembles the code section into one human-readable line per group. */ + public List disassemble() { + List lines = new ArrayList<>(codes.length); + for (int i = 0; i < codes.length; i++) { + Group code = codes[i]; + int opcode = code.opcode(); + String mnemonic = opcode >= 0 && opcode < VsmxCode.MNEMONICS.length + ? VsmxCode.MNEMONICS[opcode] + : "UNKNOWN_" + Integer.toHexString(opcode); + + String operand = switch (opcode) { + case VsmxCode.VID_CONST_BOOL -> code.value() == 1 ? "true" : code.value() == 0 ? "false" : "0x" + Integer.toHexString(code.value()); + case VsmxCode.VID_CONST_INT, VsmxCode.VID_DEBUG_LINE -> Integer.toString(code.value()); + case VsmxCode.VID_CONST_FLOAT -> Float.toString(code.floatValue()); + case VsmxCode.VID_CONST_STRING, VsmxCode.VID_DEBUG_FILE -> "'" + texts[code.value()] + "'"; + case VsmxCode.VID_VARIABLE -> names[code.value()]; + case VsmxCode.VID_PROPERTY, VsmxCode.VID_METHOD, VsmxCode.VID_SET_ATTR, VsmxCode.VID_UNSET, VsmxCode.VID_OBJ_ADD_ATTR -> + properties[code.value()]; + case VsmxCode.VID_FUNCTION -> { + int args = (code.id() >> 8) & 0xFF; + int localVars = (code.id() >> 24) & 0xFF; + yield "args=" + args + ", localVars=" + localVars + ", startLine=" + code.value(); + } + case VsmxCode.VID_UNNAMED_VAR -> Integer.toString(code.value()); + case VsmxCode.VID_JUMP, VsmxCode.VID_JUMP_TRUE, VsmxCode.VID_JUMP_FALSE -> "line=" + code.value(); + case VsmxCode.VID_CALL_FUNC, VsmxCode.VID_CALL_METHOD, VsmxCode.VID_CALL_NEW -> "args=" + code.value(); + case VsmxCode.VID_MAKE_FLOAT_ARRAY -> "items=" + code.value(); + default -> code.value() == 0 ? "" : "0x" + Integer.toHexString(code.value()); + }; + + lines.add(String.format("%5d: %-22s %s", i, mnemonic, operand)); + } + return lines; + } + + private static String[] readStrings(byte[] buffer, int offset, int length, int entries, Charset charset, int bytesPerChar) { + String[] strings = new String[entries]; + int stringIndex = 0; + int stringStart = 0; + for (int i = 0; i < length && stringIndex < entries; i += bytesPerChar) { + boolean zero = true; + for (int j = 0; j < bytesPerChar; j++) { + if (buffer[offset + i + j] != 0) { + zero = false; + break; + } + } + if (zero) { + strings[stringIndex++] = new String(buffer, offset + stringStart, i - stringStart, charset); + stringStart = i + bytesPerChar; + } + } + return strings; + } + + private static final class ByteCursor { + private final byte[] buffer; + private int offset; + + ByteCursor(byte[] buffer) { + this.buffer = buffer; + } + + int read8() { + return buffer[offset++] & 0xFF; + } + + int read16() { + return read8() | (read8() << 8); + } + + int read32() { + return read16() | (read16() << 16); + } + + void seek(int offset) { + this.offset = offset; + } + } +} diff --git a/core/src/main/java/dev/quoije/umd/rco/vsmx/VsmxCode.java b/core/src/main/java/dev/quoije/umd/rco/vsmx/VsmxCode.java new file mode 100644 index 0000000..8343554 --- /dev/null +++ b/core/src/main/java/dev/quoije/umd/rco/vsmx/VsmxCode.java @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.rco.vsmx; + +/** VSMX bytecode opcode constants and mnemonic names (ported from JPCSP). */ +public final class VsmxCode { + + public static final int VID_NOTHING = 0x00; + public static final int VID_OPERATOR_ASSIGN = 0x01; + public static final int VID_OPERATOR_ADD = 0x02; + public static final int VID_OPERATOR_SUBTRACT = 0x03; + public static final int VID_OPERATOR_MULTIPLY = 0x04; + public static final int VID_OPERATOR_DIVIDE = 0x05; + public static final int VID_OPERATOR_MOD = 0x06; + public static final int VID_OPERATOR_POSITIVE = 0x07; + public static final int VID_OPERATOR_NEGATE = 0x08; + public static final int VID_OPERATOR_NOT = 0x09; + public static final int VID_P_INCREMENT = 0x0A; + public static final int VID_P_DECREMENT = 0x0B; + public static final int VID_INCREMENT = 0x0C; + public static final int VID_DECREMENT = 0x0D; + public static final int VID_OPERATOR_EQUAL = 0x0E; + public static final int VID_OPERATOR_NOT_EQUAL = 0x0F; + public static final int VID_OPERATOR_IDENTITY = 0x10; + public static final int VID_OPERATOR_NON_IDENTITY = 0x11; + public static final int VID_OPERATOR_LT = 0x12; + public static final int VID_OPERATOR_LTE = 0x13; + public static final int VID_OPERATOR_GTE = 0x14; + public static final int VID_OPERATOR_GT = 0x15; + public static final int VID_OPERATOR_INSTANCEOF = 0x16; + public static final int VID_OPERATOR_IN = 0x17; + public static final int VID_OPERATOR_TYPEOF = 0x18; + public static final int VID_OPERATOR_B_AND = 0x19; + public static final int VID_OPERATOR_B_XOR = 0x1A; + public static final int VID_OPERATOR_B_OR = 0x1B; + public static final int VID_OPERATOR_B_NOT = 0x1C; + public static final int VID_OPERATOR_LSHIFT = 0x1D; + public static final int VID_OPERATOR_RSHIFT = 0x1E; + public static final int VID_OPERATOR_URSHIFT = 0x1F; + public static final int VID_STACK_COPY = 0x20; + public static final int VID_STACK_SWAP = 0x21; + public static final int VID_END_STMT = 0x22; + public static final int VID_CONST_NULL = 0x23; + public static final int VID_CONST_EMPTYARRAY = 0x24; + public static final int VID_CONST_BOOL = 0x25; + public static final int VID_CONST_INT = 0x26; + public static final int VID_CONST_FLOAT = 0x27; + public static final int VID_CONST_STRING = 0x28; + public static final int VID_CONST_OBJECT = 0x29; + public static final int VID_FUNCTION = 0x2A; + public static final int VID_ARRAY = 0x2B; + public static final int VID_THIS = 0x2C; + public static final int VID_UNNAMED_VAR = 0x2D; + public static final int VID_VARIABLE = 0x2E; + public static final int VID_PROPERTY = 0x2F; + public static final int VID_METHOD = 0x30; + public static final int VID_SET_ATTR = 0x31; + public static final int VID_UNSET = 0x32; + public static final int VID_OBJ_ADD_ATTR = 0x33; + public static final int VID_ARRAY_INDEX = 0x34; + public static final int VID_ARRAY_INDEX_KEEP_OBJ = 0x35; + public static final int VID_ARRAY_INDEX_ASSIGN = 0x36; + public static final int VID_ARRAY_DELETE = 0x37; + public static final int VID_ARRAY_PUSH = 0x38; + public static final int VID_JUMP = 0x39; + public static final int VID_JUMP_TRUE = 0x3A; + public static final int VID_JUMP_FALSE = 0x3B; + public static final int VID_CALL_FUNC = 0x3C; + public static final int VID_CALL_METHOD = 0x3D; + public static final int VID_CALL_NEW = 0x3E; + public static final int VID_RETURN = 0x3F; + public static final int VID_THROW = 0x40; + public static final int VID_TRY_BLOCK_IN = 0x41; + public static final int VID_TRY_BLOCK_OUT = 0x42; + public static final int VID_CATCH_FINALLY_BLOCK_IN = 0x43; + public static final int VID_CATCH_FINALLY_BLOCK_OUT = 0x44; + public static final int VID_END = 0x45; + public static final int VID_DEBUG_FILE = 0x46; + public static final int VID_DEBUG_LINE = 0x47; + public static final int VID_MAKE_FLOAT_ARRAY = 0x49; + + /** Mnemonics indexed by opcode (0x00..0x49). */ + public static final String[] MNEMONICS = { + "UNKNOWN_0", + "ASSIGN", + "ADD", + "SUBTRACT", + "MULTIPLY", + "DIVIDE", + "MODULUS", + "POSITIVE", + "NEGATE", + "NOT", + "PRE_INCREMENT", + "PRE_DECREMENT", + "INCREMENT", + "DECREMENT", + "TEST_EQUAL", + "TEST_NOT_EQUAL", + "TEST_IDENTITY", + "TEST_NON_IDENTITY", + "TEST_LESS_THAN", + "TEST_LESS_EQUAL_THAN", + "TEST_MORE_EQUAL_THAN", + "TEST_MORE_THAN", + "INSTANCEOF", + "IN", + "TYPEOF", + "BINARY_AND", + "BINARY_XOR", + "BINARY_OR", + "BINARY_NOT", + "LSHIFT", + "RSHIFT", + "UNSIGNED_RSHIFT", + "STACK_COPY", + "STACK_SWAP", + "END_STATEMENT", + "CONST_NULL", + "CONST_EMPTY_ARRAY", + "CONST_BOOL", + "CONST_INT", + "CONST_FLOAT", + "CONST_STRING", + "CONST_OBJECT", + "FUNCTION", + "CONST_ARRAY", + "THIS_OBJECT", + "UNNAMED_VARIABLE", + "NAME", + "PROPERTY", + "METHOD", + "SET", + "UNSET", + "OBJECT_ADD_ATTRIBUTE", + "ARRAY_INDEX", + "ARRAY_INDEX_KEEP_OBJ", + "ARRAY_INDEX_ASSIGN", + "ARRAY_DELETE", + "ARRAY_PUSH", + "JUMP", + "JUMP_IF_TRUE", + "JUMP_IF_FALSE", + "CALL_FUNCTION", + "CALL_METHOD", + "CALL_NEW", + "RETURN", + "THROW", + "TRY_BLOCK_IN", + "TRY_BLOCK_OUT", + "CATCH_FINALLY_BLOCK_IN", + "CATCH_FINALLY_BLOCK_OUT", + "END_SCRIPT", + "DEBUG_FILE", + "DEBUG_LINE", + "UNKNOWN_48", + "MAKE_FLOAT_ARRAY", + }; + + private VsmxCode() { + } +}