converts .mesh files created from QTQuick3D back to OBJ files.
This was created while reverse engineering a game that uses QT and has assets embedded
This script is very shitty, it works so its good enough
| import argparse | |
| import itertools | |
| import math | |
| import struct | |
| from enum import Enum | |
| from io import BytesIO | |
| from os import path | |
| from pathlib import Path | |
| from typing import List | |
| from binreader import BinaryReader | |
| class Vector3(object): | |
| x: float | |
| y: float | |
| z: float | |
| def __init__(self, x, y, z) -> None: | |
| self.x = x | |
| self.y = y | |
| self.z = z | |
| def __str__(self) -> str: | |
| return f"x={self.x}, y={self.y}, z={self.z}" | |
| class Vector2(object): | |
| x: float | |
| y: float | |
| def __init__(self, x, y) -> None: | |
| self.x = x | |
| self.y = y | |
| def __str__(self) -> str: | |
| return f"x={self.x}, y={self.y}" | |
| class ComponentType(Enum): | |
| UINT8 = 1 | |
| INT8 = 2 | |
| UINT16 = 3 | |
| INT16 = 4 | |
| UINT32 = 5 | |
| INT32 = 6 | |
| UINT64 = 7 | |
| INT64 = 8 | |
| FLOAT16 = 9 | |
| FLOAT32 = 10 | |
| FLOAT64 = 11 | |
| COMPONENT_TYPE_SIZES = { | |
| ComponentType.UINT8: 1, | |
| ComponentType.INT8: 1, | |
| ComponentType.UINT16: 2, | |
| ComponentType.INT16: 2, | |
| ComponentType.UINT32: 4, | |
| ComponentType.INT32: 4, | |
| ComponentType.UINT64: 8, | |
| ComponentType.INT64: 8, | |
| ComponentType.FLOAT16: 2, | |
| ComponentType.FLOAT32: 4, | |
| ComponentType.FLOAT64: 8, | |
| } | |
| class DrawMode(Enum): | |
| POINTS = 1 | |
| LINE_STRIP = 2 | |
| LINE_LOOP = 3 | |
| LINE = 4 | |
| TRIANGLE_STRIP = 5 | |
| TRIANGLE_FAN = 6 | |
| TRIANGLES = 7 | |
| class Winding(Enum): | |
| CW = 1 | |
| CCW = 2 | |
| class VertexBufferEntry(object): | |
| component_type = ComponentType.FLOAT32 | |
| component_count = 0 | |
| offset = 0 | |
| name = "" | |
| class Size(object): | |
| width: int | |
| height: int | |
| def __init__(self, width: int, height: int) -> None: | |
| self.width = width | |
| self.height = height | |
| def __str__(self) -> str: | |
| return f"width: {self.width}, height: {self.height}" | |
| class SubsetBounds(object): | |
| _min: Vector3 | |
| _max: Vector3 | |
| def __str__(self) -> str: | |
| return f"min: {self.min}, max: {self.max}" | |
| class Lod(object): | |
| count = 0 | |
| offset = 0 | |
| distance = 0.0 | |
| class Subset(object): | |
| raw_name_utf16 = b"" | |
| name_length = 0 | |
| bounds = SubsetBounds() | |
| offset = 0 | |
| count = 0 | |
| lightmap_size_hint: Size | |
| lod_count = 0 | |
| lods: List[Lod] = [] | |
| def __str__(self) -> str: | |
| return f"Name: {self.raw_name_utf16}, Bounds: {self.bounds}, Offset: {self.offset}, Count: {self.count}, Lightmap size hint: {self.lightmap_size_hint}, Lod count: {self.lod_count}" | |
| class MeshOffsetTracker(object): | |
| start_offset = 0 | |
| byte_counter = 0 | |
| def __init__(self, offset: int) -> None: | |
| self.start_offset = offset | |
| def offset(self) -> int: | |
| return self.start_offset + self.byte_counter | |
| def align_advance(self, advance_amount: int) -> int: | |
| self.advance(advance_amount) | |
| alignment_amount = 4 - (self.byte_counter % 4) | |
| self.byte_counter += alignment_amount | |
| return alignment_amount | |
| def advance(self, advance_amount: int) -> None: | |
| self.byte_counter += advance_amount | |
| def assert_component_type(component_type: int): | |
| assert component_type in ComponentType._value2member_map_, "Invalid component type" | |
| return ComponentType(component_type) | |
| def assert_draw_mode(draw_mode: int): | |
| assert draw_mode in DrawMode._value2member_map_, "Invalid draw mode" | |
| return DrawMode(draw_mode) | |
| def assert_winding(winding: int): | |
| assert winding in Winding._value2member_map_, "Invalid winding" | |
| return Winding(winding) | |
| class Mesh(object): | |
| version = 0 | |
| flags = 0 | |
| size = 0 | |
| draw_mode: DrawMode | |
| winding: Winding | |
| vertex_buffer: List[VertexBufferEntry] = [] | |
| index_buffer_data: BinaryReader | |
| index_buffer_type: ComponentType | |
| vertex_buffer_data: BinaryReader | |
| vertex_buffer_size = 0 | |
| index_buffer_size = 0 | |
| @classmethod | |
| def set_vertex_buffer_data(self, data: bytes, size: int) -> None: | |
| self.vertex_buffer_data = BinaryReader(BytesIO(data)) | |
| self.vertex_buffer_size = size | |
| @classmethod | |
| def set_index_buffer_data(self, data: bytes, size: int) -> None: | |
| self.index_buffer_data = BinaryReader(BytesIO(data)) | |
| self.index_buffer_size = size | |
| def read_model(file_name): | |
| with open(file_name, "rb") as f: | |
| reader = BinaryReader(f) | |
| mesh = Mesh() | |
| offset_tracker = MeshOffsetTracker(0) | |
| assert offset_tracker.offset() == reader.tell() | |
| magic = reader.read_uint32() | |
| mesh.version = reader.read_uint16() | |
| mesh.flags = reader.read_uint16() | |
| mesh.size = reader.read_uint32() | |
| assert magic == 3365961549, "Invalid QSSG mesh" | |
| assert mesh.version <= 7, "Invalid QSSG mesh version" | |
| assert mesh.version >= 3, "Legacy QSSG mesh version is not supported" | |
| target_buffer_entries_count = reader.read_uint32() | |
| vertex_buffer_entries_count = reader.read_uint32() | |
| stride = reader.read_uint32() | |
| target_buffer_data_size = reader.read_uint32() | |
| vertex_buffer_data_size = reader.read_uint32() | |
| def has_seperate_target_buffer(): | |
| return mesh.version >= 7 | |
| def has_lightmap_size_hint(): | |
| return mesh.version >= 5 | |
| def has_lod_data_hint(): | |
| return mesh.version >= 6 | |
| print(f"Version: {mesh.version}") | |
| print(f"Flags: {mesh.flags}") | |
| print(f"Size: {mesh.size}") | |
| print(f"Target buffer entries count: {target_buffer_entries_count}") | |
| print(f"Vertex buffer entries count: {vertex_buffer_entries_count}") | |
| print(f"Stride: {stride}") | |
| print(f"Target buffer data size: {target_buffer_data_size}") | |
| print(f"Vertex buffer data size: {vertex_buffer_data_size}") | |
| if not has_seperate_target_buffer(): | |
| target_buffer_entries_count = 0 | |
| target_buffer_data_size = 0 | |
| index_buffer_type = reader.read_uint32() | |
| mesh.index_buffer_type = assert_component_type(index_buffer_type) | |
| index_buffer_data_offset = reader.read_uint32() | |
| index_buffer_data_size = reader.read_uint32() | |
| print(f"Index buffer component type: {mesh.index_buffer_type}") | |
| print(f"Index buffer data offset: {index_buffer_data_offset}") | |
| print(f"Index buffer data size: {index_buffer_data_size}") | |
| target_count = reader.read_uint32() | |
| subsets_count = reader.read_uint32() | |
| print(f"Target count: {target_count}") | |
| print(f"Subsets count: {subsets_count}") | |
| joints_offset = reader.read_uint32() | |
| joints_count = reader.read_uint32() | |
| draw_mode = reader.read_uint32() | |
| mesh.draw_mode = assert_draw_mode(draw_mode) | |
| winding = reader.read_uint32() | |
| mesh.winding = assert_winding(winding) | |
| print(f"Joints offset: {joints_offset}") | |
| print(f"Joints count: {joints_count}") | |
| print(f"Draw mode: {mesh.draw_mode}") | |
| print(f"Winding: {mesh.winding}") | |
| offset_tracker.advance(16) | |
| entries_byte_size = 0 | |
| print() | |
| print("\t -- Vertex Buffer --") | |
| print(f"\tentry count: {vertex_buffer_entries_count}") | |
| for i in range(vertex_buffer_entries_count): | |
| # print(f"\t\tvertex buffer entry {i}") | |
| vbe = VertexBufferEntry() | |
| name_offset = reader.read_uint32() | |
| component_type = reader.read_uint32() | |
| component_type = assert_component_type(component_type) | |
| vbe.component_count = reader.read_uint32() | |
| vbe.offset = reader.read_uint32() | |
| vbe.component_type = component_type | |
| mesh.vertex_buffer.append(vbe) | |
| entries_byte_size += 16 | |
| align_amount = offset_tracker.align_advance(entries_byte_size) | |
| if align_amount: | |
| reader.read(align_amount) | |
| # vertex buffer entry names | |
| num_targets = 0 | |
| attr_names: List[bytes] | |
| for entry in mesh.vertex_buffer: | |
| name_length = reader.read_uint32() | |
| offset_tracker.advance(struct.calcsize("I")) | |
| name = reader.read(name_length)[: name_length - 1].decode() | |
| entry.name = name | |
| print(f"\t\tName: {entry.name}") | |
| print(f"\t\tComponent type: {entry.component_type}") | |
| print(f"\t\tComponent count: {entry.component_count}") | |
| print(f"\t\tOffset: {entry.offset}") | |
| print() | |
| align_amount = offset_tracker.align_advance(name_length) | |
| if align_amount: | |
| reader.read(align_amount) | |
| if num_targets > 0 or (not has_seperate_target_buffer() and entry.name.startswith("attr_t")): | |
| # print("fucked") | |
| # i do not give enough fucks about any of this | |
| pass | |
| vertex_buffer_data = reader.read(vertex_buffer_data_size) | |
| align_amount = offset_tracker.align_advance(vertex_buffer_data_size) | |
| if align_amount: | |
| reader.read(align_amount) | |
| mesh.set_vertex_buffer_data(vertex_buffer_data, vertex_buffer_data_size) | |
| index_buffer_data = reader.read(index_buffer_data_size) | |
| align_amount = offset_tracker.align_advance(index_buffer_data_size) | |
| if align_amount: | |
| reader.read(align_amount) | |
| mesh.set_index_buffer_data(index_buffer_data, index_buffer_data_size) | |
| subset_byte_size = 0 | |
| internal_subsets: List[Subset] = [] | |
| for i in range(subsets_count): | |
| subset = Subset() | |
| subset.count = reader.read_uint32() | |
| subset.offset = reader.read_uint32() | |
| min_x = reader.read_float() | |
| min_y = reader.read_float() | |
| min_z = reader.read_float() | |
| max_x = reader.read_float() | |
| max_y = reader.read_float() | |
| max_z = reader.read_float() | |
| name_offset = reader.read_uint32() | |
| subset.name_length = reader.read_uint32() | |
| subset.name_length = reader.read_uint32() | |
| subset.bounds.min = Vector3(min_x, min_y, min_z) | |
| subset.bounds.max = Vector3(max_x, max_y, max_z) | |
| if has_lightmap_size_hint(): | |
| width = reader.read_uint32() | |
| height = reader.read_uint32() | |
| subset.lightmap_size_hint = Size(width, height) | |
| if has_lod_data_hint(): | |
| subset.lod_count = reader.read_uint32() | |
| subset_byte_size += 52 # v6 | |
| else: | |
| subset_byte_size += 48 # v5 | |
| else: | |
| subset.lightmap_size_hint = Size(0, 0) | |
| subset_byte_size += 40 # v3 and v4 | |
| internal_subsets.append(subset) | |
| align_amount = offset_tracker.align_advance(subset_byte_size) | |
| if align_amount: | |
| reader.read(align_amount) | |
| for subset in internal_subsets: | |
| subset.raw_name_utf16 = reader.read(subset.name_length * 2) # utf16-le | |
| print(subset.raw_name_utf16.decode("utf-16-le")) | |
| align_amount = offset_tracker.align_advance(subset.name_length * 2) | |
| if align_amount: | |
| reader.read(align_amount) | |
| lod_byte_size = 0 | |
| subsets: List[Subset] = [] | |
| for subset in internal_subsets: | |
| for i in range(subset.lod_count): | |
| lod = Lod() | |
| count = reader.read_uint32() | |
| offset = reader.read_uint32() | |
| distance = reader.read_float() | |
| lod.count = count | |
| lod.offset = offset | |
| lod.distance = distance | |
| subset.lods.append(lod) | |
| print(f"Lod {i}/{subset.lod_count}") | |
| lod_byte_size += 12 | |
| subsets.append(subset) | |
| align_amount = offset_tracker.align_advance(lod_byte_size) | |
| if align_amount: | |
| reader.read(align_amount) | |
| target_buffer: List[VertexBufferEntry] = [] | |
| target_buffer_data: bytes | |
| # morph targets | |
| if target_buffer_entries_count > 0: | |
| if has_seperate_target_buffer(): | |
| entries_byte_size = 0 | |
| for i in range(target_buffer_entries_count): | |
| vbe = VertexBufferEntry() | |
| name_offset = reader.read_uint32() | |
| component_type = reader.read_uint32() | |
| component_type = assert_component_type(component_type) | |
| vbe.component_count = reader.read_uint32() | |
| vbe.offset = reader.read_uint32() | |
| vbe.component_type = component_type | |
| target_buffer.append(vbe) | |
| entries_byte_size += 16 | |
| align_amount = offset_tracker.align_advance(entries_byte_size) | |
| if align_amount: | |
| reader.read(align_amount) | |
| for entry in target_buffer: | |
| name_length = reader.read_uint32() | |
| offset_tracker.advance(struct.calcsize("I")) | |
| name = reader.read(name_length - 1) | |
| entry.name = name.decode("utf-8") | |
| print(f" Name: {entry.name}") | |
| align_amount = offset_tracker.align_advance(name_length) | |
| if align_amount: | |
| reader.read(align_amount) | |
| target_buffer_data = reader.read(target_buffer_data_size) | |
| else: | |
| # remove target entries from vertex buffer entries | |
| start_index = vertex_buffer_entries_count - target_buffer_entries_count | |
| del mesh.vertex_buffer[start_index : start_index + target_buffer_entries_count] | |
| vertex_count = vertex_buffer_data_size / stride | |
| target_entry_tex_width = math.ceil(math.sqrt(vertex_count)) | |
| target_comp_stride = target_entry_tex_width * target_entry_tex_width * 4 * struct.calcsize("f") | |
| num_comps = target_buffer_entries_count / num_targets | |
| for i in range(target_buffer_entries_count): | |
| entry = target_buffer[i] | |
| dst_buf_index = (i // num_comps) * target_comp_stride + (i % num_comps) * ( | |
| target_comp_stride * num_targets | |
| ) | |
| dst_buf = memoryview(target_buffer_data)[dst_buf_index:] | |
| src_buf_index = entry.offset | |
| src_buf = memoryview(mesh.vertex_buffer_data)[src_buf_index:] | |
| for j in range(vertex_count): | |
| dst_index = j * 4 * struct.calcsize("f") | |
| src_index = j * stride | |
| dst_buf[dst_index : dst_index + 3 * struct.calcsize("f")] = src_buf[ | |
| src_index : src_index + 3 * struct.calcsize("f") | |
| ] | |
| entry.offset = i * target_comp_stride | |
| # now we don't need to have redundant targetbuffer entries | |
| start_index = num_comps | |
| end_index = target_buffer_entries_count - (target_buffer_entries_count - num_comps) | |
| del target_buffer[start_index:end_index] | |
| return mesh | |
| def read(reader: BinaryReader, t: ComponentType): | |
| if t == ComponentType.UINT8: | |
| return reader.read_uint() | |
| elif t == ComponentType.INT8: | |
| return reader.read_int() | |
| elif t == ComponentType.UINT16: | |
| return reader.read_uint16() | |
| elif t == ComponentType.INT16: | |
| return reader.read_int16() | |
| elif t == ComponentType.UINT32: | |
| return reader.read_uint32() | |
| elif t == ComponentType.INT32: | |
| return reader.read_int32() | |
| elif t == ComponentType.UINT64: | |
| return reader.read_uint64() | |
| elif t == ComponentType.INT64: | |
| return reader.read_int64() | |
| elif t == ComponentType.FLOAT16: | |
| return reader.read_float16() | |
| elif t == ComponentType.FLOAT32: | |
| return reader.read_float() | |
| elif t == ComponentType.FLOAT64: | |
| return reader.read_double() | |
| else: | |
| raise Exception(f"Unsupported component type: {t}") | |
| class Vertex(object): | |
| position: Vector3 = None | |
| normal: Vector3 = None | |
| uv: Vector2 = None | |
| tangant: Vector3 = None | |
| binormal: Vector2 = None | |
| def __str__(self) -> str: | |
| return f"Position: {self.position}, Normal: {self.normal}, UV: {self.uv}, Tangant: {self.tangant}, Binormal: {self.binormal}" | |
| def mesh_to_obj(mesh: Mesh) -> str: | |
| obj = "# Generated using QTQuick3D QSSG Mesh Converter by Puyodead1\nhttps:\/\/github.com\puyodead1\n" | |
| vbo = mesh.vertex_buffer_data | |
| vertex_size = sum(map(lambda x: COMPONENT_TYPE_SIZES[x.component_type] * x.component_count, mesh.vertex_buffer)) | |
| vertex_count = mesh.vertex_buffer_size // vertex_size | |
| indicie_count = mesh.index_buffer_size // COMPONENT_TYPE_SIZES[mesh.index_buffer_type] | |
| vertices: List[Vertex] = [] | |
| indicies: List[int] = tuple(read(mesh.index_buffer_data, mesh.index_buffer_type) for _ in range(indicie_count)) | |
| for i in range(vertex_count): | |
| vertex = Vertex() | |
| for entry in mesh.vertex_buffer: | |
| t = Vector3 if entry.component_count == 3 else Vector2 | |
| a = tuple(read(vbo, entry.component_type) for _ in range(entry.component_count)) | |
| a = t(*a) | |
| if entry.name == "attr_pos": | |
| vertex.position = a | |
| elif entry.name == "attr_norm": | |
| vertex.normal = a | |
| elif entry.name == "attr_uv0": | |
| vertex.uv = a | |
| elif entry.name == "attr_textan": | |
| vertex.tangant = a | |
| elif entry.name == "attr_binormal": | |
| vertex.binormal = a | |
| vertices.append(vertex) | |
| for vertex in vertices: | |
| obj += f"v {vertex.position.x} {vertex.position.y} {vertex.position.z}\n" | |
| for vertex in vertices: | |
| obj += f"vn {vertex.normal.x} {vertex.normal.y} {vertex.normal.z}\n" | |
| for vertex in vertices: | |
| obj += f"vt {vertex.uv.x} {vertex.uv.y}\n" | |
| for i in range(0, len(indicies), 3): | |
| d = indicies[i : i + 3] | |
| obj += f"f {d[0] + 1}/{d[0] + 1}/{d[0] + 1} {d[1] + 1}/{d[1] + 1}/{d[1] + 1} {d[2] + 1}/{d[2] + 1}/{d[2] + 1}\n" | |
| return obj | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("file", type=str) | |
| args = parser.parse_args() | |
| infile = Path(args.file) | |
| mesh = read_model(infile) | |
| obj = mesh_to_obj(mesh) | |
| outfile = Path("converted", infile.name.split(".")[0] + ".obj") | |
| outfile.parent.mkdir(exist_ok=True, parents=True) | |
| with open(outfile, "w") as f: | |
| f.write(obj) | |
| print(f"Output written to {outfile}") |