Parse PLAYLIST.UMD: streams, chapters and timestamps

- Add little-endian readers to UmdIsoFile
- Playlist parser (streams + chapter markers) ported from JPCSP
- dump command now lists streams, durations and chapter markers
- Unit test for chapter/timestamp mapping
This commit is contained in:
2026-09-15 21:03:37 -04:00
parent 797b0507bd
commit b09e23208a
5 changed files with 242 additions and 0 deletions
@@ -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);
@@ -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<Stream> streams;
private Playlist(List<Stream> streams) {
this.streams = List.copyOf(streams);
}
public List<Stream> streams() {
return streams;
}
/** A single MPS stream (playlist item). */
public record Stream(
String name,
int width,
int height,
int firstTimestamp,
int lastTimestamp,
List<Marker> 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<Stream> 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<Marker> 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);
}
}
+1
View File
@@ -2,4 +2,5 @@
module dev.quoije.umd.core {
exports dev.quoije.umd.iso;
exports dev.quoije.umd.sfo;
exports dev.quoije.umd.playlist;
}
@@ -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);
}
}