From 1feaae1d68a69f56ae791cd4118158a68d4ce8df Mon Sep 17 00:00:00 2001 From: Eugene 'marengohue' Rebedailo Date: Sun, 7 May 2023 12:45:24 +0200 Subject: [PATCH 1/2] Pre-reformat --- src/pymapconv.py | 7 ++- src/smf/ExtraHeader.py | 2 + src/smf/MapFeature.py | 2 + src/smf/MapTileHeader.py | 2 + src/smf/SMFHeader.py | 111 +++++++++++++++++++++++++++++++++++++++ src/smf/SMFWriter.py | 0 6 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/smf/ExtraHeader.py create mode 100644 src/smf/MapFeature.py create mode 100644 src/smf/MapTileHeader.py create mode 100644 src/smf/SMFHeader.py create mode 100644 src/smf/SMFWriter.py diff --git a/src/pymapconv.py b/src/pymapconv.py index 9a2f071..62ff2aa 100644 --- a/src/pymapconv.py +++ b/src/pymapconv.py @@ -1324,12 +1324,17 @@ def __init__(self, filename, minimaponly = False, skiptexture = False): parser.add_argument('-x', '--maxheight', help='|MAXIMUM HEIGHT| (required) What altitude in spring the max(0xff for 8 bit images or 0xffff for 16bit images) level of the height map represents', default=100.0, type=float) + parser.add_argument('-n', '--minheight', help='|MINIMUM HEIGHT| (required) What altitude in spring the minimum level (0) of the height map represents', default=-50.0, type=float) + + executable_dir = os.path.dirname(os.path.realpath(__file__)) + geovent_default_path = os.path.join(executable_dir, 'resources/geovent.bmp') + parser.add_argument('-g', '--geoventfile', help='|GEOVENT DECAL| The decal for geothermal vents; appears on the compiled map at each vent. Custom geovent decals should use all white as transparent, clear this if you do not wish to have geovents drawn.', - default='./resources/geovent.bmp', type=str) + default=geovent_default_path, type=str) # parser.add_argument('-c', '--compress', help = ' How much we should try to compress the texture map. Values between [0;1] lower values make higher quality, larger files. [NOT IMPLEMENTED YET]', default = 0.0, type = float ) # parser.add_argument('-i', '--invert', help = 'Flip the height map image upside-down on reading.', default = False, action='store_true' ) diff --git a/src/smf/ExtraHeader.py b/src/smf/ExtraHeader.py new file mode 100644 index 0000000..3949b8c --- /dev/null +++ b/src/smf/ExtraHeader.py @@ -0,0 +1,2 @@ +class ExtraHeader: + pass diff --git a/src/smf/MapFeature.py b/src/smf/MapFeature.py new file mode 100644 index 0000000..c53d6fd --- /dev/null +++ b/src/smf/MapFeature.py @@ -0,0 +1,2 @@ +class MapFeature: + pass diff --git a/src/smf/MapTileHeader.py b/src/smf/MapTileHeader.py new file mode 100644 index 0000000..02e7e3d --- /dev/null +++ b/src/smf/MapTileHeader.py @@ -0,0 +1,2 @@ +class MapTileHeader: + pass diff --git a/src/smf/SMFHeader.py b/src/smf/SMFHeader.py new file mode 100644 index 0000000..cc2e93f --- /dev/null +++ b/src/smf/SMFHeader.py @@ -0,0 +1,111 @@ +from struct import Struct +import random + + +class SMFHeader: + """ + Python representation of the SMFHeader struct. Maps to the following: + + char magic[16]; + int version; + int mapid; + + int mapx; + int mapy; + int squareSize; + int texelPerSquare; + int tilesize; + float minHeight; + float maxHeight; + + int heightmapPtr; + int typeMapPtr; + int tilesPtr; + int minimapPtr; + int metalmapPtr; + int featurePtr; + + int numExtraHeaders; + """ + + # Magic value - null-terminated 'spring map file' string + _magic: bytes = [] + + # Version (of the SMF protocol?) must be "1" + _version: int + + # Unique ID of the map, chosen randomly + _map_id: int + + # X-size of the map. + _map_x: int + + # Y-size of the map + _map_y: int + + # Spring-engine height corresponding to 0 on the heightmap + _min_height: float + + # Spring engine height corresponding to 0xFFFF on the heightmap + _max_height: float + + # Amount of headers following the main header + _extra_header_count: int + + # Distance between vertices. Must be 8. + __square_size: int + + # Number of texels per square. Must be 8. + __texel_per_square: int + + # Number of texels in a tile. Must be 32. + __tile_size: int + + # File offset to elevation data (short int[(mapy+1)*(mapx+1)]) + _heightmap_offset: int + + # File offset to type data (unsigned char[mapy//2 * mapx//2]) + _type_map_offset: int + + # File offset to tile data (see MapTileHeader) + _tiles_offset: int + + # File offset to minimap (always 1024*1024 dxt1 compressed data plus 8 mipmap sublevels) + _minimap_offset: int + + # File offset to metal map (unsigned char[mapx//2 * mapy//2]) + _metal_map_offset: int + + # File offset to feature data (see MapFeatureHeader) + _feature_data_offset: int + + # Layout of the struct used for packing. + __struct_def: Struct = Struct('< 16s i i i i i i i f f i i i i i i i') + + def __init__(self): + self._magic = 'spring map file\0'.encode() + self._version = 1 + self._map_id = random.randint(0, 31 ** 2) + self.__square_size = 8 + self.__texel_per_square = 8 + self.__tile_size = 32 + + def pack(self): + """ Produces a binary representation of the SMF file """ + return self.__struct_def.pack( + self._magic, + self._version, + self._map_id, + self._map_x, + self._map_y, + self.__square_size, + self.__texel_per_square, + self.__tile_size, + self._min_height, + self._max_height + ) + + + + +SMFHeader_struct = Struct('< 16s i i i i i i i f f i i i i i i i') diff --git a/src/smf/SMFWriter.py b/src/smf/SMFWriter.py new file mode 100644 index 0000000..e69de29 From c4410ce48ed49e48343f1db677b354b670ce9985 Mon Sep 17 00:00:00 2001 From: Eugene 'marengohue' Rebedailo Date: Sun, 14 May 2023 20:05:32 +0200 Subject: [PATCH 2/2] WIP compilation rework --- doc/SMF.md | 99 ++++++++++ src/{smf/SMFWriter.py => __init__.py} | 0 src/data/heightmap.py | 1 + src/pymapconv.py | 263 +------------------------- src/smf/ExtraHeader.py | 2 - src/smf/MapFeature.py | 2 - src/smf/MapTileHeader.py | 2 - src/smf/SMFHeader.py | 111 ----------- src/smf/__init__.py | 0 src/smf/decompiler.py | 236 +++++++++++++++++++++++ src/smf/headers/__init__.py | 0 src/smf/headers/base.py | 16 ++ src/smf/headers/extra.py | 31 +++ src/smf/headers/features.py | 56 ++++++ src/smf/headers/root.py | 168 ++++++++++++++++ src/smf/headers/tiles.py | 99 ++++++++++ src/smf/smf_file.py | 12 ++ src/smf/writer.py | 50 +++++ tests/__init__.py | 0 tests/test_header_base.py | 17 ++ tests/test_root_header.py | 17 ++ tests/test_tiles_headers.py | 29 +++ tests/test_writer.py | 54 ++++++ 23 files changed, 886 insertions(+), 379 deletions(-) create mode 100644 doc/SMF.md rename src/{smf/SMFWriter.py => __init__.py} (100%) create mode 100644 src/data/heightmap.py delete mode 100644 src/smf/ExtraHeader.py delete mode 100644 src/smf/MapFeature.py delete mode 100644 src/smf/MapTileHeader.py delete mode 100644 src/smf/SMFHeader.py create mode 100644 src/smf/__init__.py create mode 100644 src/smf/decompiler.py create mode 100644 src/smf/headers/__init__.py create mode 100644 src/smf/headers/base.py create mode 100644 src/smf/headers/extra.py create mode 100644 src/smf/headers/features.py create mode 100644 src/smf/headers/root.py create mode 100644 src/smf/headers/tiles.py create mode 100644 src/smf/smf_file.py create mode 100644 src/smf/writer.py create mode 100644 tests/__init__.py create mode 100644 tests/test_header_base.py create mode 100644 tests/test_root_header.py create mode 100644 tests/test_tiles_headers.py create mode 100644 tests/test_writer.py diff --git a/doc/SMF.md b/doc/SMF.md new file mode 100644 index 0000000..e6ae32b --- /dev/null +++ b/doc/SMF.md @@ -0,0 +1,99 @@ +# SMF File Structure +This document outlines the structure of the spring map format file. It shows how the map data is packed into the binary +representation as is expected by the sprint engine. + +# Header structure +SMF map file is made up of bits of structured data called "headers" which all have fixed structure. The chunks are +serialized into the binary format and are placed in the file. +Different headers can have links to different chunks of binary data through offsets specified in the header. +For example, the root header of the map has a few links to additional headers which are set through offsets. + +In order to specify additional information, SMF uses `ExtraHeader`s which have a well-known size. +These headers must directly follow the root header. In order for the engine to know the exact amount +of headers to read, `extra_header_count` property has to be set on the root header. + +Note that the order of binary data chunks in the map is arbitrary. +The chunks on the diagram are presented in the way they currently are packaged by the +map compiler. The overall structure of the map is as follows. +```mermaid +--- +title: SMF Map Logical Structure +--- +classDiagram + note "All header properties are listed in the order they are packed. + File must begin with root header and continue with all Extra Headers. + Chunks of data could be placed arbitrarily so long as the offsets are setup + to correctly point at them." + note for SMFRootHeader "See smf.headers.root.py for additional information on generic data. + All of the PTR properties refer to an offset of the data chunk inside the file. + e.g. metal_map_offset should be equal to the byte offset at which the binary data for metal map begins. + " + note for SMFExtraHeader_1 "Extra headers must directly follow the root header. + D" + note for MinimapData "Minimal data has to be exactly 699048 bytes long + which seems pretty weird considering it doesnt align to KiB or MiB. + More investigation is needed." + note for TileData "Tile data is structured. Refer to additional diagrams for details." + note for FeatureData "Feature data is structured. Refer to additional diagrams for details." + + %% Order of appearance in the binary file %% + SMFRootHeader <-- SMFExtraHeader_1: Directly Follows + SMFExtraHeader_1 <-- SMFExtraHeader_2: Directly Follows + SMFExtraHeader_2 <-- SMFExtraHeader_N: ... + VegetationMapData --> SMFExtraHeader_N: Follows + HeightMapData --> VegetationMapData: Follows + TypeMapData --> HeightMapData: Follows + MinimapData --> TypeMapData: Follows + MetalMapData --> MinimapData: Follows + TileData --> MetalMapData: Follows + FeatureData --> TileData: Follows + + class SMFRootHeader { + ==Generic Map Data== + + +heightmap_offset : ptr + +type_map_offset : ptr + +tiles_offset : ptr + +minimap_offset : ptr + +metal_map_offset : ptr + +feature_data_offset : ptr + +extra_header_count : ptr + } + + class SMFExtraHeader_1 { + +size: int + +type: int + +extra_offset : ptr + } + class SMFExtraHeader_2 { + +size: int + +type: int + +extra_offset : ptr + } + class SMFExtraHeader_N { + +size: int + +type: int + +extra_offset : ptr + } + class VegetationMapData { + == BINARY DATA == + } + class HeightMapData { + == BINARY DATA == + } + class TypeMapData { + == BINARY DATA == + } + class MinimapData { + == BINARY DATA == + } + class MetalMapData { + == BINARY DATA == + } + class TileData { + == BINARY DATA == + } + class FeatureData { + == BINARY DATA == + } +``` diff --git a/src/smf/SMFWriter.py b/src/__init__.py similarity index 100% rename from src/smf/SMFWriter.py rename to src/__init__.py diff --git a/src/data/heightmap.py b/src/data/heightmap.py new file mode 100644 index 0000000..945f5b6 --- /dev/null +++ b/src/data/heightmap.py @@ -0,0 +1 @@ +class Heightmap: diff --git a/src/pymapconv.py b/src/pymapconv.py index 62ff2aa..0d479d2 100644 --- a/src/pymapconv.py +++ b/src/pymapconv.py @@ -43,7 +43,7 @@ def print_flushed(*args): int squareSize; ///< Distance between vertices. Must be 8 int texelPerSquare; ///< Number of texels per square, must be 8 for now int tilesize; ///< Number of texels in a tile, must be 32 for now - float minHeight; ///< Height value that 0 in the heightmap corresponds to + float minHeight; ///< Height value that 0 in the heightmap corresponds tonie float maxHeight; ///< Height value that 0xffff in the heightmap corresponds to int heightmapPtr; ///< File offset to elevation data (short int[(mapy+1)*(mapx+1)]) @@ -1042,267 +1042,6 @@ def ReadTile(xpos, ypos, sourcebuf): # xpos and ypos are multiples of 32 print_flushed ('All Done! You may now close the main window to exit the program :) Finished in %.2f seconds'%(time.time()-starttime)) return 0 - -class SMFMapDecompiler: - def __init__(self, filename, minimaponly = False, skiptexture = False): - verbose = True - self.savedir, self.filename = os.path.split(filename) - self.basename = filename.rpartition('.')[0] - self.smffile = open(os.path.join(self.savedir,filename), 'rb').read() - self.SMFHeader = SMFHeader_struct.unpack_from(self.smffile, 0) - - self.magic = self.SMFHeader[0] # ; ///< "spring map file\0" - self.version = self.SMFHeader[1] # ; ///< Must be 1 for now - self.mapid = self.SMFHeader[ - 2] # ; ///< Sort of a GUID of the file, just set to a random value when writing a map - - self.mapx = self.SMFHeader[3] # ; ///< Must be divisible by 128 - self.mapy = self.SMFHeader[4] # ; ///< Must be divisible by 128 - self.squareSize = self.SMFHeader[5] # ; ///< Distance between vertices. Must be 8 - self.texelPerSquare = self.SMFHeader[6] # ; ///< Number of texels per square, must be 8 for now - self.tilesize = self.SMFHeader[7] # ; ///< Number of texels in a tile, must be 32 for now - self.minHeight = self.SMFHeader[8] # ; ///< Height value that 0 in the heightmap corresponds to - self.maxHeight = self.SMFHeader[9] # ; ///< Height value that 0xffff in the heightmap corresponds to - - self.heightmapPtr = self.SMFHeader[10] # ; ///< File offset to elevation data (short int[(mapy+1)*(mapx+1)]) - self.typeMapPtr = self.SMFHeader[11] # ; ///< File offset to typedata (unsigned char[mapy//2 * mapx//2]) - self.tilesPtr = self.SMFHeader[12] # ; ///< File offset to tile data (see MapTileHeader) - self.minimapPtr = self.SMFHeader[ - 13] # ; ///< File offset to minimap (always 1024*1024 dxt1 compresed data plus 8 mipmap sublevels) - self.metalmapPtr = self.SMFHeader[14] # ; ///< File offset to metalmap (unsigned char[mapx//2 * mapy//2]) - self.featurePtr = self.SMFHeader[15] # ; ///< File offset to feature data (see MapFeatureHeader) - - self.numExtraHeaders = self.SMFHeader[16] # ; ///< Numbers of extra headers following main header''' - if verbose: - attrs = vars(self) - print_flushed (self.SMFHeader) - - print_flushed ('Writing minimap') - miniddsheaderstr = ([68, 68, 83, 32, 124, 0, 0, 0, 7, 16, 10, 0, 0, 4, 0, 0, 0, 4, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, - 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 4, 0, 0, 0, 68, 88, 84, 49, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 8, 16, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) - self.minimap = self.smffile[self.minimapPtr:self.minimapPtr + MINIMAP_SIZE] - if minimaponly: - minimap_file = open(os.path.join(self.savedir,self.basename + '_%ix%i_mini.dds'%(self.mapx//64, self.mapy//64)), 'wb') - else: - minimap_file = open(os.path.join(self.savedir,self.basename + '_mini.dds'), 'wb') - for c in miniddsheaderstr: - minimap_file.write(struct.pack('< B', c)) - minimap_file.write(self.minimap) - minimap_file.close() - - if minimaponly: - return - - - - self.heightmap = struct.unpack_from('< %iH' % ((1 + self.mapx) * (1 + self.mapy)), self.smffile, - self.heightmapPtr) - - ''' - -- The following are obsolete: - print_flushed ('Writing heightmap RAW (Remember, this is a %i by %i 16bit 1 channel IBM byte order raw!)' % () - (1 + self.mapx), (1 + self.mapy)) - heightmap_file = open(os.path.join(self.savedir,self.basename + '_height.raw'), 'wb') - for pixel in self.heightmap: - heightmap_file.write(struct.pack('< H', pixel)) - heightmap_file.close() - - print_flushed ('Writing heightmap BMP') - heightmap_img = Image.new('RGB', (1 + self.mapx, 1 + self.mapy), 'black') - heightmap_img_pixels = heightmap_img.load() - for x in range(heightmap_img.size[0]): - for y in range(heightmap_img.size[1]): - height = self.heightmap[(heightmap_img.size[0]) * y + x] / 256 - heightmap_img_pixels[x, y] = (height, height, height) - heightmap_img.save(self.basename + '_height.bmp') - ''' - print_flushed ('Writing heightmap PNG') - heightmap_png_file = open(os.path.join(self.savedir,self.basename + '_height.png'), 'wb') - heightmap_png_writer = png.Writer(width=1 + self.mapx, height=1 + self.mapy, greyscale=True, bitdepth=16) - heightmap_per_rows = [] - for y in range(self.mapy + 1): - heightmap_per_rows.append(self.heightmap[(self.mapx + 1) * y: (self.mapx + 1) * (y + 1)]) - heightmap_png_writer.write(heightmap_png_file, heightmap_per_rows) - heightmap_png_file.close() - - print_flushed ('Writing MetalMap') - self.metalmap = struct.unpack_from('< %iB' % ((self.mapx // 2) * (self.mapy // 2)), self.smffile, - self.metalmapPtr) - metalmap_img = Image.new('RGB', (self.mapx // 2, self.mapy // 2), 'black') - metalmap_img_pixels = metalmap_img.load() - for x in range(metalmap_img.size[0]): - for y in range(metalmap_img.size[1]): - metal = self.metalmap[(metalmap_img.size[0]) * y + x] - metalmap_img_pixels[x, y] = (metal, 0, 0) - metalmap_img.save(self.basename + '_metal.bmp') - - print_flushed ('Writing typemap') - self.typemap = struct.unpack_from('< %iB' % ((self.mapx // 2) * (self.mapy // 2)), self.smffile, self.typeMapPtr) - typemap_img = Image.new('RGB', (self.mapx // 2, self.mapy // 2), 'black') - typemap_img_pixels = typemap_img.load() - for x in range(typemap_img.size[0]): - for y in range(typemap_img.size[1]): - typep = self.typemap[(typemap_img.size[0]) * y + x] - typemap_img_pixels[x, y] = (typep, 0, 0) - typemap_img.save(self.basename + '_type.bmp') - - - print_flushed ('Writing grassmap') - # vegmapoffset = SMFHeader_struct.size+ExtraHeader_struct.size+4 - for extraheader_index in range(self.numExtraHeaders): - extraheader = ExtraHeader_struct.unpack_from(self.smffile, - extraheader_index * ExtraHeader_struct.size + SMFHeader_struct.size) - if verbose: - print_flushed ('Extraheader:', extraheader, '(size, type, extraoffset)') - extraheader_size, extraheader_type, extraoffset = extraheader - # print_flushed ('ExtraHeader',extraheader) - if extraheader_type == 1: # grass - # self.grassmap=struct.unpack_from('< %iB'%((self.mapx//4)*(self.mapy//4)),self.smffile,ExtraHeader_struct.size+SMFHeader_struct.size+extraheader_size) - self.grassmap = struct.unpack_from('< %iB' % ((self.mapx // 4) * (self.mapy // 4)), self.smffile, - extraoffset) - grassmap_img = Image.new('RGB', (self.mapx // 4, self.mapy // 4), 'black') - grassmap_img_pixels = grassmap_img.load() - - - grassValuemax = 0 - for x in range(grassmap_img.size[0]): - for y in range(grassmap_img.size[1]): - grass = self.grassmap[(grassmap_img.size[0]) * y + x] - grassValuemax = max(grassValuemax, grass) - - for x in range(grassmap_img.size[0]): - for y in range(grassmap_img.size[1]): - grass = self.grassmap[(grassmap_img.size[0]) * y + x] - if grassValuemax == 1 and grass == 1: - grass = 255 - grassmap_img_pixels[x, y] = (grass, grass, grass) - - if grassValuemax == 0: - print_flushed ("Map has no grass, but writing image anyway") - elif grassValuemax == 1: - print_flushed ("Map seems to have old style (binary) grass") - else: - print_flushed ("Map seems to have new style 0-254 awesome grass", grassValuemax) - grassmap_img.save(self.basename + '_grass.bmp') - - # MapFeatureHeader is followed by numFeatureType zero terminated strings indicating the names - # of the features in the map. Then follow numFeatures MapFeatureStructs. - self.mapfeaturesheader = MapFeatureHeader_struct.unpack_from(self.smffile, self.featurePtr) - if verbose: - print_flushed ('MapFeatureHeader=', self.mapfeaturesheader, '(numFeatureType, numFeatures)') - print_flushed ('MapTileHeader=', MapTileHeader_struct.unpack_from(self.smffile, self.tilesPtr), '(numTileFiles, numTiles)') - self.somelulz = self.smffile[self.tilesPtr - 10:self.tilesPtr + 30] - self.numFeatureType, self.numFeatures = self.mapfeaturesheader - self.featurenames = [] - featureoffset = self.featurePtr + MapFeatureHeader_struct.size - while len(self.featurenames) < self.numFeatureType: - featurename = unpack_null_terminated_string(self.smffile, featureoffset) - self.featurenames.append(featurename) - featureoffset += len(featurename) + 1 # cause of null terminator - print_flushed (featurename) - '''nextchar= 'N' - while nextchar != '\0': - nextchar=struct.unpack_from('c',self.smffile,len(featurename)+self.featurePtr+MapFeatureHeader_struct.size - +sum([len(fname)+1 for fname in self.featurenames]))[0] - if nextchar =='\0': - self.featurenames.append(featurename) - featurename='' - else: - featurename+=nextchar''' - - print_flushed ('Features found in map definition', self.featurenames) - feature_offset = self.featurePtr + MapFeatureHeader_struct.size + sum( - [len(fname) + 1 for fname in self.featurenames]) - self.features = [] - for feature_index in range(self.numFeatures): - feat = MapFeatureStruct_struct.unpack_from(self.smffile, - feature_offset + MapFeatureStruct_struct.size * feature_index) - # print_flushed (feat) - self.features.append( - {'name': self.featurenames[feat[0]], 'x': feat[1], 'y': feat[2], 'z': feat[3], 'rotation': feat[4], - 'relativeSize': feat[5], }) - # print_flushed (self.features[-1]) - print_flushed ('Writing feature placement file') - feature_file = open(os.path.join(self.savedir,self.basename + '_featureplacement.lua'), 'w') - for feature in self.features: - feature_file.write('{ name = \'%s\', x = %i, z = %i, rot = "%i" ,scale = %f },\n' % ( - feature['name'], feature['x'], feature['z'], feature['rotation'], feature['relativeSize'])) - feature_file.close() - - - if not skiptexture: - print_flushed ('loading tile files') - self.maptileheader = MapTileHeader_struct.unpack_from(self.smffile, self.tilesPtr) - self.numtilefiles, self.numtiles = self.maptileheader - self.tilefiles = [] - tileoffset = self.tilesPtr + MapTileHeader_struct.size - for i in range(self.numtilefiles): - numtilesinfile = struct.unpack_from('< i', self.smffile, tileoffset)[0] - tileoffset += 4 # sizeof(int) - tilefilename = unpack_null_terminated_string(self.smffile, tileoffset) - tileoffset += len(tilefilename) + 1 # cause of null terminator - self.tilefiles.append( - #[tilefilename, numtilesinfile, open(filename.rpartition('\\')[0] + '\\' + tilefilename, 'rb').read()]) - [tilefilename, numtilesinfile, open(os.path.join(self.savedir,tilefilename), 'rb').read()]) - print_flushed (tilefilename, 'has', numtilesinfile, 'tiles') - self.tileindices = struct.unpack_from('< %ii' % ((self.mapx // 4) * (self.mapy // 4)), self.smffile, tileoffset) - - self.tiles = [] - for tilefile in self.tilefiles: - tileFileHeader = TileFileHeader_struct.unpack_from(tilefile[2], 0) - magic, version, numTiles, tileSize, compressionType = tileFileHeader - # print_flushed (tilefile[0],': magic,version,numTiles,tileSize,compressionType',magic,version,numTiles,tileSize,compressionType) - for i in range(numTiles): - self.tiles.append(struct.unpack_from('< %is' % (SMALL_TILE_SIZE), tilefile[2], - TileFileHeader_struct.size + i * SMALL_TILE_SIZE)[0]) - - # TODO: Parallelize? - print_flushed ('Generating texture, this is very very slow (few minutes)') - textureimage = Image.new('RGB', (self.mapx * 8, self.mapy * 8), 'black') - textureimagepixels = textureimage.load() - for ty in range(self.mapy // 4): - # print_flushed ('row',ty) - for tx in range(self.mapx // 4): - currtile = self.tiles[self.tileindices[(self.mapx // 4) * ty + tx]] - # print_flushed ('Tile',(self.mapx//4)*ty+tx) - # one tile is 32x32, and pythonDecodeDXT1 will need one 'row' of data, assume this is 8*8 bytes - for rows in range(8): - # print_flushed ("currtile",currtile) - dxdata = currtile[rows * 64:(rows + 1) * 64] - # print_flushed (len(dxdata),dxdata) - dxtrows = pythonDecodeDXT1(dxdata) # decode in 8 block chunks - for x in range(tx * 32, (tx + 1) * 32): - for y in range(ty * 32 + 4 * rows, ty * 32 + 4 + 4 * rows): - # print_flushed (rows, tx,ty,x,y) - # print_flushed (dxtrows) - oy = (ty * 32 + 4 * rows) - textureimagepixels[x, y] = ( - ord(dxtrows[y - oy][3 * (x - tx * 32) + 0]), ord(dxtrows[y - oy][3 * (x - tx * 32) + 1]), - ord(dxtrows[y - oy][3 * (x - tx * 32) + 2])) - textureimage.save(self.basename + '_texture.bmp') - infofile = open(os.path.join(self.savedir,self.basename + '_compilation_settings.txt'), 'w') - - infofile.write('-%s\n%s\n' % ('n', str(self.minHeight))) - infofile.write('-%s\n%s\n' % ('x', str(self.maxHeight))) - infofile.write('-%s\n%s\n' % ('o', self.basename + '_recompiled.smf')) - infofile.write('-%s\n%s\n' % ('m', self.basename + '_metal.bmp')) - infofile.write('-%s\n%s\n' % ('t', self.basename + '_texture.bmp')) - infofile.write('-%s\n%s\n' % ('a', self.basename + '_height.png')) - infofile.write('-%s\n%s\n' % ('g', '')) - infofile.write('-%s\n%s\n' % ('y', self.basename + '_type.bmp')) - infofile.write('-%s\n%s\n' % ('r', self.basename + '_grass.bmp')) - infofile.write('-%s\n%s\n' % ('k', self.basename + '_featureplacement.lua')) - - infofile.close() - - print_flushed ('Done, one final bit of important info: the maps maxheight is %i, while the minheight is %i' % ( - self.maxHeight, self.minHeight)) - - if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument('-o', '--outfile', diff --git a/src/smf/ExtraHeader.py b/src/smf/ExtraHeader.py deleted file mode 100644 index 3949b8c..0000000 --- a/src/smf/ExtraHeader.py +++ /dev/null @@ -1,2 +0,0 @@ -class ExtraHeader: - pass diff --git a/src/smf/MapFeature.py b/src/smf/MapFeature.py deleted file mode 100644 index c53d6fd..0000000 --- a/src/smf/MapFeature.py +++ /dev/null @@ -1,2 +0,0 @@ -class MapFeature: - pass diff --git a/src/smf/MapTileHeader.py b/src/smf/MapTileHeader.py deleted file mode 100644 index 02e7e3d..0000000 --- a/src/smf/MapTileHeader.py +++ /dev/null @@ -1,2 +0,0 @@ -class MapTileHeader: - pass diff --git a/src/smf/SMFHeader.py b/src/smf/SMFHeader.py deleted file mode 100644 index cc2e93f..0000000 --- a/src/smf/SMFHeader.py +++ /dev/null @@ -1,111 +0,0 @@ -from struct import Struct -import random - - -class SMFHeader: - """ - Python representation of the SMFHeader struct. Maps to the following: - - char magic[16]; - int version; - int mapid; - - int mapx; - int mapy; - int squareSize; - int texelPerSquare; - int tilesize; - float minHeight; - float maxHeight; - - int heightmapPtr; - int typeMapPtr; - int tilesPtr; - int minimapPtr; - int metalmapPtr; - int featurePtr; - - int numExtraHeaders; - """ - - # Magic value - null-terminated 'spring map file' string - _magic: bytes = [] - - # Version (of the SMF protocol?) must be "1" - _version: int - - # Unique ID of the map, chosen randomly - _map_id: int - - # X-size of the map. - _map_x: int - - # Y-size of the map - _map_y: int - - # Spring-engine height corresponding to 0 on the heightmap - _min_height: float - - # Spring engine height corresponding to 0xFFFF on the heightmap - _max_height: float - - # Amount of headers following the main header - _extra_header_count: int - - # Distance between vertices. Must be 8. - __square_size: int - - # Number of texels per square. Must be 8. - __texel_per_square: int - - # Number of texels in a tile. Must be 32. - __tile_size: int - - # File offset to elevation data (short int[(mapy+1)*(mapx+1)]) - _heightmap_offset: int - - # File offset to type data (unsigned char[mapy//2 * mapx//2]) - _type_map_offset: int - - # File offset to tile data (see MapTileHeader) - _tiles_offset: int - - # File offset to minimap (always 1024*1024 dxt1 compressed data plus 8 mipmap sublevels) - _minimap_offset: int - - # File offset to metal map (unsigned char[mapx//2 * mapy//2]) - _metal_map_offset: int - - # File offset to feature data (see MapFeatureHeader) - _feature_data_offset: int - - # Layout of the struct used for packing. - __struct_def: Struct = Struct('< 16s i i i i i i i f f i i i i i i i') - - def __init__(self): - self._magic = 'spring map file\0'.encode() - self._version = 1 - self._map_id = random.randint(0, 31 ** 2) - self.__square_size = 8 - self.__texel_per_square = 8 - self.__tile_size = 32 - - def pack(self): - """ Produces a binary representation of the SMF file """ - return self.__struct_def.pack( - self._magic, - self._version, - self._map_id, - self._map_x, - self._map_y, - self.__square_size, - self.__texel_per_square, - self.__tile_size, - self._min_height, - self._max_height - ) - - - - -SMFHeader_struct = Struct('< 16s i i i i i i i f f i i i i i i i') diff --git a/src/smf/__init__.py b/src/smf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/smf/decompiler.py b/src/smf/decompiler.py new file mode 100644 index 0000000..9d6faba --- /dev/null +++ b/src/smf/decompiler.py @@ -0,0 +1,236 @@ +import os + + +class SMFMapDecompiler: + + def __init__(self, filename, minimaponly=False, skiptexture=False): + verbose = True + self.savedir, self.filename = os.path.split(filename) + self.basename = filename.rpartition('.')[0] + self.smffile = open(os.path.join(self.savedir, filename), 'rb').read() + self.SMFHeader = SMFHeader_struct.unpack_from(self.smffile, 0) + + self.magic = self.SMFHeader[0] # ; ///< "spring map file\0" + self.version = self.SMFHeader[1] # ; ///< Must be 1 for now + self.mapid = self.SMFHeader[ + 2] # ; ///< Sort of a GUID of the file, just set to a random value when writing a map + + self.mapx = self.SMFHeader[3] # ; ///< Must be divisible by 128 + self.mapy = self.SMFHeader[4] # ; ///< Must be divisible by 128 + self.squareSize = self.SMFHeader[5] # ; ///< Distance between vertices. Must be 8 + self.texelPerSquare = self.SMFHeader[6] # ; ///< Number of texels per square, must be 8 for now + self.tilesize = self.SMFHeader[7] # ; ///< Number of texels in a tile, must be 32 for now + self.minHeight = self.SMFHeader[8] # ; ///< Height value that 0 in the heightmap corresponds to + self.maxHeight = self.SMFHeader[9] # ; ///< Height value that 0xffff in the heightmap corresponds to + + self.heightmapPtr = self.SMFHeader[10] # ; ///< File offset to elevation data (short int[(mapy+1)*(mapx+1)]) + self.typeMapPtr = self.SMFHeader[11] # ; ///< File offset to typedata (unsigned char[mapy//2 * mapx//2]) + self.tilesPtr = self.SMFHeader[12] # ; ///< File offset to tile data (see MapTileHeader) + self.minimapPtr = self.SMFHeader[ + 13] # ; ///< File offset to minimap (always 1024*1024 dxt1 compresed data plus 8 mipmap sublevels) + self.metalmapPtr = self.SMFHeader[14] # ; ///< File offset to metalmap (unsigned char[mapx//2 * mapy//2]) + self.featurePtr = self.SMFHeader[15] # ; ///< File offset to feature data (see MapFeatureHeader) + + self.numExtraHeaders = self.SMFHeader[16] # ; ///< Numbers of extra headers following main header''' + if verbose: + attrs = vars(self) + print_flushed(self.SMFHeader) + + print_flushed('Writing minimap') + miniddsheaderstr = ([68, 68, 83, 32, 124, 0, 0, 0, 7, 16, 10, 0, 0, 4, 0, 0, 0, 4, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, + 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 4, 0, 0, 0, 68, 88, 84, 49, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 8, 16, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) + self.minimap = self.smffile[self.minimapPtr:self.minimapPtr + MINIMAP_SIZE] + if minimaponly: + minimap_file = open( + os.path.join(self.savedir, self.basename + '_%ix%i_mini.dds' % (self.mapx // 64, self.mapy // 64)), + 'wb') + else: + minimap_file = open(os.path.join(self.savedir, self.basename + '_mini.dds'), 'wb') + for c in miniddsheaderstr: + minimap_file.write(struct.pack('< B', c)) + minimap_file.write(self.minimap) + minimap_file.close() + + if minimaponly: + return + + self.heightmap = struct.unpack_from('< %iH' % ((1 + self.mapx) * (1 + self.mapy)), self.smffile, + self.heightmapPtr) + + print_flushed('Writing heightmap PNG') + heightmap_png_file = open(os.path.join(self.savedir, self.basename + '_height.png'), 'wb') + heightmap_png_writer = png.Writer(width=1 + self.mapx, height=1 + self.mapy, greyscale=True, bitdepth=16) + heightmap_per_rows = [] + for y in range(self.mapy + 1): + heightmap_per_rows.append(self.heightmap[(self.mapx + 1) * y: (self.mapx + 1) * (y + 1)]) + heightmap_png_writer.write(heightmap_png_file, heightmap_per_rows) + heightmap_png_file.close() + + print_flushed('Writing MetalMap') + self.metalmap = struct.unpack_from('< %iB' % ((self.mapx // 2) * (self.mapy // 2)), self.smffile, + self.metalmapPtr) + metalmap_img = Image.new('RGB', (self.mapx // 2, self.mapy // 2), 'black') + metalmap_img_pixels = metalmap_img.load() + for x in range(metalmap_img.size[0]): + for y in range(metalmap_img.size[1]): + metal = self.metalmap[(metalmap_img.size[0]) * y + x] + metalmap_img_pixels[x, y] = (metal, 0, 0) + metalmap_img.save(self.basename + '_metal.bmp') + + print_flushed('Writing typemap') + self.typemap = struct.unpack_from('< %iB' % ((self.mapx // 2) * (self.mapy // 2)), self.smffile, + self.typeMapPtr) + typemap_img = Image.new('RGB', (self.mapx // 2, self.mapy // 2), 'black') + typemap_img_pixels = typemap_img.load() + for x in range(typemap_img.size[0]): + for y in range(typemap_img.size[1]): + typep = self.typemap[(typemap_img.size[0]) * y + x] + typemap_img_pixels[x, y] = (typep, 0, 0) + typemap_img.save(self.basename + '_type.bmp') + + print_flushed('Writing grassmap') + # vegmapoffset = SMFHeader_struct.size+ExtraHeader_struct.size+4 + for extraheader_index in range(self.numExtraHeaders): + extraheader = ExtraHeader_struct.unpack_from(self.smffile, + extraheader_index * ExtraHeader_struct.size + SMFHeader_struct.size) + if verbose: + print_flushed('Extraheader:', extraheader, '(size, type, extraoffset)') + extraheader_size, extraheader_type, extraoffset = extraheader + # print_flushed ('ExtraHeader',extraheader) + if extraheader_type == 1: # grass + # self.grassmap=struct.unpack_from('< %iB'%((self.mapx//4)*(self.mapy//4)),self.smffile,ExtraHeader_struct.size+SMFHeader_struct.size+extraheader_size) + self.grassmap = struct.unpack_from('< %iB' % ((self.mapx // 4) * (self.mapy // 4)), self.smffile, + extraoffset) + grassmap_img = Image.new('RGB', (self.mapx // 4, self.mapy // 4), 'black') + grassmap_img_pixels = grassmap_img.load() + + grassValuemax = 0 + for x in range(grassmap_img.size[0]): + for y in range(grassmap_img.size[1]): + grass = self.grassmap[(grassmap_img.size[0]) * y + x] + grassValuemax = max(grassValuemax, grass) + + for x in range(grassmap_img.size[0]): + for y in range(grassmap_img.size[1]): + grass = self.grassmap[(grassmap_img.size[0]) * y + x] + if grassValuemax == 1 and grass == 1: + grass = 255 + grassmap_img_pixels[x, y] = (grass, grass, grass) + + if grassValuemax == 0: + print_flushed("Map has no grass, but writing image anyway") + elif grassValuemax == 1: + print_flushed("Map seems to have old style (binary) grass") + else: + print_flushed("Map seems to have new style 0-254 awesome grass", grassValuemax) + grassmap_img.save(self.basename + '_grass.bmp') + + # MapFeatureHeader is followed by numFeatureType zero terminated strings indicating the names + # of the features in the map. Then follow numFeatures MapFeatureStructs. + self.mapfeaturesheader = MapFeatureHeader_struct.unpack_from(self.smffile, self.featurePtr) + if verbose: + print_flushed('MapFeatureHeader=', self.mapfeaturesheader, '(numFeatureType, numFeatures)') + print_flushed('MapTileHeader=', MapTileHeader_struct.unpack_from(self.smffile, self.tilesPtr), + '(numTileFiles, numTiles)') + self.somelulz = self.smffile[self.tilesPtr - 10:self.tilesPtr + 30] + self.numFeatureType, self.numFeatures = self.mapfeaturesheader + self.featurenames = [] + featureoffset = self.featurePtr + MapFeatureHeader_struct.size + while len(self.featurenames) < self.numFeatureType: + featurename = unpack_null_terminated_string(self.smffile, featureoffset) + self.featurenames.append(featurename) + featureoffset += len(featurename) + 1 # cause of null terminator + print_flushed(featurename) + + print_flushed('Features found in map definition', self.featurenames) + feature_offset = self.featurePtr + MapFeatureHeader_struct.size + sum( + [len(fname) + 1 for fname in self.featurenames]) + self.features = [] + for feature_index in range(self.numFeatures): + feat = MapFeatureStruct_struct.unpack_from(self.smffile, + feature_offset + MapFeatureStruct_struct.size * feature_index) + # print_flushed (feat) + self.features.append( + {'name': self.featurenames[feat[0]], 'x': feat[1], 'y': feat[2], 'z': feat[3], 'rotation': feat[4], + 'relativeSize': feat[5], }) + # print_flushed (self.features[-1]) + print_flushed('Writing feature placement file') + feature_file = open(os.path.join(self.savedir, self.basename + '_featureplacement.lua'), 'w') + for feature in self.features: + feature_file.write('{ name = \'%s\', x = %i, z = %i, rot = "%i" ,scale = %f },\n' % ( + feature['name'], feature['x'], feature['z'], feature['rotation'], feature['relativeSize'])) + feature_file.close() + + if not skiptexture: + print_flushed('loading tile files') + self.maptileheader = MapTileHeader_struct.unpack_from(self.smffile, self.tilesPtr) + self.numtilefiles, self.numtiles = self.maptileheader + self.tilefiles = [] + tileoffset = self.tilesPtr + MapTileHeader_struct.size + for i in range(self.numtilefiles): + numtilesinfile = struct.unpack_from('< i', self.smffile, tileoffset)[0] + tileoffset += 4 # sizeof(int) + tilefilename = unpack_null_terminated_string(self.smffile, tileoffset) + tileoffset += len(tilefilename) + 1 # cause of null terminator + self.tilefiles.append( + # [tilefilename, numtilesinfile, open(filename.rpartition('\\')[0] + '\\' + tilefilename, 'rb').read()]) + [tilefilename, numtilesinfile, open(os.path.join(self.savedir, tilefilename), 'rb').read()]) + print_flushed(tilefilename, 'has', numtilesinfile, 'tiles') + self.tileindices = struct.unpack_from('< %ii' % ((self.mapx // 4) * (self.mapy // 4)), self.smffile, + tileoffset) + + self.tiles = [] + for tilefile in self.tilefiles: + tileFileHeader = TileFileHeader_struct.unpack_from(tilefile[2], 0) + magic, version, numTiles, tileSize, compressionType = tileFileHeader + # print_flushed (tilefile[0],': magic,version,numTiles,tileSize,compressionType',magic,version,numTiles,tileSize,compressionType) + for i in range(numTiles): + self.tiles.append(struct.unpack_from('< %is' % (SMALL_TILE_SIZE), tilefile[2], + TileFileHeader_struct.size + i * SMALL_TILE_SIZE)[0]) + + # TODO: Parallelize? + print_flushed('Generating texture, this is very very slow (few minutes)') + textureimage = Image.new('RGB', (self.mapx * 8, self.mapy * 8), 'black') + textureimagepixels = textureimage.load() + for ty in range(self.mapy // 4): + # print_flushed ('row',ty) + for tx in range(self.mapx // 4): + currtile = self.tiles[self.tileindices[(self.mapx // 4) * ty + tx]] + # print_flushed ('Tile',(self.mapx//4)*ty+tx) + # one tile is 32x32, and pythonDecodeDXT1 will need one 'row' of data, assume this is 8*8 bytes + for rows in range(8): + # print_flushed ("currtile",currtile) + dxdata = currtile[rows * 64:(rows + 1) * 64] + # print_flushed (len(dxdata),dxdata) + dxtrows = pythonDecodeDXT1(dxdata) # decode in 8 block chunks + for x in range(tx * 32, (tx + 1) * 32): + for y in range(ty * 32 + 4 * rows, ty * 32 + 4 + 4 * rows): + # print_flushed (rows, tx,ty,x,y) + # print_flushed (dxtrows) + oy = (ty * 32 + 4 * rows) + textureimagepixels[x, y] = ( + ord(dxtrows[y - oy][3 * (x - tx * 32) + 0]), + ord(dxtrows[y - oy][3 * (x - tx * 32) + 1]), + ord(dxtrows[y - oy][3 * (x - tx * 32) + 2])) + textureimage.save(self.basename + '_texture.bmp') + infofile = open(os.path.join(self.savedir, self.basename + '_compilation_settings.txt'), 'w') + + infofile.write('-%s\n%s\n' % ('n', str(self.minHeight))) + infofile.write('-%s\n%s\n' % ('x', str(self.maxHeight))) + infofile.write('-%s\n%s\n' % ('o', self.basename + '_recompiled.smf')) + infofile.write('-%s\n%s\n' % ('m', self.basename + '_metal.bmp')) + infofile.write('-%s\n%s\n' % ('t', self.basename + '_texture.bmp')) + infofile.write('-%s\n%s\n' % ('a', self.basename + '_height.png')) + infofile.write('-%s\n%s\n' % ('g', '')) + infofile.write('-%s\n%s\n' % ('y', self.basename + '_type.bmp')) + infofile.write('-%s\n%s\n' % ('r', self.basename + '_grass.bmp')) + infofile.write('-%s\n%s\n' % ('k', self.basename + '_featureplacement.lua')) + + infofile.close() + + print_flushed('Done, one final bit of important info: the maps maxheight is %i, while the minheight is %i' % (\ + self.maxHeight, self.minHeight)) diff --git a/src/smf/headers/__init__.py b/src/smf/headers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/smf/headers/base.py b/src/smf/headers/base.py new file mode 100644 index 0000000..8c125f6 --- /dev/null +++ b/src/smf/headers/base.py @@ -0,0 +1,16 @@ +from struct import Struct + + +class SMFHeader: + + # Data layout for the header + _struct_def: Struct + + def pack(self) -> bytes: + raise NotImplementedError("Pack not implemented for header type!") + + def unpack(self, payload: bytes): + raise NotImplementedError("Unpack not implemented for header type!") + + def validate(self): + raise NotImplementedError("Validate is not implemented for header type!") diff --git a/src/smf/headers/extra.py b/src/smf/headers/extra.py new file mode 100644 index 0000000..1b8af43 --- /dev/null +++ b/src/smf/headers/extra.py @@ -0,0 +1,31 @@ +from struct import Struct +from .base import SMFHeader + + +class SMFExtraHeader(SMFHeader): + """ + Python representation of the extra header. Maps to the following: + + int size; + int type; + int extraOffset; + """ + + # Size of the extra header + _size: int + + # Type of the extra header. e.g. 1=vegetation map + _type: int + + # Missing from docs. Only exists if type=1 (vegetation map) + _extra_offset: int + + # Layout of the struct used for packing. + __struct_def: Struct = Struct('< i i i') + + def pack(self): + return self.__struct_def.pack( + self._size, + self._type, + self._extra_offset + ) diff --git a/src/smf/headers/features.py b/src/smf/headers/features.py new file mode 100644 index 0000000..c086e95 --- /dev/null +++ b/src/smf/headers/features.py @@ -0,0 +1,56 @@ +from struct import Struct +from .root import SMFRootHeader + + +class SMFFeatureHeader(SMFRootHeader): + """ + Python representation for MapFeatureHeader struct. Maps to the following: + + int numFeatureType; + int numFeatures; + """ + + feature_type: int + + feature_count: int + + __struct_def: Struct = Struct('< i i') + + def pack(self): + return self.__struct_def.pack( + self.feature_type, + self.feature_count + ) + + +class SMFFeatureInstanceHeader(SMFRootHeader): + """ + Python representation for MapFeature struct. Maps to the following: + + int featureType; + float xPos; + float yPos; + float zPos; + float rotation; + float scale; + """ + + feature_type: int + + x_pos: float + y_pos: float + z_pos: float + rotation: float + scale: float + + __struct_def: Struct = Struct('< i f f f f f') + + def pack(self): + return self.__struct_def.pack( + self.feature_type, + self.x_pos, + self.y_pos, + self.z_pos, + self.rotation, + self.scale + ) diff --git a/src/smf/headers/root.py b/src/smf/headers/root.py new file mode 100644 index 0000000..d3bf6dd --- /dev/null +++ b/src/smf/headers/root.py @@ -0,0 +1,168 @@ +from struct import Struct +import random +from .base import SMFHeader + + +class SMFRootHeader(SMFHeader): + """ + Python representation of the root map header struct. Maps to the following: + + char magic[16]; + int version; + int mapId; + + int mapX; + int mapY; + int squareSize; + int texelPerSquare; + int tileSize; + float minHeight; + float maxHeight; + + int heightmapPtr; + int typeMapPtr; + int tilesPtr; + int minimapPtr; + int metalMapPtr; + int featurePtr; + + int numExtraHeaders; + """ + + # Magic value. Null-terminated string of 15 characters. + magic: bytes + + # Version (of the SMF protocol?) must be "1" + version: int + + # Unique ID of the map, chosen randomly + map_id: int + + # X-size of the map. + map_x: int = None + + # Y-size of the map + map_y: int = None + + # Spring-engine height corresponding to 0 on the heightmap + min_height: float = None + + # Spring engine height corresponding to 0xFFFF on the heightmap + max_height: float = None + + # Amount of headers following the main header + extra_header_count: int + + # Distance between vertices. Must be 8. + square_size: int + + # Number of texels per square. Must be 8. + texel_per_square: int + + # Number of texels in a tile. Must be 32. + tile_size: int + + # File offset to elevation data (short int[(mapy+1)*(mapx+1)]) + heightmap_offset: int = None + + # File offset to type data (unsigned char[mapy//2 * mapx//2]) + type_map_offset: int = None + + # File offset to tile data (see MapTileHeader) + tiles_offset: int = None + + # File offset to minimap (always 1024*1024 dxt1 compressed data plus 8 mipmap sublevels) + minimap_offset: int = None + + # File offset to metal map (unsigned char[mapx//2 * mapy//2]) + metal_map_offset: int = None + + # File offset to feature data (see MapFeatureHeader) + feature_data_offset: int = None + + # Layout of the struct used for packing. + _struct_def: Struct = Struct('< 16s i i i i i i i f f i i i i i i i') + + def __init__(self): + self.magic = 'spring map file\0'.encode() + self.version = 1 + self.map_id = random.randint(0, 31 ** 2) + self.square_size = 8 + self.texel_per_square = 8 + self.tile_size = 32 + self.extra_header_count = 0 + + def set_map_size(self, x: int, y: int): + self.map_x = x + self.map_y = y + + def set_map_heights(self, min_height: float, max_height: float): + self.min_height = min_height + self.max_height = max_height + + def set_heightmap_offset(self, offset: int): + self.heightmap_offset = offset + + def set_metal_map_offset(self, offset: int): + self.metal_map_offset = offset + + def pack(self): + return self._struct_def.pack( + self.magic, + self.version, + self.map_id, + self.map_x, + self.map_y, + self.square_size, + self.texel_per_square, + self.tile_size, + self.min_height, + self.max_height, + self.heightmap_offset, + self.type_map_offset, + self.tiles_offset, + self.minimap_offset, + self.metal_map_offset, + self.feature_data_offset, + self.extra_header_count + ) + + def validate(self): + if self.map_x is None or self.map_y is None: + raise ValueError("Map size is not set") + if self.min_height is None or self.max_height is None: + raise ValueError("Map min/max heights are not set") + if self.heightmap_offset is None: + raise ValueError("Heightmap is not set") + if self.type_map_offset is None: + raise ValueError("Type map is not set") + if self.tiles_offset is None: + raise ValueError("Tile data is not set") + if self.minimap_offset is None: + raise ValueError("Minimap data is not set") + if self.metal_map_offset is None: + raise ValueError("Metal map is not set") + if self.feature_data_offset is None: + raise ValueError("Feature data is not set") + + def unpack(self, payload: bytes) -> SMFHeader: + unpacked = self._struct_def.unpack(payload) + self.magic,\ + self.version,\ + self.map_id,\ + self.map_x,\ + self.map_y,\ + self.square_size,\ + self.texel_per_square,\ + self.tile_size,\ + self.min_height,\ + self.max_height,\ + self.heightmap_offset,\ + self.type_map_offset,\ + self.tiles_offset,\ + self.minimap_offset,\ + self.metal_map_offset,\ + self.feature_data_offset,\ + self.extra_header_count = unpacked + + return self diff --git a/src/smf/headers/tiles.py b/src/smf/headers/tiles.py new file mode 100644 index 0000000..eab8b7d --- /dev/null +++ b/src/smf/headers/tiles.py @@ -0,0 +1,99 @@ +from struct import Struct + + +class TileFileHeader: + """ + Python representation of the TileFile header. Maps to the following: + + char magic[16]; + int version; + + int numTiles; + int tileSize; + int compressionType; + """ + + # Total number of tiles in this file + tile_count: int + + # Magic value. Null-terminated string of 15 characters. + magic: bytes + + # Version (of the SMF protocol?) must be "1" + version: int + + # Number of texels in a tile. Must be 32 for now. + tile_size: int + + # Compression type used for tiles. 1 = DTX1 is currently supported. + compression_type: int + + # Data layout for the header + _struct_def: Struct = Struct('< 16s i i i i') + + def __init__(self): + self.magic = 'spring tilefile\0'.encode() + self.version = 1 + self.tile_size = 32 + self.compression_type = 1 # 1=DXT1 + + def __eq__(self, other): + if not isinstance(other, TileFileHeader): + return False + + # Magic values don't strictly matter so long as they are correct size + return self.version == other.version\ + and self.tile_size == other.tile_size\ + and self.compression_type == other.compression_type + + def pack(self): + return self._struct_def.pack( + self.magic, + self.version, + self.tile_count, + self.tile_size, + self.compression_type + ) + + def unpack(self, payload: bytes): + self.magic, self.version, self.tile_count, self.tile_size, self.compression_type =\ + self._struct_def.unpack(payload) + return self + + +class TileHeader: + """ + Python representation of the Map Tile Header. Maps to the following: + + int numTileFiles; + int numTiles; + """ + + # Number of tile files to read in (usually 1) + tile_file_count: int + + # Total number of tiles + tile_count: int + + # Data layout for the header + _struct_def: Struct = Struct('< i i') + + def __init__(self): + self.tile_file_count = 1 + + def __eq__(self, other): + if not isinstance(other, TileHeader): + return False + + return self.tile_file_count == other.tile_file_count\ + and self.tile_count == other.tile_count + + def pack(self): + return self._struct_def.pack( + self.tile_file_count, + self.tile_count + ) + + def unpack(self, payload: bytes): + self.tile_file_count, self.tile_count = self._struct_def.unpack(payload) + return self diff --git a/src/smf/smf_file.py b/src/smf/smf_file.py new file mode 100644 index 0000000..7c7538c --- /dev/null +++ b/src/smf/smf_file.py @@ -0,0 +1,12 @@ +class SMFFile: + def __init__(self): + pass + + def write(self, data: bytes): + pass + + def is_writeable(self): + pass + + def close(self): + pass diff --git a/src/smf/writer.py b/src/smf/writer.py new file mode 100644 index 0000000..57d3b15 --- /dev/null +++ b/src/smf/writer.py @@ -0,0 +1,50 @@ +import logging + +from .headers.root import SMFRootHeader +from .smf_file import SMFFile + +from contextlib import AbstractContextManager + + +class SMFWriter(AbstractContextManager): + + _root_header: SMFRootHeader = None + _output: SMFFile + + def __init__(self, output: SMFFile): + if not output.is_writeable(): + raise AssertionError("""Output map is not writable.""") + + def set_map_size(self, x: int, y: int): + self.__ensure_root_header() + self._root_header.set_map_size(x, y) + + def set_map_heights(self, min_height: float, max_height: float): + self.__ensure_root_header() + self._root_header.set_map_heights(min_height, max_height) + + def set_heightmap(self, heightmap_data: bytes): + + + def write(self): + self.__validate() + + root_header_bytes = self._root_header.pack() + logging.info("Writing SMF Root header(%i bytes)", len(root_header_bytes)) + self._output.write(root_header_bytes) + + def __ensure_root_header(self): + self._root_header = self._root_header or SMFRootHeader() + + def __validate(self): + self.__ensure_root_header() + self._root_header.validate() + + def close(self): + self._output.close() + + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_header_base.py b/tests/test_header_base.py new file mode 100644 index 0000000..da28b29 --- /dev/null +++ b/tests/test_header_base.py @@ -0,0 +1,17 @@ +import unittest +import struct + +from src.smf.headers.base import SMFHeader + + +class SmfHeaderBaseTests(unittest.TestCase): + _base_header: SMFHeader + + def setUp(self): + self._base_header = SMFHeader() + + def test_should_throw_when_using_base(self): + self.assertRaises(NotImplementedError, lambda: (self._base_header.pack())) + + test_payload = struct.pack("< i", 10) + self.assertRaises(NotImplementedError, lambda: (self._base_header.unpack(test_payload))) diff --git a/tests/test_root_header.py b/tests/test_root_header.py new file mode 100644 index 0000000..c6561b3 --- /dev/null +++ b/tests/test_root_header.py @@ -0,0 +1,17 @@ +import unittest +import struct + +from src.smf.headers.base import SMFHeader + + +class SmfRootHeader(unittest.TestCase): + _base_header: SMFHeader + + def setUp(self): + self._base_header = SMFHeader() + + def test_should_throw_when_using_base(self): + self.assertRaises(NotImplementedError, lambda: (self._base_header.pack())) + + test_payload = struct.pack("< i", 10) + self.assertRaises(NotImplementedError, lambda: (self._base_header.unpack(test_payload))) diff --git a/tests/test_tiles_headers.py b/tests/test_tiles_headers.py new file mode 100644 index 0000000..7e32fe6 --- /dev/null +++ b/tests/test_tiles_headers.py @@ -0,0 +1,29 @@ +import unittest +import random +from src.smf.headers import TileFileHeader, TileHeader + + +class SmfHeaderBaseTests(unittest.TestCase): + tile_file: TileFileHeader + tile: TileHeader + + def setUp(self): + self.tile_file = TileFileHeader() + self.tile = TileHeader() + + def test_tile_file_same_after_pack_unpack(self): + self.tile_file.tile_count = random.randint(0, 1000) + + test_payload = self.tile_file.pack() + unpacked = TileFileHeader().unpack(test_payload) + + self.assertEqual(self.tile_file, unpacked) + + def test_tile_same_after_pack_unpack(self): + self.tile.tile_file_count = 3 + self.tile.tile_count = 100 + + test_payload = self.tile.pack() + unpacked = TileHeader().unpack(test_payload) + + self.assertEqual(self.tile, unpacked) diff --git a/tests/test_writer.py b/tests/test_writer.py new file mode 100644 index 0000000..3dfe276 --- /dev/null +++ b/tests/test_writer.py @@ -0,0 +1,54 @@ +import unittest +from io import BytesIO + +from src.smf.writer import SMFWriter +from src.smf.smf_file import SMFFile + + +class InMemorySMFMap(SMFFile): + """ + In-memory implementation of the SMFMap. Used for testing. + """ + + _buffer: BytesIO + + def __init__(self): + super().__init__() + self._buffer = BytesIO() + + def is_writeable(self): + return self._buffer.writable() + + def write(self, data: bytes): + self._buffer.write(data) + + def read(self) -> bytes: + return self._buffer.read() + + def clean_up(self): + self._buffer.close() + + +class SmfWriterTests(unittest.TestCase): + + _writer: SMFWriter + _in_memory_map: InMemorySMFMap + + def setUp(self) -> None: + self._in_memory_map = InMemorySMFMap() + self._writer = SMFWriter(self._in_memory_map) + + def tearDown(self) -> None: + self._in_memory_map.clean_up() + + def test_writing_non_configured_map_fails(self): + self.assertRaises(ValueError, lambda: self._writer.write()) + + def test_writing_configured_root_ok(self): + writer = self._writer + writer.set_map_size(24, 24) + writer.set_map_heights(0, 100) + writer. + writer.write() + map_bytes = self._in_memory_map.read() + self.assertNotEquals(len(map_bytes), 0)