commit 3a7b2e7d39d603933827f8169d3e48e51e2e69f8 Author: red031000 Date: Mon Jul 10 08:02:11 2023 +0100 partial parsing, up to start of material diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..772c0ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.vscode/ +nitrog3d.zip diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d3c364b --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: all clean + +all: nitrog3d + @: + +nitrog3d: + mkdir -p io_scene_g3d + cp __init__.py import_nsbmd.py utils.py io_scene_g3d + zip -r nitrog3d.zip io_scene_g3d + rm -rf io_scene_g3d + +clean: + rm -f nitrog3d.zip diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..11f5625 --- /dev/null +++ b/__init__.py @@ -0,0 +1,108 @@ +import bpy +from bpy.props import StringProperty, BoolProperty, CollectionProperty +from bpy_extras.io_utils import ImportHelper +from .utils import log, debug +import os + +bl_info = { + "name": "Nitro G3D Importer/Exporter", + "author": "red031000", + "version": (0, 1, 0), + "blender": (3, 6, 0), + "location": "File > Import-Export", + "description": "Import/Export 3D compiled files for Nitro", + 'support': 'COMMUNITY', + "category": "Import-Export", +} + +def reload_package(module_dict_main): + import importlib + from pathlib import Path + + def reload_package_recursive(current_dir, module_dict): + for path in current_dir.iterdir(): + if "__init__" in str(path) or path.stem not in module_dict: + continue + + if path.is_file() and path.suffix == ".py": + importlib.reload(module_dict[path.stem]) + elif path.is_dir(): + reload_package_recursive(path, module_dict[path.stem].__dict__) + + reload_package_recursive(Path(__file__).parent, module_dict_main) + + +if "bpy" in locals(): + reload_package(locals()) + +class ImportNitro(bpy.types.Operator, ImportHelper): + bl_idname = "import_scene.g3d" + bl_label = "Import Nitro" + bl_options = {'PRESET'} + + filter_glob: StringProperty( + default="*.nsbmd", + options={'HIDDEN'}, + ) + + files: CollectionProperty( + name="File Path", + type=bpy.types.OperatorFileListElement, + ) + + #generate_log: BoolProperty(name="Generate Log", default=False) + + def execute(self, context): + return self.process_import() + + def draw(self, context): + pass + #layout = self.layout + + #layout.use_property_split = True + #layout.use_property_decorate = False + + #layout.prop(self, "generate_log") + + def process_import(self): + import_settings = self.as_keywords() + + if self.files: + ret = {'FINISHED'} + dirname = os.path.dirname(self.filepath) + for file in self.files: + path = os.path.join(dirname, file.name) + if self.try_import(path, import_settings) != {'FINISHED'}: + ret = {'CANCELLED'} + return ret + else: + return self.try_import(self.filepath, import_settings) + + def try_import(self, filename, import_settings): + try: + if filename.lower().endswith('.nsbmd'): + log("Valid file type", self.report) + from .import_nsbmd import NSBMDImporter + nsbmd_importer = NSBMDImporter(filename, import_settings, self.report) + data = nsbmd_importer.read() + else: + raise Exception('Unsupported file type') + #todo + return {'FINISHED'} + except Exception as e: + self.report(type={'ERROR'}, message=str(e)) + return {'CANCELLED'} + +def menu_func_import(self, context): + self.layout.operator(ImportNitro.bl_idname, text="Nitro Compiled (.nsbmd)") + +def register(): + bpy.utils.register_class(ImportNitro) + bpy.types.TOPBAR_MT_file_import.append(menu_func_import) + +def unregister(): + bpy.utils.unregister_class(ImportNitro) + bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) + +if __name__ == "__main__": + register() diff --git a/import_nsbmd.py b/import_nsbmd.py new file mode 100644 index 0000000..712d28b --- /dev/null +++ b/import_nsbmd.py @@ -0,0 +1,284 @@ +from enum import IntEnum, IntFlag +from os.path import isfile +from .utils import read8, read16, read32, read_str, log, debug, parse_dictionary, fixed_to_float +import numpy as np + +class ScalingRule(IntEnum): + NORMAL = 0 + MAYA = 1 + SOFTIMAGE = 2 + +class TextureMatrixMode(IntEnum): + MAYA = 0 + SOFTIMAGE_3D = 1 + _3DSMAX = 2 + SOFTIMAGE_XSI = 3 + +class NSBMDOptions(): + def __init__(self): + self.scalingRule = ScalingRule.NORMAL + self.textureMatrixMode = TextureMatrixMode.MAYA + self.jointNumber = 0 + self.materialNumber = 0 + self.shapeNumber = 0 + self.firstUnusedMatrixStackId = 0 + self.positionScale = 0 + self.inversePositionScale = 0 + self.vertexNumber = 0 + self.polygonNumber = 0 + self.triangleNumber = 0 + self.quadNumber = 0 + self.boxX = 0 + self.boxY = 0 + self.boxZ = 0 + self.boxWidth = 0 + self.boxHeight = 0 + self.boxDepth = 0 + self.boxPositionScale = 0 + self.boxInversePositionScale = 0 + +class NodePivotData(IntEnum): + MASK = 0xF0 + SHIFT = 4 + +class NodeFlags(IntFlag): + TRANSLATION_ZERO = 0x0001 + ROTATION_ZERO = 0x0002 + SCALE_ONE = 0x0004 + ROTATION_COMPRESSED = 0x0008 + PIVOT_MINUS = 0x0100 + PIVOT_REVERSED_C = 0x0200 + PIVOT_REVERSED_D = 0x0400 + + +class NSBMDNode(): + pivot_table = [ + [ + [(1, 1), (1, 2), (2, 1), (2, 2)], + [(1, 0), (1, 2), (2, 0), (2, 2)], + [(1, 0), (1, 1), (2, 0), (2, 1)] + ], + [ + [(0, 1), (0, 2), (2, 1), (2, 2)], + [(0, 0), (0, 2), (2, 0), (2, 2)], + [(0, 0), (0, 1), (2, 0), (2, 1)] + ], + [ + [(0, 1), (0, 2), (1, 1), (1, 2)], + [(0, 0), (0, 2), (1, 0), (1, 2)], + [(0, 0), (0, 1), (1, 0), (1, 1)] + ] + ] + + def __init__(self, name): + self.name = name + self.translation = np.array([0, 0, 0]) + self.rotation = np.identity(3, dtype=np.float32) + self.scale = np.array([1, 1, 1], dtype=np.float32) + self.inverseScale = np.array([1, 1, 1], dtype=np.float32) + + def parse_data(self, flags, report_func, data): + offset = 4 + if (flags & NodeFlags.TRANSLATION_ZERO) != 0: + log('Translation zero', report_func) + self.translation = np.array([0, 0, 0], dtype=np.float32) + else: + self.translation = np.array([fixed_to_float(read32(data, offset)), fixed_to_float(read32(data, offset + 4)), fixed_to_float(read32(data, offset + 8))], dtype=np.float32) + log('Translation: ' + str(self.translation), report_func) + offset += 12 + if (flags & NodeFlags.ROTATION_ZERO) != 0: + log('Rotation zero', report_func) + self.rotation = np.identity(3, dtype=np.float32) + elif (flags & NodeFlags.ROTATION_COMPRESSED) != 0: + log('Rotation compressed', report_func) + A = fixed_to_float(read16(data, offset)) + B = fixed_to_float(read16(data, offset + 2)) + pivot = (flags & NodePivotData.MASK) >> NodePivotData.SHIFT + self.rotation = np.identity(3, dtype=np.float32) + row = pivot / 3 + column = pivot % 3 + self.rotation[row, column] = -1.0 if (flags & NodeFlags.PIVOT_MINUS) else 1.0 + AIndex = NSBMDNode.pivot_table[row][column][0] + BIndex = NSBMDNode.pivot_table[row][column][1] + CIndex = NSBMDNode.pivot_table[row][column][2] + DIndex = NSBMDNode.pivot_table[row][column][3] + self.rotation[AIndex[0], AIndex[1]] = A + self.rotation[BIndex[0], BIndex[1]] = B + self.rotation[CIndex[0], CIndex[1]] = -B if (flags & NodeFlags.PIVOT_REVERSED_C) else B + self.rotation[DIndex[0], DIndex[1]] = -A if (flags & NodeFlags.PIVOT_REVERSED_D) else A + log('Rotation: ' + str(self.rotation), report_func) + offset += 4 + else: + self.rotation = np.identity(3, dtype=np.float32) + self.rotation[0, 0] = fixed_to_float(read16(data, 2)) + self.rotation[0, 1] = fixed_to_float(read16(data, offset)) + self.rotation[0, 2] = fixed_to_float(read16(data, offset + 2)) + self.rotation[1, 0] = fixed_to_float(read16(data, offset + 4)) + self.rotation[1, 1] = fixed_to_float(read16(data, offset + 6)) + self.rotation[1, 2] = fixed_to_float(read16(data, offset + 8)) + self.rotation[2, 0] = fixed_to_float(read16(data, offset + 10)) + self.rotation[2, 1] = fixed_to_float(read16(data, offset + 12)) + self.rotation[2, 2] = fixed_to_float(read16(data, offset + 14)) + log('Rotation: ' + str(self.rotation), report_func) + offset += 16 + if (flags & NodeFlags.SCALE_ONE) != 0: + log('Scale one', report_func) + self.scale = np.array([1, 1, 1], dtype=np.float32) + self.inverseScale = np.array([1, 1, 1], dtype=np.float32) + else: + self.scale = np.array([fixed_to_float(read32(data, offset)), fixed_to_float(read32(data, offset + 4)), fixed_to_float(read32(data, offset + 8))], dtype=np.float32) + self.inverseScale = np.array([fixed_to_float(read32(data, offset + 12)), fixed_to_float(read32(data, offset + 16)), fixed_to_float(read32(data, offset + 20))], dtype=np.float32) + log('Scale: ' + str(self.scale), report_func) + offset += 24 + return offset + +class NSBMDModel(): + def __init__(self, name): + self.name = name + self.nodes = [] + + def add_node(self, node): + self.nodes.append(node) + +class NSBMD(): + def __init__(self, has_textures, model_offset, texture_offset): + self.has_textures = has_textures + self.model_offset = model_offset + self.texture_offset = texture_offset + self.models = [] + + def add_model(self, model): + self.models.append(model) + + +class NSBMDImporter(): + def __init__(self, filename, import_settings, report_func): + self.filename = filename + self.import_settings = import_settings + self.report = report_func + + def read(self): + if not isfile(self.filename): + raise Exception('File not found') + + data = [] + with open(self.filename, 'rb') as f: + data = memoryview(f.read()) + + if data[0:4] != b'BMD0': + raise Exception('Invalid file format') + + return self.parse(data) + + def parse(self, data): + has_textures = read16(data, 0x0E) == 2 + model_offset = read32(data, 0x10) + texture_offset = 0 + if has_textures: + texture_offset = read32(data, 0x14) + + nsbmd = NSBMD(has_textures, model_offset, texture_offset) + + log('Model offset: %08X' % model_offset, self.report) + if has_textures: + log('Texture offset: %08X' % texture_offset, self.report) + + modelset_data = data[model_offset:] + + if modelset_data[0:4] != b'MDL0': + raise Exception('Invalid file format') + + dictionary = parse_dictionary(modelset_data[8:]) + + for key, value in dictionary.items(): + model = NSBMDModel(key) + log('%s: %08X' % (key, value), self.report) + model_data = modelset_data[value:] + sbc_offset = read32(model_data, 0x04) + log('SBC offset: %08X' % sbc_offset, self.report) + materialset_offset = read32(model_data, 0x08) + log('Materialset offset: %08X' % materialset_offset, self.report) + shape_offset = read32(model_data, 0x0C) + log('Shape offset: %08X' % shape_offset, self.report) + envelope_matrix_offset = read32(model_data, 0x10) + log('Envelope matrix offset: %08X' % envelope_matrix_offset, self.report) + + model.options = NSBMDOptions() + model.options.scalingRule = ScalingRule(read8(model_data, 0x15)) + log('Scaling rule: %s' % model.options.scalingRule.name, self.report) + model.options.textureMatrixMode = TextureMatrixMode(read8(model_data, 0x16)) + log('Texture matrix mode: %s' % model.options.textureMatrixMode.name, self.report) + model.options.jointNumber = read8(model_data, 0x17) + log('Joint number: %d' % model.options.jointNumber, self.report) + model.options.materialNumber = read8(model_data, 0x18) + log('Material number: %d' % model.options.materialNumber, self.report) + model.options.shapeNumber = read8(model_data, 0x19) + log('Shape number: %d' % model.options.shapeNumber, self.report) + model.options.firstUnusedMatrixStackId = read8(model_data, 0x1A) + log('First unused matrix stack ID: %d' % model.options.firstUnusedMatrixStackId, self.report) + model.options.positionScale = fixed_to_float(read32(model_data, 0x1C)) + log('Position scale: %.12f' % model.options.positionScale, self.report) + model.options.inversePositionScale = fixed_to_float(read32(model_data, 0x20)) + log('Inverse position scale: %.12f' % model.options.inversePositionScale, self.report) + model.options.vertexNumber = read16(model_data, 0x24) + log('Vertex number: %d' % model.options.vertexNumber, self.report) + model.options.polygonNumber = read16(model_data, 0x26) + log('Polygon number: %d' % model.options.polygonNumber, self.report) + model.options.triangleNumber = read16(model_data, 0x28) + log('Triangle number: %d' % model.options.triangleNumber, self.report) + model.options.quadNumber = read16(model_data, 0x2A) + log('Quad number: %d' % model.options.quadNumber, self.report) + model.options.boxX = fixed_to_float(read16(model_data, 0x2C)) + log('Box X: %.12f' % model.options.boxX, self.report) + model.options.boxY = fixed_to_float(read16(model_data, 0x2E)) + log('Box Y: %.12f' % model.options.boxY, self.report) + model.options.boxZ = fixed_to_float(read16(model_data, 0x30)) + log('Box Z: %.12f' % model.options.boxZ, self.report) + model.options.boxWidth = fixed_to_float(read16(model_data, 0x32)) + log('Box width: %.12f' % model.options.boxWidth, self.report) + model.options.boxHeight = fixed_to_float(read16(model_data, 0x34)) + log('Box height: %.12f' % model.options.boxHeight, self.report) + model.options.boxDepth = fixed_to_float(read16(model_data, 0x36)) + log('Box depth: %.12f' % model.options.boxDepth, self.report) + model.options.boxPositionScale = fixed_to_float(read32(model_data, 0x38)) + log('Box position scale: %.12f' % model.options.boxPositionScale, self.report) + model.options.inverseBoxPositionScale = fixed_to_float(read32(model_data, 0x3C)) + log('Inverse box position scale: %.12f' % model.options.inverseBoxPositionScale, self.report) + + nodeset_data = model_data[0x40:] + node_dictionary = parse_dictionary(nodeset_data) + offset = 0 + for node_key, node_value in node_dictionary.items(): + log('%s: %08X' % (node_key, node_value), self.report) + node = NSBMDNode(node_key) + node_data = nodeset_data[node_value:] + node_flags = read16(node_data, 0x00) + node_offset = node.parse_data(node_flags, self.report, node_data) + offset = node_value + node_offset + model.add_node(node) + + log('Offset: %08X' % (offset + 0x40), self.report) + model.sbc = model_data[sbc_offset:materialset_offset].tobytes() + log('SBC: %s' % model.sbc.hex(" "), self.report) + + materialset_data = model_data[materialset_offset:] + offsetDictTextToMat = read16(materialset_data, 0x00) + offsetDictPlttToMat = read16(materialset_data, 0x02) + materialset_dictionary = parse_dictionary(materialset_data[4:]) + text_to_mat_dictionary = parse_dictionary(materialset_data[offsetDictTextToMat:]) + pltt_to_mat_dictionary = parse_dictionary(materialset_data[offsetDictPlttToMat:]) + + # no offsets so this code is gonna be fucky + matIdxDataEnd = 0xFFFFFFFF + + for material_key, material_value in materialset_dictionary.items(): + log('%s: %08X' % (material_key, material_value), self.report) + if material_value < matIdxDataEnd: + matIdxDataEnd = material_value + + dict_size = read16(materialset_data[offsetDictPlttToMat:], 2) + model.matIdxData = materialset_data[offsetDictPlttToMat + dict_size:matIdxDataEnd].tobytes() # no idea how this is used, but essential + log('Material id data: %s' % model.matIdxData.hex(" "), self.report) + + #todo + return nsbmd diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..5587157 --- /dev/null +++ b/utils.py @@ -0,0 +1,52 @@ +def read8(data, offset): + return data[offset] + +def read16(data, offset): + return data[offset] | (data[offset + 1] << 8) + +def read32(data, offset): + return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24) + +def read_str(data, offset): + end = offset + while data[end] != 0: + end += 1 + return data[offset:end].tobytes().decode('ascii') + +def read_dict_string(data, offset): + end = offset + while data[end] != 0: + end += 1 + if (end - offset) == 16: + break + return data[offset:end].tobytes().decode('ascii') + +def log(string, report_func): + report_func(type={"INFO"}, message=string) + +def debug(string, report_func): + report_func(type={"DEBUG"}, message=string) + +def parse_dictionary(data): + num_entries = read8(data, 0x01) + data_offset = read16(data, 0x06) + + data_size = read16(data, data_offset + 0x00) + name_offset = read16(data, data_offset + 0x02) + + dictionary = {} + for i in range(num_entries): + name = read_dict_string(data, data_offset + name_offset + i * 0x10) + value = 0 + if data_size == 1: + value = read8(data, data_offset + 0x04 + i * 0x01) + elif data_size == 2: + value = read16(data, data_offset + 0x04 + i * 0x02) + elif data_size == 4: + value = read32(data, data_offset + 0x04 + i * 0x04) + dictionary[name] = value + + return dictionary + +def fixed_to_float(value): + return float(value / 4096.0)