diff --git a/core/src/com/riiablo/video/BIK.java b/core/src/com/riiablo/video/BIK.java new file mode 100644 index 00000000..f940cb94 --- /dev/null +++ b/core/src/com/riiablo/video/BIK.java @@ -0,0 +1,172 @@ +package com.riiablo.video; + +import io.netty.buffer.ByteBuf; +import org.apache.commons.io.FileUtils; + +import com.riiablo.io.BitUtils; +import com.riiablo.io.ByteInput; +import com.riiablo.io.InvalidFormat; +import com.riiablo.logger.LogManager; +import com.riiablo.logger.Logger; +import com.riiablo.logger.MDC; + +public class BIK { + private static final Logger log = LogManager.getLogger(BIK.class); + + static final int EXTRADATA_SIZE = 1; + static final int MAX_WIDTH = 7680; + static final int MAX_HEIGHT = 4800; + static final int MAX_TRACKS = 256; + static final int SMUSH_BLOCK_SIZE = 512; + + private static final byte[] SIGNATURE = new byte[] { 0x42, 0x49, 0x4B }; + + private static final int FLAG_VIDEO_ALPHA = 0x00100000; + private static final int FLAG_VIDEO_GRAYSCALE = 0x00020000; + + final ByteBuf buffer; + final short version; + final int size; + final int numFrames; + final int largestFrame; + final int width; + final int height; + final float fps; + final int flags; + final int numTracks; + final BinkAudio[] tracks; + final int[] offsets; + final BinkVideo video; + + public static BIK loadFromByteBuf(ByteBuf buffer) { + return new BIK(buffer); + } + + BIK(ByteBuf buffer) { + log.trace("Reading bik..."); + this.buffer = buffer; + + ByteInput in = ByteInput.wrap(buffer); + + log.trace("Validating bik signature"); + in.readSignature(SIGNATURE); + + version = in.read8u(); + log.tracef("version: %c (0x%1$02x)", version); + if (version != 0x69) { + throw new InvalidFormat(in, "version(" + String.format("0x%02x", version) + ") is not supported!"); + } + + size = in.readSafe32u() + 8; // include {SIGNATURE, version, size} + log.trace("size: {} ({} bytes)", FileUtils.byteCountToDisplaySize(size), size); + + numFrames = in.readSafe32u(); + log.trace("numFrames: {}", numFrames); + + largestFrame = in.readSafe32u(); + log.trace("largestFrame: {} bytes", largestFrame); + + in.skipBytes(4); // skip duplicate of numFrames + + width = in.readSafe32u(); + log.trace("width: {}", width); + if (width < 1 || width > MAX_WIDTH) { + throw new InvalidFormat(in, "width(" + width + ") not in [" + 1 + ".." + MAX_WIDTH + "]"); + } + + height = in.readSafe32u(); + log.trace("height: {}", height); + if (height < 1 || height > MAX_HEIGHT) { + throw new InvalidFormat(in, "height(" + height + ") not in [" + 1 + ".." + MAX_HEIGHT + "]"); + } + + final int fpsDividend = in.readSafe32u(); + log.trace("fpsDividend: {}", fpsDividend); + if (fpsDividend <= 0) { + throw new InvalidFormat(in, "fpsDividend(" + fpsDividend + ") <= 0"); + } + + final int fpsDivisor = in.readSafe32u(); + log.trace("fpsDivisor: {}", fpsDivisor); + if (fpsDivisor <= 0) { + throw new InvalidFormat(in, "fpsDivisor(" + fpsDivisor + ") <= 0"); + } + + fps = (float) fpsDividend / fpsDivisor; + log.trace("fps: {} fps", fps); + + flags = in.read32(); + if (log.traceEnabled()) log.tracef("flags: 0x%08x (%s)", flags, getFlagsString()); + + numTracks = in.readSafe32u(); + log.trace("numTracks: {}", numTracks); + if (numTracks < 0 || numTracks > MAX_TRACKS) { + throw new InvalidFormat(in, "numTracks(" + numTracks + ") not in [" + 0 + ".." + MAX_TRACKS + "]"); + } + + tracks = new BinkAudio[numTracks]; + for (int i = 0, s = numTracks; i < s; i++) { + try { + MDC.put("track", i); + tracks[i] = new BinkAudio(in); + } finally { + MDC.remove("track"); + } + } + + video = new BinkVideo(); + + offsets = BitUtils.readSafe32u(in, numFrames + 1); + if (log.traceEnabled()) { + StringBuilder builder = new StringBuilder(256); + builder.append('['); + for (int offset : offsets) { + builder.append(Integer.toHexString(offset)).append(','); + } + builder.setCharAt(builder.length() - 1, ']'); + log.trace("offsets: {}", builder); + } + } + + public String getFlagsString() { + if (flags == 0) return "0"; + StringBuilder builder = new StringBuilder(64); + if ((flags & FLAG_VIDEO_ALPHA) == FLAG_VIDEO_ALPHA) builder.append("ALPHA|"); + if ((flags & FLAG_VIDEO_GRAYSCALE) == FLAG_VIDEO_GRAYSCALE) builder.append("GRAYSCALE|"); + if (builder.length() > 0) builder.setLength(builder.length() - 1); + return builder.toString(); + } + + BinkAudio track(int track) { + return tracks[track]; + } + + int numTracks() { + return numTracks; + } + + void decode(int frame) { + final int offset = offsets[frame]; + log.tracef("offset: +%x", offset); + final ByteBuf slice = buffer.slice(offset, offsets[frame + 1] - offset); + final ByteInput in = ByteInput.wrap(slice); + + for (int i = 0, s = numTracks; i < s; i++) { + try { + MDC.put("track", i); + final int packetSize = in.readSafe32u(); + log.trace("packetSize: {} bytes", packetSize); + + final ByteInput audioPacket = in.readSlice(packetSize); + final int numSamples = audioPacket.readSafe32u(); + log.trace("numSamples: {}", numSamples); + + log.trace("bytesRemaining: {} bytes", audioPacket.bytesRemaining()); + } finally { + MDC.remove("track"); + } + } + + log.trace("videoPacket.bytesRemaining: {} bytes", in.bytesRemaining()); + } +} diff --git a/core/src/com/riiablo/video/BinkAudio.java b/core/src/com/riiablo/video/BinkAudio.java new file mode 100644 index 00000000..fa744d19 --- /dev/null +++ b/core/src/com/riiablo/video/BinkAudio.java @@ -0,0 +1,86 @@ +package com.riiablo.video; + +import com.riiablo.io.ByteInput; +import com.riiablo.io.InvalidFormat; +import com.riiablo.logger.LogManager; +import com.riiablo.logger.Logger; + +public class BinkAudio { + private static final Logger log = LogManager.getLogger(BinkAudio.class); + + static final int SIZE = 0x0C; + static final int MAX_CHANNELS = 2; + + static final int FLAG_AUDIO_16BITS = 0x4000; + static final int FLAG_AUDIO_STEREO = 0x2000; + static final int FLAG_AUDIO_DCT = 0x1000; + + private static final int[] BANDS = { + 100, 200, 300, 400, 510, 630, 770, 920, 1080, 1270, 1480, 1720, 2000, + 2320, 2700, 3150, 3700, 4400, 5300, 6400, 7700, 9500, 12000, 15500, + 24500 + }; + + private static final int[] RLE = { + 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16, 32, 64 + }; + + final short numChannels; + final int sampleRate; + final int flags; + final int id; + + final int frameLen; + final int overlapLen; + final int blockSize; + final int halfSampleRate; + + BinkAudio(ByteInput in) { + log.trace("slicing {} bytes", SIZE); + in = in.readSlice(SIZE); + + in.skipBytes(2); // unknown -- I've seen { 0x00, 0x1C } + + numChannels = in.readSafe16u(); + log.trace("numChannels: {}", numChannels); + if (numChannels < 1 || numChannels > MAX_CHANNELS) { + throw new InvalidFormat(in, "numChannels(" + numChannels + ") not in [" + 1 + ".." + MAX_CHANNELS + "]"); + } + + sampleRate = in.read16u(); + log.trace("sampleRate: {} Hz", sampleRate); + + flags = in.read16u(); + log.tracef("flags: 0x%04x (%s)", flags, getFlagsString()); + + id = in.readSafe32u(); + log.trace("id: {}", id); + + final int frameLenBits; + if (sampleRate < 22050) { + frameLenBits = 9; + } else if (sampleRate < 44100) { + frameLenBits = 10; + } else { + frameLenBits = 11; + } + + frameLen = 1 << frameLenBits; + overlapLen = frameLen >> 4; + blockSize = (frameLen - overlapLen) * numChannels; + halfSampleRate = (sampleRate + 1) / 2; + } + + public String getFlagsString() { + if (flags == 0) return "0"; + StringBuilder builder = new StringBuilder(64); + if ((flags & FLAG_AUDIO_16BITS) == FLAG_AUDIO_16BITS) builder.append("16BITS|"); + if ((flags & FLAG_AUDIO_STEREO) == FLAG_AUDIO_STEREO) builder.append("STEREO|"); + if (builder.length() > 0) builder.setLength(builder.length() - 1); + return builder.toString(); + } + + public boolean isMono() { + return (flags & FLAG_AUDIO_STEREO) == 0; + } +} diff --git a/core/src/com/riiablo/video/BinkVideo.java b/core/src/com/riiablo/video/BinkVideo.java new file mode 100644 index 00000000..78a6e6b3 --- /dev/null +++ b/core/src/com/riiablo/video/BinkVideo.java @@ -0,0 +1,7 @@ +package com.riiablo.video; + +public class BinkVideo { + + BinkVideo() {} + +} diff --git a/core/test/com/riiablo/video/BIKTest.java b/core/test/com/riiablo/video/BIKTest.java new file mode 100644 index 00000000..385df515 --- /dev/null +++ b/core/test/com/riiablo/video/BIKTest.java @@ -0,0 +1,49 @@ +package com.riiablo.video; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.audio.AudioDevice; +import com.badlogic.gdx.files.FileHandle; + +import com.riiablo.RiiabloTest; +import com.riiablo.logger.Level; + +public class BIKTest extends RiiabloTest { + @BeforeClass + public static void before() { + com.riiablo.logger.LogManager.setLevel("com.riiablo.video", Level.TRACE); + } + + @Test + public void load_bik240() { + FileHandle handle = Gdx.files.internal("test\\New_Bliz640x240.bik"); + ByteBuf buffer = Unpooled.wrappedBuffer(handle.readBytes()); + BIK.loadFromByteBuf(buffer); + + } + + @Test + public void load_bik480() { + FileHandle handle = Gdx.files.internal("test\\New_Bliz640x480.bik"); + ByteBuf buffer = Unpooled.wrappedBuffer(handle.readBytes()); + BIK.loadFromByteBuf(buffer); + } + + @Test + public void read_bik480() { + FileHandle handle = Gdx.files.internal("test\\New_Bliz640x480.bik"); + ByteBuf buffer = Unpooled.wrappedBuffer(handle.readBytes()); + BIK bik = BIK.loadFromByteBuf(buffer); + bik.decode(0); + + AudioDevice audio = Gdx.audio.newAudioDevice(44100, false); + // TODO: create video tool + // TODO: test audio playback + // TODO: test injecting bik audio into AudioDevice stream + // TODO: test AudioDevice android support + } +} \ No newline at end of file