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 6afc961..620433e 100644 --- a/cli/src/main/java/dev/quoije/umd/cli/Main.java +++ b/cli/src/main/java/dev/quoije/umd/cli/Main.java @@ -3,6 +3,7 @@ package dev.quoije.umd.cli; import dev.quoije.umd.iso.UmdIsoFile; import dev.quoije.umd.iso.UmdIsoReader; +import dev.quoije.umd.playlist.Playlist; import dev.quoije.umd.sfo.Sfo; import java.io.IOException; @@ -65,6 +66,8 @@ public final class Main { printSfo(iso); System.out.println(); printTree(iso); + System.out.println(); + printPlaylist(iso); } return 0; } @@ -120,6 +123,34 @@ public final class Main { } } + private static void printPlaylist(UmdIsoReader iso) { + try { + Playlist playlist = Playlist.parse(iso); + System.out.println("Playlist (" + playlist.streams().size() + " streams):"); + for (Playlist.Stream stream : playlist.streams()) { + System.out.printf(" %s %dx%d %s .. %s (%d chapters)%n", + stream.name(), stream.width(), stream.height(), + formatTimestamp(stream.firstTimestamp()), + formatTimestamp(stream.lastTimestamp()), + stream.markers().size()); + for (Playlist.Marker marker : stream.markers()) { + System.out.printf(" %s @ %s%n", marker.name(), formatTimestamp(marker.timestamp())); + } + } + } catch (Exception e) { + System.out.println("Playlist: error: " + e.getMessage()); + } + } + + private static String formatTimestamp(long timestamp) { + long seconds = timestamp / 90000; // 90 kHz MPEG clock + long hundredth = (timestamp - seconds * 90000) * 100 / 90000; + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + long secs = seconds % 60; + return String.format("%02d:%02d:%02d.%02d", hours, minutes, secs, hundredth); + } + private static byte[] readFile(UmdIsoReader iso, String path) throws IOException { if (!iso.hasFile(path)) { return null; diff --git a/core/src/main/java/dev/quoije/umd/iso/UmdIsoFile.java b/core/src/main/java/dev/quoije/umd/iso/UmdIsoFile.java index 6b0d4b7..c1a4f40 100644 --- a/core/src/main/java/dev/quoije/umd/iso/UmdIsoFile.java +++ b/core/src/main/java/dev/quoije/umd/iso/UmdIsoFile.java @@ -108,6 +108,35 @@ public final class UmdIsoFile implements AutoCloseable { } } + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + public int readUnsignedByte() throws IOException { + return read(); + } + + public byte readByte() throws IOException { + return (byte) read(); + } + + /** Reads a 16-bit little-endian value, as used by the PSP. */ + public short readShort() throws IOException { + return (short) (readUnsignedByte() | (readUnsignedByte() << 8)); + } + + /** Reads a 32-bit little-endian value, as used by the PSP. */ + public int readInt() throws IOException { + return readUnsignedByte() + | (readUnsignedByte() << 8) + | (readUnsignedByte() << 16) + | (readUnsignedByte() << 24); + } + + public int skipBytes(int bytes) throws IOException { + return (int) skip(bytes); + } + public void seek(long offset) throws IOException { if (offset < 0) { throw new IOException("Negative seek offset: " + offset); diff --git a/core/src/main/java/dev/quoije/umd/playlist/Playlist.java b/core/src/main/java/dev/quoije/umd/playlist/Playlist.java new file mode 100644 index 0000000..b130347 --- /dev/null +++ b/core/src/main/java/dev/quoije/umd/playlist/Playlist.java @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.playlist; + +import dev.quoije.umd.iso.UmdIsoFile; +import dev.quoije.umd.iso.UmdIsoReader; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Parsed contents of {@code UMD_VIDEO/PLAYLIST.UMD}: the list of MPS streams + * (playlist items) and their chapter markers. + */ +public final class Playlist { + + public static final int UMDV_MAGIC = 0x56444D55; // "UMDV" + + private final List streams; + + private Playlist(List streams) { + this.streams = List.copyOf(streams); + } + + public List streams() { + return streams; + } + + /** A single MPS stream (playlist item). */ + public record Stream( + String name, + int width, + int height, + int firstTimestamp, + int lastTimestamp, + List markers, + int playListNumber) { + + /** Returns the 0-based chapter index for a 90 kHz timestamp. */ + public int getChapterNumber(long timestamp) { + int marker = -1; + for (int i = 0; i < markers.size(); i++) { + if (markers.get(i).timestamp() <= timestamp) { + marker = i; + } else { + break; + } + } + return marker; + } + } + + /** A chapter marker within a stream. */ + public record Marker(String name, long timestamp) { + } + + public static Playlist parse(UmdIsoReader iso) throws IOException { + List streams = new ArrayList<>(); + + try (UmdIsoFile file = iso.getFile("UMD_VIDEO/PLAYLIST.UMD")) { + int umdvMagic = file.readInt(); + file.readInt(); // version + int globalDataOffset = endianSwap32(file.readInt()); + + file.seek(globalDataOffset); + + file.readInt(); // playlist size (big-endian) + int playListTracksNum = endianSwap16(file.readShort()); + file.skipBytes(2); // NULL + + if (umdvMagic != UMDV_MAGIC) { + throw new IOException(String.format("Invalid PLAYLIST.UMD magic 0x%08X", umdvMagic)); + } + + for (int i = 0; i < playListTracksNum; i++) { + file.skipBytes(2); // 0x035C + file.skipBytes(2); // 0x0310 + file.skipBytes(2); // 0x0332 + file.skipBytes(30); // NULL + file.skipBytes(2); // 0x02E8 + file.readShort(); // unknown + readByteHexTo10(file); // release date (year, high digits) + readByteHexTo10(file); // release date (year, low digits) + file.readUnsignedByte(); // release date day + file.readUnsignedByte(); // release date month + file.skipBytes(4); // NULL + file.skipBytes(4); // unknown (0x00000900) + + int nameLength = file.readUnsignedByte(); + byte[] nameBuffer = new byte[nameLength]; + file.read(nameBuffer); + file.skipBytes(732 - nameLength); // NULL area (0x2DC) + + int streamHeight = file.readUnsignedByte() * 0x10; + file.skipBytes(2); // NULL + file.skipBytes(4); // 0x00010000 + file.skipBytes(1); // NULL + int streamWidth = file.readUnsignedByte() * 0x10; + file.skipBytes(1); // NULL + + int streamNameCharsNum = file.readUnsignedByte(); + byte[] stringBuf = new byte[streamNameCharsNum]; + file.read(stringBuf); + String streamName = new String(stringBuf, StandardCharsets.US_ASCII); + file.skipBytes(8 - streamNameCharsNum); // NULL chars + + file.skipBytes(2); // NULL + int streamFirstTimestamp = endianSwap32(file.readInt()); + file.skipBytes(2); // NULL + int streamLastTimestamp = endianSwap32(file.readInt()); + file.skipBytes(2); // NULL + + int streamMarkerDataLength = endianSwap16(file.readShort()); + List markers = new ArrayList<>(); + if (streamMarkerDataLength > 0) { + int streamMarkersNum = endianSwap16(file.readShort()); + for (int j = 0; j < streamMarkersNum; j++) { + file.skipBytes(1); // 0x05 + int markerCharsNum = file.readUnsignedByte(); + file.skipBytes(4); // NULL + long markerTimestamp = endianSwap32(file.readInt()) & 0xFFFFFFFFL; + file.skipBytes(2); // NULL + file.skipBytes(4); // NULL + + byte[] markerBuf = new byte[markerCharsNum]; + file.read(markerBuf); + String markerName = new String(markerBuf, StandardCharsets.US_ASCII); + file.skipBytes(24 - markerCharsNum); + + markers.add(new Marker(markerName, markerTimestamp)); + } + file.skip(2); // NULL + } + + streams.add(new Stream(streamName, streamWidth, streamHeight, + streamFirstTimestamp, streamLastTimestamp, List.copyOf(markers), i + 1)); + } + } + + return new Playlist(streams); + } + + private static int readByteHexTo10(UmdIsoFile file) throws IOException { + int hex = file.readUnsignedByte(); + return (hex >> 4) * 10 + (hex & 0x0F); + } + + private static int endianSwap16(int value) { + return ((value & 0xFF) << 8) | ((value >>> 8) & 0xFF); + } + + private static int endianSwap32(int value) { + return Integer.reverseBytes(value); + } +} diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index 26b6455..3707bd9 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -2,4 +2,5 @@ module dev.quoije.umd.core { exports dev.quoije.umd.iso; exports dev.quoije.umd.sfo; + exports dev.quoije.umd.playlist; } diff --git a/core/src/test/java/dev/quoije/umd/playlist/PlaylistTest.java b/core/src/test/java/dev/quoije/umd/playlist/PlaylistTest.java new file mode 100644 index 0000000..f8326d8 --- /dev/null +++ b/core/src/test/java/dev/quoije/umd/playlist/PlaylistTest.java @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.playlist; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlaylistTest { + + @Test + void chapterNumberMapsTimestampToMarker() { + Playlist.Stream stream = new Playlist.Stream( + "00001", 352, 480, 90000, 200000, + List.of(new Playlist.Marker("A", 90000), new Playlist.Marker("B", 150000)), + 1); + + assertThat(stream.getChapterNumber(0)).isEqualTo(-1); + assertThat(stream.getChapterNumber(90000)).isEqualTo(0); + assertThat(stream.getChapterNumber(149999)).isEqualTo(0); + assertThat(stream.getChapterNumber(150000)).isEqualTo(1); + assertThat(stream.getChapterNumber(999999)).isEqualTo(1); + } +}