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
+ }
}