diff --git a/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpFileDune2.groovy b/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpFileDune2.groovy index a51c0cae..cacc841a 100644 --- a/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpFileDune2.groovy +++ b/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpFileDune2.groovy @@ -43,11 +43,11 @@ import java.nio.ByteBuffer * it's 0, seems to be a commonly accepted practice amongst existing Dune 2 SHP * file readers: *

- * + *

  * A 2-byte offset file: 01 00 06 00 EC 00 45 0A ...
  * A 4-byte offset file: 01 00 08 00 00 00 EC 00 ...
  *                                   ^^
- * 
+ * 
*

* The marked byte will be 0 in 4-byte offset files, non 0 in 2-byte offset * files. @@ -84,14 +84,18 @@ import java.nio.ByteBuffer * * @author Emanuel Rabina */ +@SuppressWarnings('GrFinalVariableAccess') class ShpFileDune2 { + static final int MAX_WIDTH = 65535 + static final int MAX_HEIGHT = 255 + // File header - final short numImages + final int numImages final int[] imageOffsets // Image data - final ByteBuffer[] images + final ByteBuffer[] imagesData /** * Constructor, creates a new SHP file from the given file data. @@ -121,15 +125,15 @@ class ShpFileDune2 { def lcw = new LCW() def rleZero = new RLEZero() - images = new ByteBuffer[numImages] - images.length.times { i -> + imagesData = new ByteBuffer[numImages] + imagesData.length.times { i -> def imageHeader = new ShpImageInfoDune2(input) def imageData = ByteBuffer.wrapNative(input.readNBytes(imageHeader.compressedSize)) def imageSize = imageHeader.width * imageHeader.height // Decompress the image data def image = ByteBuffer.allocate(imageSize) - images[i] = imageHeader.compressed ? + imagesData[i] = imageHeader.compressed ? rleZero.decode( lcw.decode( imageData, diff --git a/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpFileWriter.groovy b/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpFileWriter.groovy index 95bbf3c5..78acb852 100644 --- a/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpFileWriter.groovy +++ b/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpFileWriter.groovy @@ -60,30 +60,15 @@ class ShpFileWriter extends FileWriter { output.writeShort(0) // flags // Split the image file data into multiple smaller images - def imageSize = width * height - def rawImages = new ByteBuffer[numImages].collect { ByteBuffer.allocateNative(imageSize) } - - // TODO: What's happening here is similar to the single-loop-for-buffer code - // in the VqaFileWorker. See if we can't figure a way to generalize - // this? - def sourceData = source.imageData - def imagesAcross = source.width / width - for (def pointer = 0; pointer < imageSize; pointer += width) { - def frame = (pointer / imagesAcross as int) * source.width + (pointer * width) - - // Fill the target frame with 1 row from the current pointer - rawImages[frame].put(sourceData, width) - } - rawImages*.rewind() - sourceData.rewind() + def imagesData = source.imageData.splitImage(source.width, source.height, width, height) def lcw = new LCW() // Encode images def encodedImages = new ByteBuffer[numImages] numImages.each { index -> - def rawImage = rawImages[index] - encodedImages[index] = lcw.encode(rawImage, ByteBuffer.allocateNative(rawImage.capacity())) + def imageData = imagesData[index] + encodedImages[index] = lcw.encode(imageData, ByteBuffer.allocateNative(imageData.capacity())) } // Write images offset data diff --git a/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpFileWriterDune2.groovy b/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpFileWriterDune2.groovy new file mode 100644 index 00000000..b277194e --- /dev/null +++ b/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpFileWriterDune2.groovy @@ -0,0 +1,144 @@ +/* + * Copyright 2022, Emanuel Rabina (http://www.ultraq.net.nz/) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nz.net.ultraq.redhorizon.classic.filetypes.shp + +import nz.net.ultraq.redhorizon.classic.codecs.RLEZero +import nz.net.ultraq.redhorizon.filetypes.ImageFile +import nz.net.ultraq.redhorizon.filetypes.io.FileWriter +import nz.net.ultraq.redhorizon.filetypes.io.NativeDataOutputStream +import static nz.net.ultraq.redhorizon.classic.filetypes.shp.ShpFileDune2.* +import static nz.net.ultraq.redhorizon.filetypes.ColourFormat.FORMAT_INDEXED + +import groovy.transform.InheritConstructors +import java.nio.ByteBuffer + +/** + * Write a Dune 2 SHP file from a source image. + * + * @author Emanuel Rabina + */ +@InheritConstructors +class ShpFileWriterDune2 extends FileWriter { + + @Override + void write(ImageFile source, Map options) { + + def width = options.width as int + def height = options.height as int + def numImages = options.numImages as int + + // Check options for converting a single image to an SHP file are valid + assert width < MAX_WIDTH : "Image width must be less than ${MAX_WIDTH}" + assert height < MAX_HEIGHT : "Image height must be less than ${MAX_HEIGHT}" + assert source.width % width == 0 : "Source file doesn't divide cleanly into ${width}x${height} images" + assert source.height % height == 0 : "Source file doesn't divide cleanly into ${width}x${height} images" + assert source.format == FORMAT_INDEXED : 'Source file must contain paletted image data' + + def widths = new int[numImages] + Arrays.fill(widths, width) + def heights = new int[numImages] + Arrays.fill(heights, height) + + // Split the image file data into multiple smaller images + def imagesData = source.imageData.splitImage(source.width, source.height, width, height) + + def rleZero = new RLEZero() +// def lcw = new LCW() + def preOffsetSize = (numImages + 1) * 4 + + // Encode each image, update image headers, create image offsets + def imageOffsets = ByteBuffer.allocateNative(preOffsetSize) + int offsetTotal = preOffsetSize + + def imageHeaders = new ShpImageInfoDune2[numImages] + def encodedImages = new ByteBuffer[numImages] + numImages.each { index -> + def imageData = imagesData[index] + byte[] colourTable = null + + // If meant for faction colours, generate a colour table for the frame, + // while at the same time replacing the image bytes with the index + if (options.faction) { + LinkedHashMap colours = [:] + + // Track colour values used, replace colour values with table values + byte tableIndex = 0 + for (def imageByteIndex = 0; imageByteIndex < imageData.limit(); imageByteIndex++) { + def colour = imageData.get(imageByteIndex) + if (!colours.containsKey(colour)) { + colours.put(colour, tableIndex++) + } + imageData.put(imageByteIndex, colours.get(colour)) + } + + // Convert from hashmap -> byte[] + colourTable = new byte[Math.max(colours.size(), 16)] + def j = 0 + for (byte colour: colours.keySet()) { + colourTable[j++] = colour + } + } + + // Encode image data + // NOTE: Compression with Format80 is skipped for Dune 2 SHP files due + // to my implementation of Format80 compression causing "Memory + // Corrupts!" error messages to come from Dune 2 itself. + def encodedImage = ByteBuffer.allocateNative(imageData.capacity() * 1.5 as int) + rleZero.encode(imageData, encodedImage) + + // Build image header + def imageHeader = new ShpImageInfoDune2( + width: widths[index], + height: heights[index], + lookupTable: colourTable, + compressedSize: encodedImage.limit(), + uncompressedSize: encodedImage.limit() + ) + imageHeaders[index] = imageHeader + encodedImages[index] = encodedImage + + // Track offset values + imageOffsets.putInt(offsetTotal) + offsetTotal += imageHeader.compressedSize & 0xffff + } + + // Add the special end-of-file offset + imageOffsets.putInt(offsetTotal).rewind() + + def output = new NativeDataOutputStream(outputStream) + + // Write header + output.writeShort(numImages) + + // Write offset data + output.write(imageOffsets.array()) + + // Write image headers + data + numImages.each { index -> + def imageHeader = imageHeaders[index] + def encodedImage = encodedImages[index] + + output.writeShort(imageHeader.flags) + output.write(imageHeader.slices) + output.writeShort(imageHeader.width) + output.write(imageHeader.height) + output.writeShort(imageHeader.compressedSize) + output.writeShort(imageHeader.uncompressedSize) + output.write(encodedImage.array()) + } + } +} diff --git a/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpImageInfoDune2.groovy b/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpImageInfoDune2.groovy index 8870d474..b960e454 100644 --- a/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpImageInfoDune2.groovy +++ b/redhorizon-classic/source/nz/net/ultraq/redhorizon/classic/filetypes/shp/ShpImageInfoDune2.groovy @@ -18,12 +18,17 @@ package nz.net.ultraq.redhorizon.classic.filetypes.shp import nz.net.ultraq.redhorizon.filetypes.io.NativeDataInputStream +import groovy.transform.MapConstructor +import groovy.transform.PackageScope + /** * Representation of the Dune 2 SHP image header (different from the file * header), which contains data on the image it references. * * @author Emanuel Rabina */ +@MapConstructor +@PackageScope class ShpImageInfoDune2 { // The various known flags @@ -69,5 +74,8 @@ class ShpImageInfoDune2 { lookupTable[i] = input.readByte() } } + else { + lookupTable = null + } } } diff --git a/redhorizon-cli/source/nz/net/ultraq/redhorizon/cli/converter/Png2ShpDune2Converter.groovy b/redhorizon-cli/source/nz/net/ultraq/redhorizon/cli/converter/Png2ShpDune2Converter.groovy new file mode 100644 index 00000000..a847b644 --- /dev/null +++ b/redhorizon-cli/source/nz/net/ultraq/redhorizon/cli/converter/Png2ShpDune2Converter.groovy @@ -0,0 +1,96 @@ +/* + * Copyright 2022, Emanuel Rabina (http://www.ultraq.net.nz/) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nz.net.ultraq.redhorizon.cli.converter + +import nz.net.ultraq.redhorizon.classic.filetypes.shp.ShpFileWriterDune2 +import nz.net.ultraq.redhorizon.filetypes.png.PngFile + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import picocli.CommandLine.Command +import picocli.CommandLine.Option +import picocli.CommandLine.Parameters + +import java.util.concurrent.Callable + +/** + * Subcommand for converting PNG files to the Dune 2 SHP format. + * + * @author Emanuel Rabina + */ +@Command( + name = 'png2shpd2', + description = 'Convert a paletted PNG file to a Dune 2 SHP file' +) +class Png2ShpDune2Converter implements Callable { + + private static final Logger logger = LoggerFactory.getLogger(Png2ShpDune2Converter) + + @Parameters(index = '0', description = 'The sounce PNG image.') + File sourceFile + + @Parameters(index = '1', description = 'Path for the SHP file to be written to.') + File destFile + + @Option(names = ['--width', '-w'], required = true, description = 'Width of each SHP image') + int width + + @Option(names = ['--height', '-h'], required = true, description = 'Height of each SHP image') + int height + + @Option(names = ['--numImages', '-n'], required = true, description = 'The number of images for the SHP file') + int numImages + + @Option( + names = ['--faction'], + description = 'Generate a SHP file whose red-palette colours (indexes 144-150) will be exchanged for the proper faction colours in-game.') + boolean faction + + /** + * Perform the file conversion. + * + * @return + */ + @Override + Integer call() { + + logger.info('Loading {}...', sourceFile) + if (sourceFile.exists()) { + if (!destFile.exists()) { + sourceFile.withInputStream { inputStream -> + destFile.withOutputStream { outputStream -> + def pngFile = new PngFile(inputStream) + new ShpFileWriterDune2(outputStream).write(pngFile, [ + width: width, + height: height, + numImages: numImages, + faction: faction + ]) + } + } + return 0 + } + else { + logger.error('Output file, {}, already exists', destFile) + } + } + else { + logger.error('{} not found', sourceFile) + } + return 1 + } +} diff --git a/redhorizon-filetypes/source/nz/net/ultraq/redhorizon/filetypes/extensions/ByteBufferImageExtensions.groovy b/redhorizon-filetypes/source/nz/net/ultraq/redhorizon/filetypes/extensions/ByteBufferImageExtensions.groovy index 2913b45b..ea061e13 100644 --- a/redhorizon-filetypes/source/nz/net/ultraq/redhorizon/filetypes/extensions/ByteBufferImageExtensions.groovy +++ b/redhorizon-filetypes/source/nz/net/ultraq/redhorizon/filetypes/extensions/ByteBufferImageExtensions.groovy @@ -102,4 +102,35 @@ class ByteBufferImageExtensions { } return flippedImageBuffer.flip() } + + /** + * Take the image data for a single image and split it into several smaller + * images of the given dimensions. + * + * @param self + * @param sourceWidth + * @param sourceHeight + * @param targetWidth + * @param targetHeight + * @return + */ + static ByteBuffer[] splitImage(ByteBuffer self, int sourceWidth, int sourceHeight, int targetWidth, int targetHeight) { + + def imagesAcross = sourceWidth / targetWidth as int + def numImages = imagesAcross * (sourceHeight / targetHeight) as int + def targetSize = targetWidth * targetHeight as int + def images = new ByteBuffer[numImages].collect { ByteBuffer.allocateNative(targetSize) } as ByteBuffer[] + + for (int pointer = 0; pointer < targetSize; pointer += sourceWidth) { + def frame = (pointer / imagesAcross as int) * sourceWidth + (pointer * sourceWidth) + + // Fill the target frame with 1 row from the current pointer + images[frame].put(self, sourceWidth) + } + + images*.rewind() + self.rewind() + + return images + } }