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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user