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}") |