diff --git a/io_scene_a3d/AlternativaProtocol.py b/io_scene_a3d/AlternativaProtocol.py new file mode 100644 index 0000000..a6e2f3d --- /dev/null +++ b/io_scene_a3d/AlternativaProtocol.py @@ -0,0 +1,192 @@ +''' +Copyright (c) 2024 Pyogenics + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +''' + +from io import BytesIO +from struct import unpack +from array import array +from zlib import decompress + +class OptionalMask: + def __init__(self): + self.optionalMask = [] + + def read(self, stream): + print("Read optional mask") + # Read "Null-mask" field + nullMask = b"" + nullMaskOffset = 0 + + nullMaskField = int.from_bytes(stream.read(1), "little") + nullMaskType = nullMaskField & 0b10000000 + if nullMaskType == 0: + # Short null-mask: 5-29 bits + nullMaskLength = nullMaskField & 0b01100000 + + nullMask += bytes(nullMaskField & 0b00011111) + nullMask += stream.read(nullMaskLength) # 1,2 or 3 bytes + nullMaskOffset = 3 + else: + # Long null-mask: 64 - 4194304 bytes + nullMaskLengthSize = nullMaskField & 0b01000000 + nullMaskLength = nullMaskField & 0b00111111 + if nullMaskLengthSize > 0: + # Long length: 22 bits + nullMaskLength = nullMaskLength << 16 + nullMaskLength += int.from_bytes(stream.read(2), "big") + else: + # Short length: 6 bits + pass + + nullMask += stream.read(nullMaskLength) + nullMaskOffset = 0 + + nullMask = BytesIO(nullMask) + # Process first byte (the first byte is missing some bits on some nullmask configs) + maskByte = int.from_bytes(nullMask.read(1)) + for bitI in range(7 - nullMaskOffset, -1, -1): + self.optionalMask.append( + not bool(maskByte & (2**bitI)) + ) + + # Process the rest of the bytes + for maskByte in nullMask.read(): + for bitI in range(7, -1, -1): + self.optionalMask.append( + not bool(maskByte & (2**bitI)) + ) + + print(f"Optional mask flags: {len(self.optionalMask)}") + + def getOptional(self): + optional = self.optionalMask.pop(0) + return optional + + def getOptionals(self, count): + optionals = () + for _ in range(count): + optionals += (self.optionalMask.pop(0),) + return optionals + + def getLength(self): + return len(self.optionalMask) + +def readPacket(stream): + print("Reading packet") + + # Read "Package Length" field + packageLength = 0 + packageGzip = False + + packageLengthField = int.from_bytes(stream.read(1), "little") + packageLengthSize = packageLengthField & 0b10000000 + if packageLengthSize == 0: + # Short package: 14 bits + packageLength += (packageLengthField & 0b00111111) << 8 + packageLength += int.from_bytes(stream.read(1), "little") + + packageGzip = packageLengthField & 0b01000000 + else: + # Long package: 31 bits + packageLength += (packageLengthField & 0b00111111) << 24 + packageLength += int.from_bytes(stream.read(3), "little") + + packageGzip = packageLengthField & 0b01000000 + + # Decompress gzip data + package = stream.read() + if packageGzip: + print("Decompressing packet") + package = decompress(package) + package = BytesIO(package) + + return package + +''' +Array +''' +def readArrayLength(package): + arrayLength = 0 + + arrayField = int.from_bytes(package.read(1), "little") + arrayLengthType = arrayField & 0b10000000 + # Short array length + if arrayLengthType == 0: + # Length of the array is contained in the last 7 bits of this byte + arrayLength = arrayField & 0b01111111 + else: # Must be large array length + longArrayLengthType = arrayField & 0b01000000 + # Length in last 6 bits + next byte + if longArrayLengthType == 0: + lengthByte = int.from_bytes(package.read(1), "little") + arrayLength = (arrayField & 0b00111111) << 8 + arrayLength += lengthByte + else: # Length in last 6 bits + next 2 bytes + lengthBytes = int.from_bytes(package.read(2), "big") + arrayLength = (arrayField & 0b00111111) << 16 + arrayLength += lengthBytes + + return arrayLength + +def readObjectArray(package, objReader, optionalMask): + length = readArrayLength(package) + objects = [] + for _ in range(length): + obj = objReader() + obj.read(package, optionalMask) + objects.append(obj) + + return objects + +def readString(package): + stringLength = readArrayLength(package) + string = package.read(stringLength) + string = string.decode("utf-8") + + return string + +def readInt16Array(package): + length = readArrayLength(package) + integers = unpack(f"{length}h", package.read(length*2)) + integers = array("h", integers) + + return integers + +def readIntArray(package): + length = readArrayLength(package) + integers = unpack(f"{length}i", package.read(length*4)) + integers = array("i", integers) + + return integers + +def readInt64Array(package): + length = readArrayLength(package) + integers = unpack(f"{length}q", package.read(length*8)) + integers = array("q", integers) + + return integers + +def readFloatArray(package): + length = readArrayLength(package) + floats = unpack(f">{length}f", package.read(length*4)) + floats = array("f", floats) + + return floats \ No newline at end of file diff --git a/io_scene_a3d/BattleMap.py b/io_scene_a3d/BattleMap.py new file mode 100644 index 0000000..4a337e7 --- /dev/null +++ b/io_scene_a3d/BattleMap.py @@ -0,0 +1,328 @@ +''' +Copyright (c) 2024 Pyogenics + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +''' + +from .IOTools import unpackStream +from . import AlternativaProtocol + +''' +Objects +''' +class AtlasRect: + def __init__(self): + self.height = 0 + self.libraryName = "" + self.name = "" + self.width = 0 + self.x = 0 + self.y = 0 + + def read(self, stream, optionalMask): + print("Read AtlasRect") + self.height, = unpackStream(">I", stream) + self.libraryName = AlternativaProtocol.readString(stream) + self.name = AlternativaProtocol.readString(stream) + self.width, self.x, self.y = unpackStream(">3I", stream) + +class CollisionBox: + def __init__(self): + self.position = (0.0, 0.0, 0.0) + self.rotation = (0.0, 0.0, 0.0) + self.size = (0.0, 0.0, 0.0) + + def read(self, stream, optionalMask): + print("Read CollisionBox") + self.position = unpackStream(">3f", stream) + self.rotation = unpackStream(">3f", stream) + self.size = unpackStream(">3f", stream) + +class CollisionPlane: + def __init__(self): + self.length = 0.0 + self.position = (0.0, 0.0, 0.0) + self.rotation = (0.0, 0.0, 0.0) + self.width = 0.0 + + def read(self, stream, optionalMask): + print("Read CollisionPlane") + self.length, = unpackStream(">d", stream) + self.position = unpackStream(">3f", stream) + self.rotation = unpackStream(">3f", stream) + self.width, = unpackStream(">d", stream) + +class CollisionTriangle: + def __init__(self): + self.length = 0.0 + self.position = (0.0, 0.0, 0.0) + self.rotation = (0.0, 0.0, 0.0) + self.v0 = (0.0, 0.0, 0.0) + self.v1 = (0.0, 0.0, 0.0) + self.v2 = (0.0, 0.0, 0.0) + + def read(self, stream, optionalMask): + print("Read CollisionTriangle") + self.length, = unpackStream(">d", stream) + self.position = unpackStream(">3f", stream) + self.rotation = unpackStream(">3f", stream) + self.v0 = unpackStream(">3f", stream) + self.v1 = unpackStream(">3f", stream) + self.v2 = unpackStream(">3f", stream) + +class ScalarParameter: + def __init__(self): + self.name = "" + self.value = 0.0 + + def read(self, stream, optionalMask): + print("Read ScalarParameters") + self.name = AlternativaProtocol.readString(stream) + self.value, = unpackStream(">f", stream) + +class TextureParameter: + def __init__(self): + self.name = "" + self.textureName = "" + + # Optional + self.libraryName = None + + def read(self, stream, optionalMask): + print("Read TextureParameter") + if optionalMask.getOptional(): + self.libraryName = AlternativaProtocol.readString(stream) + self.name = AlternativaProtocol.readString(stream) + self.textureName = AlternativaProtocol.readString(stream) + +class Vector2Parameter: + def __init__(self): + self.name = "" + self.value = (0.0, 0.0) + + def __init__(self, stream, optionalMask): + print("Read Vector2Parameters") + self.name = AlternativaProtocol.readString(stream) + self.value = unpackStream(">2f", stream) + +class Vector3Parameter: + def __init__(self): + self.name = "" + self.value = (0.0, 0.0, 0.0) + + def __init__(self, stream, optionalMask): + print("Read Vector3Parameters") + self.name = AlternativaProtocol.readString(stream) + self.value = unpackStream(">3f", stream) + +class Vector4Parameter: + def __init__(self): + self.name = "" + self.value = (0.0, 0.0, 0.0, 0.0) + + def read(self, stream, optionalMask): + print("Read Vector4Parameters") + self.name = AlternativaProtocol.readString(stream) + self.value = unpackStream(">4f", stream) + +''' +Main objects +''' +class Atlas: + def __init__(self): + self.height = 0 + self.name = "" + self.padding = 0 + self.rects = [] + self.width = 0 + + # Get the rect's texture from an atlas + # XXX: Handle padding? + def resolveRectImage(self, rectName, atlasImage): + rect = None + for childRect in self.rects: + if childRect.name == rectName: + rect = childRect + if rect == None: + raise RuntimeError(f"Couldn't find rect with name: {rectName}") + + # Cut the texture out + rectTexture = atlasImage.crop( + (rect.x, rect.y, rect.x+rect.width, rect.y+rect.height) + ) + return rectTexture + + def read(self, stream, optionalMask): + print("Read Atlas") + self.height, unpackStream(">i", stream) + self.name = AlternativaProtocol.readString(stream) + self.padding = unpackStream(">I", stream) + self.rects = AlternativaProtocol.readObjectArray(stream, AtlasRect, optionalMask) + self.width, = unpackStream(">I", stream) + +class Batch: + def __init__(self): + self.materialID = 0 + self.name = "" + self.position = (0.0, 0.0, 0.0) + self.propIDs = "" + + def read(self, stream, optionalMask): + print("Read Batch") + self.materialID, = unpackStream(">I", stream) + self.name = AlternativaProtocol.readString(stream) + self.position = unpackStream(">3f", stream) + self.propIDs = AlternativaProtocol.readString(stream) + +class CollisionGeometry: + def __init__(self): + self.boxes = [] + self.planes = [] + self.triangles = [] + + def read(self, stream, optionalMask): + print("Read CollisionGeometry") + self.boxes = AlternativaProtocol.readObjectArray(stream, CollisionBox, optionalMask) + self.planes = AlternativaProtocol.readObjectArray(stream, CollisionPlane, optionalMask) + self.triangles = AlternativaProtocol.readObjectArray(stream, CollisionTriangle, optionalMask) + +class Material: + def __init__(self): + self.ID = 0 + self.name = "" + self.shader = "" + self.textureParameters = None + + # Optional + self.scalarParameters = None + self.vector2Parameters = None + self.vector3Parameters = None + self.vector4Parameters = None + + def getTextureParameterByName(self, name): + for textureParameter in self.textureParameters: + if textureParameter.name == name: return textureParameter + + raise RuntimeError(f"Couldn't find texture parameter with name: {name}") + + def read(self, stream, optionalMask): + print(f"Read Material") + self.ID, = unpackStream(">I", stream) + self.name = AlternativaProtocol.readString(stream) + if optionalMask.getOptional(): + self.scalarParameters = AlternativaProtocol.readObjectArray(stream, ScalarParameter, optionalMask) + self.shader = AlternativaProtocol.readString(stream) + self.textureParameters = AlternativaProtocol.readObjectArray(stream, TextureParameter, optionalMask) + if optionalMask.getOptional(): + self.vector2Parameters = AlternativaProtocol.readObjectArray(stream, Vector2Parameter, optionalMask) + if optionalMask.getOptional(): + self.vector3Parameters = AlternativaProtocol.readObjectArray(stream, Vector3Parameter, optionalMask) + if optionalMask.getOptional(): + self.vector4Parameters = AlternativaProtocol.readObjectArray(stream, Vector4Parameter, optionalMask) + +class SpawnPoint: + def __init__(self): + self.position = (0.0, 0.0, 0.0) + self.rotation = (0.0, 0.0, 0.0) + self.type = 0 + + def read(self, stream, optionalMask): + print("Read SpawnPoint") + self.position = unpackStream(">3f", stream) + self.rotation = unpackStream(">3f", stream) + self.type, = unpackStream(">I", stream) + +class Prop: + def __init__(self): + self.ID = 0 + self.libraryName = "" + self.materialID = 0 + self.name = "" + self.position = (0.0, 0.0, 0.0) + + # Optional + self.groupName = "" + self.rotation = (0.0, 0.0, 0.0) + self.scale = (0.0, 0.0, 0.0) + + def read(self, stream, optionalMask): + print(f"Read Prop") + if optionalMask.getOptional(): + self.groupName = AlternativaProtocol.readString(stream) + self.ID, = unpackStream(">I", stream) + self.libraryName = AlternativaProtocol.readString(stream) + self.materialID, = unpackStream(">I", stream) + self.name = AlternativaProtocol.readString(stream) + self.position = unpackStream(">3f", stream) + if optionalMask.getOptional(): + self.rotation = unpackStream(">3f", stream) + if optionalMask.getOptional(): + self.scale = unpackStream(">3f", stream) + +''' +Main +''' +class BattleMap: + def __init__(self): + self.atlases = [] + self.batches = [] + self.collisionGeometry = [] + self.collisionGeometryOutsideGamingZone = [] + self.materials = [] + self.spawnPoints = [] + self.staticGeometry = [] + + ''' + Getters + ''' + def getMaterialByID(self, materialID): + for material in self.materials: + if material.ID == materialID: return material + + raise RuntimeError(f"Couldn't find material with ID: {materialID}") + + ''' + IO + ''' + def read(self, stream): + print("Reading BIN map") + + # Read packet + packet = AlternativaProtocol.readPacket(stream) + with open("packet.bin", "wb") as packetFile: + packetFile.write( + packet.read() + ) + packet.seek(0) + optionalMask = AlternativaProtocol.OptionalMask() + optionalMask.read(packet) + + # Read data + if optionalMask.getOptional(): + self.atlases = AlternativaProtocol.readObjectArray(packet, Atlas, optionalMask) + if optionalMask.getOptional(): + self.batches = AlternativaProtocol.readObjectArray(packet, Batch, optionalMask) + self.collisionGeometry = CollisionGeometry() + self.collisionGeometry.read(packet, optionalMask) + self.collisionGeometryOutsideGamingZone = CollisionGeometry() + self.collisionGeometryOutsideGamingZone.read(packet, optionalMask) + self.materials = AlternativaProtocol.readObjectArray(packet, Material, optionalMask) + if optionalMask.getOptional(): + self.spawnPoints = AlternativaProtocol.readObjectArray(packet, SpawnPoint, optionalMask) + self.staticGeometry = AlternativaProtocol.readObjectArray(packet, Prop, optionalMask) \ No newline at end of file diff --git a/io_scene_a3d/__init__.py b/io_scene_a3d/__init__.py index 98356fc..bb3e346 100644 --- a/io_scene_a3d/__init__.py +++ b/io_scene_a3d/__init__.py @@ -27,6 +27,7 @@ from bpy_extras.io_utils import ImportHelper from .A3D import A3D from .A3DBlenderImporter import A3DBlenderImporter +from .BattleMap import BattleMap from glob import glob @@ -40,8 +41,8 @@ class ImportA3D(Operator, ImportHelper): bl_options = {'PRESET', 'UNDO'} filter_glob: StringProperty(default="*.a3d", options={'HIDDEN'}) - directory: StringProperty(subtype='DIR_PATH', options={'HIDDEN'}) - files: CollectionProperty(type=OperatorFileListElement, options={"HIDDEN", "SKIP_SAVE"}) + directory: StringProperty(subtype="DIR_PATH", options={'HIDDEN'}) + files: CollectionProperty(type=OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'}) # User options create_collection: BoolProperty(name="Create collection", description="Create a collection to hold all the model objects", default=False) @@ -78,6 +79,29 @@ class ImportA3D(Operator, ImportHelper): return {"FINISHED"} +class ImportBattleMap(Operator, ImportHelper): + bl_idname = "import_scene.tanki_battlemap" + bl_label = "Import map" + bl_description = "Import a BIN format Tanki Online map file" + bl_options = {'PRESET', 'UNDO'} + + filter_glob: StringProperty(default="*.bin", options={'HIDDEN'}) + directory: StringProperty(subtype="DIR_PATH", options={'HIDDEN'}) + + def draw(self, context): + pass + + def invoke(self, context, event): + return ImportHelper.invoke(self, context, event) + + def execute(self, context): + print(f"Reading BattleMap data from {self.filepath}") + mapData = BattleMap() + with open(self.filepath, "rb") as file: + mapData.read(file) + + return {"FINISHED"} + ''' Menu ''' @@ -92,22 +116,28 @@ def import_panel_options(layout, operator): def menu_func_import_a3d(self, context): self.layout.operator(ImportA3D.bl_idname, text="Alternativa3D HTML5 (.a3d)") +def menu_func_import_battlemap(self, context): + self.layout.operator(ImportBattleMap.bl_idname, text="Tanki Online BattleMap (.bin)") + ''' Registration ''' classes = [ - ImportA3D + ImportA3D, + ImportBattleMap ] def register(): for c in classes: bpy.utils.register_class(c) bpy.types.TOPBAR_MT_file_import.append(menu_func_import_a3d) + bpy.types.TOPBAR_MT_file_import.append(menu_func_import_battlemap) def unregister(): for c in classes: bpy.utils.unregister_class(c) bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_a3d) + bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_battlemap) if __name__ == "__main__": register() \ No newline at end of file