Skip to content

Commit

Permalink
Add PNG -> SHP (Dune 2) file converter, #34
Browse files Browse the repository at this point in the history
  • Loading branch information
ultraq committed Apr 17, 2022
1 parent 6dc3706 commit b4361b2
Show file tree
Hide file tree
Showing 6 changed files with 293 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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:
* <p>
* <code>
* <code><pre>
* 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 ...
* ^^
* </code>
* </pre></code>
* <p>
* The marked byte will be 0 in 4-byte offset files, non 0 in 2-byte offset
* files.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,30 +60,15 @@ class ShpFileWriter extends FileWriter<ImageFile> {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ImageFile> {

@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<Byte,Byte> 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())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -69,5 +74,8 @@ class ShpImageInfoDune2 {
lookupTable[i] = input.readByte()
}
}
else {
lookupTable = null
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Integer> {

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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

0 comments on commit b4361b2

Please sign in to comment.