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 620433e..3800f69 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.mps.MpsDemuxer; import dev.quoije.umd.playlist.Playlist; import dev.quoije.umd.sfo.Sfo; @@ -14,7 +15,7 @@ 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 "; + private static final String USAGE = "usage: umdplayer dump \n umdplayer mps "; private Main() { } @@ -29,6 +30,7 @@ public final class Main { try { exitCode = switch (args[0]) { case "dump" -> dump(requireArg(args, 1)); + case "mps" -> mps(requireArg(args, 1), requireArg(args, 2)); case "help", "--help", "-h" -> { System.out.println(USAGE); yield 0; @@ -151,6 +153,47 @@ public final class Main { return String.format("%02d:%02d:%02d.%02d", hours, minutes, secs, hundredth); } + private static int mps(String imagePath, String streamName) throws IOException { + try (UmdIsoReader iso = new UmdIsoReader(Path.of(imagePath))) { + try (MpsDemuxer demuxer = MpsDemuxer.open(iso, streamName)) { + int count = 0; + int videoPackets = 0; + int audioPackets = 0; + while (count < 40) { + MpsDemuxer.Packet packet = demuxer.nextPacket(); + if (packet == null) { + break; + } + count++; + String type = packet.isVideo() + ? "video" + : packet.isAudio() ? "audio(ch=" + packet.channel() + ")" : "other"; + System.out.printf("%d: stream=0x%02X %s pts=%d len=%d%n", + count, packet.streamId(), type, packet.pts(), packet.payload().length); + if (packet.isVideo()) { + videoPackets++; + if (videoPackets == 1) { + System.out.println(" first video payload: " + hexPrefix(packet.payload(), 16)); + } + } else if (packet.isAudio()) { + audioPackets++; + } + } + System.out.printf("Sampled %d packets (%d video, %d audio)%n", + count, videoPackets, audioPackets); + } + } + return 0; + } + + private static String hexPrefix(byte[] data, int max) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < Math.min(data.length, max); i++) { + sb.append(String.format("%02X ", data[i] & 0xFF)); + } + return sb.toString().trim(); + } + 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/mps/MpsDemuxer.java b/core/src/main/java/dev/quoije/umd/mps/MpsDemuxer.java new file mode 100644 index 0000000..267597e --- /dev/null +++ b/core/src/main/java/dev/quoije/umd/mps/MpsDemuxer.java @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.quoije.umd.mps; + +import dev.quoije.umd.iso.UmdIsoFile; +import dev.quoije.umd.iso.UmdIsoReader; + +import java.io.EOFException; +import java.io.IOException; + +/** + * Demuxer for the MPEG program stream inside a UMD {@code .MPS} file. + * + *

The stream starts with a PSMF header (magic {@code "PSMF"}) followed by + * MPEG program stream packets: video (stream id {@code 0x1E0}-{@code 0x1EF}, + * H.264) and audio (stream id {@code 0x1BD}, ATRAC3plus).

