269 lines
8.5 KiB
Python
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)
|