├── bfres ├── __init__.py ├── Common │ ├── __init__.py │ └── StringTable.py ├── Exporter │ ├── __init__.py │ ├── ExportOperator.py │ └── Exporter.py ├── Importer │ ├── __init__.py │ ├── Preferences.py │ ├── ModelImporter.py │ ├── TextureImporter.py │ ├── SkeletonImporter.py │ ├── ImportOperator.py │ ├── MaterialImporter.py │ ├── Importer.py │ └── LodImporter.py ├── BinaryStruct │ ├── Offset.py │ ├── Flags.py │ ├── Vector.py │ ├── BinaryObject.py │ ├── Switch │ │ └── __init__.py │ ├── Padding.py │ ├── StringOffset.py │ └── __init__.py ├── BNTX │ ├── pixelfmt │ │ ├── bc │ │ │ ├── __init__.py │ │ │ ├── bc2.py │ │ │ ├── bc4.py │ │ │ ├── bc3.py │ │ │ ├── bc1.py │ │ │ ├── base.py │ │ │ └── bc5.py │ │ ├── __init__.py │ │ ├── swizzle.py │ │ ├── rgb.py │ │ └── base.py │ ├── NX.py │ ├── __init__.py │ └── BRTI.py ├── YAZ0 │ ├── __init__.py │ └── Decoder.py ├── FRES │ ├── FresObject.py │ ├── BufferSection.py │ ├── FMDL │ │ ├── Attribute │ │ │ ├── __init__.py │ │ │ └── types.py │ │ ├── Vertex.py │ │ ├── Buffer.py │ │ ├── FSHP.py │ │ ├── __init__.py │ │ ├── LOD.py │ │ ├── FSKL.py │ │ ├── Bone.py │ │ ├── FVTX.py │ │ └── FMAT.py │ ├── EmbeddedFile.py │ ├── Dict.py │ ├── DumpMixin.py │ ├── RLT.py │ └── __init__.py ├── Exceptions │ └── __init__.py ├── logger │ └── __init__.py └── BinaryFile │ └── __init__.py ├── .gitignore ├── notes ├── fres-Animal_Fox.Tex-dump.txt ├── fres-textures-bntx-dump.txt └── notes.md ├── README.md └── __init__.py /bfres/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bfres/Common/__init__.py: -------------------------------------------------------------------------------- 1 | from .StringTable import StringTable 2 | -------------------------------------------------------------------------------- /bfres/Exporter/__init__.py: -------------------------------------------------------------------------------- 1 | from .Exporter import Exporter 2 | from .ExportOperator import ExportOperator 3 | -------------------------------------------------------------------------------- /bfres/Importer/__init__.py: -------------------------------------------------------------------------------- 1 | from .Importer import Importer 2 | from .ImportOperator import ImportOperator 3 | from .Preferences import BfresPreferences 4 | -------------------------------------------------------------------------------- /bfres/BinaryStruct/Offset.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from .BinaryObject import BinaryObject 3 | import struct 4 | 5 | class Offset(BinaryObject): 6 | """An offset of another object in a binary file.""" 7 | DisplayFormat = '0x%08X' 8 | -------------------------------------------------------------------------------- /bfres/BNTX/pixelfmt/bc/__init__.py: -------------------------------------------------------------------------------- 1 | # Most of the code in this module is ported from 2 | # https://github.com/gdkchan/BnTxx 3 | # which is published with no license. 4 | from .bc1 import BC1 5 | from .bc2 import BC2 6 | from .bc3 import BC3 7 | from .bc4 import BC4 8 | from .bc5 import BC5 9 | -------------------------------------------------------------------------------- /bfres/YAZ0/__init__.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from .Decoder import Decoder 3 | 4 | def decompressFile(infile, outfile): 5 | """Decompress from `infile` to `outfile`.""" 6 | decoder = Decoder(infile) 7 | for data in decoder.bytes(): 8 | outfile.write(data) 9 | -------------------------------------------------------------------------------- /bfres/FRES/FresObject.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | 3 | class FresObject: 4 | """Base class for an object in an FRES.""" 5 | 6 | def __init__(self, fres): 7 | self.fres = fres 8 | 9 | 10 | def readFromFRES(self, offset=None): 11 | """Read this object from the FRES.""" 12 | raise NotImplementedError 13 | -------------------------------------------------------------------------------- /bfres/BNTX/pixelfmt/__init__.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from .base import TextureFormat, fmts, types 3 | from . import rgb, bc 4 | 5 | for cls in TextureFormat.__subclasses__(): 6 | fmts[cls.id] = cls 7 | 8 | # define a subclass for each remaining format 9 | for name, typ in types.items(): 10 | if typ['id'] not in fmts: 11 | cls = type(name, (TextureFormat,), typ) 12 | fmts[typ['id']] = cls 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # misc/temp files 2 | **/~* 3 | *.log 4 | __pycache__ 5 | **/build/* 6 | **/bin/* 7 | **/old/* 8 | 9 | # from https://github.com/github/gitignore 10 | # Object files 11 | *.o 12 | *.ko 13 | *.obj 14 | *.elf 15 | 16 | # Libraries 17 | *.lib 18 | *.a 19 | *.la 20 | *.lo 21 | 22 | # Shared objects (inc. Windows DLLs) 23 | *.dll 24 | *.so 25 | *.so.* 26 | *.dylib 27 | 28 | # Executables 29 | *.exe 30 | *.out 31 | *.app 32 | *.i*86 33 | *.x86_64 34 | *.hex 35 | -------------------------------------------------------------------------------- /bfres/Exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | class UnsupportedFileTypeError(Exception): 2 | """File type not supported""" 3 | def __init__(self, magic): 4 | super().__init__() 5 | self.magic = magic 6 | 7 | 8 | def __str__(self): 9 | return "UnsupportedFileTypeError(%s)" % str(self.magic) 10 | 11 | 12 | class UnsupportedFormatError(Exception): 13 | """Some particular format used in this file is not supported.""" 14 | 15 | 16 | class MalformedFileError(Exception): 17 | """File is corrupted or unreadable.""" 18 | -------------------------------------------------------------------------------- /bfres/BNTX/NX.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.Switch import Offset32, Offset64 5 | 6 | 7 | class NX(BinaryStruct): 8 | """A 'NX' texture in a BNTX.""" 9 | magic = b'NX ' 10 | fields = ( 11 | ('4s', 'magic'), 12 | ('I', 'num_textures'), 13 | Offset64('info_ptrs_offset'), 14 | Offset64('data_blk_offset'), 15 | Offset64('dict_offset'), 16 | ('I', 'str_dict_len'), 17 | ) 18 | -------------------------------------------------------------------------------- /bfres/BinaryStruct/Flags.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from .BinaryObject import BinaryObject 3 | import struct 4 | 5 | 6 | class Flags(BinaryObject): 7 | """A set of bitflags.""" 8 | def __init__(self, name, flags, fmt='I'): 9 | self.name = name 10 | self._flags = flags 11 | self.fmt = fmt 12 | self.size = struct.calcsize(fmt) 13 | 14 | 15 | def readFromFile(self, file, offset=None): 16 | val = file.read(self.fmt, offset) 17 | res = {'_raw':val} 18 | for name, mask in self._flags.items(): 19 | res[name] = (val & mask) == mask 20 | return res 21 | -------------------------------------------------------------------------------- /bfres/BinaryStruct/Vector.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from .BinaryObject import BinaryObject 3 | import struct 4 | 5 | SIZEOF_FLOAT = 4 6 | SIZEOF_DOUBLE = 8 7 | 8 | 9 | class Vector(BinaryObject): 10 | """A mathematical vector.""" 11 | def __init__(self, name): 12 | self.name = name 13 | self.size = struct.calcsize(self.fmt) * self.count 14 | 15 | 16 | def readFromFile(self, file, offset=None): 17 | return file.read(self.fmt, offset, count=self.count) 18 | 19 | 20 | class Vec3f(Vector): 21 | count = 3 22 | fmt = 'f' 23 | 24 | class Vec4f(Vector): 25 | count = 4 26 | fmt = 'f' 27 | -------------------------------------------------------------------------------- /bfres/FRES/BufferSection.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryFile import BinaryFile 7 | from .FresObject import FresObject 8 | import tempfile 9 | 10 | 11 | class BufferSection(BinaryStruct): 12 | """Defines the buffer offsets and sizes.""" 13 | fields = ( 14 | ('I', 'unk00'), # 0x00 15 | ('I', 'size'), # 0x04 16 | Offset64("buf_offs"), # 0x08 17 | Padding(16), # 0x10 18 | ) 19 | size = 0x20 20 | -------------------------------------------------------------------------------- /bfres/Importer/Preferences.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import bpy 3 | 4 | 5 | class BfresPreferences(bpy.types.AddonPreferences): 6 | bl_idname = "import_scene.nxbfres" 7 | 8 | def _onUpdate(self, context): 9 | print("BFRES _onUpdate", context) 10 | 11 | debugDumpFiles = bpy.props.BoolProperty( 12 | name="Dump debug info to files", 13 | description="Create `fres-SomeFile-dump.txt` files for debugging.", 14 | default=False, 15 | options={'ANIMATABLE'}, 16 | subtype='NONE', 17 | update=_onUpdate, 18 | ) 19 | 20 | def draw(self, context): 21 | print("BfresPreferences draw") 22 | self.layout.prop(self, "debugDumpFiles") 23 | -------------------------------------------------------------------------------- /bfres/BinaryStruct/BinaryObject.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import struct 3 | #from BinaryFile import BinaryFile 4 | 5 | def format(*val): 6 | val = val[-1] # we may or may not receive `self` argument 7 | if type(val) is int: return '0x%X' % val 8 | if type(val) is str: return '"%s"' % val 9 | return str(val) 10 | 11 | 12 | class BinaryObject: 13 | """An object in a binary file.""" 14 | 15 | DisplayFormat = format 16 | 17 | def __init__(self, name, fmt): 18 | self.name = name 19 | self.fmt = fmt 20 | self.size = struct.calcsize(fmt) 21 | 22 | 23 | def readFromFile(self, file, offset=None): 24 | """Read this object from a file.""" 25 | return file.read(self.fmt, pos=offset) 26 | -------------------------------------------------------------------------------- /notes/fres-Animal_Fox.Tex-dump.txt: -------------------------------------------------------------------------------- 1 | FRES "Animal_Fox.Tex"/"Animal_Fox.Tex": Version: 3,5, Size: 0x0350B0, Endian: big, Align: 0x01280000 2 | Unk24: 0x00000000 UnkCA: 0x0000 0x0000 0x0000 3 | Object│Cnt │Offset │DictOffs 4 | FMDL │0000│00000000│00000000 5 | FSKA │0000│00000000│00000000 6 | FMAA │0000│00000000│00000000 7 | FVIS │0000│00000000│00000000 8 | FSHU │0000│00000000│00000000 9 | EMBED │0001│000000F0│00000100 10 | Buffer│ ?? │00000000│00000000 11 | StrTab│N/A │0000013C│N/A │size=0x00003A num_strs=2 12 | RelTab│N/A │00035000│N/A |data=0x000162 unk04=35000 5 0 0 0 0 162 0 4 0 0 13 | Models: 0 14 | Animations: 0 15 | Buffers: 0 16 | Embedded Files: 1 17 | Offset│Size │Name |ASCII dump 18 | 001000│0330B8│textures.bntx │BNTX\x00\x00\x00\x00\x00\x00\x04\x00\xFF\xFE\x0C@ -------------------------------------------------------------------------------- /bfres/BinaryStruct/Switch/__init__.py: -------------------------------------------------------------------------------- 1 | # Switch common formats 2 | import logging; log = logging.getLogger(__name__) 3 | from .. import BinaryStruct, BinaryObject 4 | from ..Offset import Offset 5 | from ..StringOffset import StringOffset 6 | 7 | class Offset32(Offset): 8 | """A 32-bit offset in a Switch file header.""" 9 | def __init__(self, name): 10 | super().__init__(name, '> (ty * 16 + tx * 4)) & 0xF 30 | out = (x*4 + tx + (y * 4 + ty) * width * 4) * 4 31 | pixels[out : out+3] = tile[toffs : toffs+3] 32 | pixels[out+3] = alpha | (alpha << 4) 33 | toffs += 4 34 | 35 | return pixels, self.depth 36 | -------------------------------------------------------------------------------- /bfres/BNTX/pixelfmt/bc/bc4.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import struct 3 | from .base import BCn, TextureFormat, unpackRGB565, clamp 4 | 5 | 6 | class BC4(TextureFormat, BCn): 7 | id = 0x1D 8 | bytesPerPixel = 8 9 | 10 | 11 | def decode(self, tex): 12 | decode = self.decodePixel 13 | bpp = self.bytesPerPixel 14 | data = tex.data 15 | width = int((tex.width + 3) / 4) 16 | height = int((tex.height + 3) / 4) 17 | pixels = bytearray(width * height * 64) 18 | swizzle = tex.swizzle.getOffset 19 | 20 | for y in range(height): 21 | for x in range(width): 22 | offs = swizzle(x, y) 23 | red = self.calcAlpha(data[offs : offs+2]) 24 | # read two extra bytes here, but we won't use them 25 | redCh = struct.unpack('Q', data[offs+2 : offs+10])[0] 26 | 27 | toffs = 0 28 | for ty in range(4): 29 | for tx in range(4): 30 | out = (x*4 + tx + (y * 4 + ty) * width * 4) * 4 31 | r = red[(redCh >> (ty * 12 + tx * 3)) & 7] 32 | pixels[out : out+4] = (r, r, r, 0xFF) 33 | toffs += 4 34 | 35 | return pixels, self.depth 36 | -------------------------------------------------------------------------------- /bfres/BNTX/pixelfmt/bc/bc3.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import struct 3 | from .base import BCn, TextureFormat, unpackRGB565, clamp 4 | 5 | 6 | class BC3(TextureFormat, BCn): 7 | id = 0x1C 8 | bytesPerPixel = 16 9 | 10 | 11 | def decode(self, tex): 12 | decode = self.decodePixel 13 | bpp = self.bytesPerPixel 14 | data = tex.data 15 | width = int((tex.width + 3) / 4) 16 | height = int((tex.height + 3) / 4) 17 | pixels = bytearray(width * height * 64) 18 | swizzle = tex.swizzle.getOffset 19 | 20 | for y in range(height): 21 | for x in range(width): 22 | offs = swizzle(x, y) 23 | tile = self.decodeTile(data, offs) 24 | alpha = self.calcAlpha(data[offs : offs+2]) 25 | alphaCh = struct.unpack('Q', alpha)[0] 26 | 27 | toffs = 0 28 | for ty in range(4): 29 | for tx in range(4): 30 | out = (x*4 + tx + (y * 4 + ty) * width * 4) * 4 31 | pixels[out : out+3] = tile[toffs : toffs+3] 32 | pixels[out+3] = \ 33 | alpha[(alphaCh >> (ty * 12 + tx * 3)) & 7] 34 | toffs += 4 35 | 36 | return pixels, self.depth 37 | -------------------------------------------------------------------------------- /bfres/FRES/FMDL/Attribute/__init__.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.Switch import String 5 | from bfres.FRES.FresObject import FresObject 6 | from .types import attrFmts 7 | 8 | 9 | class AttrStruct(BinaryStruct): 10 | """The Attribute structure in the file.""" 11 | fields = ( 12 | String('name'), 13 | ('I', 'unk04'), 14 | ('H', 'format'), 15 | Padding(2), 16 | ('H', 'buf_offs'), 17 | ('H', 'buf_idx'), 18 | ) 19 | size = 0x10 20 | 21 | 22 | class Attribute(FresObject): 23 | """An attribute in a FRES.""" 24 | def __init__(self, fvtx): 25 | self.fvtx = fvtx 26 | 27 | 28 | def readFromFRES(self, offset=None): 29 | """Read the attribute from given FRES.""" 30 | data = self.fvtx.fres.read(AttrStruct(), offset) 31 | self.name = data['name'] 32 | self.unk04 = data['unk04'] 33 | self.formatID = data['format'] 34 | self.buf_offs = data['buf_offs'] 35 | self.buf_idx = data['buf_idx'] 36 | self.format = attrFmts.get(self.formatID, None) 37 | if self.format is None: 38 | log.warning("FMDL Attribute: Unknown format 0x%04X", 39 | self.formatID) 40 | return self 41 | 42 | 43 | def dump(self, ind=0): 44 | """Dump to string for debug.""" 45 | inds = (' ' * ind) 46 | return '%s%3d│%06X│%04X %-8s│%08X│"%s"' % ( 47 | inds, 48 | self.buf_idx, 49 | self.buf_offs, 50 | self.formatID, 51 | self.format['name'], 52 | self.unk04, 53 | self.name, 54 | ) 55 | -------------------------------------------------------------------------------- /bfres/Common/StringTable.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryFile import BinaryFile 7 | 8 | 9 | class Header(BinaryStruct): 10 | """String Table header.""" 11 | magic = b'_STR' 12 | fields = ( 13 | ('4s', 'magic'), Padding(4), 14 | ('I', 'size'), Padding(4), 15 | ('I', 'num_strs'), 16 | ) 17 | size = 0x14 18 | 19 | 20 | class StringTable: 21 | """A string table in an FRES.""" 22 | Header = Header 23 | 24 | def __init__(self): 25 | self.strings = {} 26 | 27 | 28 | def readFromFile(self, file, offset=None): 29 | """Read this object from the given file.""" 30 | 31 | #log.debug("Read str table from 0x%X", offset) 32 | header = self.Header() 33 | self.header = header.readFromFile(file, offset) 34 | offset += header.size 35 | 36 | for i in range(self.header['num_strs']): 37 | offset += (offset & 1) # pad to u16 38 | length = file.read('. 15 | import logging; log = logging.getLogger(__name__) 16 | import struct 17 | import math 18 | 19 | 20 | def countLsbZeros(val): 21 | cnt = 0 22 | while ((val >> cnt) & 1) == 0: cnt += 1 23 | return cnt 24 | 25 | 26 | class Swizzle: 27 | def __init__(self, width, bpp, blkHeight=16): 28 | self.width = width 29 | self.bpp = bpp 30 | 31 | def getOffset(self, x, y): 32 | return (y * self.width) + x 33 | 34 | 35 | class BlockLinearSwizzle(Swizzle): 36 | def __init__(self, width, bpp, blkHeight=16): 37 | self.bhMask = (blkHeight * 8) - 1 38 | self.bhShift = countLsbZeros(blkHeight * 8) 39 | self.bppShift = countLsbZeros(bpp) 40 | widthGobs = math.ceil(width * bpp / 64.0) 41 | self.gobStride = 512 * blkHeight * widthGobs 42 | self.xShift = countLsbZeros(512 * blkHeight) 43 | 44 | def getOffset(self, x, y): 45 | x <<= self.bppShift 46 | return ( 47 | ((y >> self.bhShift) * self.gobStride) + 48 | ((x >> 6) << self.xShift) + 49 | (((y & self.bhMask) >> 3) << 9) + 50 | (((x & 0x3F) >> 5) << 8) + 51 | (((y & 0x07) >> 1) << 6) + 52 | (((x & 0x1F) >> 4) << 5) + 53 | ( (y & 0x01) << 4) + 54 | ( x & 0x0F) 55 | ) 56 | -------------------------------------------------------------------------------- /bfres/BNTX/pixelfmt/bc/bc1.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import struct 3 | from .base import BCn, TextureFormat, unpackRGB565, clamp 4 | 5 | 6 | class BC1(TextureFormat, BCn): 7 | id = 0x1A 8 | bytesPerPixel = 8 9 | 10 | 11 | def decode(self, tex): 12 | decode = self.decodePixel 13 | bpp = self.bytesPerPixel 14 | data = tex.data 15 | width = int((tex.width + 3) / 4) 16 | height = int((tex.height + 3) / 4) 17 | pixels = bytearray(width * height * 64) 18 | swizzle = tex.swizzle.getOffset 19 | 20 | #log.debug("BC1: %s, %d bytes/pixel, %dx%d = %d, len = %d", 21 | # tex.name, 22 | # bpp, width, height, width * height * bpp, 23 | # len(data)) 24 | 25 | for y in range(height): 26 | for x in range(width): 27 | offs = swizzle(x, y) 28 | tile = self.decodeTile(data, offs) 29 | 30 | toffs = 0 31 | for ty in range(4): 32 | for tx in range(4): 33 | out = (x*4 + tx + (y * 4 + ty) * width * 4) * 4 34 | pixels[out : out+4] = tile[toffs : toffs+4] 35 | toffs += 4 36 | 37 | return pixels, self.depth 38 | 39 | 40 | # BC1 uses different LUT calculations than other BC formats 41 | def calcCLUT2(self, lut0, lut1, c0, c1): 42 | if c0 > c1: 43 | r = int((2 * lut0[0] + lut1[0]) / 3) 44 | g = int((2 * lut0[1] + lut1[1]) / 3) 45 | b = int((2 * lut0[2] + lut1[2]) / 3) 46 | else: 47 | r = (lut0[0] + lut1[0]) >> 1 48 | g = (lut0[1] + lut1[1]) >> 1 49 | b = (lut0[2] + lut1[2]) >> 1 50 | return r, g, b, 0xFF 51 | 52 | 53 | def calcCLUT3(self, lut0, lut1, c0, c1): 54 | if c0 > c1: 55 | r = int((2 * lut0[0] + lut1[0]) / 3) 56 | g = int((2 * lut0[1] + lut1[1]) / 3) 57 | b = int((2 * lut0[2] + lut1[2]) / 3) 58 | return r, g, b, 0xFF 59 | else: 60 | return 0, 0, 0, 0 61 | -------------------------------------------------------------------------------- /bfres/Importer/ModelImporter.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import bmesh 3 | import bpy 4 | import bpy_extras 5 | import struct 6 | from .MaterialImporter import MaterialImporter 7 | from .SkeletonImporter import SkeletonImporter 8 | from .LodImporter import LodImporter 9 | 10 | 11 | class ModelImporter: 12 | """Mixin class for importing the geometric models.""" 13 | 14 | def _importModel(self, fmdl): 15 | """Import specified model.""" 16 | # create a parent if we don't have one. 17 | fmdl_obj = None 18 | if self.operator.parent_ob_name is None: 19 | fmdl_obj = bpy.data.objects.new(fmdl.name, None) 20 | self._add_object_to_group(fmdl_obj, fmdl.name) 21 | bpy.context.scene.objects.link(fmdl_obj) 22 | 23 | # import the skeleton 24 | self.fmdl = fmdl 25 | self.skelImp = SkeletonImporter(self, fmdl) 26 | self.armature = self.skelImp.importSkeleton(fmdl.skeleton) 27 | 28 | # import the materials. 29 | self.matImp = MaterialImporter(self, fmdl) 30 | for i, fmat in enumerate(fmdl.fmats): 31 | log.info("Importing material %3d / %3d...", 32 | i+1, len(fmdl.fmats)) 33 | self.matImp.importMaterial(fmat) 34 | 35 | # create the shapes. 36 | for i, fshp in enumerate(fmdl.fshps): 37 | log.info("Importing shape %3d / %3d '%s'...", 38 | i+1, len(fmdl.fshps), fshp.name) 39 | self._importShape(fmdl, fshp, fmdl_obj) 40 | 41 | 42 | def _importShape(self, fmdl, fshp, parent): 43 | """Import FSHP. 44 | 45 | fmdl: FMDL to import from. 46 | fshp: FSHP to import. 47 | parent: Object to parent the LOD models to. 48 | """ 49 | fvtx = fmdl.fvtxs[fshp.header['fvtx_idx']] 50 | for ilod, lod in enumerate(fshp.lods): 51 | log.info("Importing LOD %3d / %3d...", 52 | ilod+1, len(fshp.lods)) 53 | 54 | lodImp = LodImporter(self) 55 | meshObj = lodImp._importLod(fvtx, fmdl, fshp, lod, ilod) 56 | meshObj.parent = parent 57 | -------------------------------------------------------------------------------- /bfres/Exporter/ExportOperator.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import bmesh 3 | import bpy 4 | import bpy_extras 5 | import os 6 | import os.path 7 | from .Exporter import Exporter 8 | 9 | 10 | class ExportOperator(bpy.types.Operator, bpy_extras.io_utils.ExportHelper): 11 | """Export a BFRES model file""" 12 | 13 | bl_idname = "export_scene.nxbfres" 14 | bl_label = "Export NX BFRES" 15 | bl_options = {'UNDO'} 16 | #filename_ext = ".bfres" 17 | filename_ext = ".dat" 18 | 19 | filter_glob = bpy.props.StringProperty( 20 | default="*.sbfres;*.bfres", 21 | options={'HIDDEN'}, 22 | ) 23 | filepath = bpy.props.StringProperty( 24 | name="File Path", 25 | #maxlen=1024, 26 | description="Filepath used for exporting the BFRES file") 27 | 28 | export_tex_file = bpy.props.BoolProperty(name="Write .Tex File", 29 | description="Write textures to .Tex file with same name.", 30 | default=True) 31 | 32 | 33 | def draw(self, context): 34 | box = self.layout.box() 35 | box.label("Texture Options:", icon='TEXTURE') 36 | box.prop(self, "export_tex_file") 37 | 38 | 39 | def execute(self, context): 40 | # enter Object mode if not already 41 | try: bpy.ops.object.mode_set(mode='OBJECT') 42 | except RuntimeError: pass 43 | 44 | if self.export_tex_file: 45 | path, ext = os.path.splitext(self.properties.filepath) 46 | path = path + '.Tex' + ext 47 | if os.path.exists(path): 48 | log.info("Exporting linked file: %s", path) 49 | exporter = Exporter(self, context) 50 | exporter.exportTextures(path) 51 | log.info("exporting: %s", self.properties.filepath) 52 | exporter = Exporter(self, context) 53 | return exporter.run(self.properties.filepath) 54 | 55 | 56 | @staticmethod 57 | def menu_func_export(self, context): 58 | """Handle the Export menu item.""" 59 | self.layout.operator( 60 | ExportOperator.bl_idname, 61 | text="Nintendo Switch BFRES (.bfres)") 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Nintendo BFRES importer (and eventually exporter) for Blender. 2 | 3 | Imports models from games such as Breath of the Wild. 4 | 5 | Currently only supports Switch formats (and probably not very well). 6 | 7 | Based on https://github.com/Syroot/io_scene_bfres 8 | 9 | Very much in development and may be full of bugs and rough corners. 10 | 11 | # What works: 12 | - Importing models, with their skeletons and textures, from `.bfres` and `.sbfres` files (at least `Animal_Fox` works) 13 | - Includes cases where textures are in a separate `.Tex.sbfres` file in the same directory 14 | - Textures are embedded in the `.blend` file 15 | - Each LOD (level of detail) model is imported as a separate object, which might look strange when all of them are visible. 16 | - Materials' render/shader/material parameters are stored as Blender custom properties on the material objects. 17 | - Text files in the FRES are embedded into the blend file. 18 | 19 | # What's broken: 20 | - Specular intensity is way too high (models are shinier than they should be) 21 | - `npc_zelda_miko` is weird, needs investigation 22 | - Decompressing is slow 23 | - Progress indicator sucks, but I don't think Blender provides any way to do a better one 24 | - `Animal_Bee`'s UV map is all wrong 25 | - The addon preferences don't show up and I have no idea why 26 | - Some texture formats might not decode correctly 27 | - Extra files in the FRES, which are neither text nor a supported format, are discarded 28 | - The game probably never does this, but it's technically possible 29 | 30 | # What's not even started yet: 31 | - Animations 32 | - Exporting 33 | - Exports as a `.dat` file containing Python code, which might be useful for some scripts 34 | - Eventually will export a `bfres` file 35 | - WiiU files 36 | 37 | # Why another importer? 38 | I wanted to convert to a common format such as Collada, which any 3D editor could then use, but Blender seems to have issues with every suitable format. ¯\\\_(ツ)\_/¯ 39 | 40 | Syroot's importer didn't work for me, and as far as I know, didn't support skeletons. Since I'd already made a convertor, it was easier to build an importer from that. 41 | -------------------------------------------------------------------------------- /bfres/FRES/EmbeddedFile.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryFile import BinaryFile 7 | from .FresObject import FresObject 8 | import tempfile 9 | 10 | 11 | class Header(BinaryStruct): 12 | """Embedded file header.""" 13 | fields = ( 14 | Offset64('data_offset'), 15 | ('I', 'size'), 16 | Padding(4), 17 | ) 18 | size = 0x10 19 | 20 | 21 | class EmbeddedFile(FresObject): 22 | """A file embedded in an FRES.""" 23 | 24 | def __init__(self, fres, name=None): 25 | self.fres = fres 26 | self.name = name 27 | self.headerOffset = None 28 | self.header = None 29 | self.dataOffset = None 30 | self.data = None 31 | self.size = None 32 | self._tempFile = None 33 | self._tempBinFile = None 34 | 35 | 36 | def __str__(self): 37 | return "" %( 38 | str(self.name), 39 | '?' if self.size is None else hex(self.size), 40 | '?' if self.dataOffset is None else hex(self.dataOffset), 41 | id(self), 42 | ) 43 | 44 | 45 | def readFromFRES(self, offset=None): 46 | """Read this object from FRES.""" 47 | if offset is None: offset = self.fres.file.tell() 48 | self.headerOffset = offset 49 | self.header = self.fres.read(Header(), offset) 50 | self.dataOffset = self.header['data_offset'] 51 | self.size = self.header['size'] 52 | self.data = self.fres.read(self.size, self.dataOffset) 53 | 54 | return self 55 | 56 | 57 | def toTempFile(self) -> BinaryFile: 58 | """Dump to a temporary file.""" 59 | if self._tempFile is None: 60 | self._tempFile = tempfile.TemporaryFile() 61 | self._tempFile.write(self.data) 62 | self._tempBinFile = BinaryFile(self._tempFile, 'w+b') 63 | return self._tempBinFile 64 | -------------------------------------------------------------------------------- /bfres/Importer/TextureImporter.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import bmesh 3 | import bpy 4 | import bpy_extras 5 | import struct 6 | import os 7 | import os.path 8 | 9 | class TextureImporter: 10 | """Imports texture images from BNTX archive.""" 11 | 12 | def __init__(self, parent): 13 | self.parent = parent 14 | self.operator = parent.operator 15 | self.context = parent.context 16 | 17 | 18 | def importTextures(self, bntx): 19 | """Import textures from BNTX.""" 20 | images = {} 21 | for i, tex in enumerate(bntx.textures): 22 | log.info("Importing texture %3d/%3d '%s' (%s)...", 23 | i+1, len(bntx.textures), tex.name, 24 | type(tex.fmt_type).__name__) 25 | 26 | image = bpy.data.images.new(tex.name, 27 | width=tex.width, height=tex.height) 28 | image.use_alpha = True 29 | 30 | pixels = [None] * tex.width * tex.height 31 | offs = 0 32 | for y in range(tex.height): 33 | for x in range(tex.width): 34 | b, g, r, a = tex.pixels[offs:offs+4] 35 | pixels[(y*tex.width)+x] = ( 36 | r / 255.0, 37 | g / 255.0, 38 | b / 255.0, 39 | a / 255.0, 40 | ) 41 | offs += 4 42 | 43 | # flatten list 44 | pixels = [chan for px in pixels for chan in px] 45 | image.pixels = pixels 46 | 47 | # save to file 48 | if self.operator.dump_textures: 49 | base, ext = os.path.splitext(self.parent.path) 50 | dirPath = "%s/%s" % (base, bntx.name) 51 | os.makedirs(dirPath, exist_ok=True) 52 | image.filepath_raw = "%s/%s.png" % ( 53 | dirPath, tex.name) 54 | image.file_format = 'PNG' 55 | log.info("Saving image to %s", image.filepath_raw) 56 | image.save() 57 | 58 | image.pack(True, bytes(tex.pixels), len(tex.pixels)) 59 | images[tex.name] = image 60 | return images 61 | -------------------------------------------------------------------------------- /bfres/Importer/SkeletonImporter.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import bmesh 3 | import bpy 4 | import bpy_extras 5 | import struct 6 | import math 7 | from mathutils import Matrix, Vector 8 | 9 | class SkeletonImporter: 10 | """Imports skeleton from FMDL.""" 11 | 12 | def __init__(self, parent, fmdl): 13 | self.fmdl = fmdl 14 | self.operator = parent.operator 15 | self.context = parent.context 16 | 17 | 18 | def importSkeleton(self, fskl): 19 | """Import specified skeleton.""" 20 | name = self.fmdl.name 21 | 22 | # This is completely stupid. We basically automate the UI 23 | # to create the bones manually. 24 | # There is a better way (which still involves the UI, but 25 | # less so), but it of course doesn't work. (The created 26 | # bones fail to actually exist.) 27 | bpy.context.scene.cursor_location = (0.0, 0.0, 0.0) 28 | 29 | # Create armature and object 30 | bpy.ops.object.add( 31 | type='ARMATURE', 32 | enter_editmode=True) 33 | 34 | armObj = bpy.context.object 35 | #armObj.show_x_ray = True 36 | armObj.name = name 37 | amt = armObj.data 38 | amt.name = name+'.Armature' 39 | #amt.show_axes = True 40 | amt.layers[0] = True 41 | amt.show_names = True 42 | 43 | bpy.ops.object.mode_set(mode='EDIT') 44 | boneObjs = {} 45 | for i, bone in enumerate(fskl.bones): 46 | boneObj = amt.edit_bones.new(bone.name) 47 | boneObjs[i] = boneObj 48 | #boneObj.use_relative_parent = True 49 | #boneObj.use_local_location = True 50 | xfrm = bone.computeTransform().transposed() 51 | # rotate to make Z the up axis 52 | boneObj.matrix = Matrix.Rotation( 53 | math.radians(90), 4, (1,0,0)) * xfrm 54 | 55 | if bone.parent: 56 | boneObj.parent = boneObjs[bone.parent_idx] 57 | boneObj.head = boneObj.parent.tail 58 | boneObj.use_connect = True 59 | else: 60 | boneObj.head = (0,0,0) 61 | 62 | bpy.ops.object.mode_set(mode='OBJECT') 63 | return armObj 64 | -------------------------------------------------------------------------------- /bfres/FRES/FMDL/Vertex.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | 3 | class TexCoord2f: 4 | def __init__(self, u=0, v=0): 5 | self.u = u 6 | self.v = v 7 | 8 | def set(self, *vals): 9 | if len(vals) > 0: self.u = vals[0] 10 | if len(vals) > 1: self.v = vals[1] 11 | 12 | 13 | class Vec4f: 14 | def __init__(self, x=0, y=0, z=0, w=1): 15 | self.x = x 16 | self.y = y 17 | self.z = z 18 | self.w = w 19 | 20 | def set(self, *vals): 21 | if len(vals) > 0: self.x = vals[0] 22 | if len(vals) > 1: self.y = vals[1] 23 | if len(vals) > 2: self.z = vals[2] 24 | if len(vals) > 3: self.w = vals[3] 25 | 26 | 27 | class Color: 28 | def __init__(self, r=1, g=1, b=1, a=1): 29 | self.r = r 30 | self.g = g 31 | self.b = b 32 | self.a = a 33 | 34 | def set(self, *vals): 35 | if len(vals) > 0: self.r = vals[0] 36 | if len(vals) > 1: self.g = vals[1] 37 | if len(vals) > 2: self.b = vals[2] 38 | if len(vals) > 3: self.a = vals[3] 39 | 40 | 41 | class Vertex: 42 | """A vertex in an FMDL.""" 43 | 44 | def __init__(self): 45 | self.pos = Vec4f() 46 | self.normal = Vec4f() 47 | self.color = Color() 48 | self.texcoord = TexCoord2f() 49 | self.idx = [0, 0, 0, 0, 0] 50 | self.weight = [1, 0, 0, 0] 51 | self.extra = {} # extra attributes 52 | 53 | 54 | def setAttr(self, attr, val): 55 | if attr.name == '_p0': self.pos.set(*val) 56 | elif attr.name == '_n0': self.normal.set(*val) 57 | elif attr.name == '_u0': self.texcoord.set(*val) # XXX cast ints 58 | 59 | elif attr.name == '_i0': 60 | for i, d in enumerate(val): 61 | self.idx[i] = d 62 | 63 | elif attr.name == '_w0': 64 | for i, d in enumerate(val): 65 | self.weight[i] = d # XXX cast ints 66 | 67 | # XXX _t0, _b0; both are u8 x4 68 | # XXX _u1 (u16 x2) 69 | 70 | else: 71 | #log.warn("Unknown attribute '%s'", attr.name) 72 | self.extra[attr.name] = val 73 | 74 | 75 | def __str__(self): 76 | return "" % ( 77 | self.pos.x, self.pos.y, self.pos.z, 78 | id(self)) 79 | -------------------------------------------------------------------------------- /bfres/FRES/FMDL/Buffer.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryFile import BinaryFile 7 | from bfres.Exceptions import MalformedFileError 8 | 9 | 10 | class Buffer: 11 | """A buffer of data that can be read in various formats.""" 12 | 13 | def __init__(self, file, size, stride, offset): 14 | self.file = file 15 | self.size = size 16 | self.stride = stride 17 | self.offset = offset 18 | log.debug("Reading buffer (size 0x%X stride 0x%X) from 0x%X", 19 | size, stride, offset) 20 | self.data = file.read(size, offset) 21 | if len(self.data) < size: 22 | log.error("Buffer size is 0x%X but only read 0x%X", 23 | size, len(self.data)) 24 | raise MalformedFileError("Buffer data out of bounds") 25 | 26 | fmts = { 27 | 'int8': 'b', 28 | 'uint8': 'B', 29 | 'int16': 'h', 30 | 'uint16': 'H', 31 | ' int32': 'i', 32 | 'uint32': 'I', 33 | ' int64': 'q', 34 | 'uint64': 'Q', 35 | #'half': 'e', 36 | 'float': 'f', 37 | 'double': 'd', 38 | 'char': 'c', 39 | } 40 | for name, fmt in fmts.items(): 41 | try: 42 | view = memoryview(self.data).cast(fmt) 43 | setattr(self, name, view) 44 | except TypeError: 45 | # this just means we can't interpret the buffer as 46 | # eg int64 because its size isn't a multiple of 47 | # that type's size. 48 | pass 49 | 50 | 51 | def dump(self): 52 | """Dump to string for debug.""" 53 | data = [] 54 | try: 55 | for i in range(4): 56 | for j in range(4): 57 | b = self.data[(i*4)+j] 58 | data.append('%02X ' % b) 59 | data.append(' ') 60 | except IndexError: 61 | pass 62 | 63 | return ("%06X│%04X│%04X│%s" % ( 64 | self.offset, self.size, self.stride, ''.join(data))) 65 | -------------------------------------------------------------------------------- /bfres/BNTX/pixelfmt/bc/base.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import struct 3 | from ..base import TextureFormat 4 | 5 | 6 | def unpackRGB565(pixel): 7 | r = (pixel & 0x1F) << 3 8 | g = ((pixel >> 5) & 0x3F) << 2 9 | b = ((pixel >> 11) & 0x1F) << 3 10 | return r, g, b, 0xFF 11 | 12 | 13 | def clamp(val): 14 | if val > 1: return 0xFF 15 | if val < 0: return 0 16 | return int(val * 0xFF) 17 | 18 | 19 | class BCn: 20 | def decodeTile(self, data, offs): 21 | clut = [] 22 | try: 23 | c0, c1, idxs = struct.unpack_from('HHI', data, offs) 24 | except: 25 | log.error("BC: Failed to unpack tile data from offset 0x%X: %s", 26 | offs, data[offs:offs+8]) 27 | raise 28 | clut.append(unpackRGB565(c0)) 29 | clut.append(unpackRGB565(c1)) 30 | clut.append(self.calcCLUT2(clut[0], clut[1], c0, c1)) 31 | clut.append(self.calcCLUT3(clut[0], clut[1], c0, c1)) 32 | 33 | idxshift = 0 34 | output = bytearray(4*4*4) 35 | out = 0 36 | for ty in range(4): 37 | for tx in range(4): 38 | i = (idxs >> idxshift) & 3 39 | output[out : out+4] = clut[i] 40 | idxshift += 2 41 | out += 4 42 | return output 43 | 44 | 45 | def calcCLUT2(self, lut0, lut1, c0, c1): 46 | r = int((2 * lut0[0] + lut1[0]) / 3) 47 | g = int((2 * lut0[1] + lut1[1]) / 3) 48 | b = int((2 * lut0[2] + lut1[2]) / 3) 49 | return r, g, b, 0xFF 50 | 51 | 52 | def calcCLUT3(self, lut0, lut1, c0, c1): 53 | r = int((2 * lut0[0] + lut1[0]) / 3) 54 | g = int((2 * lut0[1] + lut1[1]) / 3) 55 | b = int((2 * lut0[2] + lut1[2]) / 3) 56 | return r, g, b, 0xFF 57 | 58 | 59 | def calcAlpha(self, alpha): 60 | # used by BC3, BC4, BC5 61 | # a0, a1 are color endpoints 62 | # the palette consists of (a0, a1, 6 more colors) 63 | # those 6 colors are a gradient from a0 to a1 64 | # if a1 > a0 then the gradient is only 4 colors long 65 | # and the last 2 are 0x00, 0xFF. 66 | # XXX this function name is bad, not really doing anything 67 | # relating to alpha here... 68 | a0, a1 = alpha[0], alpha[1] 69 | d = (a0, a1, 0, 0, 0, 0, 0, 0xFF) 70 | alpha = bytearray(bytes(d)) 71 | nCols = 8 if a0 > a1 else 6 72 | for i in range(2, nCols): 73 | b = int(((nCols-i) * a0 + (i-1) * a1) / 7) 74 | alpha[i] = min(255, max(0, b)) 75 | return alpha 76 | -------------------------------------------------------------------------------- /bfres/BNTX/pixelfmt/rgb.py: -------------------------------------------------------------------------------- 1 | # This file is part of botwtools. 2 | # 3 | # botwtools is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # botwtools is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with botwtools. If not, see . 15 | import logging; log = logging.getLogger(__name__) 16 | import struct 17 | from .base import TextureFormat 18 | 19 | 20 | class R5G6B5(TextureFormat): 21 | id = 0x07 22 | bytesPerPixel = 2 23 | 24 | def decodePixel(self, pixel): 25 | pixel = struct.unpack('H', pixel) 26 | r = (pixel & 0x1F) << 3 27 | g = ((pixel >> 5) & 0x3F) << 2 28 | b = ((pixel >> 11) & 0x1F) << 3 29 | a = 0xFF 30 | return r, g, b, a 31 | 32 | 33 | class R8G8(TextureFormat): 34 | id = 0x09 35 | bytesPerPixel = 2 36 | 37 | def decodePixel(self, pixel): 38 | pixel = struct.unpack('H', pixel) 39 | r = (pixel & 0xFF) 40 | g = ((pixel >> 8) & 0xFF) 41 | b = ((pixel >> 11) & 0x1F) << 3 42 | a = 0xFF 43 | return r, g, b, a 44 | 45 | 46 | class R16(TextureFormat): 47 | id = 0x0A 48 | bytesPerPixel = 2 49 | depth = 16 50 | 51 | def decodePixel(self, pixel): 52 | r = struct.unpack('H', pixel) 53 | g = 0xFFFF 54 | b = 0xFFFF 55 | a = 0xFFFF 56 | return r, g, b, a 57 | 58 | 59 | class R8G8B8A8(TextureFormat): 60 | id = 0x0B 61 | bytesPerPixel = 4 62 | 63 | def decodePixel(self, pixel): 64 | return pixel 65 | 66 | 67 | class R11G11B10(TextureFormat): 68 | id = 0x0F 69 | bytesPerPixel = 4 70 | depth = 16 71 | 72 | def decodePixel(self, pixel): 73 | pixel = struct.unpack('I', pixel) 74 | r = ( pixel & 0x07FF) << 5 75 | g = ((pixel >> 11) & 0x07FF) << 5 76 | b = ((pixel >> 22) & 0x03FF) << 6 77 | a = 0xFFFF 78 | return r, g, b, a 79 | 80 | 81 | class R32(TextureFormat): 82 | id = 0x14 83 | bytesPerPixel = 4 84 | depth = 32 85 | 86 | def decodePixel(self, pixel): 87 | r = struct.unpack('I', pixel) 88 | g = 0xFFFFFFFF 89 | b = 0xFFFFFFFF 90 | a = 0xFFFFFFFF 91 | return r, g, b, a 92 | -------------------------------------------------------------------------------- /bfres/BinaryStruct/StringOffset.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from .Offset import Offset 3 | from .BinaryObject import BinaryObject 4 | from bfres.BinaryFile import BinaryFile 5 | import struct 6 | 7 | 8 | class StringOffset(Offset): 9 | """An offset of a string in a binary file.""" 10 | 11 | def __init__(self, name, fmt='I', maxlen=None, encoding=None, 12 | lenprefix=None): 13 | """Create StringOffset. 14 | 15 | name: Field name. 16 | fmt: Offset format. If None, this struct represents a 17 | string; otherwise, it represents the offset of a string. 18 | maxlen: Maximum string length. 19 | encoding: String encoding. 20 | lenprefix: Format of length field ahead of string. 21 | None for null-terminated string. 22 | """ 23 | # XXX fmt=None doesn't work 24 | super().__init__(name, fmt) 25 | self.maxlen = maxlen 26 | self.encoding = encoding 27 | self.lenprefix = lenprefix 28 | 29 | if lenprefix is None: self.lensize = None 30 | else: self.lensize = struct.calcsize(lenprefix) 31 | 32 | 33 | def DisplayFormat(self, val): 34 | """Format value for display.""" 35 | return '"%s"' % str(val) 36 | 37 | 38 | def readFromFile(self, file:BinaryFile, offset=None): 39 | """Read this object from a file.""" 40 | if self.fmt is not None: 41 | # get the offset 42 | offset = super().readFromFile(file, offset) 43 | 44 | # get the string 45 | if offset is not None: file.seek(offset) 46 | if self.lenprefix is not None: 47 | s = self._readWithLengthPrefix(file) 48 | else: 49 | s = self._readNullTerminated(file) 50 | 51 | # decode the string 52 | try: 53 | if self.encoding is not None: s = s.decode(self.encoding) 54 | except UnicodeDecodeError: 55 | log.error("Can't decode string from 0x%X as '%s': %s", 56 | offset, self.encoding, s[0:15]) 57 | raise 58 | 59 | return s 60 | 61 | 62 | def _readWithLengthPrefix(self, file:BinaryFile) -> (str,bytes): 63 | """Read length-prefixed string from file.""" 64 | length = struct.unpack_from(self.lenprefix, 65 | file.read(self.lensize))[0] 66 | return file.read(length) 67 | 68 | 69 | def _readNullTerminated(self, file:BinaryFile) -> (str,bytes): 70 | """Read null-terminated string from file.""" 71 | # XXX faster method? 72 | s = [] 73 | while self.maxlen == None or len(s) < self.maxlen: 74 | b = file.read(1) 75 | if b == b'\0': break 76 | else: s.append(b) 77 | return b''.join(s) 78 | -------------------------------------------------------------------------------- /bfres/logger/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of botwtools. 2 | # 3 | # botwtools is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # botwtools is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with botwtools. If not, see . 15 | 16 | 17 | # Sets up logging nicely to output to console with colours. 18 | import logging 19 | import os 20 | 21 | log = logging.getLogger() 22 | log.setLevel(logging.DEBUG) 23 | _is_setup = False 24 | 25 | def setup(appName): 26 | global _is_setup 27 | if _is_setup: return 28 | class MyFormatter(logging.Formatter): 29 | levels = { 30 | 'CRITICAL': '\x1B[38;5;9mC', # red 31 | 'ERROR': '\x1B[38;5;9mE', # red 32 | 'WARNING': '\x1B[38;5;10mW', # yellow 33 | 'INFO': '\x1B[38;5;14mI', # cyan 34 | 'DEBUG': '\x1B[38;5;15mD', # white 35 | } 36 | def __init__(self, fmt=None, datefmt=None, style='%'): 37 | super().__init__(fmt, datefmt, style) 38 | 39 | def format(self, record): 40 | if record.levelname in self.levels: 41 | record.levelnamefmt = self.levels[record.levelname] 42 | else: 43 | record.levelnamefmt = record.levelname 44 | 45 | if record.threadName == 'MainThread': 46 | record.threadNameFmt = '' 47 | else: 48 | record.threadNameFmt = '\x1B[38;5;6m:' + record.threadName 49 | 50 | #record.pid = os.getpid() 51 | 52 | name = record.name.split('.') 53 | #if name[0] == appName: name[0] = '' 54 | record.nameshort = '.'.join(name) 55 | return super().format(record) 56 | 57 | formatter = MyFormatter( 58 | #'\x1B[38;5;7m%(asctime)s ' 59 | '%(asctime)s.%(msecs)03d ' 60 | '%(levelnamefmt)s' 61 | '\x1B[38;5;13m%(nameshort)s' 62 | '\x1B[38;5;9m:%(lineno)d' 63 | '%(threadNameFmt)s' 64 | #'\x1B[38;5;6m:%(pid)s' 65 | '\x1B[0m %(message)s', 66 | datefmt='\x1B[38;5;241m%H\x1B[38;5;246m%M\x1B[38;5;251m%S') 67 | #datefmt='%Y %m%d %H%M%S') 68 | 69 | ch = logging.StreamHandler() 70 | ch.setLevel(logging.DEBUG) 71 | ch.setFormatter(formatter) 72 | log.addHandler(ch) 73 | 74 | # uncomment to output to debug.log too 75 | #fh = logging.FileHandler('fres-importer-debug.log', mode='wt') 76 | #fh.setLevel(logging.DEBUG) 77 | #fh.setFormatter(formatter) 78 | #log.addHandler(fh) 79 | _is_setup = True 80 | -------------------------------------------------------------------------------- /bfres/BNTX/pixelfmt/bc/bc5.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import struct 3 | from .base import BCn, TextureFormat, unpackRGB565, clamp 4 | 5 | 6 | class BC5(TextureFormat, BCn): 7 | id = 0x1E 8 | bytesPerPixel = 16 9 | 10 | 11 | def decode(self, tex): 12 | decode = self.decodePixel 13 | bpp = self.bytesPerPixel 14 | data = tex.data 15 | width = int((tex.width + 3) / 4) 16 | height = int((tex.height + 3) / 4) 17 | pixels = bytearray(width * height * 64) 18 | swizzle = tex.swizzle.getOffset 19 | is_snorm = tex.fmt_dtype.name == 'SNorm' 20 | 21 | for y in range(height): 22 | for x in range(width): 23 | offs = swizzle(x, y) 24 | red = self.calcAlpha(data[offs : offs+2], tex) 25 | redCh = struct.unpack('Q', red)[0] 26 | green = self.calcAlpha(data[offs+8 : offs+10], tex) 27 | greenCh = struct.unpack('Q', green)[0] 28 | 29 | toffs = 0 30 | if is_snorm: 31 | for ty in range(4): 32 | for tx in range(4): 33 | shift = ty * 12 + tx * 3 34 | out = (x*4 + tx + (y*4 + ty)*width*4)*4 35 | r = red [(redCh >> shift) & 7] + 0x80 36 | g = green[(greenCh >> shift) & 7] + 0x80 37 | nx = (r / 255.0) * 2 - 1 38 | ny = (g / 255.0) * 2 - 1 39 | nz = math.sqrt(1 - (nx*nx + ny*ny)) 40 | pixels[out : out+4] = ( 41 | clamp((nz+1) * 0.5), 42 | clamp((ny+1) * 0.5), 43 | clamp((nx+1) * 0.5), 44 | 0xFF) 45 | toffs += 4 46 | else: 47 | for ty in range(4): 48 | for tx in range(4): 49 | shift = ty * 12 + tx * 3 50 | out = (x*4 + tx + (y*4 + ty)*width*4)*4 51 | r = red [(redCh >> shift) & 7] 52 | g = green[(greenCh >> shift) & 7] 53 | pixels[out : out+4] = (r, r, r, g) 54 | toffs += 4 55 | 56 | return pixels, self.depth 57 | 58 | 59 | def calcAlpha(self, alpha, tex): 60 | if tex.fmt_dtype.name != 'SNorm': 61 | return super().calcAlpha(alpha) 62 | 63 | a0, a1 = alpha[0], alpha[1] 64 | d = (a0, a1, 0, 0, 0, 0, 0x80, 0x7F) 65 | alpha = bytearray(bytes(d)) 66 | for i in range(2, 6): 67 | # XXX do we need to cast here? 68 | if a0 > a1: 69 | alpha[i] = int(((8-i) * a0 + (i-1) * a1) / 7) 70 | else: 71 | alpha[i] = int(((6-i) * a0 + (i-1) * a1) / 7) 72 | return alpha 73 | -------------------------------------------------------------------------------- /bfres/BNTX/pixelfmt/base.py: -------------------------------------------------------------------------------- 1 | # This file is part of botwtools. 2 | # 3 | # botwtools is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # botwtools is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with botwtools. If not, see . 15 | import logging; log = logging.getLogger(__name__) 16 | import struct 17 | 18 | types = { # name => id, bytes per pixel 19 | 'R5G6B5': {'id':0x07, 'bpp': 2}, 20 | 'R8G8': {'id':0x09, 'bpp': 2}, 21 | 'R16': {'id':0x0A, 'bpp': 2}, 22 | 'R8G8B8A8': {'id':0x0B, 'bpp': 4}, 23 | 'R11G11B10': {'id':0x0F, 'bpp': 4}, 24 | 'R32': {'id':0x14, 'bpp': 4}, 25 | 'BC1': {'id':0x1A, 'bpp': 8}, 26 | 'BC2': {'id':0x1B, 'bpp':16}, 27 | 'BC3': {'id':0x1C, 'bpp':16}, 28 | 'BC4': {'id':0x1D, 'bpp': 8}, 29 | 'BC5': {'id':0x1E, 'bpp':16}, 30 | 'BC6': {'id':0x1F, 'bpp': 8}, # XXX verify bpp 31 | 'BC7': {'id':0x20, 'bpp': 8}, # XXX verify bpp 32 | 'ASTC4x4': {'id':0x2D, 'bpp':16}, 33 | 'ASTC5x4': {'id':0x2E, 'bpp':16}, 34 | 'ASTC5x5': {'id':0x2F, 'bpp':16}, 35 | 'ASTC6x5': {'id':0x30, 'bpp':16}, 36 | 'ASTC6x6': {'id':0x31, 'bpp':16}, 37 | 'ASTC8x5': {'id':0x32, 'bpp':16}, 38 | 'ASTC8x6': {'id':0x33, 'bpp':16}, 39 | 'ASTC8x8': {'id':0x34, 'bpp':16}, 40 | 'ASTC10x5': {'id':0x35, 'bpp':16}, 41 | 'ASTC10x6': {'id':0x36, 'bpp':16}, 42 | 'ASTC10x8': {'id':0x37, 'bpp':16}, 43 | 'ASTC10x10': {'id':0x38, 'bpp':16}, 44 | 'ASTC12x10': {'id':0x39, 'bpp':16}, 45 | 'ASTC12x12': {'id':0x3A, 'bpp':16}, 46 | } 47 | 48 | fmts = {} 49 | 50 | class TextureFormat: 51 | id = None 52 | bytesPerPixel = 1 53 | depth = 8 54 | 55 | @staticmethod 56 | def get(id): 57 | try: 58 | return fmts[id] 59 | except KeyError: 60 | log.error("Unsupported texture format 0x%02X", id) 61 | raise TypeError("Unsupported texure format") 62 | 63 | 64 | def decode(self, tex): 65 | pixels = [] 66 | decode = self.decodePixel 67 | bpp = self.bytesPerPixel 68 | data = tex.data 69 | log.debug("Texture: %d bytes/pixel, %dx%d = %d, len = %d", 70 | bpp, tex.width, tex.height, tex.width * tex.height * bpp, 71 | len(data)) 72 | for i in range(0, len(data), bpp): 73 | px = data[i : i+bpp] 74 | pixels.append(decode(px)) 75 | return pixels, self.depth 76 | 77 | 78 | def decodePixel(self, pixel): 79 | raise TypeError("No decoder for textue format " + 80 | type(self).__name__) 81 | 82 | 83 | def __str__(self): 84 | return "" % ( 85 | type(self).__name__, id(self)) 86 | -------------------------------------------------------------------------------- /bfres/FRES/Dict.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryFile import BinaryFile 7 | from .FresObject import FresObject 8 | 9 | 10 | class Header(BinaryStruct): 11 | """Dict header.""" 12 | fields = ( 13 | ('4s', 'magic'), # "_DIC" or 0 14 | ('I', 'num_items'), # excluding root 15 | ) 16 | size = 8 17 | 18 | 19 | class Node(BinaryStruct): 20 | """A node in a Dict.""" 21 | fields = ( 22 | ('I', 'search_value'), 23 | ('H', 'left_idx'), 24 | ('H', 'right_idx'), 25 | String( 'name'), 26 | Offset32('data_offset'), 27 | ) 28 | size = 16 29 | 30 | def readFromFile(self, file, offset): 31 | data = super().readFromFile(file, offset) 32 | self.search_value = data['search_value'] 33 | self.left_idx = data['left_idx'] 34 | self.right_idx = data['right_idx'] 35 | self.name = data['name'] 36 | self.data_offset = data['data_offset'] 37 | self.left = None 38 | self.right = None 39 | return self 40 | 41 | 42 | class Dict(FresObject): 43 | """A name dict in an FRES.""" 44 | 45 | def __init__(self, fres): 46 | self.fres = fres 47 | self.nodes = [] 48 | 49 | 50 | def dump(self): 51 | """Dump to string for debug.""" 52 | res = [ 53 | " Dict, %3d items, magic = %s" % ( 54 | len(self.nodes), self.header['magic'], 55 | ), 56 | "\x1B[4mNode│Search │Left│Rght│DataOffs│Name\x1B[0m", 57 | ] 58 | for i, node in enumerate(self.nodes): 59 | res.append('%4d│%08X│%4d│%4d│%08X│"%s"' % ( 60 | i, node.search_value, node.left_idx, node.right_idx, 61 | node.data_offset, node.name, 62 | )) 63 | return '\n'.join(res).replace('\n', '\n ') 64 | 65 | 66 | def readFromFRES(self, offset): 67 | """Read this object from the FRES.""" 68 | self.header = Header().readFromFile(self.fres.file, offset) 69 | 70 | #log.debug("Dict @ 0x%06X: unk00=0x%08X num_items=%d", 71 | # offset, 72 | # self.header['unk00'], 73 | # self.header['num_items'], 74 | #) 75 | offset += Header.size 76 | 77 | # read nodes (+1 for root node) 78 | for i in range(self.header['num_items'] + 1): 79 | node = Node().readFromFile(self.fres.file, offset) 80 | self.nodes.append(node) 81 | #log.debug('Node %3d: S=0x%08X I=0x%04X,0x%04X D=0x%06X "%s"', 82 | # i, node.search_value, node.left_idx, 83 | # node.right_idx, node.data_offset, node.name, 84 | #) 85 | offset += Node.size 86 | 87 | # build tree 88 | self.root = self.nodes[0] 89 | for i, node in enumerate(self.nodes): 90 | try: node.left = self.nodes[node.left_idx] 91 | except IndexError: node.left = None 92 | try: node.right = self.nodes[node.right_idx] 93 | except IndexError: node.right = None 94 | 95 | return self 96 | -------------------------------------------------------------------------------- /bfres/YAZ0/Decoder.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject, Offset 3 | from bfres.BinaryFile import BinaryFile 4 | 5 | class Header(BinaryStruct): 6 | """YAZ0 file header.""" 7 | magic = (b'Yaz0', b'Yaz1') 8 | fields = ( 9 | ('4s', 'magic'), 10 | ('>I', 'size'), 11 | ) 12 | 13 | 14 | class Decoder: 15 | """YAZ0 decoder.""" 16 | def __init__(self, file:BinaryFile, progressCallback=None): 17 | self.file = file 18 | self.header = Header().readFromFile(file) 19 | self.src_pos = 16 20 | self.dest_pos = 0 21 | self.size = self.header['size'] 22 | self.dest_end = self.size 23 | self._output = [] 24 | self._outputStart = 0 25 | if progressCallback is None: 26 | progressCallback = lambda cur, total: cur 27 | self.progressCallback = progressCallback 28 | progressCallback(0, self.size) 29 | 30 | 31 | def _nextByte(self) -> bytes: 32 | """Return next byte from input, or EOF.""" 33 | self.file.seek(self.src_pos) 34 | self.src_pos += 1 35 | return self.file.read(1)[0] 36 | 37 | 38 | def _outputByte(self, b:(int,bytes)) -> bytes: 39 | """Write byte to output and return it.""" 40 | if type(b) is int: b = bytes((b,)) 41 | self._output.append(b) 42 | 43 | # we only need to keep the last 0x1111 bytes of the output 44 | # since that's the furthest back we can seek to copy from. 45 | excess = len(self._output) - 0x1111 46 | if excess > 0: 47 | self._output = self._output[-0x1111:] 48 | self._outputStart += excess 49 | 50 | self.dest_pos += 1 51 | if self.dest_pos & 0x3FF == 0 or self.dest_pos >= self.size: 52 | self.progressCallback(self.dest_pos, self.size) 53 | return b 54 | 55 | 56 | def bytes(self): 57 | """Generator that yields bytes from the decompressed stream.""" 58 | code = 0 59 | code_len = 0 60 | while self.dest_pos < self.dest_end: 61 | if not code_len: 62 | code = self._nextByte() 63 | code_len = 8 64 | if code & 0x80: # output next byte from input 65 | yield self._outputByte(self._nextByte()) 66 | else: # repeat some bytes from output 67 | b1 = self._nextByte() 68 | b2 = self._nextByte() 69 | offs = ((b1 & 0x0F) << 8) | b2 70 | copy_src = self.dest_pos - (offs & 0xFFF) - 1 71 | n = b1 >> 4 72 | if n: n += 2 73 | else: n = self._nextByte() + 0x12 74 | assert (3 <= n <= 0x111) 75 | for i in range(n): 76 | p = copy_src - self._outputStart 77 | yield self._outputByte(self._output[p]) 78 | copy_src += 1 79 | code <<= 1 80 | code_len -= 1 81 | 82 | 83 | def read(self, size:int=-1) -> bytes: 84 | """File-like interface for reading decompressed stream.""" 85 | res = [] 86 | data = self.bytes() 87 | while size < 0 or len(res) < size: 88 | try: res.append(next(data)) 89 | except StopIteration: break 90 | return b''.join(res) 91 | 92 | 93 | def __str__(self): 94 | return "" % id(self) 95 | -------------------------------------------------------------------------------- /bfres/Importer/ImportOperator.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import bmesh 3 | import bpy 4 | import bpy_extras 5 | import os 6 | import os.path 7 | from .Importer import Importer 8 | 9 | 10 | class ImportOperator(bpy.types.Operator, bpy_extras.io_utils.ImportHelper): 11 | """Load a BFRES model file""" 12 | 13 | bl_idname = "import_scene.nxbfres" 14 | bl_label = "Import NX BFRES" 15 | bl_options = {'UNDO'} 16 | filename_ext = ".bfres" 17 | 18 | filter_glob = bpy.props.StringProperty( 19 | default="*.sbfres;*.bfres;*.fres;*.szs", 20 | options={'HIDDEN'}, 21 | ) 22 | filepath = bpy.props.StringProperty( 23 | name="File Path", 24 | #maxlen=1024, 25 | description="Filepath used for importing the BFRES or compressed SZS file") 26 | 27 | import_tex_file = bpy.props.BoolProperty(name="Import .Tex File", 28 | description="Import textures from .Tex file with same name.", 29 | default=True) 30 | 31 | dump_textures = bpy.props.BoolProperty(name="Dump Textures", 32 | description="Export textures to PNG.", 33 | default=False) 34 | 35 | dump_debug = bpy.props.BoolProperty(name="Dump Debug Info", 36 | description="Create `fres-SomeFile-dump.txt` files for debugging.", 37 | default=False) 38 | 39 | smooth_faces = bpy.props.BoolProperty(name="Smooth Faces", 40 | description="Set smooth=True on generated faces.", 41 | default=False) 42 | 43 | save_decompressed = bpy.props.BoolProperty(name="Save Decompressed Files", 44 | description="Keep decompressed FRES files.", 45 | default=False) 46 | 47 | parent_ob_name = bpy.props.StringProperty(name="Name of a parent object to which FSHP mesh objects will be added.") 48 | 49 | mat_name_prefix = bpy.props.StringProperty(name="Text prepended to material names to keep them unique.") 50 | 51 | 52 | def draw(self, context): 53 | box = self.layout.box() 54 | box.label("Texture Options:", icon='TEXTURE') 55 | box.prop(self, "import_tex_file") 56 | box.prop(self, "dump_textures") 57 | 58 | box = self.layout.box() 59 | box.label("Mesh Options:", icon='OUTLINER_OB_MESH') 60 | box.prop(self, "smooth_faces") 61 | 62 | box = self.layout.box() 63 | box.label("Misc Options:", icon='PREFERENCES') 64 | box.prop(self, "save_decompressed") 65 | box.prop(self, "dump_debug") 66 | 67 | 68 | def execute(self, context): 69 | #user_preferences = context.user_preferences 70 | #addon_prefs = user_preferences.addons[self.bl_idname].preferences 71 | #print("PREFS:", user_preferences, addon_prefs) 72 | 73 | # enter Object mode if not already 74 | try: bpy.ops.object.mode_set(mode='OBJECT') 75 | except RuntimeError: pass 76 | 77 | if self.import_tex_file: 78 | path, ext = os.path.splitext(self.properties.filepath) 79 | path = path + '.Tex' + ext 80 | if os.path.exists(path): 81 | log.info("Importing linked file: %s", path) 82 | importer = Importer(self, context) 83 | importer.run(path) 84 | log.info("importing: %s", self.properties.filepath) 85 | importer = Importer(self, context) 86 | return importer.run(self.properties.filepath) 87 | 88 | 89 | @staticmethod 90 | def menu_func_import(self, context): 91 | """Handle the Import menu item.""" 92 | self.layout.operator( 93 | ImportOperator.bl_idname, 94 | text="Nintendo Switch BFRES (.bfres/.szs)") 95 | -------------------------------------------------------------------------------- /bfres/BNTX/__init__.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryFile import BinaryFile 7 | from bfres.Common import StringTable 8 | from .NX import NX 9 | from .BRTI import BRTI 10 | 11 | class Header(BinaryStruct): 12 | """BNTX header.""" 13 | magic = b'BNTX' 14 | fields = ( 15 | ('4s', 'magic'), 16 | Padding(4), 17 | ('I', 'data_len'), 18 | ('H', 'byte_order'), # FFFE or FEFF 19 | ('H', 'version'), 20 | String( 'name', lenprefix=None), 21 | Padding(2), 22 | ('H', 'strings_offs'), # relative to start of BNTX 23 | Offset32('reloc_offs'), 24 | ('I', 'file_size'), 25 | ) 26 | size = 0x20 27 | 28 | 29 | class BNTX: 30 | """BNTX texture pack.""" 31 | Header = Header 32 | 33 | def __init__(self, file:BinaryFile): 34 | self.file = file 35 | self.textures = [] 36 | self.header = self.Header().readFromFile(file) 37 | self.name = self.header['name'] 38 | 39 | if self.header['byte_order'] == 0xFFFE: 40 | self.byteOrder = 'little' 41 | self.byteOrderFmt = '<' 42 | elif self.header['byte_order'] == 0xFEFF: 43 | self.byteOrder = 'big' 44 | self.byteOrderFmt = '>' 45 | else: 46 | raise ValueError("Invalid byte order 0x%04X in BNTX header" % 47 | self.header['byte_order']) 48 | 49 | if self.header['version'] != 0x400C: 50 | log.warning("Unknown BNTX version 0x%04X", 51 | self.header['version']) 52 | 53 | #log.debug("BNTX version 0x%04X, %s endian", 54 | # self.header['version'], self.byteOrder) 55 | 56 | 57 | def dump(self): 58 | """Dump to string for debug.""" 59 | res = [] 60 | res.append(" Name: '%s'" % self.name) 61 | res.append("Version: 0x%04X, %s endian" % ( 62 | self.header['version'], self.byteOrder)) 63 | res.append("Data Len: 0x%06X" % self.header['data_len']) 64 | res.append("Str Offs: 0x%06X" % self.header['strings_offs']) 65 | res.append("Reloc Offs: 0x%06X" % self.header['reloc_offs']) 66 | res.append("File Size: 0x%06X" % self.header['file_size']) 67 | res.append("NX # Textures: %3d" % self.nx['num_textures']) 68 | res.append("NX Info Ptrs Offs: 0x%06X" % self.nx['info_ptrs_offset']) 69 | res.append("NX Data Blk Offs: 0x%06X" % self.nx['data_blk_offset']) 70 | res.append("NX Dict Offs: 0x%06X" % self.nx['dict_offset']) 71 | res.append("NX Str Dict Len: 0x%06X" % self.nx['str_dict_len']) 72 | for tex in self.textures: 73 | res.append(tex.dump()) 74 | return '\n'.join(res).replace('\n', '\n ') 75 | 76 | 77 | def decode(self): 78 | """Decode objects from the file.""" 79 | self.strings = StringTable().readFromFile(self.file, 80 | self.header['strings_offs']) 81 | 82 | self.nx = NX().readFromFile(self.file, 83 | self.Header.size) 84 | 85 | offs = self.nx['info_ptrs_offset'] 86 | for i in range(self.nx['num_textures']): 87 | brtiOffs = self.file.read('Q', offs) 88 | brti = BRTI().readFromFile(self.file, brtiOffs) 89 | self.textures.append(brti) 90 | offs += 8 91 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """BFRES importer/decoder for Blender. 3 | 4 | This script can also run from the command line without Blender, 5 | in which case it just prints useful information about the BFRES. 6 | """ 7 | 8 | bl_info = { 9 | "name": "Nintendo BFRES format", 10 | "description": "Import-Export BFRES models", 11 | "author": "RenaKunisaki", 12 | "version": (0, 0, 1), 13 | "blender": (2, 79, 0), 14 | "location": "File > Import-Export", 15 | "warning": "This add-on is under development.", 16 | "wiki_url": "https://github.com/RenaKunisaki/bfres_importer/wiki", 17 | "tracker_url": "https://github.com/RenaKunisaki/bfres_importer/issues", 18 | "support": 'COMMUNITY', 19 | "category": "Import-Export" 20 | } 21 | 22 | # Reload the package modules when reloading add-ons in Blender with F8. 23 | print("BFRES MAIN") 24 | if "bpy" in locals(): 25 | import importlib 26 | names = ('BinaryStruct', 'FRES', 'FMDL', 'Importer', 'Exporter', 27 | 'YAZ0') 28 | for name in names: 29 | ls = locals() 30 | if name in ls: 31 | importlib.reload(ls[name]) 32 | 33 | # fix up import path (why is this necessary?) 34 | import sys 35 | import os.path 36 | sys.path.append(os.path.dirname(os.path.abspath(__file__))) 37 | 38 | # set up debug log 39 | import bfres.logger 40 | bfres.logger.setup('bfres') 41 | log = bfres.logger.logging.getLogger() 42 | 43 | # import our modules 44 | import bpy 45 | from bfres import Importer, Exporter, YAZ0, FRES, BinaryStruct 46 | from bfres.Importer import ImportOperator 47 | from bfres.Exporter import ExportOperator 48 | from bfres.Importer.Preferences import BfresPreferences 49 | from bfres.BinaryFile import BinaryFile 50 | import tempfile 51 | 52 | 53 | # define Blender functions 54 | def register(): 55 | log.debug("BFRES REGISTER") 56 | bpy.utils.register_module('bfres') 57 | bpy.types.INFO_MT_file_import.append( 58 | ImportOperator.menu_func_import) 59 | bpy.types.INFO_MT_file_export.append( 60 | ExportOperator.menu_func_export) 61 | 62 | 63 | def unregister(): 64 | log.debug("BFRES UNREGISTER") 65 | bpy.utils.unregister_module('bfres') 66 | bpy.types.INFO_MT_file_import.remove( 67 | ImportOperator.menu_func_import) 68 | bpy.types.INFO_MT_file_export.remove( 69 | ExportOperator.menu_func_export) 70 | 71 | 72 | # define main function, for running script outside of Blender. 73 | # this currently doesn't work. 74 | def main(): 75 | if len(sys.argv) < 2: 76 | print("Usage: %s file" % sys.argv[0]) 77 | return 78 | 79 | InPath = sys.argv[1] 80 | InFile = None 81 | 82 | # try to decompress the input to a temporary file. 83 | file = BinaryFile(InPath, 'rb') 84 | magic = file.read(4) 85 | file.seek(0) # rewind 86 | if magic in (b'Yaz0', b'Yaz1'): 87 | print("Decompressing YAZ0...") 88 | 89 | # create temp file and write it 90 | InFile = tempfile.TemporaryFile() 91 | YAZ0.decompressFile(file, InFile) 92 | InFile.seek(0) 93 | InFile = BinaryFile(InFile) 94 | file.close() 95 | file = None 96 | 97 | elif magic == b'FRES': 98 | print("Input already decompressed") 99 | InFile = BinaryFile(file) 100 | 101 | else: 102 | file.close() 103 | file = None 104 | raise TypeError("Unsupported file type: "+str(magic)) 105 | 106 | # decode decompressed file 107 | print("Decoding FRES...") 108 | fres = FRES.FRES(InFile) 109 | fres.decode() 110 | print("FRES contents:\n" + fres.dump()) 111 | 112 | 113 | if __name__ == '__main__': 114 | main() 115 | -------------------------------------------------------------------------------- /bfres/FRES/FMDL/Attribute/types.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | 3 | def unpack10bit(val): 4 | if type(val) in (list, tuple): 5 | val = val[0] # grumble grumble struct is butts 6 | res = [] 7 | for i in range(3): 8 | s = (val >> (i*10)) & 0x200 9 | v = (val >> (i*10)) & 0x1FF 10 | if s: v = -v 11 | res.append(v / 511) 12 | return res 13 | 14 | 15 | def unpackArmHalfFloat(val): 16 | """Unpack 16-bit half-precision float. 17 | 18 | Uses ARM alternate format which does not encode Inf/NaN. 19 | """ 20 | if type(val) in (list, tuple): 21 | return list(map(unpackArmHalfFloat, val)) 22 | frac = (val & 0x3FF) / 0x3FF 23 | exp = (val >> 10) & 0x1F 24 | sign = -1 if (val & 0x8000) else 1 25 | if exp == 0: 26 | return sign * (2 ** -14) * frac 27 | else: 28 | return sign * (2 ** (exp-15)) * (1+frac) 29 | 30 | 31 | 32 | typeRanges = { # name: (min, max) 33 | 'b': ( -128, 127), 34 | 'B': ( 0, 255), 35 | 'h': ( -32768, 32767), 36 | 'H': ( 0, 65535), 37 | 'i': (-2147483648, 2147483647), 38 | 'I': ( 0, 4294967295), 39 | } 40 | 41 | 42 | # attribute format ID => struct fmt 43 | # type IDs do NOT match up with gx2Enum.h (wrong version?) 44 | attrFmts = { 45 | 0x0201: { 46 | 'fmt': 'B', # struct fmt 47 | 'ctype': 'int', # type name for eg Collada file 48 | 'name': 'u8', # for debug display 49 | }, 50 | 0x0901: { 51 | 'fmt': '2B', 52 | 'ctype': 'int', 53 | 'name': 'u8[2]', 54 | }, 55 | 0x0B01: { 56 | 'fmt': '4B', 57 | 'ctype': 'int', 58 | 'name': 'u8[4]', 59 | }, 60 | 0x1201: { 61 | 'fmt': '2H', 62 | 'ctype': 'int', 63 | 'name': 'u16[2]', 64 | }, 65 | 0x1501: { 66 | 'fmt': '2h', 67 | 'ctype': 'int', 68 | 'name': 'u16[2]', 69 | }, 70 | 0x1701: { 71 | 'fmt': '2i', 72 | 'ctype': 'int', 73 | 'name': 's32[2]', 74 | }, 75 | 0x1801: { 76 | 'fmt': '3i', 77 | 'ctype': 'int', 78 | 'name': 's32[3]', 79 | }, 80 | 0x0B02: { 81 | 'fmt': '4B', 82 | 'ctype': 'int', 83 | 'name': 'u8[4]', 84 | }, 85 | 0x0E02: { 86 | 'fmt': 'I', 87 | 'ctype': 'float', 88 | 'name': '10bit', 89 | 'func': unpack10bit, 90 | }, 91 | 0x1202: { 92 | 'fmt': '2h', 93 | 'ctype': 'int', 94 | 'name': 's16[2]', 95 | }, 96 | 0x0203: { 97 | 'fmt': 'B', 98 | 'ctype': 'int', 99 | 'name': 'u8', 100 | }, 101 | 0x0903: { 102 | 'fmt': '2B', 103 | 'ctype': 'int', 104 | 'name': 'u8[2]', 105 | }, 106 | 0x0B03: { 107 | 'fmt': '4B', 108 | 'ctype': 'int', 109 | 'name': 'u8[4]', 110 | }, 111 | 0x1205: { 112 | 'fmt': '2H', # half float 113 | 'ctype': 'float', 114 | 'name': 'half[2]', 115 | 'func': unpackArmHalfFloat, 116 | }, 117 | 0x1505: { 118 | 'fmt': '4H', 119 | 'ctype': 'float', 120 | 'name': 'half[4]', 121 | 'func': unpackArmHalfFloat, 122 | }, 123 | 0x1705: { 124 | 'fmt': '2f', 125 | 'ctype': 'float', 126 | 'name': 'float[2]', 127 | }, 128 | 0x1805: { 129 | 'fmt': '3f', 130 | 'ctype': 'float', 131 | 'name': 'float[3]', 132 | }, 133 | } 134 | 135 | for id, fmt in attrFmts.items(): 136 | typ = fmt['fmt'][-1] 137 | if typ in typeRanges: 138 | if 'min' not in fmt: fmt['min'] = typeRanges[typ][0] 139 | if 'max' not in fmt: fmt['max'] = typeRanges[typ][1] 140 | -------------------------------------------------------------------------------- /bfres/FRES/DumpMixin.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | 3 | class DumpMixin: 4 | """Mixin class for dump methods of FRES.""" 5 | def dump(self): 6 | """Dump to string for debug.""" 7 | res = [] 8 | self._dumpHeader (res) 9 | self._dumpObjects (res) 10 | self._dumpBufMemPool (res) 11 | self._dumpStringTable(res) 12 | self._dumpRLT (res) 13 | self._dumpModels (res) 14 | self._dumpAnimations (res) 15 | self._dumpBuffers (res) 16 | self._dumpEmbeds (res) 17 | return '\n'.join(res).replace('\n', '\n ') 18 | 19 | 20 | def _dumpHeader(self, res): 21 | res.append(' FRES "%s"/"%s": Version:%d,%d, Size:0x%06X, Endian:%s, Align:0x%X, AddrSize:0x%X, Flags:0x%X, BlockOffs:0x%04X' % ( 22 | self.name, 23 | self.header['name2'], 24 | self.header['version'][0], 25 | self.header['version'][1], 26 | self.header['file_size'], 27 | self.byteOrder, 28 | self.header['alignment'], 29 | self.header['addr_size'], 30 | self.header['flags'], 31 | self.header['block_offset'], 32 | )) 33 | 34 | 35 | def _dumpObjects(self, res): 36 | res.append(" \x1B[4mObject│Cnt │Offset │DictOffs\x1B[0m") 37 | objs = ('fmdl', 'fska', 'fmaa', 'fvis', 'fshu', 'embed') 38 | for obj in objs: 39 | res.append(" %-6s│%04X│%08X│%08X" % ( 40 | obj.upper(), 41 | self.header[obj+'_cnt'], 42 | self.header[obj+'_offset'], 43 | self.header[obj+'_dict_offset'], 44 | )) 45 | 46 | 47 | def _dumpBufMemPool(self, res): 48 | res.append(" Buffer│ ?? │%08X│%08X" % ( 49 | self.header['buf_mem_pool'], 50 | self.header['buf_section_offset'], 51 | )) 52 | 53 | 54 | def _dumpStringTable(self, res): 55 | res.append(" StrTab│N/A │%08X│N/A │size=0x%06X num_strs=%d" % ( 56 | self.header['str_tab_offset'], 57 | self.header['str_tab_size'], 58 | self.strtab.header['num_strs'], 59 | )) 60 | 61 | 62 | def _dumpRLT(self, res): 63 | res.append(self.rlt.dump()) 64 | 65 | 66 | def _dumpModels(self, res): 67 | res.append(" Models: %d" % len(self.models)) 68 | for i, model in enumerate(self.models): 69 | res.append(model.dump()) 70 | 71 | 72 | def _dumpAnimations(self, res): 73 | res.append(" Animations: %d" % len(self.animations)) 74 | for i, animation in enumerate(self.animations): 75 | res.append(animation.dump()) 76 | 77 | 78 | def _dumpBuffers(self, res): 79 | res.append(" Buffers: %d" % len(self.buffers)) 80 | for i, buffer in enumerate(self.buffers): 81 | res.append(buffer.dump()) 82 | if self.bufferSection: 83 | res.append(" Buffer section: unk00=0x%X offset=0x%X size=0x%X" % ( 84 | self.bufferSection['unk00'], 85 | self.bufferSection['buf_offs'], 86 | self.bufferSection['size'], 87 | )) 88 | else: 89 | res.append(" Buffer section: none") 90 | 91 | 92 | def _dumpEmbeds(self, res): 93 | res.append(" Embedded Files: %d" % len(self.embeds)) 94 | if len(self.embeds) > 0: 95 | res.append(' \x1B[4mOffset│Size │Name |ASCII dump\x1B[0m') 96 | for i, embed in enumerate(self.embeds): 97 | res.append(' %06X│%06X│%-16s│%s' % ( 98 | embed.dataOffset, 99 | embed.size, embed.name, 100 | ''.join(map( 101 | lambda b: \ 102 | chr(b) if (b >= 0x20 and b < 0x7F) \ 103 | else ('\\x%02X' % b), 104 | embed.data[0:16] 105 | )), 106 | )) 107 | -------------------------------------------------------------------------------- /bfres/FRES/FMDL/FSHP.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryFile import BinaryFile 7 | from bfres.FRES.FresObject import FresObject 8 | from bfres.FRES.Dict import Dict 9 | from .Attribute import Attribute, AttrStruct 10 | from .Buffer import Buffer 11 | from .FVTX import FVTX 12 | from .LOD import LOD 13 | from .Vertex import Vertex 14 | import struct 15 | 16 | 17 | class Header(BinaryStruct): 18 | """FSHP header.""" 19 | magic = b'FSHP' 20 | fields = ( 21 | ('4s', 'magic'), # 0x00 22 | ('3I', 'unk04'), # 0x04 23 | String('name'), Padding(4), # 0x10 24 | 25 | Offset64('fvtx_offset'), # 0x18 => FVTX 26 | 27 | Offset64('lod_offset'), # 0x20 => LOD models 28 | Offset64('fskl_idx_array_offs'), # 0x28 => 00030002 00050004 00070006 00090008 000B000A 000D000C 000F000E 00110010 29 | 30 | Offset64('unk30'), # 0x30; 0 31 | Offset64('unk38'), # 0x38; 0 32 | 33 | # bounding box and bounding radius 34 | Offset64('bbox_offset'), # 0x40 => ~24 floats / 8 Vec3s / 6 Vec4s 35 | Offset64('bradius_offset'), # 0x48 => => 3F03ADA8 3EFC1658 00000000 00000D14 00000000 00000000 00000000 00000000 36 | # as floats: 37 | # 3F03ADA8 = 0.5143685340881348 38 | # 3EFC1658 = 0.4923579692840576 39 | 40 | Offset64('unk50'), # 0x50 41 | ('I', 'flags'), # 0x58 42 | ('H', 'index'), # 0x5C 43 | ('H', 'fmat_idx'), # 0x5E 44 | 45 | ('H', 'single_bind'), # 0x60 46 | ('H', 'fvtx_idx'), # 0x62 47 | ('H', 'skin_bone_idx_cnt'), # 0x64 48 | ('B', 'vtx_skin_cnt'), # 0x66 49 | ('B', 'lod_cnt'), # 0x67 50 | ('I', 'vis_group_cnt'), # 0x68 51 | ('H', 'fskl_array_cnt'), # 0x6C 52 | Padding(2), # 0x6E 53 | ) 54 | size = 0x70 55 | 56 | 57 | class FSHP(FresObject): 58 | """A shape object in an FRES.""" 59 | Header = Header 60 | 61 | def __init__(self, fres): 62 | self.fres = fres 63 | self.fvtx = None 64 | self.lods = None 65 | self.header = None 66 | self.headerOffset = None 67 | 68 | 69 | def __str__(self): 70 | return "" %( 71 | '?' if self.headerOffset is None else hex(self.headerOffset), 72 | id(self), 73 | ) 74 | 75 | 76 | def dump(self): 77 | """Dump to string for debug.""" 78 | res = [] 79 | res.append("Flags: 0x%08X" % self.header['flags']) 80 | res.append("Idx: 0x%04X" % self.header['index']) 81 | res.append("FmatIdx: 0x%04X" % self.header['fmat_idx']) 82 | res.append("SingleBind: 0x%04X" % self.header['single_bind']) 83 | res.append("LODs: %3d" % len(self.lods)) 84 | res.append("Name: '%s'" % self.name) 85 | res = ', '.join(res) 86 | #return '\n'.join(res).replace('\n', '\n ') 87 | return res 88 | 89 | 90 | def readFromFRES(self, offset=None): 91 | """Read this object from given file.""" 92 | if offset is None: offset = self.fres.file.tell() 93 | log.debug("Reading FSHP from 0x%06X", offset) 94 | self.headerOffset = offset 95 | self.header = self.fres.read(Header(), offset) 96 | self.name = self.header['name'] 97 | 98 | # read FVTX 99 | self.fvtx = FVTX(self.fres).readFromFRES( 100 | self.header['fvtx_offset']) 101 | 102 | # read LODs 103 | self.lods = [] 104 | offs = self.header['lod_offset'] 105 | for i in range(self.header['lod_cnt']): 106 | model = LOD(self.fres).readFromFRES(offs) 107 | offs += LOD.Header.size 108 | self.lods.append(model) 109 | 110 | return self 111 | -------------------------------------------------------------------------------- /bfres/FRES/RLT.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryFile import BinaryFile 7 | from .FresObject import FresObject 8 | 9 | # https://wiki.oatmealdome.me/BFRES_(File_Format)#Relocation_Table 10 | # BFRES always has 5 sections (some may be unused): 11 | # 0. Pointers up to the end of the string pool. 12 | # 1. Pointers for index buffer. 13 | # 2. Pointers for vertex buffer. 14 | # 3. Pointers for memory pool. 15 | # 4. Pointers for external files. 16 | # Relocation table is used to quickly load files; rather than 17 | # actually parsing them, the whole file is loaded into memory, 18 | # and this table is used to locate the structs. So the pointers 19 | # must be correct, but mostly aren't needed to parse the file. 20 | 21 | class Header(BinaryStruct): 22 | """RLT header.""" 23 | magic = b'_RLT' 24 | fields = ( 25 | ('4s', 'magic'), 26 | ('I', 'curOffset'), # offset of the RLT 27 | ('I', 'numSections'), # always 5 28 | Padding(4), 29 | ) 30 | size = 0x10 31 | 32 | 33 | class Section(BinaryStruct): 34 | """Section in relocation table.""" 35 | fields = ( 36 | Offset64('base'), 37 | Offset32('curOffset'), 38 | Offset32('size'), 39 | Offset32('idx'), # entry index 40 | Offset32('count'), # entry count 41 | ) 42 | size = 0x18 43 | 44 | 45 | class Entry(BinaryStruct): 46 | """Entry in relocation section.""" 47 | fields = ( 48 | Offset32('curOffset'), 49 | ('H', 'structCount'), 50 | ('B', 'offsetCount'), 51 | ('B', 'stride'), # sometimes incorrectly called paddingCount 52 | ) 53 | size = 0x08 54 | 55 | 56 | #def readFromFile(self, file, offset): 57 | # data = super().readFromFile(file, offset) 58 | # for i in range(data['structCount']): 59 | 60 | 61 | class RLT(FresObject): 62 | """A relocation table in an FRES.""" 63 | 64 | def __init__(self, fres): 65 | self.fres = fres 66 | 67 | 68 | def dump(self): 69 | """Dump to string for debug.""" 70 | res = [] 71 | res.append(" Relocation table Offset: 0x%08X" % 72 | self.header['curOffset']) 73 | 74 | res.append(" \x1B[4mSection│BaseOffs│CurOffs │Size │EntryIdx│EntryCnt\x1B[0m") 75 | for i, section in enumerate(self.sections): 76 | res.append(" %7d│%08X│%08X│%08X│%8d|%4d" % ( 77 | i, section['base'], section['curOffset'], 78 | section['size'], section['idx'], section['count'])) 79 | 80 | res.append(" \x1B[4mEntry│CurOffs │Structs│Offsets│Padding\x1B[0m") 81 | for i, entry in enumerate(self.entries): 82 | res.append(" %5d│%08X│%7d│%7d│%4d" % ( 83 | i, entry['curOffset'], entry['structCount'], 84 | entry['offsetCount'], entry['stride'])) 85 | 86 | return '\n'.join(res).replace('\n', '\n ') 87 | 88 | 89 | def readFromFRES(self, offset=None): 90 | """Read this object from the FRES.""" 91 | if offset is None: 92 | offset = self.fres.header['rlt_offset'] 93 | self.header = Header().readFromFile(self.fres.file, offset) 94 | offset += Header.size 95 | 96 | # read sections 97 | numEntries = 0 98 | self.sections = [] 99 | for i in range(self.header['numSections']): 100 | sec = Section().readFromFile(self.fres.file, offset) 101 | self.sections.append(sec) 102 | numEntries += sec['count'] 103 | offset += Section.size 104 | 105 | # read entries 106 | self.entries = [] 107 | for i in range(numEntries): 108 | entry = Entry().readFromFile(self.fres.file, offset) 109 | offset += Entry.size 110 | self.entries.append(entry) 111 | 112 | return self 113 | -------------------------------------------------------------------------------- /bfres/BinaryFile/__init__.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import struct 3 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 4 | 5 | class BinaryFile: 6 | """Wrapper around files that provides binary I/O methods.""" 7 | 8 | _seekNames = { 9 | 'start': 0, 10 | 'cur': 1, 11 | 'end': 2, 12 | } 13 | 14 | _endianFmts = { 15 | 'big': '>', 16 | 'little': '<', 17 | } 18 | 19 | def __init__(self, file, mode='rb', endian='little'): 20 | if type(file) is str: file = open(file, mode) 21 | self.file = file 22 | self.name = file.name 23 | self.endian = endian 24 | 25 | # get size 26 | pos = file.tell() 27 | self.size = self.seek(0, 'end') 28 | file.seek(pos) 29 | 30 | 31 | @staticmethod 32 | def open(path, mode='rb'): 33 | file = open(path, mode) 34 | return FileReader(file) 35 | 36 | 37 | @property 38 | def endianFmt(self): 39 | return self._endianFmts[self.endian] 40 | 41 | 42 | def seek(self, pos:int, whence:(int,str)=0) -> int: 43 | """Seek within the file. 44 | 45 | pos: Position to seek to. 46 | whence: Where to seek from: 47 | 0 or 'start': Beginning of file. 48 | 1 or 'cur': Current position. 49 | 2 or 'end': Backward from end of file. 50 | 51 | Returns new position. 52 | """ 53 | whence = self._seekNames.get(whence, whence) 54 | try: 55 | return self.file.seek(pos, whence) 56 | except: 57 | log.error("Error seeking to 0x%X from %s", 58 | pos, str(whence)) 59 | raise 60 | 61 | 62 | def read(self, fmt:(int,str,BinaryStruct,BinaryObject)=-1, 63 | pos:int=None, count:int=1): 64 | """Read from the file. 65 | 66 | fmt: Number of bytes to read, or a `struct` format string, 67 | or a BinaryStruct or BinaryObject, 68 | or a class which is a subclass of one of those two. 69 | pos: Position to seek to first. (optional) 70 | count: Number of items to read. If not 1, returns a list. 71 | 72 | Returns the data read. 73 | """ 74 | if pos is not None: self.seek(pos) 75 | pos = self.tell() 76 | if count < 0: raise ValueError("Count cannot be negative") 77 | elif count == 0: return [] 78 | 79 | res = [] 80 | #log.debug("BinaryFile read fmt: %s, offset: %s", fmt, pos) 81 | 82 | try: 83 | if issubclass(fmt, BinaryObject): 84 | fmt = fmt("" % pos) 85 | elif issubclass(fmt, BinaryStruct): 86 | fmt = fmt() 87 | except TypeError: 88 | pass # fmt is not a class 89 | 90 | if type(fmt) is str: # struct format string 91 | size = struct.calcsize(fmt) 92 | for i in range(count): 93 | offs = self.file.tell() 94 | try: 95 | r = struct.unpack(fmt, self.file.read(size)) 96 | except struct.error as ex: 97 | log.error("Failed to unpack format '%s' from offset 0x%X (max 0x%X): %s", 98 | fmt, offs, self.size, ex) 99 | raise 100 | if len(r) == 1: r = r[0] # grumble 101 | res.append(r) 102 | 103 | elif isinstance(fmt, (BinaryStruct, BinaryObject)): 104 | size = getattr(fmt, 'size', 0) 105 | if count > 1 and not size: 106 | raise ValueError("Cannot read type '%s' with count >1 (class does not define a size)" % 107 | type(fmt).__name__) 108 | 109 | for i in range(count): 110 | r = fmt.readFromFile(self, pos) 111 | res.append(r) 112 | if size: pos += size 113 | 114 | else: # size in bytes 115 | for i in range(count): 116 | res.append(self.file.read(fmt)) 117 | 118 | if count == 1: return res[0] # grumble 119 | return res 120 | 121 | 122 | def tell(self) -> int: 123 | """Get current read position.""" 124 | return self.file.tell() 125 | 126 | 127 | def __enter__(self): 128 | return self 129 | 130 | 131 | def __exit__(self, exc_type, exc_val, exc_tb): 132 | self.file.close() 133 | 134 | 135 | def __str__(self): 136 | return "" % (self.name, id(self)) 137 | -------------------------------------------------------------------------------- /bfres/Importer/MaterialImporter.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import bmesh 3 | import bpy 4 | import bpy_extras 5 | import struct 6 | 7 | class MaterialImporter: 8 | """Imports material from FMDL.""" 9 | 10 | def __init__(self, parent, fmdl): 11 | self.fmdl = fmdl 12 | self.operator = parent.operator 13 | self.context = parent.context 14 | 15 | 16 | def importMaterial(self, fmat): 17 | """Import specified material.""" 18 | mat = bpy.data.materials.new(fmat.name) 19 | mat.use_transparency = True 20 | mat.alpha = 1 21 | mat.specular_alpha = 1 22 | mat.specular_intensity = 0 # Do not make materials without specular map shine exaggeratedly. 23 | self._addCustomProperties(fmat, mat) 24 | 25 | for i, tex in enumerate(fmat.textures): 26 | log.info("Importing Texture %3d / %3d '%s'...", 27 | i+1, len(fmat.textures), tex['name']) 28 | texObj = self._importTexture(fmat, tex) 29 | 30 | # Add texture slot 31 | # XXX use tex['slot'] if it's ever not -1 32 | name = tex['name'].split('.') 33 | if len(name) > 1: 34 | name, idx = name 35 | else: 36 | name = name[0] 37 | idx = 0 38 | 39 | mtex = mat.texture_slots.add() 40 | mtex.texture = texObj 41 | mtex.texture_coords = 'UV' 42 | mtex.emission_color_factor = 0.5 43 | #mtex.use_map_density = True 44 | mtex.mapping = 'FLAT' 45 | mtex.use_map_color_diffuse = False 46 | 47 | if name.endswith('_Nrm'): # normal map 48 | mtex.use_map_normal = True 49 | 50 | elif name.endswith('_Spm'): # specular map 51 | mtex.use_map_specular = True 52 | 53 | elif name.endswith('_Alb'): # albedo (regular texture) 54 | mtex.use_map_color_diffuse = True 55 | mtex.use_map_color_emission = True 56 | mtex.use_map_alpha = True 57 | 58 | elif name.endswith('_AO'): # ambient occlusion 59 | mtex.use_map_ambient = True 60 | 61 | # also seen: 62 | # `_Blur_%02d` (in Animal_Bee) 63 | # `_Damage_Alb`, `_Red_Alb` (in Link) 64 | 65 | else: 66 | log.warning("Don't know what to do with texture: %s", name) 67 | 68 | param = "uking_texture%d_texcoord" % i 69 | param = fmat.materialParams.get(param, None) 70 | if param: 71 | mat.texture_slots[0].uv_layer = "_u"+param 72 | #log.debug("Using UV layer %s for texture %s", 73 | # param, name) 74 | else: 75 | log.warning("No texcoord attribute for texture %d", i) 76 | 77 | return mat 78 | 79 | 80 | def _importTexture(self, fmat, tex): 81 | """Import specified texture to specified material.""" 82 | # do we use the texid anymore? 83 | texid = tex['name'].replace('.', '_') # XXX ensure unique 84 | texture = bpy.data.textures.new(texid, 'IMAGE') 85 | try: 86 | texture.image = bpy.data.images[tex['name']] 87 | except KeyError: 88 | log.error("Texture not found: '%s'", tex['name']) 89 | return texture 90 | 91 | 92 | def _addCustomProperties(self, fmat, mat): 93 | """Add render/shader/material parameters and sampler list 94 | as custom properties on the Blender material object. 95 | """ 96 | for name, param in fmat.renderParams.items(): 97 | val = param['vals'] 98 | if param['count'] == 1: val = val[0] 99 | mat['renderParam_'+name] = val 100 | 101 | for name, param in fmat.shaderParams.items(): 102 | mat['shaderParam_'+name] = { 103 | 'type': param['type']['name'], 104 | 'size': param['size'], 105 | 'offset': param['offset'], 106 | 'idxs': param['idxs'], 107 | 'unk00': param['unk00'], 108 | 'unk14': param['unk14'], 109 | 'data': param['data'], 110 | } 111 | 112 | for name, val in fmat.materialParams.items(): 113 | mat['matParam_'+name] = val 114 | 115 | mat['samplers'] = fmat.samplers 116 | mat['mat_flags'] = fmat.header['mat_flags'] 117 | mat['section_idx'] = fmat.header['section_idx'] 118 | mat['unkB2'] = fmat.header['unkB2'] 119 | mat['unkB4'] = fmat.header['unkB4'] 120 | -------------------------------------------------------------------------------- /bfres/FRES/FMDL/__init__.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryFile import BinaryFile 7 | from .FMAT import FMAT 8 | from .FVTX import FVTX 9 | from .FSHP import FSHP 10 | from .FSKL import FSKL 11 | from bfres.FRES.FresObject import FresObject 12 | 13 | 14 | class Header(BinaryStruct): 15 | """FMDL header.""" 16 | # offsets in this struct are relative to the beginning of 17 | # the FRES file. 18 | # I'm assuming they're 64-bit since most are a 32-bit offset 19 | # followed by 4 zero bytes. 20 | magic = b'FMDL' 21 | fields = ( 22 | ('4s', 'magic'), 23 | ('I', 'size'), 24 | Offset64('block_offset'), # always == size? 25 | String( 'name', fmt='Q'), 26 | Offset64('str_tab_end'), 27 | 28 | Offset64('fskl_offset'), 29 | Offset64('fvtx_offset'), 30 | Offset64('fshp_offset'), 31 | Offset64('fshp_dict_offset'), 32 | Offset64('fmat_offset'), 33 | Offset64('fmat_dict_offset'), 34 | Offset64('udata_offset'), 35 | Padding(16), 36 | 37 | ('H', 'fvtx_count'), 38 | ('H', 'fshp_count'), 39 | ('H', 'fmat_count'), 40 | ('H', 'udata_count'), 41 | ('H', 'total_vtxs'), 42 | Padding(6), 43 | ) 44 | size = 0x78 45 | 46 | 47 | class FMDL(FresObject): 48 | """A 3D model in an FRES.""" 49 | 50 | def __init__(self, fres, name=None): 51 | self.name = name 52 | self.fres = fres 53 | self.headerOffset = None 54 | self.header = None 55 | self.fvtxs = [] 56 | self.fshps = [] 57 | self.fmats = [] 58 | self.udatas = [] 59 | self.totalVtxs = None 60 | self.skeleton = None 61 | 62 | 63 | def __str__(self): 64 | return "" %( 65 | str(self.name), 66 | '?' if self.headerOffset is None else hex(self.headerOffset), 67 | id(self), 68 | ) 69 | 70 | 71 | def dump(self): 72 | """Dump to string for debug.""" 73 | res = [] 74 | res.append(' Model "%s":' % self.name) 75 | res.append('%4d FVTXs,' % self.header['fvtx_count']) 76 | res.append('%4d FSHPs,' % self.header['fshp_count']) 77 | res.append('%4d FMATs,' % self.header['fmat_count']) 78 | res.append('%4d Udatas,' % self.header['udata_count']) 79 | res.append('%4d total vtxs' % self.header['total_vtxs']) 80 | res = [' '.join(res)] 81 | 82 | for fvtx in self.fvtxs: 83 | res.append(fvtx.dump()) 84 | 85 | for i, fmat in enumerate(self.fmats): 86 | res.append(' FMAT %d:' % i) 87 | res.append(' '+fmat.dump().replace('\n', '\n ')) 88 | 89 | self._dumpFshps(res) 90 | res.append('Skeleton:') 91 | res.append(self.skeleton.dump()) 92 | return '\n'.join(res).replace('\n', '\n ') 93 | 94 | 95 | def _dumpFshps(self, res): 96 | for i, fshp in enumerate(self.fshps): 97 | res.append(' FSHP %3d: %s' % (i, fshp.dump())) 98 | 99 | res.append(' \x1B[4mFSHP│LOD│Mshs│IdxB│PrimType'+ 100 | ' │'+ 101 | 'IdxTp│IdxCt│VisGp│Unk08 │Unk10 │Unk34 \x1B[0m') 102 | for i, fshp in enumerate(self.fshps): 103 | for j, lod in enumerate(fshp.lods): 104 | res.append(' %s%4d│%3d│%s\x1B[0m' % ( 105 | '\x1B[4m' if j == len(fshp.lods) - 1 else '', 106 | i, j, lod.dump())) 107 | 108 | 109 | def readFromFRES(self, offset=None): 110 | """Read this object from FRES.""" 111 | if offset is None: offset = self.fres.file.tell() 112 | self.headerOffset = offset 113 | self.header = self.fres.read(Header(), offset) 114 | self.name = self.header['name'] 115 | 116 | self.fmats = self._readObjects('fmat', FMAT) 117 | self.fvtxs = self._readObjects('fvtx', FVTX) 118 | self.fshps = self._readObjects('fshp', FSHP) 119 | self.skeleton = FSKL(self.fres).readFromFRES( 120 | self.header['fskl_offset']) 121 | # XXX udata 122 | return self 123 | 124 | 125 | def _readObjects(self, name, cls): 126 | """Read objects.""" 127 | objs = [] 128 | offs = self.header[name + '_offset'] 129 | for i in range(self.header[name + '_count']): 130 | vtx = cls(self.fres).readFromFRES(offs) 131 | objs.append(vtx) 132 | offs += cls.Header.size 133 | return objs 134 | -------------------------------------------------------------------------------- /bfres/Exporter/Exporter.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import bmesh 3 | import bpy 4 | import bpy_extras 5 | import os 6 | import os.path 7 | import tempfile 8 | import shutil 9 | import struct 10 | import math 11 | from bfres.Exceptions import UnsupportedFileTypeError 12 | from bfres.BinaryFile import BinaryFile 13 | from bfres import YAZ0, FRES, BNTX 14 | #from .ModelImporter import ModelImporter 15 | #from .TextureImporter import TextureImporter 16 | 17 | 18 | class Exporter: 19 | def __init__(self, operator, context): 20 | self.operator = operator 21 | self.context = context 22 | 23 | 24 | def run(self, path): 25 | """Perform the export.""" 26 | self.wm = bpy.context.window_manager 27 | self.path = path 28 | log.info("Exporting: %s", path) 29 | 30 | #bpy.context.selected_objects doesn't work, lol 31 | objects = [o for o in bpy.context.scene.objects if o.select] 32 | if len(objects) == 0: objects = bpy.context.visible_objects 33 | if len(objects) == 0: 34 | raise RuntimeError("No objects selected and no objects visible.") 35 | 36 | self.outFile = open(self.path, 'wb') 37 | try: 38 | for i, obj in enumerate(objects): 39 | log.info("Exporting object %d of %d: %s", 40 | i+1, len(objects), obj.name) 41 | self.exportObject(obj) 42 | except: 43 | log.exception("Export FAILED") 44 | finally: 45 | self.outFile.close() 46 | 47 | log.info("Export finished.") 48 | return {'FINISHED'} 49 | 50 | 51 | def exportTextures(self, path): 52 | """Export textures to specified file.""" 53 | self.wm = bpy.context.window_manager 54 | self.path = path 55 | log.info("TEXTURE EXPORT GOES HERE") 56 | raise NotImplementedError 57 | return {'FINISHED'} 58 | 59 | 60 | def exportObject(self, obj): 61 | """Export specified object.""" 62 | log.debug("Export object: %s", obj.type) 63 | if obj.type == 'MESH': self.exportMesh(obj) 64 | elif obj.type == 'ARMATURE': self.exportArmature(obj) 65 | else: 66 | log.warning("Can't export object of type %s", obj) 67 | 68 | 69 | def exportMesh(self, obj): 70 | """Export a mesh.""" 71 | log.debug("Exporting mesh: %s", obj) 72 | attrs = self._makeAttrBufferDataForMesh(obj) 73 | faces = [] 74 | for poly in obj.data.polygons: 75 | faces.append(list(poly.vertices)) 76 | log.debug("Mesh has %d faces", len(faces)) 77 | data = { 78 | 'attrs': attrs, 79 | 'faces': faces, 80 | 'groupNames': [group.name for group in obj.vertex_groups], 81 | } 82 | data = bytes(repr(data), 'utf-8') 83 | log.debug("Writing %d bytes", len(data)) 84 | self.outFile.write(data) 85 | 86 | 87 | def _makeAttrBufferDataForMesh(self, obj): 88 | """Make the attribute buffer data for a mesh.""" 89 | positions = [] 90 | normals = [] 91 | texCoords = [] 92 | idxs = [] 93 | weights = [] 94 | 95 | # make FRES attribute dicts 96 | attrs = { 97 | '_i0': idxs, 98 | '_n0': normals, 99 | '_p0': positions, 100 | '_w0': weights, 101 | } 102 | 103 | # make a list of UV coordinates for each layer 104 | uvs = obj.data.uv_layers 105 | numUVs = len(uvs) 106 | for i in range(numUVs): 107 | lst = [] 108 | attrs['_u%d' % i] = lst 109 | texCoords.append(lst) 110 | 111 | # add each vertex to attribute buffers 112 | for vtx in obj.data.vertices: 113 | # groups: Weights for the vertex groups this vertex is member of 114 | # index: Index of this vertex 115 | # normal: Vertex Normal 116 | x, y, z = vtx.co 117 | positions.append((x, y, z)) 118 | 119 | nx, ny, nz = vtx.normal 120 | normals.append((nx, ny, nz)) 121 | 122 | if len(vtx.groups) > 4: 123 | log.warning("Vertex has more than 4 groups: %s, %s", 124 | vtx, vtx.groups) 125 | idx, wgt = [], [] 126 | # export weights sorted from strongest to weakest 127 | for group in reversed(sorted(vtx.groups, key=lambda g:g.weight)): 128 | idx.append(group.group) 129 | wgt.append(group.weight) 130 | idxs.append(idx) 131 | weights.append(wgt) 132 | 133 | for uv in range(numUVs): 134 | u, v = uvs[uv].data[vtx.index].uv 135 | texCoords[uv].append((u, v)) 136 | 137 | return attrs 138 | 139 | 140 | def exportArmature(self, obj): 141 | """Export an armature.""" 142 | # TODO 143 | -------------------------------------------------------------------------------- /bfres/Importer/Importer.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import bmesh 3 | import bpy 4 | import bpy_extras 5 | import os 6 | import os.path 7 | import tempfile 8 | import shutil 9 | import struct 10 | import math 11 | from bfres.Exceptions import UnsupportedFileTypeError 12 | from bfres.BinaryFile import BinaryFile 13 | from bfres import YAZ0, FRES, BNTX 14 | from .ModelImporter import ModelImporter 15 | from .TextureImporter import TextureImporter 16 | 17 | 18 | class Importer(ModelImporter): 19 | def __init__(self, operator, context): 20 | self.operator = operator 21 | self.context = context 22 | 23 | # Keep a link to the add-on preferences. 24 | #self.addon_prefs = #context.user_preferences.addons[__package__].preferences 25 | 26 | 27 | @staticmethod 28 | def _add_object_to_group(ob, group_name): 29 | # Get or create the required group. 30 | group = bpy.data.groups.get(group_name, 31 | bpy.data.groups.new(group_name)) 32 | 33 | # Link the provided object to it. 34 | if ob.name not in group.objects: 35 | group.objects.link(ob) 36 | return group 37 | 38 | 39 | def run(self, path): 40 | """Perform the import.""" 41 | self.wm = bpy.context.window_manager 42 | self.path = path 43 | return self.unpackFile(path) 44 | 45 | 46 | def unpackFile(self, file): 47 | """Try to unpack the given file. 48 | 49 | file: A file object, or a path to a file. 50 | 51 | If the file format is recognized, will try to unpack it. 52 | If the file is compressed, will first decompress it and 53 | then try to unpack the result. 54 | Raises UnsupportedFileTypeError if the file format isn't 55 | recognized. 56 | """ 57 | if type(file) is str: # a path 58 | file = BinaryFile(file) 59 | self.file = file 60 | 61 | # read magic from header 62 | file.seek(0) # rewind 63 | magic = file.read(4) 64 | file.seek(0) # rewind 65 | 66 | if magic in (b'Yaz0', b'Yaz1'): # compressed 67 | r = self.decompressFile(file) 68 | return self.unpackFile(r) 69 | 70 | elif magic == b'FRES': 71 | return self._importFres(file) 72 | 73 | elif magic == b'BNTX': 74 | return self._importBntx(file) 75 | 76 | else: 77 | raise UnsupportedFileTypeError(magic) 78 | 79 | 80 | def decompressFile(self, file): 81 | """Decompress given file. 82 | 83 | Returns a temporary file. 84 | """ 85 | log.debug("Decompressing input file...") 86 | result = tempfile.TemporaryFile() 87 | 88 | # make progress callback to update UI 89 | progress = 0 90 | def progressCallback(cur, total): 91 | nonlocal progress 92 | pct = math.floor((cur / total) * 100) 93 | if pct - progress >= 1: 94 | self.wm.progress_update(pct) 95 | progress = pct 96 | print("\rDecompressing... %3d%%" % pct, end='') 97 | self.wm.progress_begin(0, 100) 98 | 99 | # decompress the file 100 | decoder = YAZ0.Decoder(file, progressCallback) 101 | for data in decoder.bytes(): 102 | result.write(data) 103 | self.wm.progress_end() 104 | print("") # end status line 105 | result.seek(0) 106 | 107 | if self.operator.save_decompressed: # write back to file 108 | path, ext = os.path.splitext(file.name) 109 | # 's' prefix indicates compressed; 110 | # eg '.sbfres' is compressed '.bfres' 111 | if ext.startswith('.s'): ext = '.'+ext[2:] 112 | else: ext = '.out' 113 | log.info("Saving decompressed file to: %s", path+ext) 114 | with open(path+ext, 'wb') as save: 115 | shutil.copyfileobj(result, save) 116 | result.seek(0) 117 | 118 | return BinaryFile(result) 119 | 120 | 121 | def _importFres(self, file): 122 | """Import FRES file.""" 123 | self.fres = FRES.FRES(file) 124 | self.fres.decode() 125 | 126 | if self.operator.dump_debug: 127 | with open('./fres-%s-dump.txt' % self.fres.name, 'w') as f: 128 | f.write(self.fres.dump()) 129 | #print("FRES contents:\n" + self.fres.dump()) 130 | 131 | # decode embedded files 132 | for file in self.fres.embeds: 133 | self._importFresEmbed(file) 134 | 135 | # import the models. 136 | for i, model in enumerate(self.fres.models): 137 | log.info("Importing model %3d / %3d...", 138 | i+1, len(self.fres.models)) 139 | self._importModel(model) 140 | 141 | return {'FINISHED'} 142 | 143 | 144 | def _importFresEmbed(self, file): 145 | """Import embedded file from FRES.""" 146 | if file.name.endswith('.txt'): # embed into blend file 147 | obj = bpy.data.texts.new(file.name) 148 | obj.write(file.data.decode('utf-8')) 149 | else: # try to decode, may be BNTX 150 | try: 151 | self.unpackFile(file.toTempFile()) 152 | except UnsupportedFileTypeError as ex: 153 | log.debug("Embedded file '%s' is of unsupported type '%s'", 154 | file.name, ex.magic) 155 | 156 | 157 | def _importBntx(self, file): 158 | """Import BNTX file.""" 159 | self.bntx = BNTX.BNTX(file) 160 | self.bntx.decode() 161 | if self.operator.dump_debug: 162 | with open('./fres-%s-bntx-dump.txt' % self.bntx.name, 'w') as f: 163 | f.write(self.bntx.dump()) 164 | 165 | imp = TextureImporter(self) 166 | imp.importTextures(self.bntx) 167 | 168 | return {'FINISHED'} 169 | -------------------------------------------------------------------------------- /notes/fres-textures-bntx-dump.txt: -------------------------------------------------------------------------------- 1 | Name: 'textures' 2 | Version: 0x400C, big endian 3 | Data Len: 0x040000 4 | Str Offs: 0x0001D0 5 | Reloc Offs: 0x033000 6 | File Size: 0x0330B8 7 | NX # Textures: 7 8 | NX Info Ptrs Offs: 0x000198 9 | NX Data Blk Offs: 0x001FF0 10 | NX Dict Offs: 0x000250 11 | NX Str Dict Len: 0x000058 12 | BRTI Name: 'Fox_Alb.0' 13 | Length: 0x0002E8 / 0x0002E8 14 | Flags: 0x01 15 | Dimensions: 0x02 16 | Tile Mode: 0x0000 17 | Swiz Size: 0x0000 18 | Mipmap Cnt: 0x0009 19 | Multisample Cnt: 0x0001 20 | Reserved 1A: 0x0000 21 | Fmt Data Type: 6 SRGB 22 | Fmt Type: 26 BC1 23 | Access Flags: 0x00000020 24 | Width x Height: 256/ 256 25 | Depth: 1 26 | Array Cnt: 1 27 | Block Height: 8 28 | Unk38: 0007 0001 29 | Unk3C: 0, 0, 0, 0, 0 30 | Data Len: 0x0000B400 31 | Alignment: 0x00000200 32 | Channel Types: Red, Green, Blue, Alpha 33 | Texture Type: 0x00000001 34 | Parent Offs: 0x00000020 35 | Ptrs Offs: 0x00000578 36 | BRTI Name: 'Fox_Nrm' 37 | Length: 0x0002E8 / 0x0002E8 38 | Flags: 0x01 39 | Dimensions: 0x02 40 | Tile Mode: 0x0000 41 | Swiz Size: 0x0002 42 | Mipmap Cnt: 0x0009 43 | Multisample Cnt: 0x0001 44 | Reserved 1A: 0x0000 45 | Fmt Data Type: 1 UNorm 46 | Fmt Type: 26 BC1 47 | Access Flags: 0x00000020 48 | Width x Height: 256/ 256 49 | Depth: 1 50 | Array Cnt: 1 51 | Block Height: 8 52 | Unk38: 0007 0001 53 | Unk3C: 0, 0, 0, 0, 0 54 | Data Len: 0x0000B400 55 | Alignment: 0x00000200 56 | Channel Types: Red, Green, Blue, Alpha 57 | Texture Type: 0x00000001 58 | Parent Offs: 0x00000020 59 | Ptrs Offs: 0x00000860 60 | BRTI Name: 'Fox_Spm' 61 | Length: 0x0002E8 / 0x0002E8 62 | Flags: 0x01 63 | Dimensions: 0x02 64 | Tile Mode: 0x0000 65 | Swiz Size: 0x0003 66 | Mipmap Cnt: 0x0009 67 | Multisample Cnt: 0x0001 68 | Reserved 1A: 0x0000 69 | Fmt Data Type: 1 UNorm 70 | Fmt Type: 29 BC4 71 | Access Flags: 0x00000020 72 | Width x Height: 256/ 256 73 | Depth: 1 74 | Array Cnt: 1 75 | Block Height: 8 76 | Unk38: 0007 0001 77 | Unk3C: 0, 0, 0, 0, 0 78 | Data Len: 0x0000B400 79 | Alignment: 0x00000200 80 | Channel Types: Red, Red, Red, Red 81 | Texture Type: 0x00000001 82 | Parent Offs: 0x00000020 83 | Ptrs Offs: 0x00000B48 84 | BRTI Name: 'Fox_Eye_Alb' 85 | Length: 0x0002D8 / 0x0002D8 86 | Flags: 0x01 87 | Dimensions: 0x02 88 | Tile Mode: 0x0000 89 | Swiz Size: 0x0000 90 | Mipmap Cnt: 0x0007 91 | Multisample Cnt: 0x0001 92 | Reserved 1A: 0x0000 93 | Fmt Data Type: 6 SRGB 94 | Fmt Type: 26 BC1 95 | Access Flags: 0x00000020 96 | Width x Height: 64/ 64 97 | Depth: 1 98 | Array Cnt: 1 99 | Block Height: 2 100 | Unk38: 0007 0001 101 | Unk3C: 0, 0, 0, 0, 0 102 | Data Len: 0x00001400 103 | Alignment: 0x00000200 104 | Channel Types: Red, Green, Blue, Alpha 105 | Texture Type: 0x00000001 106 | Parent Offs: 0x00000020 107 | Ptrs Offs: 0x00000E30 108 | BRTI Name: 'Fox_Eye_Nrm' 109 | Length: 0x0002D0 / 0x0002D0 110 | Flags: 0x01 111 | Dimensions: 0x02 112 | Tile Mode: 0x0000 113 | Swiz Size: 0x0002 114 | Mipmap Cnt: 0x0006 115 | Multisample Cnt: 0x0001 116 | Reserved 1A: 0x0000 117 | Fmt Data Type: 1 UNorm 118 | Fmt Type: 26 BC1 119 | Access Flags: 0x00000020 120 | Width x Height: 32/ 32 121 | Depth: 1 122 | Array Cnt: 1 123 | Block Height: 1 124 | Unk38: 0007 0001 125 | Unk3C: 0, 0, 0, 0, 0 126 | Data Len: 0x00000C00 127 | Alignment: 0x00000200 128 | Channel Types: Red, Green, Blue, Alpha 129 | Texture Type: 0x00000001 130 | Parent Offs: 0x00000020 131 | Ptrs Offs: 0x00001108 132 | BRTI Name: 'Fox_Eye_Spm' 133 | Length: 0x0002D8 / 0x0002D8 134 | Flags: 0x01 135 | Dimensions: 0x02 136 | Tile Mode: 0x0000 137 | Swiz Size: 0x0003 138 | Mipmap Cnt: 0x0007 139 | Multisample Cnt: 0x0001 140 | Reserved 1A: 0x0000 141 | Fmt Data Type: 1 UNorm 142 | Fmt Type: 29 BC4 143 | Access Flags: 0x00000020 144 | Width x Height: 64/ 64 145 | Depth: 1 146 | Array Cnt: 1 147 | Block Height: 2 148 | Unk38: 0007 0001 149 | Unk3C: 0, 0, 0, 0, 0 150 | Data Len: 0x00001400 151 | Alignment: 0x00000200 152 | Channel Types: Red, Red, Red, Red 153 | Texture Type: 0x00000001 154 | Parent Offs: 0x00000020 155 | Ptrs Offs: 0x000013D8 156 | BRTI Name: 'Fox_Alb.1' 157 | Length: 0x000BE0 / 0x000BE0 158 | Flags: 0x01 159 | Dimensions: 0x02 160 | Tile Mode: 0x0000 161 | Swiz Size: 0x0000 162 | Mipmap Cnt: 0x0009 163 | Multisample Cnt: 0x0001 164 | Reserved 1A: 0x0000 165 | Fmt Data Type: 6 SRGB 166 | Fmt Type: 26 BC1 167 | Access Flags: 0x00000020 168 | Width x Height: 256/ 256 169 | Depth: 1 170 | Array Cnt: 1 171 | Block Height: 8 172 | Unk38: 0007 0001 173 | Unk3C: 0, 0, 0, 0, 0 174 | Data Len: 0x0000B400 175 | Alignment: 0x00000200 176 | Channel Types: Red, Green, Blue, Alpha 177 | Texture Type: 0x00000001 178 | Parent Offs: 0x00000020 179 | Ptrs Offs: 0x000016B0 -------------------------------------------------------------------------------- /bfres/FRES/FMDL/LOD.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryFile import BinaryFile 7 | from bfres.FRES.FresObject import FresObject 8 | from bfres.FRES.Dict import Dict 9 | from bfres.Exceptions import MalformedFileError 10 | import struct 11 | 12 | 13 | primTypes = { 14 | # where did these come from? 15 | # id: (min, incr, name) 16 | #0x01: (1, 1, 'points'), 17 | #0x02: (2, 2, 'lines'), 18 | #0x03: (2, 1, 'line_strip'), 19 | #0x04: (3, 3, 'triangles'), 20 | #0x05: (3, 1, 'triangle_fan'), 21 | #0x06: (3, 1, 'triangle_strip'), 22 | #0x0A: (4, 4, 'lines_adjacency'), 23 | #0x0B: (4, 1, 'line_strip_adjacency'), 24 | #0x0C: (6, 1, 'triangles_adjacency'), 25 | #0x0D: (6, 6, 'triangle_strip_adjacency'), 26 | #0x11: (3, 3, 'rects'), 27 | #0x12: (2, 1, 'line_loop'), 28 | #0x13: (4, 4, 'quads'), 29 | #0x14: (4, 2, 'quad_strip'), 30 | #0x82: (2, 2, 'tesselate_lines'), 31 | #0x83: (2, 1, 'tesselate_line_strip'), 32 | #0x84: (3, 3, 'tesselate_triangles'), 33 | #0x86: (3, 1, 'tesselate_triangle_strip'), 34 | #0x93: (4, 4, 'tesselate_quads'), 35 | #0x94: (4, 2, 'tesselate_quad_strip'), 36 | 37 | # according to Jasper... 38 | # id: (min, incr, name) 39 | 0x00: (1, 1, 'point_list'), 40 | 0x01: (2, 2, 'line_list'), 41 | 0x02: (2, 1, 'line_strip'), 42 | 0x03: (3, 3, 'triangle_list'), 43 | } 44 | idxFormats = { 45 | 0x00: 'I', 49 | 0x09: '>H', 50 | } 51 | 52 | 53 | class Header(BinaryStruct): 54 | """LOD header.""" 55 | fields = ( 56 | Offset64('submesh_array_offs'), # 0x00 57 | Offset64('unk08'), # 0x08 58 | Offset64('unk10'), # 0x10 59 | Offset64('idx_buf_offs'), # 0x18 -> buffer size in bytes 60 | ('I', 'face_offs'), # 0x20; offset into index buffer 61 | ('I', 'prim_fmt'), # 0x24; how to draw the faces 62 | ('I', 'idx_type'), # 0x28; data type of index buffer entries 63 | ('I', 'idx_cnt'), # 0x2C; total number of indices 64 | ('H', 'visibility_group'), # 0x30 65 | ('H', 'submesh_cnt'), # 0x32 66 | ('I', 'unk34'), # 0x34 67 | ) 68 | size = 0x38 69 | 70 | 71 | class LOD(FresObject): 72 | """A level-of-detail model in an FRES.""" 73 | Header = Header 74 | 75 | def __init__(self, fres): 76 | self.fres = fres 77 | self.header = None 78 | self.headerOffset = None 79 | 80 | 81 | def __str__(self): 82 | return "" %( 83 | '?' if self.headerOffset is None else hex(self.headerOffset), 84 | id(self), 85 | ) 86 | 87 | 88 | def dump(self): 89 | """Dump to string for debug.""" 90 | res = [] 91 | #res.append("Submeshes: %d" % len(self.submeshes)) 92 | #res.append("IdxBuf: 0x%04X bytes" % len(self.idx_buf)) 93 | #res.append("PrimFmt: 0x%04X (%s)" % ( 94 | # self.prim_fmt_id, self.prim_fmt)) 95 | #res.append("IdxType: 0x%02X (%s)" % ( 96 | # self.header['idx_type'], self.idx_fmt, 97 | #)) 98 | #res.append("IdxCnt: %d" % self.header['idx_cnt']) 99 | #res.append("VisGrp: %d" % self.header['visibility_group']) 100 | #res.append("Unknown: 0x%08X 0x%08X 0x%08X" % ( 101 | # self.header['unk08'], 102 | # self.header['unk10'], 103 | # self.header['unk34'], 104 | #)) 105 | #return '\n'.join(res).replace('\n', '\n ') 106 | 107 | return "%4d│%04X│%04X %-24s│%02X %s│%5d│%5d│%08X│%08X│%08X" %( 108 | len(self.submeshes), 109 | len(self.idx_buf), 110 | self.prim_fmt_id, self.prim_fmt, 111 | self.header['idx_type'], self.idx_fmt, 112 | self.header['idx_cnt'], 113 | self.header['visibility_group'], 114 | self.header['unk08'], self.header['unk10'], 115 | self.header['unk34'], 116 | ) 117 | 118 | 119 | def readFromFRES(self, offset=None): 120 | """Read this object from given file.""" 121 | if offset is None: offset = self.fres.file.tell() 122 | log.debug("Reading LOD from 0x%06X", offset) 123 | self.headerOffset = offset 124 | self.header = self.fres.read(Header(), offset) 125 | 126 | # decode primitive and index formats 127 | self.prim_fmt_id = self.header['prim_fmt'] 128 | try: 129 | self.prim_min, self.prim_size, self.prim_fmt = \ 130 | primTypes[self.header['prim_fmt']] 131 | except KeyError: 132 | raise MalformedFileError("Unknown primitive format 0x%X" % 133 | self.header['prim_fmt']) 134 | 135 | try: 136 | self.idx_fmt = idxFormats[self.header['idx_type']] 137 | except KeyError: 138 | raise MalformedFileError("Unknown index type 0x%X" % 139 | self.header['idx_type']) 140 | 141 | self._readIdxBuf() 142 | self._readSubmeshes() 143 | 144 | return self 145 | 146 | 147 | def _readIdxBuf(self): 148 | """Read the index buffer.""" 149 | base = self.fres.bufferSection['buf_offs'] 150 | self.idx_buf = self.fres.read(self.idx_fmt, 151 | pos = self.header['face_offs'] + base, 152 | count = self.header['idx_cnt']) 153 | 154 | for i in range(self.header['idx_cnt']): 155 | self.idx_buf[i] += self.header['visibility_group'] 156 | 157 | 158 | def _readSubmeshes(self): 159 | """Read the submeshes.""" 160 | self.submeshes = [] 161 | base = self.header['submesh_array_offs'] 162 | # XXX is this right, adding 1 here? 163 | for i in range(self.header['submesh_cnt']+1): 164 | offs, cnt = self.fres.read('2I', base + (i*8)) 165 | idxs = self.idx_buf[offs:offs+cnt] # XXX offs / size? 166 | self.submeshes.append({ 167 | 'offset': offs, 168 | 'count': cnt, 169 | 'idxs': idxs, 170 | }) 171 | #log.debug("FVTX submesh %d: offset=0x%06X count=0x%04X idxs=%s", 172 | # i, offs, cnt, idxs) 173 | -------------------------------------------------------------------------------- /bfres/BNTX/BRTI.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryFile import BinaryFile 7 | from bfres.Common import StringTable 8 | from enum import IntEnum 9 | from .pixelfmt import TextureFormat 10 | from .pixelfmt.swizzle import Swizzle, BlockLinearSwizzle 11 | 12 | 13 | class Header(BinaryStruct): 14 | """BRTI object header.""" 15 | magic = b'BRTI' 16 | fields = ( 17 | ('4s', 'magic'), 18 | ('I', 'length'), 19 | ('Q', 'length2'), 20 | ('B', 'flags'), 21 | ('B', 'dimensions'), 22 | ('H', 'tile_mode'), 23 | ('H', 'swizzle_size'), 24 | ('H', 'mipmap_cnt'), 25 | ('H', 'multisample_cnt'), 26 | ('H', 'reserved1A'), 27 | ('B', 'fmt_dtype', lambda v: BRTI.TextureDataType(v)), 28 | ('B', 'fmt_type', lambda v: TextureFormat.get(v)()), 29 | Padding(2), 30 | ('I', 'access_flags'), 31 | ('i', 'width'), 32 | ('i', 'height'), 33 | ('i', 'depth'), 34 | ('i', 'array_cnt'), 35 | ('i', 'block_height', lambda v: 2**v), 36 | ('H', 'unk38'), 37 | ('H', 'unk3A'), 38 | ('i', 'unk3C'), 39 | ('i', 'unk40'), 40 | ('i', 'unk44'), 41 | ('i', 'unk48'), 42 | ('i', 'unk4C'), 43 | ('i', 'data_len'), 44 | ('i', 'alignment'), 45 | ('4B', 'channel_types',lambda v:tuple(map(BRTI.ChannelType,v))), 46 | ('i', 'tex_type'), 47 | String( 'name'), 48 | Padding(4), 49 | Offset64('parent_offset'), 50 | Offset64('ptrs_offset'), 51 | ) 52 | 53 | 54 | class BRTI: 55 | """A BRTI in a BNTX.""" 56 | Header = Header 57 | 58 | class ChannelType(IntEnum): 59 | Zero = 0 60 | One = 1 61 | Red = 2 62 | Green = 3 63 | Blue = 4 64 | Alpha = 5 65 | 66 | class TextureType(IntEnum): 67 | Image1D = 0 68 | Image2D = 1 69 | Image3D = 2 70 | Cube = 3 71 | CubeFar = 8 72 | 73 | class TextureDataType(IntEnum): 74 | UNorm = 1 75 | SNorm = 2 76 | UInt = 3 77 | SInt = 4 78 | Single = 5 79 | SRGB = 6 80 | UHalf = 10 81 | 82 | def __init__(self): 83 | self.file = None 84 | self.mipOffsets = [] 85 | 86 | 87 | def dump(self): 88 | """Dump to string for debug.""" 89 | res = [] 90 | res.append("BRTI Name: '%s'" % self.name) 91 | res.append("Length: 0x%06X / 0x%06X" % ( 92 | self.header['length'], self.header['length2'])) 93 | res.append("Flags: 0x%02X" % self.header['flags']) 94 | res.append("Dimensions: 0x%02X" % self.header['dimensions']) 95 | res.append("Tile Mode: 0x%04X" % self.header['tile_mode']) 96 | res.append("Swiz Size: 0x%04X" % self.header['swizzle_size']) 97 | res.append("Mipmap Cnt: 0x%04X" % self.header['mipmap_cnt']) 98 | res.append("Multisample Cnt: 0x%04X" % self.header['multisample_cnt']) 99 | res.append("Reserved 1A: 0x%04X" % self.header['reserved1A']) 100 | res.append("Fmt Data Type: %2d %s" % ( 101 | int(self.header['fmt_dtype']), 102 | self.header['fmt_dtype'].name)) 103 | res.append("Fmt Type: %2d %s" % ( 104 | self.header['fmt_type'].id, 105 | type(self.header['fmt_type']).__name__)) 106 | res.append("Access Flags: 0x%08X" % self.header['access_flags']) 107 | res.append("Width x Height: %5d/%5d" % (self.width, self.height)) 108 | res.append("Depth: %3d" % self.header['depth']) 109 | res.append("Array Cnt: %3d" % self.header['array_cnt']) 110 | res.append("Block Height: %8d" % self.header['block_height']) 111 | res.append("Unk38: %04X %04X" % ( 112 | self.header['unk38'], self.header['unk3A'])) 113 | res.append("Unk3C: %d, %d, %d, %d, %d" % ( 114 | self.header['unk3C'], self.header['unk40'], 115 | self.header['unk44'], self.header['unk48'], 116 | self.header['unk4C'])) 117 | res.append("Data Len: 0x%08X" % self.header['data_len']) 118 | res.append("Alignment: 0x%08X" % self.header['alignment']) 119 | res.append("Channel Types: %s, %s, %s, %s" % ( 120 | self.header['channel_types'][0].name, 121 | self.header['channel_types'][1].name, 122 | self.header['channel_types'][2].name, 123 | self.header['channel_types'][3].name)) 124 | res.append("Texture Type: 0x%08X" % self.header['tex_type']) 125 | res.append("Parent Offs: 0x%08X" % self.header['parent_offset']) 126 | res.append("Ptrs Offs: 0x%08X" % self.header['ptrs_offset']) 127 | return '\n'.join(res).replace('\n', '\n ') 128 | 129 | 130 | def readFromFile(self, file:BinaryFile, offset=0): 131 | """Decode objects from the file.""" 132 | self.file = file 133 | self.header = self.Header().readFromFile(file, offset) 134 | self.name = self.header['name'] 135 | self.fmt_type = self.header['fmt_type'] 136 | self.fmt_dtype = self.header['fmt_dtype'] 137 | self.width = self.header['width'] 138 | self.height = self.header['height'] 139 | self.channel_types = self.header['channel_types'] 140 | 141 | self.swizzle = BlockLinearSwizzle(self.width, 142 | self.fmt_type.bytesPerPixel, 143 | self.header['block_height']) 144 | self._readMipmaps() 145 | self._readData() 146 | self.pixels, self.depth = self.fmt_type.decode(self) 147 | return self 148 | 149 | 150 | def _readMipmaps(self): 151 | """Read the mipmap images.""" 152 | for i in range(self.header['mipmap_cnt']): 153 | offs = self.header['ptrs_offset'] + (i*8) 154 | entry = self.file.read('I', offs) #- base 155 | self.mipOffsets.append(entry) 156 | 157 | 158 | def _readData(self): 159 | """Read the raw image data.""" 160 | base = self.file.read('Q', self.header['ptrs_offset']) 161 | self.data = self.file.read(self.header['data_len'], base) 162 | -------------------------------------------------------------------------------- /notes/notes.md: -------------------------------------------------------------------------------- 1 | Spm texture channels: 2 | red: specular 3 | green: metal 4 | blue: unused 5 | 6 | For bones: maybe "Smooth Mtx Idx" is really "group ID", to correspond with the vertex groups. So instead of adding 2, we use the bone that has the matching group ID. 7 | Maybe "smooth matrix" is just someone's way of describing the whole vertex group/weight thing, and they don't really refer to a matrix? 8 | 9 | bee u0: (22464, 10104) (1370, 2112) (55092, 53581) (9476, 26471) (33738, 19565) (12310, 45140) (57864, 33886) (4604, 30817) (27794, 12662) (40550, 639) (52128, 34650) (59632, 52558) (14784, 16492) (55444, 60747) (22464, 55672) (33737, 46189) 10 | 11 | fox u0: (51, 104) (6, 234) (127, 213) (208, 132) (130, 60) (247, 21) (51, 104) (127, 213) (6, 234) (208, 132) (130, 60) (247, 21) (51, 104) (6, 234) (127, 213) (208, 132) 12 | 13 | |Vtxs|Faces|Tris|Bones|Texs 14 | ----|----|-----|----|-----|---- 15 | Link|5557| 7104|7104| 115| 35 16 | Rena|5033| 7797|9612| 440| 3 17 | 18 | I assume the bones need to have the same names... 19 | may not matter if there are additional bones 20 | same for textures 21 | 22 | so the actual process would be: 23 | - rename bones, vtx groups, textures to match original model 24 | - replace bones, vertices, textures in the file 25 | - attributes p0, u0, i0? 26 | 27 | poach, pod are things on the belt 28 | Pod_A is shield attach? 29 | each bone has a corresponding group 30 | actually the rigs might be too different, 31 | may need to re-rig the new model manually... 32 | or make a script which compares bones in each rig to find those closest, renaming one to match the other 33 | - if source model has multiple bones in close proximity and target just has one, it could merge them, averaging their weights 34 | for now I'm going with "manual" rigging (mostly Blender auto weights) 35 | 36 | as for exporting to bfres, we can probably do that, but there are many unknown parameters we would need to generate: 37 | - shader params 38 | - render params 39 | - material params 40 | - relocation table 41 | - all unknown fields 42 | without generating most of these correctly, games probably won't accept the file. 43 | we can cheat a bit and use the properties from a previously imported file. 44 | 45 | if we want to just export a new model overtop of the existing one, all we really need to replace are the attributes (buffer data) and textures 46 | we can probably just tack the new data on to the end of the file and point them to it. worry about actually rebuilding the file and replacing the old data later, once it works. 47 | 48 | Attribute formats for Link: 49 | FVTX|p0 |n0 |t0 |b0 |c0 |u0 |u1 |i0 |w0 |FSHP 50 | ----|-------|-----|-----|-----|-----|-------|------|-----|-----|------------------------ 51 | 0|half[4]|10bit|u8[4]|u8[4]|none |half[2]|u16[2]|u8[4]|u8[4]|Belt_A_Buckle__Mt_Belt_A 52 | 1|half[4]|10bit|u8[4]|u8[4]|none |u16[2] |none |u8[2]|u8[2]|Belt_C_Buckle__Mt_Belt_C 53 | 2|half[4]|10bit|u8[4]|u8[4]|none |u16[2] |none |u8 |none |Earring__Mt_Earring 54 | 3|half[4]|10bit|u8[4]|u8[4]|none |half[2]|none |u8 |none |Eye_L__Mt_Eyeball_L 55 | 4|half[4]|10bit|u8[4]|u8[4]|none |s16[2] |none |u8 |none |Eye_R__Mt_Eyeball_R 56 | 5|half[4]|10bit|u8[4]|u8[4]|none |u16[2] |none |u8[4]|u8[4]|Eyelashes__Mt_Eyelashes 57 | 6|half[4]|10bit|u8[4]|u8[4]|u8[4]|u16[2] |u16[2]|u8[4]|u8[4]|Face__Mt_Face 58 | 7|half[4]|10bit|u8[4]|u8[4]|u8[4]|u16[2] |u16[2]|u8[4]|u8[4]|Face__Mt_Head 59 | 8|half[4]|10bit|u8[4]|u8[4]|none |u16[2] |none |u8[4]|u8[4]|Skin__Mt_Lower_Skin 60 | 9|half[4]|10bit|u8[4]|u8[4]|none |u16[2] |none |u8[4]|u8[4]|Skin__Mt_Underwear 61 | 10|half[4]|10bit|u8[4]|u8[4]|none |u16[2] |none |u8[4]|u8[4]|Skin__Mt_Upper_Skin 62 | 63 | Vtx attr names: 64 | b: binormal 65 | c: color 66 | i: index (vtx group) 67 | n: normal 68 | p: position 69 | t: tangent 70 | u: UV coord 71 | w: weight 72 | 73 | pointers are also in RLT section 2 which is empty? 74 | that can't be right, we use the RLT to find the buffers 75 | `if rel: pos += self.rlt.sections[1]['curOffset'] # XXX` 76 | section 1 curOffs=0x38000 size=0xA690 entryIdx=246 entryCnt=1 77 | entry 246: curOffs=0x0150 structs=1 offsets=1 padding=0 78 | not sure what that means, but I think the buffers are the only use of the RLT? 79 | so if we changed that pointer, it might be enough 80 | we could test by duplicating the data, changing the pointer to it, and testing 81 | maybe changing some of the texture coords to see a difference 82 | first we need to get the damn mods to load at all 83 | 84 | - open a bfres file 85 | - locate the buffer data 86 | - change the pointers to new data 87 | - scan the RLT for those old pointers 88 | - change those too 89 | 90 | original buffer layout: 91 | n|size|strd|contents |types 92 | 0|0D50|0008|p0 |half[4] 93 | 1|0D50|0008|w0 i0 |u8[4], u8[4] 94 | 2|1AA0|0010|n0 t0 u0 u1|10bit, u8[4], half[2], u16[2] 95 | 3|06A8|0004|b0 |u8[4] 96 | so it's mostly combined types of the same size 97 | not sure why two different buffers for p0 and w0/i0 98 | 99 | vtx_stridesize_offs => 100 | int32_t stride; 101 | uint32_t divisor; //should be 0 102 | uint32_t reserved1; 103 | uint32_t reserved2; 104 | 105 | Similarly, the pointer at FVTX + 0x38 106 | (currently marked as "vertex buffer size") is another 0x10-long struct 107 | which is composed of 108 | uint32_t size; 109 | uint32_t gpuAccessFlags; //should be 5 110 | uint32_t reserved1; 111 | uint32_t reserved2; 112 | 113 | field | orig | modif | data 114 | -------------|-------|-------|----- 115 | dataOffs | 42690 | 71C10 | floats? 116 | bufSize | 6450 | 71B80 | 0xD50, 0xD50, 0x1AA0, 0x6A8 117 | strideSize | 6490 | 71B90 | 8, 8, 16, 4 118 | BS.size | 39000 | 39000 | D000 D400 D700... looks like part of whatever is at 38000 119 | BS.offs | 38000 | 38000 | 0000 0100 0200... 120 | vtx_buf_offs | A690 | 39C10 | 0800 0000 1000 6200 167C 0300, last is \*"uking_enable_scene_color0_fog" 121 | data from | 42690 | 71C10 | 122 | vtx_buf_offs == RLT.section[1].size 123 | 0x38000 + 0x39000 = 0x71000 124 | 125 | RLT |original|modified|note 126 | --------|--------|--------|-------- "end of string pool" 127 | 0 base | 0| 0| 128 | 0 offset| 0| 0| 129 | 0 size | 37DAE| 37DAE| string pool ends here 130 | 0 total | 37DAE| 37DAE| 131 | --------|--------|--------|-------- "index buffer" 132 | 1 base | 0| 0| 133 | 1 offset| 38000| 38000| BufferSection.offset 134 | 1 size | A690| A690| 135 | 1 total | 42690| 42690| 136 | --------|--------|--------|-------- "vertex buffer" 137 | 2 base | 0| 0| 138 | 2 offset| 42690| 71C10| pointer to buffer data 139 | 2 size | 2E970| 2E970| 140 | 2 total | 71000| A0580| 141 | --------|--------|--------|-------- "memory pool" 142 | 3 base | 0| 0| 143 | 3 offset| 71000| 71000| 0x38000 + 0x39000 144 | 3 size | 120| 120| 145 | 3 total | 71120| 71120| 146 | --------|--------|--------|-------- "external files" 147 | 4 base | 0| 0| 148 | 4 offset| 71200| 71200| 149 | 4 size | 100| 100| 150 | 4 total | 71300| 71300| 151 | -------------------------------------------------------------------------------- /bfres/FRES/FMDL/FSKL.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryStruct.Flags import Flags 7 | from bfres.BinaryFile import BinaryFile 8 | from bfres.FRES.FresObject import FresObject 9 | from bfres.FRES.Dict import Dict 10 | from .Bone import Bone 11 | import struct 12 | import math 13 | 14 | 15 | class Header(BinaryStruct): 16 | """FSKL header.""" 17 | magic = b'FSKL' 18 | fields = ( 19 | ('4s', 'magic'), # 0x00 20 | ('I', 'size'), # 0x04 21 | ('I', 'size2'), # 0x08 22 | Padding(4), # 0x0C 23 | Offset64('bone_idx_group_offs'), # 0x10 24 | Offset64('bone_array_offs'), # 0x18 25 | Offset64('smooth_idx_offs'), # 0x20 26 | Offset64('smooth_mtx_offs'), # 0x28 27 | Offset64('unk30'), # 0x30 28 | Flags('flags', { # 0x38 29 | #'SCALE_NONE': 0x00000000, # no scaling 30 | 'SCALE_STD': 0x00000100, # standard scaling 31 | 'SCALE_MAYA': 0x00000200, # Respects Maya's segment scale 32 | # compensation which offsets child bones rather than 33 | # scaling them with the parent. 34 | 'SCALE_SOFTIMAGE': 0x00000300, # Respects the scaling method 35 | # of Softimage. 36 | 'EULER': 0x00001000, # euler rotn, not quaternion 37 | }), 38 | ('H', 'num_bones'), # 0x3C 39 | ('H', 'num_smooth_idxs'), # 0x3E 40 | ('H', 'num_rigid_idxs'), # 0x40 41 | ('H', 'num_extra'), # 0x42 42 | ('I', 'unk44'), # 0x44 43 | ) 44 | size = 0x48 45 | 46 | 47 | class FSKL(FresObject): 48 | """A skeleton in an FRES.""" 49 | Header = Header 50 | 51 | def __init__(self, fres): 52 | self.fres = fres 53 | self.header = None 54 | self.headerOffset = None 55 | 56 | 57 | def __str__(self): 58 | return "" %( 59 | '?' if self.headerOffset is None else hex(self.headerOffset), 60 | id(self), 61 | ) 62 | 63 | 64 | def _dumpBone(self, idx): 65 | """Recursively dump bone structure.""" 66 | res = [] 67 | bone = self.bones[idx] 68 | res.append("%3d: %s" % (bone.bone_idx, bone.name)) 69 | for b in self.bones: 70 | if b.parent_idx == idx: 71 | res.append(self._dumpBone(b.bone_idx)) 72 | return '\n'.join(res).replace('\n', '\n ') 73 | 74 | 75 | def dump(self): 76 | """Dump to string for debug.""" 77 | res = [] 78 | res.append(" Size: 0x%08X / 0x%08X" % ( 79 | self.header['size'], 80 | self.header['size2'])) 81 | res.append("Bone Idx Group Offs: 0x%08X" % 82 | self.header['bone_idx_group_offs']) 83 | res.append("Bone Array Offs: 0x%08X" % 84 | self.header['bone_array_offs']) 85 | res.append("Smooth Idx Offs: 0x%08X" % 86 | self.header['smooth_idx_offs']) 87 | res.append("Smooth Mtx Offs: 0x%08X" % 88 | self.header['smooth_mtx_offs']) 89 | res.append("Unk30: 0x%08X" % self.header['unk30']) 90 | res.append("Flags: %s" % str(self.header['flags'])) 91 | res.append("Bones: %3d" % self.header['num_bones']) 92 | res.append("Smooth Idxs: %3d" % self.header['num_smooth_idxs']) 93 | res.append("Rigid Idxs: %3d" % self.header['num_rigid_idxs']) 94 | res.append("Extras: %3d" % self.header['num_extra']) 95 | res.append("Unk44: 0x%08X" % self.header['unk44']) 96 | for bone in self.bones: 97 | res.append(bone.dump()) 98 | res.append("Bone structure:") 99 | res.append(self._dumpBone(0)) 100 | return '\n'.join(res).replace('\n', '\n ') 101 | 102 | 103 | def readFromFRES(self, offset=None): 104 | """Read this object from given file.""" 105 | if offset is None: offset = self.fres.file.tell() 106 | log.debug("Reading FSKL from 0x%06X", offset) 107 | self.headerOffset = offset 108 | self.header = self.fres.read(Header(), offset) 109 | 110 | self._readSmoothIdxs() 111 | self._readSmoothMtxs() 112 | self._readBones() 113 | 114 | return self 115 | 116 | 117 | def _readSmoothIdxs(self): 118 | """Read smooth matrix indices.""" 119 | self.smooth_idxs = self.fres.read('h', 120 | pos = self.header['smooth_idx_offs'], 121 | count = self.header['num_smooth_idxs']) 122 | 123 | 124 | def _readSmoothMtxs(self): 125 | """Read smooth matrices.""" 126 | self.smooth_mtxs = [] 127 | if len(self.smooth_idxs) == 0: 128 | log.info("no smooth idxs") 129 | return 130 | 131 | for i in range(max(self.smooth_idxs)): 132 | # read the matrix 133 | mtx = self.fres.read('3f', count = 4, 134 | pos = self.header['smooth_mtx_offs'] + (i*16*3)) 135 | 136 | # warn about invalid values 137 | for y in range(4): 138 | for x in range(3): 139 | n = mtx[y][x] 140 | if math.isnan(n) or math.isinf(n): 141 | log.warning("Skeleton smooth mtx %d element [%d,%d] is %s", 142 | i, x, y, n) 143 | 144 | # replace all invalid values with zeros 145 | flt = lambda e: \ 146 | 0 if (math.isnan(e) or math.isinf(e)) else e 147 | mtx = list(map(lambda row: list(map(flt, row)), mtx)) 148 | 149 | self.smooth_mtxs.append(mtx) 150 | 151 | 152 | def _readBones(self): 153 | """Read the bones.""" 154 | self.bones = [] 155 | self.bonesByName = {} 156 | self.boneIdxGroups = [] 157 | offs = self.header['bone_array_offs'] 158 | 159 | # read the bones 160 | for i in range(self.header['num_bones']): 161 | b = Bone(self.fres).readFromFRES(offs) 162 | self.bones.append(b) 163 | if b.name in self.bonesByName: 164 | log.warning("Duplicate bone name '%s'", b.name) 165 | self.bonesByName[b.name] = b 166 | offs += Bone._struct.size 167 | 168 | # set parents 169 | for bone in self.bones: 170 | bone.fskl = self 171 | if bone.parent_idx >= len(self.bones): 172 | log.warning("Bone %s has invalid parent_idx %d (max is %d)", 173 | bone.name, bone.parent_idx, len(self.bones)) 174 | bone.parent = None 175 | elif bone.parent_idx >= 0: 176 | bone.parent = self.bones[bone.parent_idx] 177 | else: 178 | bone.parent = None 179 | 180 | self.boneIdxGroups = Dict(self.fres).readFromFRES( 181 | self.header['bone_idx_group_offs']) 182 | -------------------------------------------------------------------------------- /bfres/BinaryStruct/__init__.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import struct 3 | #from BinaryFile import BinaryFile 4 | from .BinaryObject import BinaryObject 5 | 6 | 7 | class BinaryStruct: 8 | """A set of data represented by a binary structure.""" 9 | 10 | magic = None # valid values for `magic` field, if present. 11 | 12 | def __init__(self, *fields, size:int=None): 13 | """Define structure. 14 | 15 | fields: List of field definitions. 16 | size: Expected size of structure. Produces a warning message 17 | if actual size specified by `fields` does not match this. 18 | """ 19 | # if no fields given, use those defined in the subclass. 20 | if len(fields) == 0: fields = self.fields 21 | 22 | self.fields = {} 23 | self.orderedFields = [] 24 | 25 | offset = 0 26 | _checkSize = size 27 | for field in fields: 28 | conv = None 29 | 30 | if type(field) is tuple: 31 | # field = (type, name[, convertor]) 32 | typ, name = field[0:2] 33 | if len(field) > 2: conv = field[2] 34 | else: # class 35 | typ, name = field, field.name 36 | 37 | assert name not in self.fields, \ 38 | "Duplicate field name '" + name + "'" 39 | 40 | # determine size and make reader function 41 | if type(typ) is str: 42 | size = struct.calcsize(typ) 43 | func = self._makeReader(typ) 44 | disp = BinaryObject.DisplayFormat 45 | else: 46 | size = typ.size 47 | func = typ.readFromFile 48 | disp = typ.DisplayFormat 49 | 50 | field = { 51 | 'name': name, 52 | 'size': size, 53 | 'offset': offset, 54 | 'type': typ, 55 | 'read': func, 56 | 'conv': conv, 57 | 'disp': disp, 58 | } 59 | self.fields[name] = field 60 | self.orderedFields.append(field) 61 | offset += size 62 | 63 | self.size = offset 64 | if _checkSize is not None: 65 | assert _checkSize == self.size, \ 66 | "Struct size is 0x%X but should be 0x%X" % ( 67 | self.size, _checkSize) 68 | 69 | 70 | def _makeReader(self, typ): 71 | """Necessary because lolscope""" 72 | return lambda file: file.read(typ) 73 | 74 | 75 | def readFromFile(self, file, offset=None): 76 | """Read this struct from given file.""" 77 | if offset is not None: file.seek(offset) 78 | offset = file.tell() 79 | 80 | res = {} 81 | for field in self.orderedFields: 82 | try: 83 | # read the field 84 | #log.debug("Read %s.%s from 0x%X => 0x%X", 85 | # type(self).__name__, field['name'], 86 | # field['offset'], offset) 87 | file.seek(offset) 88 | func = field['read'] 89 | data = func(file) 90 | 91 | if type(data) is tuple and len(data) == 1: 92 | data = data[0] # grumble 93 | 94 | if field['conv']: data = field['conv'](data) 95 | res[field['name']] = data 96 | offset += field['size'] 97 | except Exception as ex: 98 | log.error("Failed reading field '%s' from offset 0x%X: %s", 99 | field['name'], offset, ex) 100 | 101 | #log.debug("Read %s: %s", type(self).__name__, res) 102 | self._checkMagic(res) 103 | self._checkOffsets(res, file) 104 | self._checkPadding(res) 105 | return res 106 | 107 | 108 | def _checkMagic(self, res): 109 | """Verify magic value.""" 110 | if self.magic is None: return 111 | 112 | magic = res.get('magic', None) 113 | if magic is None: 114 | raise RuntimeError("Struct %s has `magic` property but no `magic` field" % 115 | type(self).__name__) 116 | 117 | valid = self.magic 118 | if type(valid) not in (list, tuple): 119 | valid = (valid,) 120 | 121 | if magic not in valid: 122 | raise ValueError("%s: invalid magic %s; expected %s" % ( 123 | type(self).__name__, str(magic), str(valid))) 124 | 125 | 126 | def _checkOffsets(self, res, file): 127 | """Check if offsets are sane.""" 128 | from .Offset import Offset 129 | for field in self.orderedFields: 130 | typ = field['type'] 131 | name = field['name'] 132 | if isinstance(typ, Offset): 133 | try: 134 | val = int(res.get(name, None)) 135 | if val < 0 or val > file.size: 136 | # don't warn on == file.size because some files 137 | # have an offset field that's their own size 138 | log.warning("%s: Offset '%s' = 0x%X but EOF = 0x%X", 139 | type(self).__name__, name, val, file.size+1) 140 | except (TypeError, ValueError): 141 | # string offsets are Offset but not numbers 142 | pass 143 | 144 | 145 | def _checkPadding(self, res): 146 | """Check if padding values are as expected.""" 147 | from .Padding import Padding 148 | for field in self.orderedFields: 149 | typ = field['type'] 150 | name = field['name'] 151 | data = res.get(name, None) 152 | if isinstance(typ, Padding): 153 | expected = typ.value 154 | for i, byte in enumerate(data): 155 | if byte != expected: 156 | log.debug("%s: Padding byte at 0x%X is 0x%02X, should be 0x%02X", 157 | type(self).__name__, 158 | field['offset']+i, byte, expected 159 | ) 160 | 161 | 162 | def dump(self, data:dict) -> str: 163 | """Dump to string for debug.""" 164 | strs = [] 165 | longestName = 0 166 | longestType = 0 167 | for field in self.orderedFields: 168 | name = field['name'] 169 | typ = field['type'] 170 | if type(typ) is not str: typ = type(typ).__name__ 171 | value = data[name] 172 | fmt = field['disp'] 173 | if type(fmt) is str: 174 | f = fmt 175 | fmt = lambda v: f % v 176 | strs.append((field['offset'], typ, name, fmt(value))) 177 | longestName = max(longestName, len(name)) 178 | longestType = max(longestType, len(typ)) 179 | 180 | res = [] 181 | for offs, typ, name, val in strs: 182 | res.append(' [%04X %s] %s %s' % ( 183 | offs, 184 | typ.ljust(longestType), 185 | (name+':').ljust(longestName+1), 186 | val, 187 | )) 188 | return '\n'.join(res) 189 | -------------------------------------------------------------------------------- /bfres/FRES/FMDL/Bone.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryStruct.Flags import Flags 7 | from bfres.BinaryStruct.Vector import Vec3f, Vec4f 8 | from bfres.BinaryFile import BinaryFile 9 | from bfres.FRES.FresObject import FresObject 10 | from bfres.FRES.Dict import Dict 11 | from .Attribute import Attribute, AttrStruct 12 | from .Buffer import Buffer 13 | from .FVTX import FVTX 14 | from .LOD import LOD 15 | from .Vertex import Vertex 16 | import struct 17 | import math 18 | import mathutils # Blender 19 | 20 | 21 | class BoneStruct(BinaryStruct): 22 | """The bone data in the file.""" 23 | fields = ( 24 | String('name'), 25 | ('5I', 'unk04'), 26 | ('H', 'bone_idx'), 27 | ('h', 'parent_idx'), 28 | ('h', 'smooth_mtx_idx'), 29 | ('h', 'rigid_mtx_idx'), 30 | ('h', 'billboard_idx'), 31 | ('H', 'udata_count'), 32 | Flags('flags', { 33 | 'VISIBLE': 0x00000001, 34 | 'EULER': 0x00001000, # use euler rotn, not quaternion 35 | 'BB_CHILD':0x00010000, # child billboarding 36 | 'BB_WORLD_VEC':0x00020000, # World View Vector. 37 | # The Z axis is parallel to the camera. 38 | 'BB_WORLD_POINT':0x00030000, # World View Point. 39 | # The Z axis is equal to the direction the camera 40 | # is pointing to. 41 | 'BB_SCREEN_VEC':0x00040000, # Screen View Vector. 42 | # The Z axis is parallel to the camera, the Y axis is 43 | # equal to the up vector of the camera. 44 | 'BB_SCREEN_POINT':0x00050000, # Screen View Point. 45 | # The Z axis is equal to the direction the camera is 46 | # pointing to, the Y axis is equal to the up vector of 47 | # the camera. 48 | 'BB_Y_VEC':0x00060000, # Y-Axis View Vector. 49 | # The Z axis has been made parallel to the camera view 50 | # by rotating the Y axis. 51 | 'BB_Y_POINT':0x00070000, # Y-Axis View Point. 52 | # The Z axis has been made equal to the direction 53 | # the camera is pointing to by rotating the Y axis. 54 | 'SEG_SCALE_COMPENSATE':0x00800000, # Segment scale 55 | # compensation. Set for bones scaled in Maya whose 56 | # scale is not applied to child bones. 57 | 'UNIFORM_SCALE': 0x01000000, # Scale uniformly. 58 | 'SCALE_VOL_1': 0x02000000, # Scale volume by 1. 59 | 'NO_ROTATION': 0x04000000, 60 | 'NO_TRANSLATION':0x08000000, 61 | # same as previous but for hierarchy of bones 62 | 'GRP_UNIFORM_SCALE': 0x10000000, 63 | 'GRP_SCALE_VOL_1': 0x20000000, 64 | 'GRP_NO_ROTATION': 0x40000000, 65 | 'GRP_NO_TRANSLATION':0x80000000, 66 | }), 67 | Vec3f('scale'), 68 | Vec4f('rot'), 69 | Vec3f('pos'), 70 | ) 71 | size = 80 72 | 73 | 74 | class Bone(FresObject): 75 | """A bone in an FSKL.""" 76 | _struct = BoneStruct 77 | 78 | def __init__(self, fres): 79 | self.fres = fres 80 | self.offset = None 81 | self.parent = None # to be set by FSKL on read 82 | 83 | 84 | def __str__(self): 85 | return "" %( 86 | '?' if self.offset is None else hex(self.offset), 87 | id(self), 88 | ) 89 | 90 | 91 | def dump(self): 92 | """Dump to string for debug.""" 93 | flags = [] 94 | for name in sorted(self.flags.keys()): 95 | if name != '_raw': 96 | val = self.flags[name] 97 | if val: flags.append(name) 98 | flags = ', '.join(flags) 99 | rotD = ' (%3d, %3d, %3d, %3d°)' % tuple(map(math.degrees, self.rot)) 100 | 101 | res = [] 102 | res.append("Bone #%3d '%s':" % (self.bone_idx, self.name)) 103 | res.append("Position: %#5.2f, %#5.2f, %#5.2f" % tuple(self.pos)) 104 | res.append("Rotation: %#5.2f, %#5.2f, %#5.2f, %#5.2f" % tuple(self.rot) + rotD) 105 | res.append("Scale: %#5.2f, %#5.2f, %#5.2f" % tuple(self.scale)) 106 | res.append("Unk04: 0x%08X 0x%08X 0x%08X 0x%08X 0x%08X" % 107 | self.unk04) 108 | res.append("Parent Idx: %3d" % self.parent_idx) 109 | res.append("Smooth Mtx Idx: %3d" % self.smooth_mtx_idx) 110 | res.append("Rigid Mtx Idx: %3d" % self.rigid_mtx_idx) 111 | res.append("Billboard Idx: %3d" % self.billboard_idx) 112 | res.append("Udata count: %3d" % self.udata_count) 113 | res.append("Flags: %s" % flags) 114 | #res = ', '.join(res) 115 | return '\n'.join(res).replace('\n', '\n ') 116 | #return res 117 | 118 | 119 | def readFromFRES(self, offset=None): 120 | """Read this object from given file.""" 121 | if offset is None: offset = self.fres.file.tell() 122 | #log.debug("Reading Bone from 0x%06X", offset) 123 | self.offset = offset 124 | data = self.fres.read(BoneStruct(), offset) 125 | 126 | self.name = data['name'] 127 | self.pos = data['pos'] 128 | self.rot = data['rot'] 129 | self.scale = data['scale'] 130 | self.unk04 = data['unk04'] 131 | self.bone_idx = data['bone_idx'] 132 | self.parent_idx = data['parent_idx'] 133 | self.smooth_mtx_idx = data['smooth_mtx_idx'] 134 | self.rigid_mtx_idx = data['rigid_mtx_idx'] 135 | self.billboard_idx = data['billboard_idx'] 136 | self.udata_count = data['udata_count'] 137 | self.flags = data['flags'] 138 | 139 | return self 140 | 141 | 142 | def computeTransform(self): 143 | """Compute final transformation matrix.""" 144 | T = self.pos 145 | S = self.scale 146 | R = self.rot 147 | 148 | # why have these flags instead of just setting the 149 | # values to 0/1? WTF Nintendo. 150 | # they seem to only be set when the values already are 151 | # 0 (or 1, for scale) anyway. 152 | #if self.flags['NO_ROTATION']: R = Vec4(0, 0, 0, 1) 153 | #if self.flags['NO_TRANSLATION']: T = Vec3(0, 0, 0) 154 | #if self.flags['SCALE_VOL_1']: S = Vec3(1, 1, 1) 155 | if self.flags['SEG_SCALE_COMPENSATE']: 156 | # apply inverse of parent's scale 157 | if self.parent: 158 | S[0] *= 1 / self.parent.scale[0] 159 | S[1] *= 1 / self.parent.scale[1] 160 | S[2] *= 1 / self.parent.scale[2] 161 | else: 162 | log.warning("Bone '%s' has flag SEG_SCALE_COMPENSATE but no parent", self.name) 163 | # no idea what "scale uniformly" actually means. 164 | # XXX billboarding, rigid mtxs, if ever used. 165 | 166 | # Build matrices from these transformations. 167 | T = mathutils.Matrix.Translation(T).to_4x4().transposed() 168 | Sm = mathutils.Matrix.Translation((0, 0, 0)).to_4x4() 169 | Sm[0][0] = S[0] 170 | Sm[1][1] = S[1] 171 | Sm[2][2] = S[2] 172 | S = Sm 173 | R = self._fromEulerAngles(R).to_matrix().to_4x4().transposed() 174 | if self.parent: 175 | P = self.parent.computeTransform()#.to_4x4() 176 | else: 177 | P = mathutils.Matrix.Translation((0, 0, 0)).to_4x4() 178 | M = mathutils.Matrix.Translation((0, 0, 0)).to_4x4() 179 | 180 | # Apply transformations. (order is important!) 181 | M = M * S 182 | M = M * R 183 | M = M * T 184 | M = M * P 185 | 186 | #log.debug("Final bone transform %s: %s", self.name, M) 187 | return M 188 | 189 | 190 | def _fromAxisAngle(self, axis, angle): 191 | return mathutils.Quaternion(( 192 | math.cos(angle / 2), 193 | axis[0] * math.sin(angle / 2), 194 | axis[1] * math.sin(angle / 2), 195 | axis[2] * math.sin(angle / 2), 196 | )) 197 | 198 | def _fromEulerAngles(self, rot): 199 | x = self._fromAxisAngle((1,0,0), rot[0]) 200 | y = self._fromAxisAngle((0,1,0), rot[1]) 201 | z = self._fromAxisAngle((0,0,1), rot[2]) 202 | #q = x * y * z 203 | q = z * y * x 204 | if q.w < 0: q *= -1 205 | return q 206 | -------------------------------------------------------------------------------- /bfres/Importer/LodImporter.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | import bmesh 3 | import bpy 4 | import bpy_extras 5 | import struct 6 | from .MaterialImporter import MaterialImporter 7 | from .SkeletonImporter import SkeletonImporter 8 | from bfres.Exceptions import UnsupportedFormatError, MalformedFileError 9 | 10 | 11 | class LodImporter: 12 | """Handles importing LOD models.""" 13 | def __init__(self, parent): 14 | self.parent = parent 15 | 16 | 17 | def _importLod(self, fvtx, fmdl, fshp, lod, idx): 18 | """Import given LOD model.""" 19 | self.fvtx = fvtx 20 | self.fmdl = fmdl 21 | self.fshp = fshp 22 | self.lod = lod 23 | self.lodIdx = idx 24 | self.attrBuffers = self._getAttrBuffers() 25 | 26 | # Create an object for this LOD 27 | self.lodName = "%s.%d" % (self.fshp.name, self.lodIdx) 28 | self.lodObj = bpy.data.objects.new(self.lodName, None) 29 | self.meshObj = self._createMesh() 30 | 31 | self._addUvMap() 32 | self._addVertexWeights() 33 | self._addArmature() 34 | 35 | return self.meshObj 36 | 37 | 38 | def _getAttrBuffers(self): 39 | """Get attribute data for this LOD. 40 | 41 | Returns a dict of attribute name => [values]. 42 | """ 43 | attrBuffers = {} 44 | for attr in self.fvtx.attrs: 45 | attrBuffers[attr.name] = [] 46 | 47 | #log.debug("LOD submeshes: %s", self.lod.submeshes) 48 | 49 | for i, submesh in enumerate(self.lod.submeshes): 50 | log.debug("Reading submesh %d...", i) 51 | idxs = submesh['idxs'] 52 | if len(idxs) == 0: 53 | raise MalformedFileError("Submesh %d is empty" % i) 54 | #log.debug("Submesh idxs (%d): %s", len(idxs), idxs) 55 | for idx in range(max(idxs)+1): 56 | for attr in self.fvtx.attrs: 57 | fmt = attr.format 58 | func = fmt.get('func', None) 59 | size = struct.calcsize(fmt['fmt']) 60 | buf = self.fvtx.buffers[attr.buf_idx] 61 | offs = attr.buf_offs + (idx * buf.stride) 62 | data = buf.data[offs : offs + size] 63 | try: 64 | data = struct.unpack(fmt['fmt'], data) 65 | except struct.error: 66 | log.error("Submesh %d reading out of bounds for attribute '%s' (offs=0x%X len=0x%X fmt=%s)", 67 | i, attr.name, offs, len(buf.data), 68 | fmt['fmt']) 69 | raise MalformedFileError("Submesh %d reading out of bounds for attribute '%s'" % ( 70 | i, attr.name)) 71 | if func: data = func(data) 72 | attrBuffers[attr.name].append(data) 73 | 74 | #for name, data in attrBuffers.items(): 75 | # print("%s: %s" % ( 76 | # name, ' '.join(map(str, data[0:16])) 77 | # )) 78 | return attrBuffers 79 | 80 | 81 | def _createMesh(self): 82 | p0 = self.attrBuffers['_p0'] 83 | idxs = self.lod.idx_buf 84 | # the game doesn't tell how many vertices each LOD has, 85 | # but we can usually rely on this. 86 | nVtxs = int(self.lod.header['idx_cnt'] / 3) 87 | log.debug("LOD has %d vtxs, %d idxs", nVtxs, len(idxs)) 88 | 89 | # create a mesh and add faces to it 90 | mesh = bmesh.new() 91 | self._addVerticesToMesh(mesh, p0) 92 | self._createFaces(idxs, mesh) 93 | 94 | # Write the bmesh data back to a new mesh. 95 | fshpMesh = bpy.data.meshes.new(self.lodName) 96 | mesh.to_mesh(fshpMesh) 97 | mesh.free() 98 | meshObj = bpy.data.objects.new(fshpMesh.name, fshpMesh) 99 | mdata = meshObj.data 100 | bpy.context.scene.objects.link(meshObj) 101 | self.parent._add_object_to_group(meshObj, self.fmdl.name) 102 | 103 | # Add material 104 | mat = self.fmdl.fmats[self.fshp.header['fmat_idx']] 105 | mdata.materials.append(bpy.data.materials[mat.name]) 106 | 107 | return meshObj 108 | 109 | 110 | def _createFaces(self, idxs, mesh): 111 | """Create the faces.""" 112 | fmt = self.lod.prim_fmt 113 | meth = getattr(self, '_createFaces_'+fmt, None) 114 | if meth is None: 115 | log.error("Unsupported prim format: %s", fmt) 116 | raise UnsupportedFormatError( 117 | "Unsupported prim format: " + fmt) 118 | try: 119 | return meth(idxs, mesh) 120 | except (struct.error, IndexError): 121 | raise MalformedFileError("LOD submesh faces are out of bounds") 122 | 123 | def _createFacesBasic(self, idxs, mesh, step, nVtxs): 124 | for i in range(0, len(idxs), step): 125 | try: 126 | vs = list(mesh.verts[j] for j in idxs[i:i+nVtxs]) 127 | #log.debug("face %d: %s", i, vs) 128 | face = mesh.faces.new(vs) 129 | face.smooth = self.parent.operator.smooth_faces 130 | except IndexError: 131 | log.error("LOD submesh face %d is out of bounds (max %d)", 132 | i, len(idxs)) 133 | raise 134 | 135 | def _createFaces_point_list(self, idxs, mesh): 136 | return self._createFacesBasic(idxs, mesh, 1, 1) 137 | 138 | def _createFaces_line_list(self, idxs, mesh): 139 | return self._createFacesBasic(idxs, mesh, 2, 2) 140 | 141 | def _createFaces_line_strip(self, idxs, mesh): 142 | return self._createFacesBasic(idxs, mesh, 1, 2) 143 | 144 | def _createFaces_triangle_list(self, idxs, mesh): 145 | return self._createFacesBasic(idxs, mesh, 3, 3) 146 | 147 | 148 | def _addVerticesToMesh(self, mesh, vtxs): 149 | """Add vertices (from `_p0` attribute) to a `bmesh`.""" 150 | for i in range(len(vtxs)): 151 | try: 152 | if len(vtxs[i]) == 4: 153 | x, y, z, w = vtxs[i] 154 | else: 155 | x, y, z = vtxs[i] 156 | w = 1 157 | if w != 1: 158 | # Blender doesn't support the W coord, 159 | # but it's never used anyway. 160 | # XXX it can be used if the mesh uses quaternions 161 | # (or is it only skeletons that have that flag?) 162 | log.warn("FRES: FSHP vertex #%d W coord is %f, should be 1", i, w) 163 | except IndexError: 164 | log.error("LOD submesh vtx %d is out of bounds (max %d)", 165 | i, len(vtxs)) 166 | raise 167 | mesh.verts.new((x, -z, y)) 168 | mesh.verts.ensure_lookup_table() 169 | mesh.verts.index_update() 170 | 171 | 172 | def _addUvMap(self): 173 | """Add UV maps from `_u0`, `_u1`... attributes.""" 174 | idx = 0 175 | while True: 176 | attr = '_u%d' % idx 177 | try: data = self.attrBuffers[attr] 178 | except KeyError: break 179 | 180 | vMax = self.fvtx.attrsByName[attr].format.get('max', 1) 181 | mdata = self.meshObj.data 182 | mdata.uv_textures.new(attr) 183 | for i, poly in enumerate(mdata.polygons): 184 | for j, loopIdx in enumerate(poly.loop_indices): 185 | loop = mdata.loops[loopIdx] 186 | uvloop = mdata.uv_layers.active.data[loopIdx] 187 | x, y = data[loop.vertex_index] 188 | uvloop.uv.x, uvloop.uv.y = x/vMax, y/vMax 189 | idx += 1 190 | 191 | 192 | def _addVertexWeights(self): 193 | """Add vertex weights (`_w0` attribute) to mesh.""" 194 | try: 195 | self._makeVertexGroup() 196 | except KeyError: 197 | log.info("mesh '%s' has no weights", self.lodName) 198 | 199 | 200 | def _addArmature(self): 201 | """Add armature to mesh.""" 202 | mod = self.meshObj.modifiers.new(self.lodName, 'ARMATURE') 203 | mod.object = self.parent.armature 204 | mod.use_bone_envelopes = False 205 | mod.use_vertex_groups = True 206 | return mod 207 | 208 | 209 | def _makeVertexGroup(self): 210 | """Make vertex group for mesh object from attributes.""" 211 | # XXX move to SkeletonImporter? 212 | groups = {} 213 | try: 214 | w0 = self.attrBuffers['_w0'] 215 | i0 = self.attrBuffers['_i0'] 216 | except KeyError: 217 | log.info("FRES: mesh '%s' has no weights", 218 | self.meshObj.name) 219 | return 220 | 221 | # create a vertex group for each bone 222 | # each bone affects the vertex group with the same 223 | # name as that bone, and these weights define how much. 224 | for bone in self.fmdl.skeleton.bones: 225 | grp = self.meshObj.vertex_groups.new(bone.name) 226 | groups[bone.smooth_mtx_idx] = grp 227 | 228 | # i0 specifies the bone smooth matrix group. 229 | # Look for a bone with the same group. 230 | for i in range(0, len(w0)): 231 | wgt = w0[i] # how much this bone affects this vertex 232 | idx = i0[i] # which bone index group 233 | for j, w in enumerate(wgt): 234 | if w > 0: 235 | try: 236 | groups[idx[j]].add([i], w/255.0, 'REPLACE') 237 | except (KeyError, IndexError): 238 | log.warning("Bone group %d doesn't exist (referenced by weight of vtx %d, value %d)", 239 | idx[j], i, w) 240 | -------------------------------------------------------------------------------- /bfres/FRES/FMDL/FVTX.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.Padding import Padding 4 | from bfres.BinaryStruct.StringOffset import StringOffset 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryFile import BinaryFile 7 | from bfres.FRES.FresObject import FresObject 8 | from bfres.FRES.Dict import Dict 9 | from bfres.Exceptions import MalformedFileError 10 | from .Attribute import Attribute, AttrStruct 11 | from .Buffer import Buffer 12 | from .Vertex import Vertex 13 | import struct 14 | import math 15 | 16 | 17 | class BufferStrideStruct(BinaryStruct): 18 | """Vertex buffer stride info.""" 19 | fields = ( 20 | ('i', 'stride'), 21 | ('I', 'divisor'), # should be 0 22 | ('I', 'reserved1'), 23 | ('I', 'reserved2'), 24 | ) 25 | size = 0x10 26 | 27 | 28 | class BufferSizeStruct(BinaryStruct): 29 | """Vertex buffer size info.""" 30 | fields = ( 31 | ('I', 'size'), 32 | ('I', 'gpuAccessFlags'), # should be 5 33 | ('I', 'reserved1'), 34 | ('I', 'reserved2'), 35 | ) 36 | size = 0x10 37 | 38 | 39 | class Header(BinaryStruct): 40 | """FVTX header.""" 41 | magic = b'FVTX' 42 | fields = ( 43 | ('4s', 'magic'), # 0x00 44 | Padding(12), # 0x04 45 | Offset64('vtx_attrib_array_offs'), # 0x10 46 | Offset64('vtx_attrib_dict_offs'), # 0x18 47 | Offset64('mem_pool'), # 0x20 48 | Offset64('unk28'), # 0x28 49 | Offset64('unk30'), # 0x30 50 | Offset64('vtx_bufsize_offs'), # 0x38 => BufferSizeStruct 51 | Offset64('vtx_stridesize_offs'), # 0x40 => BufferStrideStruct 52 | Offset64('vtx_buf_array_offs'), # 0x48 53 | Offset32('vtx_buf_offs'), # 0x50 54 | ('B', 'num_attrs'), # 0x54 55 | ('B', 'num_bufs'), # 0x55 56 | ('H', 'index'), # 0x56; Section index: index into FVTX array of this entry. 57 | ('I', 'num_vtxs'), # 0x58 58 | ('I', 'skin_weight_influence'), # 0x5C 59 | ) 60 | size = 0x60 61 | 62 | 63 | class FVTX(FresObject): 64 | """A vertex buffer in an FRES.""" 65 | Header = Header 66 | 67 | def __init__(self, fres): 68 | self.fres = fres 69 | self.header = None 70 | self.headerOffset = None 71 | self.attrs = [] 72 | self.buffers = [] 73 | self.vtx_attrib_dict = None 74 | 75 | 76 | def __str__(self): 77 | return "" %( 78 | '?' if self.headerOffset is None else hex(self.headerOffset), 79 | id(self), 80 | ) 81 | 82 | 83 | def dump(self): 84 | """Dump to string for debug.""" 85 | res = [] 86 | res.append(' FVTX index %2d: %3d attrs, %3d buffers, %4d vtxs; Skin weight influence: %d' % ( 87 | self.header['index'], 88 | self.header['num_attrs'], 89 | self.header['num_bufs'], 90 | self.header['num_vtxs'], 91 | self.header['skin_weight_influence'], 92 | )) 93 | res.append(' Mem Pool: 0x%08X' % 94 | self.header['mem_pool'], 95 | ) 96 | res.append(' Unk28: 0x%08X 0x%08X' % ( 97 | self.header['unk28'], self.header['unk30'], 98 | )) 99 | res.append(' Attrib Array: 0x%06X, Dict: 0x%06X' % ( 100 | self.header['vtx_attrib_array_offs'], 101 | self.header['vtx_attrib_dict_offs'], 102 | )) 103 | res.append(" Attrib Dict: "+ 104 | self.vtx_attrib_dict.dump().replace('\n', '\n ')) 105 | 106 | if len(self.attrs) > 0: 107 | res.append(' Attribute Dump:') 108 | res.append(' \x1B[4m# │Idx│BufOfs│Format │Unk04 │Attr Name\x1B[0m') 109 | for i, attr in enumerate(self.attrs): 110 | res.append(' %3d│%s' % (i, attr.dump())) 111 | 112 | res.append(' Buffer Array: 0x%06X, Data: 0x%06X' % ( 113 | self.header['vtx_buf_array_offs'], 114 | self.header['vtx_buf_offs'], 115 | )) 116 | res.append(' BufSiz Array: 0x%06X, Stride: 0x%06X' % ( 117 | self.header['vtx_bufsize_offs'], 118 | self.header['vtx_stridesize_offs'], 119 | )) 120 | 121 | if len(self.buffers) > 0: 122 | res.append(' \x1B[4mOffset│Size│Strd│Buffer data\x1B[0m') 123 | for buf in self.buffers: 124 | res.append(' ' + buf.dump()) 125 | return '\n'.join(res).replace('\n', '\n ') 126 | 127 | 128 | def readFromFRES(self, offset=None): 129 | """Read this object from given file.""" 130 | if offset is None: offset = self.fres.file.tell() 131 | log.debug("Reading FVTX from 0x%06X", offset) 132 | self.headerOffset = offset 133 | self.header = self.fres.read(Header(), offset) 134 | 135 | try: 136 | self._readDicts() 137 | self._readBuffers() 138 | self._readAttrs() 139 | self._readVtxs() 140 | except struct.error: 141 | log.exception("Error reading FVTX") 142 | raise 143 | return self 144 | 145 | 146 | def _readDicts(self): 147 | """Read the dicts belonging to this FVTX.""" 148 | self.vtx_attrib_dict = Dict(self.fres) 149 | self.vtx_attrib_dict.readFromFRES( 150 | self.header['vtx_attrib_dict_offs']) 151 | 152 | 153 | def _readBuffers(self): 154 | """Read the attribute data buffers.""" 155 | #dataOffs = self.header['vtx_buf_offs'] + \ 156 | # self.fres.rlt.sections[1]['curOffset'] 157 | dataOffs = self.header['vtx_buf_offs'] + \ 158 | self.fres.bufferSection['buf_offs'] 159 | bufSize = self.header['vtx_bufsize_offs'] 160 | strideSize = self.header['vtx_stridesize_offs'] 161 | log.debug("FVTX offsets: dataOffs=0x%X bufSize=0x%X strideSize=0x%X bufferSection.size=0x%X, offs=0x%X, vtx_buf_offs=0x%X", 162 | dataOffs, bufSize, strideSize, 163 | self.fres.bufferSection['size'], 164 | self.fres.bufferSection['buf_offs'], 165 | self.header['vtx_buf_offs']) 166 | 167 | self.buffers = [] 168 | file = self.fres.file 169 | for i in range(self.header['num_bufs']): 170 | #log.debug("Read buffer %d from 0x%X", i, dataOffs) 171 | n = i*0x10 172 | sizeStruct = BufferSizeStruct().readFromFile( 173 | self.fres, bufSize+n) 174 | strideStruct = BufferStrideStruct().readFromFile( 175 | self.fres, strideSize+n) 176 | size = sizeStruct['size'] 177 | stride = strideStruct['stride'] 178 | if strideStruct['divisor'] != 0: 179 | log.warning("Buffer %d stride divisor is %d, expected 0", i, strideStruct['divisor']) 180 | 181 | #size = self.fres.read('I', bufSize+n) 182 | #stride = self.fres.read('I', strideSize+n) 183 | buf = Buffer(self.fres, size, stride, dataOffs) 184 | self.buffers.append(buf) 185 | dataOffs += buf.size 186 | 187 | 188 | def _readAttrs(self): 189 | """Read the attribute definitions.""" 190 | self.attrs = [] 191 | self.attrsByName = {} 192 | offs = self.header['vtx_attrib_array_offs'] 193 | for i in range(self.header['num_attrs']): 194 | attr = Attribute(self).readFromFRES(offs) 195 | self.attrs.append(attr) 196 | self.attrsByName[attr.name] = attr 197 | offs += AttrStruct.size 198 | 199 | 200 | def _readVtxs(self): 201 | """Read the vertices from the buffers.""" 202 | self.vtxs = [] 203 | for iVtx in range(self.header['num_vtxs']): 204 | vtx = Vertex() 205 | for attr in self.attrs: # get the data for each attribute 206 | if attr.buf_idx >= len(self.buffers) or attr.buf_idx < 0: 207 | log.error("Attribute '%s' uses buffer %d, but max index is %d", 208 | attr.name, attr.buf_idx, len(self.buffers)-1) 209 | raise MalformedFileError("Invalid buffer index for attribute "+attr.name) 210 | buf = self.buffers[attr.buf_idx] 211 | offs = attr.buf_offs + (iVtx * buf.stride) 212 | fmt = attr.format 213 | #log.debug("Read attr '%s' from buffer %d, offset 0x%X, stride 0x%X, fmt %s", 214 | # attr.name, attr.buf_idx, attr.buf_offs, 215 | # buf.stride, fmt['name']) 216 | 217 | # get the conversion function if any 218 | func = None 219 | if type(fmt) is dict: 220 | func = fmt.get('func', None) 221 | fmt = fmt['fmt'] 222 | 223 | # get the data 224 | try: 225 | data = struct.unpack_from(fmt, buf.data, offs) 226 | except struct.error: 227 | log.error("Attribute '%s' reading out of bounds from buffer %d (vtx %d of %d = offset 0x%X fmt '%s', max = 0x%X)", 228 | attr.name, attr.buf_idx, iVtx, 229 | self.header['num_vtxs'], offs, fmt, 230 | len(buf.data)) 231 | raise MalformedFileError("Invalid buffer offset for attribute "+attr.name) 232 | if func: data = func(data) 233 | 234 | # validate 235 | d = data 236 | if type(d) not in (list, tuple): d = [d] 237 | for v in d: 238 | if math.isinf(v) or math.isnan(v): 239 | log.warning("%s value in attribute %s vtx %d (offset 0x%X buffer %d base 0x%X)", 240 | str(v), attr.name, iVtx, offs, 241 | attr.buf_idx, attr.buf_offs) 242 | 243 | vtx.setAttr(attr, data) 244 | 245 | self.vtxs.append(vtx) 246 | -------------------------------------------------------------------------------- /bfres/FRES/__init__.py: -------------------------------------------------------------------------------- 1 | import logging; log = logging.getLogger(__name__) 2 | from bfres.BinaryStruct import BinaryStruct, BinaryObject 3 | from bfres.BinaryStruct.StringOffset import StringOffset 4 | from bfres.BinaryStruct.Padding import Padding 5 | from bfres.BinaryStruct.Switch import Offset32, Offset64, String 6 | from bfres.BinaryFile import BinaryFile 7 | from .RLT import RLT 8 | from bfres.Common import StringTable 9 | from bfres.Exceptions import \ 10 | UnsupportedFormatError, UnsupportedFileTypeError 11 | from .Dict import Dict 12 | from .EmbeddedFile import EmbeddedFile, Header as EmbeddedFileHeader 13 | from .FMDL import FMDL, Header as FMDLHeader 14 | from .BufferSection import BufferSection 15 | from .DumpMixin import DumpMixin 16 | import traceback 17 | import struct 18 | 19 | # XXX WiiU header 20 | 21 | class SwitchHeader(BinaryStruct): 22 | """Switch FRES header.""" 23 | magic = b'FRES ' # four spaces 24 | fields = ( 25 | ('8s', 'magic'), # 0x00 26 | ('<2H','version'), # 0x08 27 | ('H', 'byte_order'), # 0x0C; FFFE=litle, FEFF=big 28 | ('B', 'alignment'), # 0x0E 29 | ('B', 'addr_size'), # 0x0F; target address size, usually 0 30 | 31 | String('name', lenprefix=None), #0x10; null-terminated filename 32 | ('H', 'flags'), # 0x14 33 | ('H', 'block_offset'), # 0x16 34 | 35 | Offset32('rlt_offset'), # 0x18; relocation table 36 | Offset32('file_size'), # 0x1C; size of this file 37 | 38 | String('name2', fmt='Q'), # 0x20; length-prefixed filename 39 | # name and name2 seem to always both be the filename 40 | # without extension, and in fact name points to the actual 41 | # string following the length prefix that name2 points to. 42 | 43 | Offset64('fmdl_offset'), # 0x28 44 | Offset64('fmdl_dict_offset'), # 0x30 45 | 46 | Offset64('fska_offset'), # 0x38 47 | Offset64('fska_dict_offset'), # 0x40 48 | 49 | Offset64('fmaa_offset'), # 0x48 50 | Offset64('fmaa_dict_offset'), # 0x50 51 | 52 | Offset64('fvis_offset'), # 0x58 53 | Offset64('fvis_dict_offset'), # 0x60 54 | 55 | Offset64('fshu_offset'), # 0x68 56 | Offset64('fshu_dict_offset'), # 0x70 57 | 58 | Offset64('fscn_offset'), # 0x78 59 | Offset64('fscn_dict_offset'), # 0x80 60 | 61 | Offset64('buf_mem_pool'), # 0x88 62 | Offset64('buf_section_offset'), # 0x90; BufferSection offset 63 | 64 | Offset64('embed_offset'), # 0x98 65 | Offset64('embed_dict_offset'), # 0xA0 66 | 67 | Padding(8), # 0xA8; might be an unused offset? 68 | Offset64('str_tab_offset'), # 0xB0 69 | Offset32('str_tab_size'), # 0xB8 70 | 71 | ('H', 'fmdl_cnt'), # 0xBC 72 | ('H', 'fska_cnt'), # 0xBE 73 | ('H', 'fmaa_cnt'), # 0xC0 74 | ('H', 'fvis_cnt'), # 0xC2 75 | ('H', 'fshu_cnt'), # 0xC4 76 | ('H', 'fscn_cnt'), # 0xC6 77 | ('H', 'embed_cnt'), # 0xC8 78 | Padding(6), # 0xCA 79 | ) 80 | size = 0xD0 81 | 82 | 83 | class FRES(DumpMixin): 84 | """FRES file.""" 85 | 86 | def __init__(self, file:BinaryFile): 87 | self.file = file 88 | self.models = [] # fmdl 89 | self.animations = [] # fska 90 | self.buffers = [] # buffer data 91 | self.embeds = [] # embedded files 92 | 93 | # read magic and determine file type 94 | pos = file.tell() 95 | magic = file.read('8s') 96 | file.seek(pos) # return to previous position 97 | if magic == b'FRES ': 98 | Header = SwitchHeader() 99 | self.header = Header.readFromFile(file) 100 | elif magic[0:4] == b'FRES': 101 | raise UnsupportedFormatError( 102 | "Sorry, WiiU files aren't supported yet") 103 | else: 104 | raise UnsupportedFileTypeError(magic) 105 | 106 | # extract some header info. 107 | #log.debug("FRES header:\n%s", Header.dump(self.header)) 108 | self.name = self.header['name'] 109 | self.size = self.header['file_size'] 110 | self.version = self.header['version'] 111 | 112 | #self._readLogFile = open('./%s.map.csv' % self.name, 'w') 113 | 114 | if self.version != (3, 5): 115 | log.warning("Unknown FRES version 0x%04X 0x%04X", 116 | self.version[0], self.version[1]) 117 | 118 | if self.header['byte_order'] == 0xFFFE: 119 | self.byteOrder = 'little' 120 | self.byteOrderFmt = '<' 121 | elif self.header['byte_order'] == 0xFEFF: 122 | self.byteOrder = 'big' 123 | self.byteOrderFmt = '>' 124 | else: 125 | raise ValueError("Invalid byte order 0x%04X in FRES header" % 126 | self.header['byte_order']) 127 | 128 | 129 | def decode(self): 130 | """Decode objects from the file.""" 131 | self.rlt = RLT(self).readFromFRES() 132 | 133 | # str_tab_offset points to the first actual string, not 134 | # the header. (maybe it's actually the offset of some string, 135 | # which happens to be empty here?) 136 | offs = self.header['str_tab_offset'] - StringTable.Header.size 137 | self.strtab = StringTable().readFromFile(self, offs) 138 | 139 | self._readBufferSection() 140 | 141 | self.embeds = self._readObjects( 142 | EmbeddedFile, 'embed', EmbeddedFileHeader.size) 143 | 144 | self.models = self._readObjects(FMDL, 'fmdl', 145 | FMDLHeader.size) 146 | # XXX fska, fmaa, fvis, fshu, fscn 147 | 148 | 149 | def _readObjects(self, typ, name, size): 150 | """Read array of objects from the file.""" 151 | offs = self.header[name + '_offset'] 152 | cnt = self.header[name + '_cnt'] 153 | dofs = self.header[name + '_dict_offset'] 154 | objs = [] 155 | log.debug("Reading dict '%s' from 0x%X", name, dofs) 156 | if dofs == 0: return objs 157 | objDict = Dict(self).readFromFRES(dofs) 158 | for i in range(cnt): 159 | objName = objDict.root.left.name 160 | log.debug('Reading %s #%2d @ %06X: "%s"', 161 | typ.__name__, i, offs, objName) 162 | obj = typ(self, objName).readFromFRES(offs) 163 | objs.append(obj) 164 | offs += size 165 | return objs 166 | 167 | 168 | def _readBufferSection(self): 169 | """Read the BufferSection struct.""" 170 | if self.header['buf_section_offset'] != 0: 171 | self.bufferSection = BufferSection().readFromFile(self.file, 172 | self.header['buf_section_offset']) 173 | log.debug("BufferSection at 0x%06X: unk=0x%X size=0x%X offs=0x%X", 174 | self.header['buf_section_offset'], 175 | self.bufferSection['unk00'], 176 | self.bufferSection['size'], 177 | self.bufferSection['buf_offs']) 178 | else: 179 | self.bufferSection = None 180 | log.debug("No BufferSection in this FRES") 181 | 182 | 183 | def _logRead(self, size, pos, count, rel): 184 | """Log reads to file for debug.""" 185 | typ = '-' 186 | if type(size) is str: 187 | typ = size 188 | size = struct.calcsize(size) 189 | elif type(size) is not int: 190 | typ = type(size).__name__ 191 | size = size.size 192 | size *= count 193 | file, line, func = '?', 0, '?' 194 | 195 | stack = traceback.extract_stack() 196 | for frame in reversed(stack): 197 | file, line, func = frame.filename, frame.lineno, frame.name 198 | file = file.split('/') 199 | if file[-1] == '__init__.py': file = file[0:-1] 200 | for i, p in enumerate(file): 201 | if p == 'bfres': 202 | file = file[i+1:] 203 | break 204 | file = '/'.join(file) 205 | 206 | ok = True 207 | if func in ('', 'read', '_logRead', 'readStr', 'readHex', 'readHexWords', 'decode'): 208 | ok = False 209 | if file in ( 210 | 'Importer/Importer.py', 211 | 'Importer/ImportOperator.py', 212 | 'BinaryStruct', 213 | 'BinaryStruct/BinaryObject.py',): 214 | ok = False 215 | if ok: break 216 | 217 | s = "%06X,%06X,%s,%d,%s,%s,%s\n" % ( 218 | pos, size, file, line, func, typ, rel) 219 | self._readLogFile.write(s) 220 | 221 | 222 | def read(self, size:(int,str,BinaryStruct), 223 | pos:int=None, count:int=1, 224 | rel:bool=False): 225 | """Read data from file. 226 | 227 | fmt: Number of bytes to read, or a `struct` format string, 228 | or a BinaryStruct. 229 | pos: Position to seek to first. (optional) 230 | count: Number of items to read. If not 1, returns a list. 231 | 232 | Returns the data read. 233 | """ 234 | if rel: 235 | log.warning("Read using rel=True: %s", traceback.extract_stack()) 236 | if pos is None: pos = self.file.tell() 237 | if rel: pos += self.rlt.sections[1]['curOffset'] # XXX 238 | #self._logRead(size, pos, count, rel) 239 | return self.file.read(pos=pos, fmt=size, count=count) 240 | 241 | 242 | def seek(self, pos, whence=0): 243 | """Seek the file.""" 244 | return self.file.seek(pos, whence) 245 | 246 | 247 | def tell(self): 248 | """Report the current position of the file.""" 249 | return self.file.tell() 250 | 251 | 252 | def readStr(self, offset, fmt=' offsets of attr names 29 | Offset64('vtx_attr_dict'), 30 | Offset64('tex_attr_names'), 31 | Offset64('tex_attr_dict'), 32 | Offset64('mat_param_vals'), # names from dict 33 | Offset64('mat_param_dict'), Padding(4), 34 | ('B', 'num_vtx_attrs'), 35 | ('B', 'num_tex_attrs'), 36 | ('H', 'num_mat_params'), 37 | ) 38 | 39 | 40 | class Header(BinaryStruct): 41 | """FMAT header.""" 42 | magic = b'FMAT' 43 | fields = ( 44 | ('4s', 'magic'), # 0x00 45 | ('I', 'size'), # 0x04 46 | ('I', 'size2'), Padding(4), # 0x08 47 | String ('name'), Padding(4), # 0x10 48 | Offset64('render_param_offs'), # 0x18 49 | Offset64('render_param_dict_offs'), # 0x20 50 | Offset64('shader_assign_offs'), # 0x28 -> name offsets 51 | Offset64('unk30_offs'), # 0x30 52 | Offset64('tex_ref_array_offs'), # 0x38 53 | Offset64('unk40_offs'), # 0x40 54 | Offset64('sampler_list_offs'), # 0x48 55 | Offset64('sampler_dict_offs'), # 0x50 56 | Offset64('shader_param_array_offs'), # 0x58 57 | Offset64('shader_param_dict_offs'), # 0x60 58 | Offset64('shader_param_data_offs'), # 0x68 59 | Offset64('user_data_offs'), # 0x70 60 | Offset64('user_data_dict_offs'), # 0x78 61 | Offset64('volatile_flag_offs'), # 0x80 62 | Offset64('user_offs'), # 0x88 63 | Offset64('sampler_slot_offs'), # 0x90 64 | Offset64('tex_slot_offs'), # 0x98 65 | ('I', 'mat_flags'), # 0xA0 66 | ('H', 'section_idx'), # 0xA4 67 | ('H', 'render_param_cnt'), # 0xA6 68 | ('B', 'tex_ref_cnt'), # 0xA8 69 | ('B', 'sampler_cnt'), # 0xA9 70 | ('H', 'shader_param_cnt'), # 0xAA 71 | ('H', 'shader_param_data_size'), # 0xAC 72 | ('H', 'raw_param_data_size'), # 0xAE 73 | ('H', 'user_data_cnt'), # 0xB0 74 | ('H', 'unkB2'), # 0xB2; usually 0 or 1 75 | ('I', 'unkB4'), # 0xB4 76 | ) 77 | size = 0xB8 78 | 79 | 80 | class FMAT(FresObject): 81 | """A material object in an FRES.""" 82 | Header = Header 83 | 84 | def __init__(self, fres): 85 | self.fres = fres 86 | self.header = None 87 | self.headerOffset = None 88 | self.name = None 89 | 90 | 91 | def __str__(self): 92 | return "" %( 93 | str(self.name), 94 | '?' if self.headerOffset is None else hex(self.headerOffset), 95 | id(self), 96 | ) 97 | 98 | 99 | def dump(self): 100 | """Dump to string for debug.""" 101 | dicts = ('render_param', 'sampler', 'shader_param', 'user_data') 102 | 103 | res = [] 104 | # Dump dicts 105 | #for i, name in enumerate(dicts): 106 | # d = getattr(self, name+'_dict') 107 | # if i == 0: name = ' '+name 108 | # if d is None: 109 | # res.append(name+": ") 110 | # else: 111 | # res.append(name+': '+ d.dump()) 112 | 113 | # Dump render params 114 | res.append("Render params:") 115 | res.append(" \x1B[4mParam "+ 116 | "│Type │Cnt│Value\x1B[0m") 117 | for name, param in self.renderParams.items(): 118 | res.append(" %-32s│%-8s│%3d│%s" % ( 119 | name, param['type'], param['count'], param['vals'])) 120 | 121 | # Dump shader params 122 | res.append("Shader params:") 123 | res.append(" \x1B[4mParam "+ 124 | "│Type │Size│Offset│Idx0│Idx1│Unk00│Unk14│Data\x1B[0m") 125 | for name, param in self.shaderParams.items(): 126 | res.append(" %-40s│%6s│%4d│%06X│%4d│%4d│%5d│%5d│%s" % ( 127 | name, 128 | param['type']['name'], 129 | param['size'], param['offset'], 130 | param['idxs'][0], param['idxs'][1], 131 | param['unk00'], param['unk14'], 132 | param['data'])) 133 | 134 | # Dump texture list 135 | res.append("Textures:") 136 | res.append(" \x1B[4mIdx│Slot│Name\x1B[0m") 137 | for i, tex in enumerate(self.textures): 138 | res.append(" %3d│%4d│%s" % ( 139 | i, tex['slot'], tex['name'])) 140 | 141 | # Dump sampler list 142 | res.append("Samplers:") 143 | res.append(" \x1B[4mIdx│Slot│Data\x1B[0m") 144 | for i, smp in enumerate(self.samplers): 145 | res.append(" %3d│%4d│%s" % ( 146 | i, smp['slot'], smp['data'])) 147 | 148 | # Dump mat param list 149 | res.append("Material Parameters:") 150 | for name, val in self.materialParams.items(): 151 | res.append(" %-45s: %4s" % (name, val)) 152 | 153 | # Dump tex/vtx attrs 154 | res.append("Texture Attributes: " + (', '.join(self.texAttrs))) 155 | res.append("Vertex Attributes: " + (', '.join(self.vtxAttrs))) 156 | 157 | return '\n'.join(res).replace('\n', '\n ') 158 | 159 | 160 | def readFromFRES(self, offset=None): 161 | """Read this object from given file.""" 162 | if offset is None: offset = self.fres.file.tell() 163 | log.debug("Reading FMAT from 0x%06X", offset) 164 | self.headerOffset = offset 165 | self.header = self.fres.read(Header(), offset) 166 | self.name = self.header['name'] 167 | 168 | self._readDicts() 169 | self._readRenderParams() 170 | self._readShaderParams() 171 | self._readTextureList() 172 | self._readSamplerList() 173 | self._readShaderAssign() 174 | 175 | return self 176 | 177 | 178 | def _readDicts(self): 179 | """Read the dicts.""" 180 | dicts = ('render_param', 'sampler', 'shader_param', 'user_data') 181 | for name in dicts: 182 | offs = self.header[name + '_dict_offs'] 183 | if offs: data = self._readDict(offs, name) 184 | else: data = None 185 | setattr(self, name + '_dict', data) 186 | 187 | 188 | def _readDict(self, offs, name): 189 | """Read a Dict.""" 190 | d = Dict(self.fres).readFromFRES(offs) 191 | return d 192 | 193 | 194 | def _readRenderParams(self): 195 | """Read the render params list.""" 196 | self.renderParams = {} 197 | types = ('float[2]', 'float', 'str') 198 | base = self.header['render_param_offs'] 199 | 200 | for i in range(self.header['render_param_cnt']): 201 | name, offs, cnt, typ, pad = self.fres.read( 202 | 'QQHHI', base + (i*24)) 203 | name = self.fres.readStr(name) 204 | 205 | if pad != 0: 206 | log.warning("FRES: FMAT Render info '%s' padding=0x%X", 207 | name, pad) 208 | try: typeName = types[typ] 209 | except IndexError: typeName = '0x%X' % typ 210 | 211 | param = { 212 | 'name': name, 213 | 'count': cnt, 214 | 'type': types[typ], 215 | 'vals': [], 216 | } 217 | for j in range(cnt): 218 | if typ == 0: val=self.fres.read('2f', offs) 219 | elif typ == 1: val=self.fres.read('f', offs) 220 | elif typ == 2: 221 | offs = self.fres.read('Q', offs) 222 | val = self.fres.readStr(offs) 223 | else: 224 | log.warning("FMAT Render param '%s' unknown type 0x%X", 225 | name, typ) 226 | val = '' 227 | param['vals'].append(val) 228 | 229 | #log.debug("Render param: %-5s[%d] %-32s: %s", 230 | # typeName, cnt, name, ', '.join(map(str, param['vals']))) 231 | 232 | if name in self.renderParams: 233 | log.warning("FMAT: Duplicate render param '%s'", name) 234 | self.renderParams[name] = param 235 | 236 | 237 | def _readShaderParams(self): 238 | """Read the shader param list.""" 239 | self.shaderParams = {} 240 | #print("FRES: FMAT Shader params:") 241 | 242 | array_offs = self.header['shader_param_array_offs'] 243 | data_offs = self.header['shader_param_data_offs'] 244 | for i in range(self.header['shader_param_cnt']): 245 | # unk0: always 0; unk14: always -1 246 | # idx0, idx1: both always == i 247 | unk0, name, type, size, offset, unk14, idx0, idx1 = \ 248 | self.fres.read('QQBBHiHH', array_offs + (i*32)) 249 | 250 | name = self.fres.readStr(name) 251 | type = shaderParamTypes[type] 252 | if unk0: 253 | log.debug("Shader param '%s' unk0=0x%X", name, unk0) 254 | if unk14 != -1: 255 | log.debug("Shader param '%s' unk14=%d", name, unk14) 256 | if idx0 != i or idx1 != i: 257 | log.debug("Shader param '%s' idxs=%d, %d (expected %d)", 258 | name, idx0, idx1, i) 259 | 260 | data = self.fres.read(size, data_offs + offset) 261 | data = struct.unpack(type['fmt'], data) 262 | 263 | #log.debug("%-38s %-5s %s", name, type['name'], 264 | # type['outfmt'] % data) 265 | 266 | if name in self.shaderParams: 267 | log.warning("Duplicate shader param '%s'", name) 268 | 269 | self.shaderParams[name] = { 270 | 'name': name, 271 | 'type': type, 272 | 'size': size, 273 | 'offset': offset, 274 | 'idxs': (idx0, idx1), 275 | 'unk00': unk0, 276 | 'unk14': unk14, 277 | 'data': data, 278 | } 279 | 280 | 281 | def _readTextureList(self): 282 | """Read the texture list.""" 283 | self.textures = [] 284 | for i in range(self.header['tex_ref_cnt']): 285 | offs = self.header['tex_ref_array_offs'] + (i*8) 286 | offs = self.fres.read('Q', offs) 287 | name = self.fres.readStr(offs) 288 | slot = self.fres.read('q', 289 | self.header['tex_slot_offs'] + (i*8)) 290 | #log.debug("%3d (%2d): %s", i, slot, name) 291 | self.textures.append({'name':name, 'slot':slot}) 292 | 293 | 294 | def _readSamplerList(self): 295 | """Read the sampler list.""" 296 | self.samplers = [] 297 | for i in range(self.header['sampler_cnt']): 298 | data = self.fres.readHexWords(8, 299 | self.header['sampler_list_offs'] + (i*32)) 300 | slot = self.fres.read('q', 301 | self.header['sampler_slot_offs'] + (i*8)) 302 | #log.debug("%3d (%2d): %s", i, slot, data) 303 | self.samplers.append({'slot':slot, 'data':data}) 304 | # XXX no idea what to do with this data 305 | 306 | 307 | def _readShaderAssign(self): 308 | """Read the shader assign data.""" 309 | assign = ShaderAssign() 310 | assign = assign.readFromFile(self.fres.file, 311 | self.header['shader_assign_offs']) 312 | self.shader_assign = assign 313 | 314 | self.vtxAttrs = [] 315 | for i in range(assign['num_vtx_attrs']): 316 | offs = self.fres.read('Q', assign['vtx_attr_names']+(i*8)) 317 | name = self.fres.readStr(offs) 318 | self.vtxAttrs.append(name) 319 | 320 | self.texAttrs = [] 321 | for i in range(assign['num_tex_attrs']): 322 | offs = self.fres.read('Q', assign['tex_attr_names']+(i*8)) 323 | name = self.fres.readStr(offs) 324 | self.texAttrs.append(name) 325 | 326 | self.mat_param_dict = self._readDict( 327 | assign['mat_param_dict'], "mat_params") 328 | self.materialParams = {} 329 | #log.debug("material params:") 330 | for i in range(assign['num_mat_params']): 331 | name = self.mat_param_dict.nodes[i+1].name 332 | offs = self.fres.read('Q', assign['mat_param_vals']+(i*8)) 333 | val = self.fres.readStr(offs) 334 | #log.debug("%-40s: %s", name, val) 335 | if name in self.materialParams: 336 | log.warning("FMAT: duplicate mat_param '%s'", name) 337 | if name != '': 338 | self.materialParams[name] = val 339 | --------------------------------------------------------------------------------