+ */ +public final class MpsDemuxer implements AutoCloseable { + + public static final int PACK_START_CODE = 0x1BA; + public static final int SYSTEM_HEADER_START_CODE = 0x1BB; + public static final int PRIVATE_STREAM_1 = 0x1BD; + public static final int PADDING_STREAM = 0x1BE; + public static final int PRIVATE_STREAM_2 = 0x1BF; + + public static final long UNKNOWN_TIMESTAMP = -1L; + + private final UmdIsoFile file; + + private MpsDemuxer(UmdIsoFile file) { + this.file = file; + } + + /** A single PES packet extracted from the stream. */ + public record Packet(int streamId, long pts, long dts, int channel, byte[] payload) { + public boolean isVideo() { + return streamId >= 0x1E0 && streamId <= 0x1EF; + } + + public boolean isAudio() { + return streamId == PRIVATE_STREAM_1; + } + } + + public static MpsDemuxer open(UmdIsoReader iso, String streamName) throws IOException { + UmdIsoFile file = iso.getFile("UMD_VIDEO/STREAM/" + streamName + ".MPS"); + return new MpsDemuxer(file); + } + + /** + * Reads the next media packet, or {@code null} at end of stream. + * Non-media packets (pack headers, padding, private stream 2) are skipped. + */ + public Packet nextPacket() throws IOException { + while (true) { + int startCode = read32(); + if (startCode == -1) { + return null; + } + + switch (startCode) { + case PACK_START_CODE -> skip(10); + case SYSTEM_HEADER_START_CODE -> skip(14); + case PADDING_STREAM, PRIVATE_STREAM_2 -> { + int len = read16(); + if (len < 0) { + return null; + } + skip(len); + } + case PRIVATE_STREAM_1 -> { + int codeLength = read16(); + if (codeLength < 0) { + return null; + } + PesHeader pes = readPesHeader(startCode); + int payloadLength = codeLength - pes.headerLength(); + byte[] payload = readPayload(payloadLength); + return new Packet(startCode, pes.pts(), pes.dts(), pes.channel(), payload); + } + default -> { + if (startCode >= 0x1E0 && startCode <= 0x1EF) { + int codeLength = read16(); + if (codeLength < 0) { + return null; + } + PesHeader pes = readPesHeader(startCode); + int payloadLength = codeLength - pes.headerLength(); + byte[] payload = readPayload(payloadLength); + return new Packet(startCode, pes.pts(), pes.dts(), -1, payload); + } + throw new IOException(String.format("Unknown MPEG-PS start code 0x%08X", startCode)); + } + } + } + } + + private byte[] readPayload(int length) throws IOException { + if (length <= 0) { + return new byte[0]; + } + byte[] data = new byte[length]; + file.readFully(data); + return data; + } + + private record PesHeader(long pts, long dts, int channel, int headerLength) { + } + + private PesHeader readPesHeader(int startCode) throws IOException { + int pesLength = 0; + int c = read8(); + pesLength++; + while (c == 0xFF) { + c = read8(); + pesLength++; + } + + if ((c & 0xC0) == 0x40) { + skip(1); + c = read8(); + pesLength += 2; + } + + long pts = UNKNOWN_TIMESTAMP; + long dts = UNKNOWN_TIMESTAMP; + if ((c & 0xE0) == 0x20) { + long first = readPts(c); + pesLength += 4; + dts = first; + pts = first; + if ((c & 0x10) != 0) { + pts = readPts(); + pesLength += 5; + } + } else if ((c & 0xC0) == 0x80) { + int flags = read8(); + int headerLength = read8(); + pesLength += 2 + headerLength; + if ((flags & 0x80) != 0) { + pts = readPts(); + dts = pts; + headerLength -= 5; + if ((flags & 0x40) != 0) { + dts = readPts(); + headerLength -= 5; + } + } + if ((flags & 0x3F) != 0 && headerLength == 0) { + flags &= 0xC0; + } + if ((flags & 0x01) != 0) { + int pesExt = read8(); + headerLength--; + int skipLen = (pesExt >> 4) & 0x0B; + skipLen += skipLen & 0x09; + if ((pesExt & 0x40) != 0 || skipLen > headerLength) { + pesExt = 0; + skipLen = 0; + } + skip(skipLen); + headerLength -= skipLen; + if ((pesExt & 0x01) != 0) { + int ext2Length = read8(); + headerLength--; + if ((ext2Length & 0x7F) != 0) { + int idExt = read8(); + headerLength--; + if ((idExt & 0x80) == 0) { + startCode = ((startCode & 0xFF) << 8) | idExt; + } + } + } + } + skip(headerLength); + } + + int channel = -1; + if (startCode == PRIVATE_STREAM_1) { + channel = read8(); + pesLength++; + if (channel >= 0x80 && channel <= 0xCF) { + skip(3); + pesLength += 3; + if (channel >= 0xB0 && channel <= 0xBF) { + skip(1); + pesLength++; + } + } else { + skip(3); + pesLength += 3; + } + } + + return new PesHeader(pts, dts, channel, pesLength); + } + + private long readPts(int c) throws IOException { + return (((long) (c & 0x0E)) << 29) + | ((long) (read16() >> 1) << 15) + | (read16() >> 1); + } + + private long readPts() throws IOException { + return readPts(read8()); + } + + private int read8() throws IOException { + try { + return file.read(); + } catch (EOFException e) { + return -1; + } + } + + private int read16() throws IOException { + int a = read8(); + if (a < 0) { + return -1; + } + int b = read8(); + if (b < 0) { + return -1; + } + return (a << 8) | b; + } + + private int read32() throws IOException { + int a = read16(); + if (a < 0) { + return -1; + } + int b = read16(); + if (b < 0) { + return -1; + } + return (a << 16) | (b & 0xFFFF); + } + + private void skip(int n) throws IOException { + if (n > 0) { + file.skip(n); + } + } + + @Override + public void close() { + file.close(); + } +} diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index 3707bd9..d81bc3f 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -3,4 +3,5 @@ module dev.quoije.umd.core { exports dev.quoije.umd.iso; exports dev.quoije.umd.sfo; exports dev.quoije.umd.playlist; + exports dev.quoije.umd.mps; }