files = new ArrayList<>();
+
+ public Iso9660Directory(UmdIsoReader reader, int directorySector, long directorySize) throws IOException {
+ try (UmdIsoFile data = new UmdIsoFile(reader, directorySector, directorySize, null)) {
+ long remaining = directorySize;
+ while (remaining >= 1) {
+ int entryLength = data.read();
+ if (entryLength < 0) {
+ break;
+ }
+ if (entryLength == 0) {
+ // Padding byte.
+ remaining--;
+ continue;
+ }
+ remaining -= entryLength;
+
+ byte[] record = new byte[entryLength - 1];
+ int read = data.read(record, 0, record.length);
+ if (read <= 0) {
+ break;
+ }
+ files.add(new Iso9660File(record, read, reader.hasJolietExtension()));
+ }
+ }
+ }
+
+ public Iso9660File getEntryByIndex(int index) {
+ return files.get(index);
+ }
+
+ public int getFileIndex(String fileName) throws IOException {
+ for (int i = 0; i < files.size(); i++) {
+ if (files.get(i).getName().equalsIgnoreCase(fileName)) {
+ return i;
+ }
+ }
+ throw new IOException("File '" + fileName + "' not found in directory");
+ }
+
+ public String[] getFileList() {
+ String[] list = new String[files.size()];
+ for (int i = 0; i < files.size(); i++) {
+ list[i] = files.get(i).getName();
+ }
+ return list;
+ }
+}
diff --git a/core/src/main/java/dev/quoije/umd/iso/Iso9660File.java b/core/src/main/java/dev/quoije/umd/iso/Iso9660File.java
new file mode 100644
index 0000000..8c6e4b3
--- /dev/null
+++ b/core/src/main/java/dev/quoije/umd/iso/Iso9660File.java
@@ -0,0 +1,124 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package dev.quoije.umd.iso;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.TimeZone;
+
+/**
+ * A single ISO 9660 directory entry (a file or a directory).
+ *
+ * The {@code data} array starts at the "extended attribute record length" byte of
+ * the directory record: the leading length byte has already been consumed by the
+ * caller.
+ */
+public final class Iso9660File {
+
+ private static final Charset JOLIET_CHARSET = Charset.forName("UTF-16BE");
+
+ private final int lba;
+ private final long size;
+ private final int properties;
+ private final String name;
+ private final Date timestamp;
+
+ public Iso9660File(byte[] data, int length, boolean jolietExtension) throws IOException {
+ lba = ubyte(data[1]) | (ubyte(data[2]) << 8) | (ubyte(data[3]) << 16) | (data[4] << 24);
+ size = ubyte(data[9])
+ | ((long) ubyte(data[10]) << 8)
+ | ((long) ubyte(data[11]) << 16)
+ | ((long) ubyte(data[12]) << 24);
+
+ int year = ubyte(data[17]);
+ int month = ubyte(data[18]);
+ int day = ubyte(data[19]);
+ int hour = ubyte(data[20]);
+ int minute = ubyte(data[21]);
+ int second = ubyte(data[22]);
+ int gmtOffset = data[23]; // signed, in 15-minute intervals
+
+ int gmtOffsetHours = gmtOffset / 4;
+ int gmtOffsetMinutes = (gmtOffset % 4) * 15;
+ String timeZoneName = "GMT";
+ if (gmtOffset >= 0) {
+ timeZoneName += "+";
+ }
+ timeZoneName += gmtOffsetHours;
+ if (gmtOffsetMinutes > 0) {
+ if (gmtOffsetMinutes < 10) {
+ timeZoneName += "0";
+ }
+ timeZoneName += gmtOffsetMinutes;
+ }
+
+ TimeZone timeZone = TimeZone.getTimeZone(timeZoneName);
+ Calendar calendar = Calendar.getInstance(timeZone);
+ calendar.clear();
+ calendar.set(1900 + year, month - 1, day, hour, minute, second);
+ timestamp = calendar.getTime();
+
+ properties = ubyte(data[24]);
+
+ if (lba < 0 || size < 0) {
+ throw new IOException("Invalid ISO entry: lba or size < 0");
+ }
+
+ int nameLength = ubyte(data[31]);
+ if (jolietExtension) {
+ if (nameLength == 1) {
+ char c = (char) data[32];
+ if (c == 0) {
+ c = '.';
+ }
+ name = Character.toString(c);
+ } else {
+ String raw = new String(data, 32, nameLength, JOLIET_CHARSET);
+ int version = raw.indexOf(';');
+ name = version >= 0 ? raw.substring(0, version) : raw;
+ }
+ } else {
+ for (int i = 0; i < nameLength; i++) {
+ if (data[32 + i] == 0) {
+ data[32 + i] = (byte) '.';
+ }
+ }
+ name = new String(data, 32, nameLength, StandardCharsets.US_ASCII);
+ }
+ }
+
+ private static int ubyte(byte b) {
+ return b & 0xFF;
+ }
+
+ public int getLba() {
+ return lba;
+ }
+
+ public long getSize() {
+ return size;
+ }
+
+ public int getProperties() {
+ return properties;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Date getTimestamp() {
+ return timestamp;
+ }
+
+ public boolean isDirectory() {
+ return (properties & 0x02) != 0;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+}
diff --git a/core/src/main/java/dev/quoije/umd/iso/Iso9660Handler.java b/core/src/main/java/dev/quoije/umd/iso/Iso9660Handler.java
new file mode 100644
index 0000000..316f591
--- /dev/null
+++ b/core/src/main/java/dev/quoije/umd/iso/Iso9660Handler.java
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package dev.quoije.umd.iso;
+
+import java.io.IOException;
+
+/**
+ * The root directory of the ISO 9660 filesystem, resolved from the primary or
+ * Joliet volume descriptor.
+ */
+public final class Iso9660Handler extends Iso9660Directory {
+
+ private final Iso9660Directory internalDir;
+
+ public Iso9660Handler(UmdIsoReader reader) throws IOException {
+ super(reader, 0, 0);
+
+ int sector = reader.hasJolietExtension()
+ ? UmdIsoReader.START_SECTOR_JOLIET
+ : UmdIsoReader.START_SECTOR;
+
+ byte[] volumeDescriptor = reader.readSector(sector);
+
+ // The root directory record begins at byte 156 of the volume descriptor.
+ // Skip its leading length byte (157) and read the record body.
+ byte[] record = new byte[38];
+ System.arraycopy(volumeDescriptor, 157, record, 0, record.length);
+
+ Iso9660File rootEntry = new Iso9660File(record, record.length, reader.hasJolietExtension());
+ internalDir = new Iso9660Directory(reader, rootEntry.getLba(), rootEntry.getSize());
+ }
+
+ @Override
+ public Iso9660File getEntryByIndex(int index) {
+ return internalDir.getEntryByIndex(index);
+ }
+
+ @Override
+ public int getFileIndex(String fileName) throws IOException {
+ return internalDir.getFileIndex(fileName);
+ }
+
+ @Override
+ public String[] getFileList() {
+ return internalDir.getFileList();
+ }
+}
diff --git a/core/src/main/java/dev/quoije/umd/iso/RawIsoSectorDevice.java b/core/src/main/java/dev/quoije/umd/iso/RawIsoSectorDevice.java
new file mode 100644
index 0000000..cbebda4
--- /dev/null
+++ b/core/src/main/java/dev/quoije/umd/iso/RawIsoSectorDevice.java
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package dev.quoije.umd.iso;
+
+import java.io.IOException;
+import java.io.RandomAccessFile;
+
+/** A {@link SectorDevice} backed directly by a raw ISO image file. */
+public final class RawIsoSectorDevice implements SectorDevice {
+
+ private final RandomAccessFile file;
+ private final int numSectors;
+
+ public RawIsoSectorDevice(RandomAccessFile file) throws IOException {
+ this.file = file;
+ this.numSectors = (int) (file.length() / SECTOR_LENGTH);
+ }
+
+ @Override
+ public int getNumSectors() {
+ return numSectors;
+ }
+
+ @Override
+ public void readSector(int sectorNumber, byte[] buffer, int offset) throws IOException {
+ if (sectorNumber < 0 || sectorNumber >= numSectors) {
+ throw new IOException("Sector " + sectorNumber + " out of range (numSectors=" + numSectors + ")");
+ }
+ file.seek((long) sectorNumber * SECTOR_LENGTH);
+ file.readFully(buffer, offset, SECTOR_LENGTH);
+ }
+
+ @Override
+ public int readSectors(int sectorNumber, int numberSectors, byte[] buffer, int offset) throws IOException {
+ if (numberSectors == 0) {
+ return 0;
+ }
+ if (sectorNumber < 0 || sectorNumber + numberSectors > numSectors) {
+ throw new IOException("Sector range " + sectorNumber + "+" + numberSectors
+ + " out of range (numSectors=" + numSectors + ")");
+ }
+ file.seek((long) sectorNumber * SECTOR_LENGTH);
+ file.readFully(buffer, offset, numberSectors * SECTOR_LENGTH);
+ return numberSectors;
+ }
+
+ @Override
+ public void close() throws IOException {
+ file.close();
+ }
+}
diff --git a/core/src/main/java/dev/quoije/umd/iso/SectorDevice.java b/core/src/main/java/dev/quoije/umd/iso/SectorDevice.java
new file mode 100644
index 0000000..43b0234
--- /dev/null
+++ b/core/src/main/java/dev/quoije/umd/iso/SectorDevice.java
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package dev.quoije.umd.iso;
+
+import java.io.IOException;
+
+/** A sector-oriented, read-only storage device. A sector is 2048 bytes. */
+public interface SectorDevice {
+
+ int SECTOR_LENGTH = 2048;
+
+ int getNumSectors() throws IOException;
+
+ void readSector(int sectorNumber, byte[] buffer, int offset) throws IOException;
+
+ int readSectors(int sectorNumber, int numberSectors, byte[] buffer, int offset) throws IOException;
+
+ void close() throws IOException;
+}
diff --git a/core/src/main/java/dev/quoije/umd/iso/UmdIsoFile.java b/core/src/main/java/dev/quoije/umd/iso/UmdIsoFile.java
new file mode 100644
index 0000000..6b0d4b7
--- /dev/null
+++ b/core/src/main/java/dev/quoije/umd/iso/UmdIsoFile.java
@@ -0,0 +1,175 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package dev.quoije.umd.iso;
+
+import java.io.EOFException;
+import java.io.IOException;
+
+/** A file within a UMD image, presented as a seekable, sector-backed stream. */
+public final class UmdIsoFile implements AutoCloseable {
+
+ private final UmdIsoReader reader;
+ private final int startSector;
+ private final long maxOffset;
+ private final String name;
+
+ private long currentOffset;
+ private int currentSectorNumber;
+ private int sectorOffset;
+ private byte[] currentSector;
+
+ UmdIsoFile(UmdIsoReader reader, int startSector, long lengthInBytes, String name) throws IOException {
+ this.reader = reader;
+ this.startSector = startSector;
+ this.currentSectorNumber = startSector;
+ this.currentOffset = 0;
+ this.name = name;
+ this.sectorOffset = 0;
+ this.maxOffset = computeLength(lengthInBytes);
+ this.currentSector = maxOffset == 0 ? null : reader.readSector(startSector);
+ }
+
+ private long computeLength(long lengthInBytes) {
+ if (lengthInBytes <= 0) {
+ return 0;
+ }
+ int endSector = startSector
+ + (int) ((lengthInBytes + SectorDevice.SECTOR_LENGTH - 1) / SectorDevice.SECTOR_LENGTH)
+ - 1;
+ if (endSector >= reader.getNumSectors()) {
+ endSector = reader.getNumSectors() - 1;
+ return (long) (endSector - startSector + 1) * SectorDevice.SECTOR_LENGTH;
+ }
+ return lengthInBytes;
+ }
+
+ public int read() throws IOException {
+ if (currentOffset >= maxOffset) {
+ throw new EOFException();
+ }
+ checkSectorAvailable();
+ currentOffset++;
+ return currentSector[sectorOffset++] & 0xFF;
+ }
+
+ public int read(byte[] b, int off, int len) throws IOException {
+ if (b == null) {
+ throw new NullPointerException();
+ }
+ if (off < 0 || len < 0 || len > b.length - off) {
+ throw new IndexOutOfBoundsException();
+ }
+ if (len > (maxOffset - currentOffset)) {
+ len = (int) (maxOffset - currentOffset);
+ }
+
+ int total = 0;
+
+ int first = readInternal(b, off, Math.min(len, SectorDevice.SECTOR_LENGTH - sectorOffset));
+ off += first;
+ len -= first;
+ total += first;
+
+ if (len >= SectorDevice.SECTOR_LENGTH) {
+ int n = len / SectorDevice.SECTOR_LENGTH;
+ reader.readSectors(currentSectorNumber + 1, n, b, off);
+ currentSectorNumber += n;
+ sectorOffset = SectorDevice.SECTOR_LENGTH;
+ int bytes = n * SectorDevice.SECTOR_LENGTH;
+ currentOffset += bytes;
+ checkSectorAvailable();
+ off += bytes;
+ len -= bytes;
+ total += bytes;
+ }
+
+ if (len > 0) {
+ checkSectorAvailable();
+ total += readInternal(b, off, len);
+ }
+
+ return total;
+ }
+
+ public void readFully(byte[] b) throws IOException {
+ readFully(b, 0, b.length);
+ }
+
+ public void readFully(byte[] b, int off, int len) throws IOException {
+ while (len > 0) {
+ if (currentOffset >= maxOffset) {
+ throw new EOFException();
+ }
+ int n = read(b, off, len);
+ if (n <= 0) {
+ throw new EOFException();
+ }
+ off += n;
+ len -= n;
+ }
+ }
+
+ public void seek(long offset) throws IOException {
+ if (offset < 0) {
+ throw new IOException("Negative seek offset: " + offset);
+ }
+ int newSectorNumber = startSector + (int) (offset / SectorDevice.SECTOR_LENGTH);
+ if (newSectorNumber != currentSectorNumber) {
+ currentSector = reader.readSector(newSectorNumber);
+ }
+ currentOffset = offset;
+ currentSectorNumber = newSectorNumber;
+ sectorOffset = (int) (offset % SectorDevice.SECTOR_LENGTH);
+ }
+
+ public long skip(long n) throws IOException {
+ if (n <= 0) {
+ return 0;
+ }
+ long oldOffset = currentOffset;
+ seek(Math.min(maxOffset, currentOffset + n));
+ return currentOffset - oldOffset;
+ }
+
+ public long length() {
+ return maxOffset;
+ }
+
+ public long getFilePointer() {
+ return currentOffset;
+ }
+
+ public int getStartSector() {
+ return startSector;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ private int readInternal(byte[] b, int off, int len) {
+ if (len > 0) {
+ System.arraycopy(currentSector, sectorOffset, b, off, len);
+ sectorOffset += len;
+ currentOffset += len;
+ }
+ return len;
+ }
+
+ private void checkSectorAvailable() throws IOException {
+ if (sectorOffset == SectorDevice.SECTOR_LENGTH && currentOffset < maxOffset) {
+ currentSectorNumber++;
+ currentSector = reader.readSector(currentSectorNumber);
+ sectorOffset = 0;
+ }
+ }
+
+ @Override
+ public void close() {
+ // The underlying image is owned by the reader; nothing to release here.
+ }
+
+ @Override
+ public String toString() {
+ return "UmdIsoFile(name=" + name + ", length=" + maxOffset + ", startSector=" + startSector + ")";
+ }
+}
diff --git a/core/src/main/java/dev/quoije/umd/iso/UmdIsoReader.java b/core/src/main/java/dev/quoije/umd/iso/UmdIsoReader.java
new file mode 100644
index 0000000..caf0b9b
--- /dev/null
+++ b/core/src/main/java/dev/quoije/umd/iso/UmdIsoReader.java
@@ -0,0 +1,215 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package dev.quoije.umd.iso;
+
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Reader for UMD disc images (ISO 9660 filesystems). */
+public final class UmdIsoReader implements AutoCloseable {
+
+ public static final int START_SECTOR = 16;
+ public static final int START_SECTOR_JOLIET = 17;
+
+ private static final System.Logger LOG = System.getLogger(UmdIsoReader.class.getName());
+
+ private final SectorDevice sectorDevice;
+ private final int numSectors;
+ private boolean hasJolietExtension;
+
+ private final Map fileCache = new HashMap<>();
+ private final Map dirCache = new HashMap<>();
+
+ public UmdIsoReader(Path path) throws IOException {
+ RandomAccessFile file = new RandomAccessFile(path.toFile(), "r");
+ this.sectorDevice = new RawIsoSectorDevice(file);
+ this.numSectors = sectorDevice.getNumSectors();
+ if (!hasIsoHeader()) {
+ sectorDevice.close();
+ throw new IOException("Unsupported or corrupted disc image: " + path);
+ }
+ }
+
+ public boolean hasJolietExtension() {
+ return hasJolietExtension;
+ }
+
+ public int getNumSectors() {
+ return numSectors;
+ }
+
+ public byte[] readSector(int sectorNumber) throws IOException {
+ byte[] buffer = new byte[SectorDevice.SECTOR_LENGTH];
+ readSector(sectorNumber, buffer, 0);
+ return buffer;
+ }
+
+ public void readSector(int sectorNumber, byte[] buffer, int offset) throws IOException {
+ if (sectorNumber < 0 || sectorNumber >= numSectors) {
+ Arrays.fill(buffer, offset, offset + SectorDevice.SECTOR_LENGTH, (byte) 0);
+ LOG.log(System.Logger.Level.WARNING, "Sector {0} out of range ({1} sectors)",
+ sectorNumber, numSectors);
+ return;
+ }
+ sectorDevice.readSector(sectorNumber, buffer, offset);
+ }
+
+ public int readSectors(int sectorNumber, int numberSectors, byte[] buffer, int offset) throws IOException {
+ if (sectorNumber < 0 || sectorNumber + numberSectors > numSectors) {
+ Arrays.fill(buffer, offset, offset + numberSectors * SectorDevice.SECTOR_LENGTH, (byte) 0);
+ LOG.log(System.Logger.Level.WARNING, "Sectors {0}..{1} out of range ({2} sectors)",
+ sectorNumber, sectorNumber + numberSectors, numSectors);
+ return numberSectors;
+ }
+ return sectorDevice.readSectors(sectorNumber, numberSectors, buffer, offset);
+ }
+
+ private boolean hasIsoHeader() throws IOException {
+ if (numSectors <= 0) {
+ return false;
+ }
+
+ byte[] sector = readSector(START_SECTOR);
+ if (sector[1] != 'C' || sector[2] != 'D' || sector[3] != '0' || sector[4] != '0' || sector[5] != '1') {
+ return false;
+ }
+
+ hasJolietExtension = false;
+ if (numSectors > START_SECTOR_JOLIET) {
+ sector = readSector(START_SECTOR_JOLIET);
+ if (sector[0] == 2
+ && sector[1] == 'C' && sector[2] == 'D'
+ && sector[3] == '0' && sector[4] == '0' && sector[5] == '1') {
+ hasJolietExtension = true;
+ }
+ }
+ return true;
+ }
+
+ public UmdIsoFile getFile(String filePath) throws IOException {
+ if (numSectors == 0) {
+ throw new IOException(filePath + ": empty image");
+ }
+
+ int fileStart;
+ long fileLength;
+ String fileName = null;
+
+ if (filePath.isEmpty()) {
+ fileStart = 0;
+ fileLength = (long) numSectors * SectorDevice.SECTOR_LENGTH;
+ } else {
+ Iso9660File info = getFileEntry(filePath);
+ if (info == null || info.isDirectory()) {
+ throw new IOException("File '" + filePath + "' not found or is not a file");
+ }
+ fileStart = info.getLba();
+ fileLength = info.getSize();
+ fileName = info.getName();
+ }
+
+ return new UmdIsoFile(this, fileStart, fileLength, fileName);
+ }
+
+ public boolean hasFile(String filePath) {
+ try (UmdIsoFile file = getFile(filePath)) {
+ return true;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ public String[] listDirectory(String filePath) throws IOException {
+ Iso9660Directory dir;
+ if (filePath.isEmpty()) {
+ dir = new Iso9660Handler(this);
+ } else {
+ Iso9660File info = getFileEntry(filePath);
+ if (info == null || !info.isDirectory()) {
+ throw new IOException("'" + filePath + "' not found or is not a directory");
+ }
+ dir = new Iso9660Directory(this, info.getLba(), info.getSize());
+ }
+ return dir.getFileList();
+ }
+
+ public boolean isDirectory(String filePath) throws IOException {
+ if (filePath.isEmpty()) {
+ return true;
+ }
+ Iso9660File info = getFileEntry(filePath);
+ return info != null && info.isDirectory();
+ }
+
+ private Iso9660File getFileEntry(String filePath) throws IOException {
+ Iso9660File info = fileCache.get(filePath);
+ if (info != null) {
+ return info;
+ }
+
+ int parentIndex = filePath.lastIndexOf('/');
+ if (parentIndex >= 0) {
+ String parentDirectory = filePath.substring(0, parentIndex);
+ Iso9660Directory dir = dirCache.get(parentDirectory);
+ if (dir != null) {
+ int index = dir.getFileIndex(filePath.substring(parentIndex + 1));
+ info = dir.getEntryByIndex(index);
+ if (info != null) {
+ fileCache.put(filePath, info);
+ return info;
+ }
+ }
+ }
+
+ Iso9660Directory dir = new Iso9660Handler(this);
+
+ String[] path = filePath.split("[\\\\/]");
+ int pathLength = path.length;
+ for (int i = 0; i < pathLength; ) {
+ if (path[i].equals(".")) {
+ pathLength = removePath(path, i, pathLength);
+ } else if (path[i].equals("..")) {
+ pathLength = removePath(path, i, pathLength);
+ pathLength = removePath(path, i - 1, pathLength);
+ } else {
+ i++;
+ }
+ }
+
+ for (int i = 0; i < pathLength; i++) {
+ int index = dir.getFileIndex(path[i]);
+ info = dir.getEntryByIndex(index);
+ if (info != null && info.isDirectory()) {
+ dir = new Iso9660Directory(this, info.getLba(), info.getSize());
+ StringBuilder dirPath = new StringBuilder(path[0]);
+ for (int j = 1; j <= i; j++) {
+ dirPath.append('/').append(path[j]);
+ }
+ dirCache.put(dirPath.toString(), dir);
+ }
+ }
+
+ if (info != null) {
+ fileCache.put(filePath, info);
+ }
+ return info;
+ }
+
+ private static int removePath(String[] path, int index, int length) {
+ if (index < 0 || index >= length) {
+ return length;
+ }
+ for (int i = index + 1; i < length; i++) {
+ path[i - 1] = path[i];
+ }
+ return length - 1;
+ }
+
+ @Override
+ public void close() throws IOException {
+ sectorDevice.close();
+ }
+}
diff --git a/core/src/main/java/dev/quoije/umd/sfo/Sfo.java b/core/src/main/java/dev/quoije/umd/sfo/Sfo.java
new file mode 100644
index 0000000..62b2890
--- /dev/null
+++ b/core/src/main/java/dev/quoije/umd/sfo/Sfo.java
@@ -0,0 +1,118 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package dev.quoije.umd.sfo;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Parser for the PSP "SFO" metadata format (for example {@code UMD_VIDEO/param.sfo}).
+ */
+public final class Sfo {
+
+ public static final int FORMAT_UTF8 = 0x0204;
+ public static final int FORMAT_INT32 = 0x0404;
+
+ private final Map entries = new LinkedHashMap<>();
+
+ /** A single SFO key/value entry. */
+ public record Entry(int format, byte[] data) {
+ public String asString() {
+ int end = data.length;
+ while (end > 0 && data[end - 1] == 0) {
+ end--;
+ }
+ return new String(data, 0, end, StandardCharsets.UTF_8);
+ }
+
+ public int asInt() {
+ return (data[0] & 0xFF)
+ | ((data[1] & 0xFF) << 8)
+ | ((data[2] & 0xFF) << 16)
+ | ((data[3] & 0xFF) << 24);
+ }
+ }
+
+ public static Sfo parse(byte[] buf) throws IOException {
+ if (buf.length < 20) {
+ throw new IOException("SFO data too short");
+ }
+ if (buf[0] != 0 || buf[1] != 'P' || buf[2] != 'S' || buf[3] != 'F') {
+ throw new IOException("Invalid SFO magic");
+ }
+
+ int keyTableOffset = le32(buf, 8);
+ int dataTableOffset = le32(buf, 12);
+ int numEntries = le32(buf, 16);
+
+ Sfo sfo = new Sfo();
+ for (int i = 0; i < numEntries; i++) {
+ int off = 20 + i * 16;
+ if (off + 16 > buf.length) {
+ throw new IOException("SFO entry table truncated");
+ }
+
+ int keyOffset = le16(buf, off);
+ int format = le16(buf, off + 2);
+ int dataLength = le32(buf, off + 4);
+ int dataUsed = le32(buf, off + 8);
+ int dataOffset = le32(buf, off + 12);
+
+ String key = readNullTerminated(buf, keyTableOffset + keyOffset);
+
+ int used = dataUsed;
+ if (used <= 0 || used > dataLength) {
+ used = dataLength;
+ }
+ if (dataTableOffset + dataOffset + used > buf.length) {
+ throw new IOException("SFO data for '" + key + "' truncated");
+ }
+
+ byte[] data = new byte[used];
+ System.arraycopy(buf, dataTableOffset + dataOffset, data, 0, used);
+ sfo.entries.put(key, new Entry(format, data));
+ }
+ return sfo;
+ }
+
+ public Entry get(String key) {
+ return entries.get(key);
+ }
+
+ public String getString(String key) {
+ Entry entry = entries.get(key);
+ return entry == null ? null : entry.asString();
+ }
+
+ public Integer getInt(String key) {
+ Entry entry = entries.get(key);
+ return entry == null ? null : entry.asInt();
+ }
+
+ public Map entries() {
+ return entries;
+ }
+
+ private static String readNullTerminated(byte[] buf, int offset) throws IOException {
+ int end = offset;
+ while (end < buf.length && buf[end] != 0) {
+ end++;
+ }
+ if (end == buf.length) {
+ throw new IOException("Unterminated SFO key");
+ }
+ return new String(buf, offset, end - offset, StandardCharsets.US_ASCII);
+ }
+
+ private static int le16(byte[] b, int o) {
+ return (b[o] & 0xFF) | ((b[o + 1] & 0xFF) << 8);
+ }
+
+ private static int le32(byte[] b, int o) {
+ return (b[o] & 0xFF)
+ | ((b[o + 1] & 0xFF) << 8)
+ | ((b[o + 2] & 0xFF) << 16)
+ | ((b[o + 3] & 0xFF) << 24);
+ }
+}
diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java
new file mode 100644
index 0000000..26b6455
--- /dev/null
+++ b/core/src/main/java/module-info.java
@@ -0,0 +1,5 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+module dev.quoije.umd.core {
+ exports dev.quoije.umd.iso;
+ exports dev.quoije.umd.sfo;
+}
diff --git a/core/src/test/java/dev/quoije/umd/iso/UmdIsoReaderTest.java b/core/src/test/java/dev/quoije/umd/iso/UmdIsoReaderTest.java
new file mode 100644
index 0000000..186dfad
--- /dev/null
+++ b/core/src/test/java/dev/quoije/umd/iso/UmdIsoReaderTest.java
@@ -0,0 +1,144 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package dev.quoije.umd.iso;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class UmdIsoReaderTest {
+
+ private static final int SECTOR = 2048;
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void listsAndReadsFiles() throws Exception {
+ Path isoPath = tempDir.resolve("test.iso");
+ Files.write(isoPath, buildImage());
+
+ try (UmdIsoReader iso = new UmdIsoReader(isoPath)) {
+ assertThat(iso.hasJolietExtension()).isFalse();
+ assertThat(iso.listDirectory("")).containsExactlyInAnyOrder(".", "..", "HELLO.TXT");
+ assertThat(iso.isDirectory("")).isTrue();
+
+ try (UmdIsoFile file = iso.getFile("HELLO.TXT")) {
+ assertThat(file.length()).isEqualTo(6);
+ byte[] data = new byte[6];
+ file.readFully(data);
+ assertThat(new String(data, StandardCharsets.US_ASCII)).isEqualTo("hello\n");
+ }
+ }
+ }
+
+ @Test
+ void rejectsMissingFile() throws Exception {
+ Path isoPath = tempDir.resolve("test.iso");
+ Files.write(isoPath, buildImage());
+
+ try (UmdIsoReader iso = new UmdIsoReader(isoPath)) {
+ assertThatThrownBy(() -> iso.getFile("NOPE.TXT")).isInstanceOf(Exception.class);
+ }
+ }
+
+ private static byte[] buildImage() {
+ int numSectors = 32;
+ byte[] image = new byte[numSectors * SECTOR];
+
+ int rootLba = 20;
+ int fileLba = 21;
+
+ // Primary volume descriptor at sector 16.
+ int pvd = 16 * SECTOR;
+ image[pvd] = 1;
+ image[pvd + 1] = 'C';
+ image[pvd + 2] = 'D';
+ image[pvd + 3] = '0';
+ image[pvd + 4] = '0';
+ image[pvd + 5] = '1';
+ image[pvd + 6] = 1;
+ System.arraycopy(dirRecord(rootLba, 2048, true, "."), 0, image, pvd + 156, 34);
+
+ // Root directory at sector 20: ".", "..", "HELLO.TXT".
+ int dir = rootLba * SECTOR;
+ int off = dir;
+ off += writeDirRecord(image, off, rootLba, 2048, true, ".");
+ off += writeDirRecord(image, off, rootLba, 2048, true, "..");
+ off += writeDirRecord(image, off, fileLba, 6, false, "HELLO.TXT");
+
+ // File content at sector 21.
+ byte[] content = "hello\n".getBytes(StandardCharsets.US_ASCII);
+ System.arraycopy(content, 0, image, fileLba * SECTOR, content.length);
+
+ return image;
+ }
+
+ private static int writeDirRecord(byte[] image, int offset, int lba, int size, boolean directory, String name) {
+ byte[] record = dirRecord(lba, size, directory, name);
+ System.arraycopy(record, 0, image, offset, record.length);
+ return record.length;
+ }
+
+ private static byte[] dirRecord(int lba, int size, boolean directory, String name) {
+ byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
+ int nameLength = nameBytes.length;
+ int recordLength = 33 + nameLength + (nameLength % 2 == 0 ? 1 : 0);
+
+ byte[] r = new byte[recordLength];
+ r[0] = (byte) recordLength;
+ r[1] = 0; // extended attribute record length
+ le32(r, 2, lba);
+ be32(r, 6, lba);
+ le32(r, 10, size);
+ be32(r, 14, size);
+ // Recording date/time: year (since 1900), month, day, hour, min, sec, gmt.
+ r[18] = 126;
+ r[19] = 9;
+ r[20] = 15;
+ r[21] = 0;
+ r[22] = 0;
+ r[23] = 0;
+ r[24] = 0;
+ r[25] = (byte) (directory ? 0x02 : 0x00); // file flags
+ r[26] = 0; // file unit size
+ r[27] = 0; // interleave gap size
+ le16(r, 28, 1); // volume sequence number (little)
+ be16(r, 30, 1); // volume sequence number (big)
+ r[32] = (byte) nameLength;
+ System.arraycopy(nameBytes, 0, r, 33, nameLength);
+ if (nameLength % 2 == 0) {
+ r[33 + nameLength] = 0; // padding
+ }
+ return r;
+ }
+
+ private static void le16(byte[] b, int o, int v) {
+ b[o] = (byte) (v & 0xFF);
+ b[o + 1] = (byte) ((v >> 8) & 0xFF);
+ }
+
+ private static void be16(byte[] b, int o, int v) {
+ b[o] = (byte) ((v >> 8) & 0xFF);
+ b[o + 1] = (byte) (v & 0xFF);
+ }
+
+ private static void le32(byte[] b, int o, int v) {
+ b[o] = (byte) (v & 0xFF);
+ b[o + 1] = (byte) ((v >> 8) & 0xFF);
+ b[o + 2] = (byte) ((v >> 16) & 0xFF);
+ b[o + 3] = (byte) ((v >> 24) & 0xFF);
+ }
+
+ private static void be32(byte[] b, int o, int v) {
+ b[o] = (byte) ((v >> 24) & 0xFF);
+ b[o + 1] = (byte) ((v >> 16) & 0xFF);
+ b[o + 2] = (byte) ((v >> 8) & 0xFF);
+ b[o + 3] = (byte) (v & 0xFF);
+ }
+}
diff --git a/core/src/test/java/dev/quoije/umd/sfo/SfoTest.java b/core/src/test/java/dev/quoije/umd/sfo/SfoTest.java
new file mode 100644
index 0000000..e7b4e17
--- /dev/null
+++ b/core/src/test/java/dev/quoije/umd/sfo/SfoTest.java
@@ -0,0 +1,112 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package dev.quoije.umd.sfo;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SfoTest {
+
+ private record TestEntry(String key, int format, byte[] data) {
+ }
+
+ @Test
+ void parsesStringAndIntegerEntries() throws Exception {
+ byte[] sfo = build(
+ new TestEntry("TITLE", Sfo.FORMAT_UTF8, "Test Disc".getBytes(StandardCharsets.UTF_8)),
+ new TestEntry("MEMSIZE", Sfo.FORMAT_INT32, le32(1)),
+ new TestEntry("ATTRIBUTE", Sfo.FORMAT_INT32, le32(0x8000))
+ );
+
+ Sfo parsed = Sfo.parse(sfo);
+
+ assertThat(parsed.getString("TITLE")).isEqualTo("Test Disc");
+ assertThat(parsed.getInt("MEMSIZE")).isEqualTo(1);
+ assertThat(parsed.getInt("ATTRIBUTE")).isEqualTo(0x8000);
+ assertThat(parsed.entries()).containsOnlyKeys("TITLE", "MEMSIZE", "ATTRIBUTE");
+ }
+
+ @Test
+ void rejectsInvalidMagic() {
+ byte[] bad = new byte[64];
+ assertThatThrownBy(() -> Sfo.parse(bad)).isInstanceOf(IOException.class);
+ }
+
+ private static byte[] build(TestEntry... entries) {
+ Map keyBytes = new LinkedHashMap<>();
+ Map dataBytes = new LinkedHashMap<>();
+ for (TestEntry entry : entries) {
+ keyBytes.put(entry.key, (entry.key + "\0").getBytes(StandardCharsets.US_ASCII));
+ dataBytes.put(entry.key, entry.data);
+ }
+
+ int keyTableOffset = 20 + entries.length * 16;
+ int dataTableOffset = keyTableOffset
+ + keyBytes.values().stream().mapToInt(b -> b.length).sum();
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ out.writeBytes(new byte[]{0, 'P', 'S', 'F'});
+ writeLe32(out, 0x101); // version
+ writeLe32(out, keyTableOffset);
+ writeLe32(out, dataTableOffset);
+ writeLe32(out, entries.length);
+
+ Map keyOffsets = new LinkedHashMap<>();
+ int keyOff = 0;
+ for (String key : keyBytes.keySet()) {
+ keyOffsets.put(key, keyOff);
+ keyOff += keyBytes.get(key).length;
+ }
+ Map dataOffsets = new LinkedHashMap<>();
+ int dataOff = 0;
+ for (String key : dataBytes.keySet()) {
+ dataOffsets.put(key, dataOff);
+ dataOff += dataBytes.get(key).length;
+ }
+
+ for (TestEntry entry : entries) {
+ writeLe16(out, keyOffsets.get(entry.key));
+ writeLe16(out, entry.format);
+ writeLe32(out, entry.data.length);
+ writeLe32(out, entry.data.length);
+ writeLe32(out, dataOffsets.get(entry.key));
+ }
+
+ for (byte[] kb : keyBytes.values()) {
+ out.writeBytes(kb);
+ }
+ for (byte[] db : dataBytes.values()) {
+ out.writeBytes(db);
+ }
+
+ return out.toByteArray();
+ }
+
+ private static void writeLe16(ByteArrayOutputStream out, int v) {
+ out.write(v & 0xFF);
+ out.write((v >> 8) & 0xFF);
+ }
+
+ private static void writeLe32(ByteArrayOutputStream out, int v) {
+ out.write(v & 0xFF);
+ out.write((v >> 8) & 0xFF);
+ out.write((v >> 16) & 0xFF);
+ out.write((v >> 24) & 0xFF);
+ }
+
+ private static byte[] le32(int v) {
+ return new byte[]{
+ (byte) (v & 0xFF),
+ (byte) ((v >> 8) & 0xFF),
+ (byte) ((v >> 16) & 0xFF),
+ (byte) ((v >> 24) & 0xFF)
+ };
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..71d5353
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,18 @@
+[versions]
+junit = "5.11.4"
+junit-platform = "1.11.4"
+assertj = "3.27.3"
+slf4j = "2.0.16"
+logback = "1.5.16"
+javafx = "21.0.5"
+ffmpeg = "7.1.1-1.5.11"
+
+[libraries]
+junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
+junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
+junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit-platform" }
+assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" }
+slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
+logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
+javafx-controls = { module = "org.openjfx:javafx-controls", version.ref = "javafx" }
+ffmpeg-platform = { module = "org.bytedeco:ffmpeg-platform", version.ref = "ffmpeg" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..eddabd2
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..ad7845b
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..379a658
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob//platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..8508ef6
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,82 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..8b23911
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,5 @@
+rootProject.name = "useless-umd-player"
+
+include("core")
+include("ui")
+include("cli")
diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts
new file mode 100644
index 0000000..01ff93c
--- /dev/null
+++ b/ui/build.gradle.kts
@@ -0,0 +1,9 @@
+plugins {
+ `java-library`
+}
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
+}