Files
coven/modules/ufbx/misc/transmute_fbx.py

269 lines
8.5 KiB
Python

#!/usr/bin/env python3
from dataclasses import dataclass
import struct
from typing import Any, Tuple
import zlib
import sys
import io
import argparse
@dataclass
class BinaryFormat:
version: int
big_endian: bool
array_encoding: int = 1
array_original: bool = False
@dataclass
class Value:
type: str
value: Any
original_data: Tuple[int, bytes] = (0, b"")
@dataclass
class Node:
name: bytes
values: list[Value]
children: list["Node"]
@dataclass
class FbxFile:
root: Node
version: int
format: str
footer: bytes = b""
def pack(stream, fmt, *args):
stream.write(struct.pack(fmt, *args))
def unpack(stream, fmt):
size = struct.calcsize(fmt)
data = stream.read(size)
return struct.unpack(fmt, data)
primitive_fmt = {
b"C": "b", b"B": "b",
b"Y": "h",
b"I": "l", b"F": "f",
b"L": "q", b"D": "d",
}
def binary_parse_value(stream, bf):
endian = "<>"[bf.big_endian]
type = stream.read(1)
fmt = primitive_fmt.get(type)
if fmt:
value, = unpack(stream, endian + fmt)
return Value(type, value)
if type in b"cbilfd":
arr_fmt = endian + "L" * 3
fmt = primitive_fmt[type.upper()]
count, encoding, encoded_size = unpack(stream, arr_fmt)
original_data = arr_data = stream.read(encoded_size)
if encoding == 0: pass # Nop
elif encoding == 1:
arr_data = zlib.decompress(arr_data)
else:
raise ValueError(f"Unknown encoding: {encoding}")
values = list(v[0] for v in struct.iter_unpack(endian + fmt, arr_data))
assert len(values) == count
return Value(type, values, original_data=(encoding, original_data))
elif type in b"SR":
length, = unpack(stream, endian + "L")
return Value(type, stream.read(length))
else:
raise ValueError(f"Bad type: '{type}'")
def binary_parse_node(stream, bf):
pos = stream.tell()
endian = "<>"[bf.big_endian]
head_fmt = endian + "LQ"[bf.version >= 7500] * 3 + "B"
end_offset, num_values, values_len, name_len = unpack(stream, head_fmt)
if end_offset == 0 and name_len == 0: return None
name = stream.read(name_len)
values_end = stream.tell() + values_len
values = [binary_parse_value(stream, bf) for _ in range(num_values)]
children = []
if stream.tell() != values_end:
assert stream.tell() < values_end
stream.seek(pos + values_end)
while stream.tell() < end_offset:
node = binary_parse_node(stream, bf)
if not node: break
children.append(node)
return Node(name, values, children)
def parse_fbx(stream):
magic = stream.read(22)
if magic == b"Kaydara FBX Binary \x00\x1a":
big_endian = stream.read(1) != b"\x00"
endian = "<>"[big_endian]
version, = unpack(stream, endian + "L")
bf = BinaryFormat(version, big_endian)
children = []
while True:
node = binary_parse_node(stream, bf)
if not node: break
children.append(node)
footer = stream.read(16)
root = Node("", [], children)
format = "binary-be" if big_endian else "binary"
return FbxFile(root, version, format, footer)
else:
# TODO
raise NotImplementedError()
def binary_dump_value(stream, value: Value, bf: BinaryFormat):
endian = "<>"[bf.big_endian]
fmt = primitive_fmt.get(value.type)
stream.write(value.type)
if fmt:
pack(stream, endian + fmt, value.value)
elif value.type in b"cbilfd":
fmt = endian + primitive_fmt[value.type.upper()]
arr_fmt = endian + "L" * 3
if bf.array_original:
encoding, arr_data = value.original_data
pack(stream, arr_fmt, len(value.value), encoding, len(arr_data))
stream.write(arr_data)
else:
with io.BytesIO() as ds:
for v in value.value:
pack(ds, fmt, v)
arr_data = ds.getvalue()
count = len(value.value)
encoding = bf.array_encoding
if encoding == 1:
arr_data = zlib.compress(arr_data)
encoded_size = len(arr_data)
pack(stream, arr_fmt, count, encoding, encoded_size)
stream.write(arr_data)
elif value.type in b"SR":
pack(stream, endian + "L", len(value.value))
stream.write(value.value)
else:
raise ValueError(f"Bad type: '{value.type}'")
def binary_dump_node(stream, node: Node, bf: BinaryFormat):
endian = "<>"[bf.big_endian]
head_size = 25 if bf.version >= 7500 else 13
head_null = b"\x00" * head_size
off_start = stream.tell()
stream.write(head_null)
stream.write(node.name)
off_value_start = stream.tell()
for value in node.values:
binary_dump_value(stream, value, bf)
values_size = stream.tell() - off_value_start
for child in node.children:
binary_dump_node(stream, child, bf)
if node.children or node.name in { b"References", b"AnimationStack", b"AnimationLayer" }:
stream.write(head_null)
off_end = stream.tell()
head_fmt = endian + "LQ"[bf.version >= 7500] * 3 + "B"
stream.seek(off_start)
pack(stream, head_fmt, off_end, len(node.values), values_size, len(node.name))
stream.seek(off_end)
def binary_dump_root(stream, root: Node, bf: BinaryFormat, footer: bytes):
head_size = 25 if bf.version >= 7500 else 13
head_null = b"\x00" * head_size
endian = "<>"[bf.big_endian]
stream.write(b"Kaydara FBX Binary \x00\x1a")
pack(stream, "B", bf.big_endian)
pack(stream, endian + "L", bf.version)
for node in root.children:
binary_dump_node(stream, node, bf)
stream.write(head_null)
stream.write(footer)
stream.write(b"\x00" * 4)
ofs = stream.tell()
pad = ((ofs + 15) & ~15) - ofs
if pad == 0:
pad = 16
stream.write(b"\0" * pad)
pack(stream, endian + "I", bf.version)
stream.write(b"\0" * 120)
stream.write(b"\xf8\x5a\x8c\x6a\xde\xf5\xd9\x7e\xec\xe9\x0c\xe3\x75\x8f\x29\x0b")
def ascii_dump_value(stream, value: Value, indent: str):
if value.type in b"CBYILFD":
stream.write(str(value.value))
elif value.type in b"SR":
s = str(value.value)[2:-1]
stream.write(f"\"{s}\"")
elif value.type in b"cbilfd":
stream.write(f"* {len(value.value)} {{")
first = True
for v in value.value:
stream.write(" " if first else ", ")
stream.write(str(v))
first = False
stream.write(" }")
else:
raise ValueError(f"Bad value type: '{value.type}'")
def ascii_dump_node(stream, node: Node, indent: str):
name = node.name.decode("utf-8")
stream.write(f"{indent}{name}:")
first = True
for value in node.values:
stream.write(" " if first else ", ")
first = False
ascii_dump_value(stream, value, indent + " ")
if node.children:
stream.write(" {\n")
for node in node.children:
ascii_dump_node(stream, node, indent + " ")
stream.write(indent + "}\n")
else:
stream.write("\n")
def ascii_dump_root(stream, root: Node, version: int):
v0 = version // 1000 % 10
v1 = version // 100 % 10
v2 = version // 10 % 10
stream.write(f"; FBX {v0}.{v1}.{v2} project file\n")
stream.write("----------------------------------------------------\n")
for child in root.children:
ascii_dump_node(stream, child, "")
if __name__ == "__main__":
parser = argparse.ArgumentParser(usage="transmute_fbx.py src -o dst -v 7400 -f binary-be")
parser.add_argument("src", help="Source file to read")
parser.add_argument("--output", "-o", required=True, help="Output filename")
parser.add_argument("--version", "-v", help="File version")
parser.add_argument("--format", "-f", help="File format")
argv = parser.parse_args()
with open(argv.src, "rb") as f:
fbx = parse_fbx(f)
format = argv.format
if not format:
format = fbx.format
version = argv.version
if not version:
version = fbx.version
with open(argv.output, "wt" if format == "ascii" else "wb") as f:
if format == "ascii":
ascii_dump_root(f, fbx.root, version)
else:
if format == "binary-be":
bf = BinaryFormat(version, True, 0)
elif format == "binary":
bf = BinaryFormat(version, False, 1)
else:
raise ValueError(f"Unknown format: {format}")
binary_dump_root(f, fbx.root, bf, fbx.footer)