Ace Combat Assault Horizon FHM meshes and IMG textures

Skeletons, animations, shaders, texturing, converting, fixing and anything else related to read game models
Octave
Posts: 1
Joined: Mon Mar 01, 2021 5:27 pm

Ace Combat Assault Horizon FHM meshes and IMG textures

Post by Octave »

It's possible to unpack the game's QDF archives using ZXstudio's QDF tool
http://zxstudio.org/blog/open-horizon-downloads/

/target/model_id/mech/plyr contains the meshes for hero aircraft
/target/model_id/mech/airp/o_* contains the meshes for NPC aircraft
/target/model_id/mech/airp/d_* contains the meshes for destroyed aircraft
/target/model_id/tdb/plyr and airp contains the textures for their respective meshes

I tried using the FHM tool for Mobile Suit Gundam Extreme Vs Full Boost which has the same publisher, but it's not compatible.

The .img textures have DDS DXT4 in their headers but renaming the extension doesn't prove to be a quick fix.

You can get the files for the Tornado Gr4 below if anyone wants to take a stab at it.
http://www.mediafire.com/file/ozovxovel ... d.zip/file
enmacderm
Posts: 19
Joined: Wed Nov 02, 2016 4:15 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by enmacderm »

Hi, in fact you're right on the FHM storing the model data, most specific the pcom.fhm, but yeah, despite Gundam also begin developed by Bandai Namco, and using the same in-house container, the structure of both is very different, as NDXR meshes tends to get different variations also, making it different from other games and incompatible with plugins developed for some.

The .img textures of the aircraft textures, isn't something too ordinary(is obviously an exception with .nut texture files BTW), simply rename it to .dds, and drag it into photoshop or GIMP, if you need a viewer, download WTV from Nvidia, or XnView.

Now about the models, i managed to get some help to crack the structure of them, despite not begin ACAH exactly, i managed to reverse the F-16XL from Ace Combat Infinity, the main difference of both structures is the ACAH:EE and XBOX 360 ones are in Big Endian, when the PS3 ones are in Little Endian, along there are some minimum bytes the Big Endian pcom.fhm got that i wasn't able to debug correctly by memory dump. unfortunately, the process turns to be tedious by manual input, as the ACAH and ACI models, mostly the default aircraft, tends to get almost 70 to 100 meshes, along it obviously lack the armatures, but it wouldn't be such a issue if you got brief knowledge at replicating them for some parts, compared to a human rig.
Image

I would be interresed at least if someone could be developing a Blender plugin preferably, to automize the process for importing all parts of the mesh, mostly i would prefer using this method compared to the previous one i managed to develop using Renderdoc, the main negative point of it is the lack of UV maps intact, along the model would be squizzed in vertical axis too. for the people interresed, i would recommend the hex2obj explained tutorial:
https://forum.xentax.com/viewtopic.php?f=29&t=17890
along using the triangle strip reference from this thread:
https://forum.xentax.com/viewtopic.php?t=22230#p163865
mariokart64n
Posts: 12
Joined: Fri Aug 08, 2014 12:59 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by mariokart64n »

Image

Below is a python script I wrote for blender that was tested in the latest version (currently 2.92.0)

usage: start blender, goto the script tab then press the 'NEW' button, then paste the below script into the editor. To execute the script press ALT+P or press the [>] play button. When Pressed a file selection box will appear which will prompt to open a file to import. Once the script has executed once, thereafter the file prompt may then be accessed through the File > Import Dialog until blender is restarted


Code: Select all

""" ======================================================================

    PythonScript:   [PC] Ace Combat Assault Horizon
    Author:         mariokart64n
    Date:           March 22, 2021
    Version:        0.1

    ======================================================================

    ChangeLog:

    2021-03-22
        Script Wrote

    2021-03-23
        Adapted to work with PS3 Memory dump from Ace Combat Infinity

    ====================================================================== """

import bpy  # Needed to interface with blender
import struct  # Needed for Binary Reader
import math
from pathlib import Path  # Needed for os stuff


useOpenDialog = True


signed, unsigned = 0, 1  # Enums for read function
seek_set, seek_cur, seek_end = 0, 1, 2  # Enums for seek function
SEEK_ABS, SEEK_REL, SEEK_END = 0, 1, 2  # Enums for seek function


def messageBox(message="", title="Message Box", icon='INFO'):
    def draw(self, context): self.layout.label(text=message)

    bpy.context.window_manager.popup_menu(draw, title=title, icon=icon)
    return None


def clearListener(len=64):
    for i in range(0, len): print('')


def getFilenameFile(file):  # returns: "myImage"
    return Path(file).stem


def getFilenameType(file):  # returns: ".jpg"
    return Path(file).suffix


def toUpper(string):
    return string.upper()


def findItem(array, value):
    index = -1
    try:
        index = array.index(value)
    except:
        pass
    return index


def append(array, value):
    array.append(value)
    return None


def appendIfUnique(array, value):
    try:
        array.index(value)
    except:
        array.append(value)
    return None


class StandardMaterial:
    data = None
    bsdf = None

    def __init__(self, name="Material"):
        # make material
        self.data = bpy.data.materials.new(name=name)
        self.data.use_nodes = True
        self.data.use_backface_culling = True
        self.bsdf = self.data.node_tree.nodes["Principled BSDF"]
        self.bsdf.label = "Standard"
        return None

    def diffuse(self, colour=(0.0, 0.0, 0.0, 0.0), name="Diffuse"):
        rgbaColor = self.data.node_tree.nodes.new('ShaderNodeRGB')
        rgbaColor.label = name
        rgbaColor.outputs[0].default_value = (colour[0], colour[1], colour[2], colour[3])
        if self.bsdf != None:
            self.data.node_tree.links.new(self.bsdf.inputs['Base Color'], rgbaColor.outputs['Color'])
        return None

    def diffuseMap(self, imageTex=None):
        if imageTex != None and self.bsdf != None:
            self.data.node_tree.links.new(self.bsdf.inputs['Base Color'], imageTex.outputs['Color'])
        return None

    def opacityMap(self, imageTex=None):
        if imageTex != None and self.bsdf != None:
            self.data.blend_method = 'BLEND'
            self.data.shadow_method = 'HASHED'
            self.data.show_transparent_back = False
            self.data.node_tree.links.new(self.bsdf.inputs['Alpha'], imageTex.outputs['Alpha'])
        return None

    def normalMap(self, imageNode=None):
        if imageTex != None and self.bsdf != None:
            imageTex.image.colorspace_settings.name = 'Linear'
            normMap = self.data.node_tree.nodes.new('ShaderNodeNormalMap')
            normMap.label = 'ShaderNodeNormalMap'
            self.data.node_tree.links.new(normMap.inputs['Color'], imageTex.outputs['Color'])
            self.data.node_tree.links.new(self.bsdf.inputs['Normal'], normMap.outputs['Normal'])
        return None

    def specularMap(self, imageNode=None, invert=True):
        if imageTex != None and self.bsdf != None:
            if invert:
                invertRGB = self.data.node_tree.nodes.new('ShaderNodeInvert')
                invertRGB.label = 'ShaderNodeInvert'
                self.data.node_tree.links.new(invertRGB.inputs['Color'], imageTex.outputs['Color'])
                self.data.node_tree.links.new(self.bsdf.inputs['Roughness'], invertRGB.outputs['Color'])
            else:
                self.data.node_tree.links.new(self.bsdf.inputs['Roughness'], imageTex.outputs['Color'])
        return None


class fopen:
    little_endian = True
    file = ""
    mode = 'rb'
    data = bytearray()
    size = 0
    pos = 0
    isGood = False

    def __init__(self, filename=None, mode='rb', isLittleEndian=True):
        if mode == 'rb':
            if filename != None and Path(filename).is_file():
                self.data = open(filename, mode).read()
                self.size = len(self.data)
                self.pos = 0
                self.mode = mode
                self.file = filename
                self.little_endian = isLittleEndian
                self.isGood = True
        else:
            self.file = filename
            self.mode = mode
            self.data = bytearray()
            self.pos = 0
            self.size = 0
            self.little_endian = isLittleEndian
            self.isGood = False

        return None

    # def __del__(self):
    #    self.flush()

    def resize(self, dataSize=0):
        if dataSize > 0:
            self.data = bytearray(dataSize)
        else:
            self.data = bytearray()
        self.pos = 0
        self.size = dataSize
        self.isGood = False
        return None

    def flush(self):
        print("flush")
        print("file:\t%s" % self.file)
        print("isGood:\t%s" % self.isGood)
        print("size:\t%s" % len(self.data))
        if self.file != "" and not self.isGood and len(self.data) > 0:
            self.isGood = True

            s = open(self.file, 'w+b')
            s.write(self.data)
            s.close()

    def read_and_unpack(self, unpack, size):
        '''
          Charactor, Byte-order
          @,         native, native
          =,         native, standard
          <,         little endian
          >,         big endian
          !,         network

          Format, C-type,         Python-type, Size[byte]
          c,      char,           byte,        1
          b,      signed char,    integer,     1
          B,      unsigned char,  integer,     1
          h,      short,          integer,     2
          H,      unsigned short, integer,     2
          i,      int,            integer,     4
          I,      unsigned int,   integer,     4
          f,      float,          float,       4
          d,      double,         float,       8
        '''
        value = 0
        if self.size > 0 and self.pos + size < self.size:
            value = struct.unpack_from(unpack, self.data, self.pos)[0]
            self.pos += size
        return value

    def pack_and_write(self, pack, size, value):
        if self.pos + size > self.size:
            self.data.extend(b'\x00' * ((self.pos + size) - self.size))
            self.size = self.pos + size
        try:
            struct.pack_into(pack, self.data, self.pos, value)
        except:
            print('Pos:\t%i / %i (buf:%i) [val:%i:%i:%s]' % (self.pos, self.size, len(self.data), value, size, pack))
            pass
        self.pos += size
        return None

    def set_pointer(self, offset):
        self.pos = offset
        return None

    def set_endian(self, isLittle = True):
        self.little_endian = isLittle
        return isLittle

def fclose(bitStream):
    bitStream.flush()
    bitStream.isGood = False


def fseek(bitStream, offset, dir):
    if dir == 0:
        bitStream.set_pointer(offset)
    elif dir == 1:
        bitStream.set_pointer(bitStream.pos + offset)
    elif dir == 2:
        bitStream.set_pointer(bitStream.pos - offset)
    return None


def ftell(bitStream):
    return bitStream.pos


def readByte(bitStream, isSigned=0):
    fmt = 'b' if isSigned == 0 else 'B'
    return (bitStream.read_and_unpack(fmt, 1))


def readShort(bitStream, isSigned=0):
    fmt = '>' if not bitStream.little_endian else '<'
    fmt += 'h' if isSigned == 0 else 'H'
    return (bitStream.read_and_unpack(fmt, 2))


def readLong(bitStream, isSigned=0):
    fmt = '>' if not bitStream.little_endian else '<'
    fmt += 'i' if isSigned == 0 else 'I'
    return (bitStream.read_and_unpack(fmt, 4))

def readFloat(bitStream):
    fmt = '>f' if not bitStream.little_endian else '<f'
    return (bitStream.read_and_unpack(fmt, 4))


def readHalf(bitStream):
    uint16 = bitStream.read_and_unpack('>H' if not bitStream.little_endian else '<H', 2)
    uint32 = (
            (((uint16 & 0x03FF) << 0x0D) | ((((uint16 & 0x7C00) >> 0x0A) + 0x70) << 0x17)) |
            (((uint16 >> 0x0F) & 0x00000001) << 0x1F)
    )
    return struct.unpack('f', struct.pack('I', uint32))[0]


def readString(bitStream, length=0):
    string = ''
    pos = bitStream.pos
    lim = length if length != 0 else bitStream.size - bitStream.pos
    for i in range(0, lim):
        b = bitStream.read_and_unpack('B', 1)
        if b != 0:
            string += chr(b)
        else:
            if length > 0:
                bitStream.set_pointer(pos + length)
            break
    return string


def mesh_validate (vertices=[], faces=[]):
    #
    # Returns True if mesh is BAD
    #
    # check face index bound
    face_min = 0
    face_max = len(vertices) - 1
   
    for face in faces:
        for side in face:
            if side < face_min or side > face_max:
                print("Face Index Out of Range:\t[%i / %i]" % (side, face_max))
                return True
    return False

def mesh(
    vertices=[],
    faces=[],
    materialIDs=[],
    tverts=[],
    normals=[],
    colours=[],
    materials=[],
    mscale=1.0,
    flipAxis=False,
    obj_name="Object",
    lay_name='',
    position = (0.0, 0.0, 0.0)
    ):
    #
    # This function is pretty, ugly
    # imports the mesh into blender
    #
    # Clear Any Object Selections
    # for o in bpy.context.selected_objects: o.select = False
    bpy.context.view_layer.objects.active = None
   
    # Get Collection (Layers)
    if lay_name != '':
        # make collection
        layer = bpy.data.collections.get(lay_name)
        if layer == None:
            layer = bpy.data.collections.new(lay_name)
            bpy.context.scene.collection.children.link(layer)
    else:
        if len(bpy.data.collections) == 0:
            layer = bpy.data.collections.new("Collection")
            bpy.context.scene.collection.children.link(layer)
        else:
            try:
                layer = bpy.data.collections[bpy.context.view_layer.active_layer_collection.name]
            except:
                layer = bpy.data.collections[0]
   

    # make mesh
    msh = bpy.data.meshes.new('Mesh')

    # msh.name = msh.name.replace(".", "_")

    # Apply vertex scaling
    # mscale *= bpy.context.scene.unit_settings.scale_length
    if len(vertices) > 0:
        vertArray = [[float] * 3] * len(vertices)
        if flipAxis:
            for v in range(0, len(vertices)):
                vertArray[v] = (
                    vertices[v][0] * mscale,
                    -vertices[v][2] * mscale,
                    vertices[v][1] * mscale
                )
        else:
            for v in range(0, len(vertices)):
                vertArray[v] = (
                    vertices[v][0] * mscale,
                    vertices[v][1] * mscale,
                    vertices[v][2] * mscale
                )

    # assign data from arrays
    if mesh_validate(vertArray, faces):
        # Erase Mesh
        msh.user_clear()
        bpy.data.meshes.remove(msh)
        print("Mesh Deleted!")
        return None
   
    msh.from_pydata(vertArray, [], faces)

    # set surface to smooth
    msh.polygons.foreach_set("use_smooth", [True] * len(msh.polygons))

    # Set Normals
    if len(faces) > 0:
        if len(normals) > 0:
            msh.use_auto_smooth = True
            if len(normals) == (len(faces) * 3):
                msh.normals_split_custom_set(normals)
            else:
                normArray = [[float] * 3] * (len(faces) * 3)
                if flipAxis:
                    for i in range(0, len(faces)):
                        for v in range(0, 3):
                            normArray[(i * 3) + v] = (
                                [normals[faces[i][v]][0],
                                 -normals[faces[i][v]][2],
                                 normals[faces[i][v]][1]]
                            )
                else:
                    for i in range(0, len(faces)):
                        for v in range(0, 3):
                            normArray[(i * 3) + v] = (
                                [normals[faces[i][v]][0],
                                 normals[faces[i][v]][1],
                                 normals[faces[i][v]][2]]
                            )
                msh.normals_split_custom_set(normArray)

        # create texture corrdinates
        #print("tverts ", len(tverts))
        # this is just a hack, i just add all the UVs into the same space <<<
        if len(tverts) > 0:
            uvw = msh.uv_layers.new()
            # if len(tverts) == (len(faces) * 3):
            #    for v in range(0, len(faces) * 3):
            #        msh.uv_layers[uvw.name].data[v].uv = tverts[v]
            # else:
            uvwArray = [[float] * 2] * len(tverts[0])
            for i in range(0, len(tverts[0])):
                uvwArray[i] = [0.0, 0.0]

            for v in range(0, len(tverts[0])):
                for i in range(0, len(tverts)):
                    uvwArray[v][0] += tverts[i][v][0]
                    uvwArray[v][1] += 1.0 - tverts[i][v][1]

            for i in range(0, len(faces)):
                for v in range(0, 3):
                    msh.uv_layers[uvw.name].data[(i * 3) + v].uv = (
                        uvwArray[faces[i][v]][0],
                        uvwArray[faces[i][v]][1]
                    )


    # Create Face Maps?
    # msh.face_maps.new()

    # Update Mesh
    msh.update()

    # Check mesh is Valid
    # Without this blender may crash!!! lulz
    # However the check will throw false positives so
    # and additional or a replacement valatiation function
    # would be required
   
    if msh.validate():
        print("Mesh Failed Validation")

       

    # Assign Mesh to Object
    obj = bpy.data.objects.new(obj_name, msh)
    obj.location = position
    # obj.name = obj.name.replace(".", "_")

    for i in range(0, len(materials)):

        if len(obj.material_slots) < (i + 1):
            # if there is no slot then we append to create the slot and assign
            obj.data.materials.append(materials[i])
        else:
            # we always want the material in slot[0]
            obj.material_slots[0].material = materials[i]
        # obj.active_material = obj.material_slots[i].material

    if len(materialIDs) == len(obj.data.polygons):
        for i in range(0, len(materialIDs)):
            obj.data.polygons[i].material_index = materialIDs[i] % len(materials)
    elif len(materialIDs) > 0:
        print("Error:\tMaterial Index Out of Range")

    # obj.data.materials.append(material)
    layer.objects.link(obj)

    # Generate a Material
    # img_name = "Test.jpg"  # dummy texture
    # mat_count = len(texmaps)

    # if mat_count == 0 and len(materialIDs) > 0:
    #    for i in range(0, len(materialIDs)):
    #        if (materialIDs[i] + 1) > mat_count: mat_count = materialIDs[i] + 1

    # Assign Material ID's
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.mode_set(mode='EDIT', toggle=False)
    bpy.context.tool_settings.mesh_select_mode = [False, False, True]

    bpy.ops.object.mode_set(mode='OBJECT')
    # materialIDs

    # Redraw Entire Scene
    # bpy.context.scene.update()

    return obj


def deleteScene(include=[]):
    if len(include) > 0:
        # Exit and Interactions
        if bpy.context.view_layer.objects.active != None:
            bpy.ops.object.mode_set(mode='OBJECT')

        # Select All
        bpy.ops.object.select_all(action='SELECT')

        # Loop Through Each Selection
        for o in bpy.context.view_layer.objects.selected:
            for t in include:
                if o.type == t:
                    bpy.data.objects.remove(o, do_unlink=True)
                    break

        # De-Select All
        bpy.ops.object.select_all(action='DESELECT')
    return None

class ndxr_entry_info_cmd_table2:
    unk081 = 0
    name_addr = 0
    name = ""
    unk083 = 0
    unk084 = 0
    unk085 = 0.0
    unk086 = 0.0
    unk087 = 0.0
    unk088 = 0.0

    def read_info_cmd_table2(self, strings_addr=0, f=fopen()):
        self.unk081 = readLong(f, unsigned)
        self.name_addr = readLong(f, unsigned)
        self.unk083 = readLong(f, unsigned)
        self.unk084 = readLong(f, unsigned)
        self.unk085 = readFloat(f)
        self.unk086 = readFloat(f)
        self.unk087 = readFloat(f)
        self.unk088 = readFloat(f)
        pos = ftell(f)
        fseek(f, (strings_addr + self.name_addr), seek_set)
        self.name = readString(f)
        fseek(f, pos, seek_set)


class ndxr_entry_info_cmd_table1:  # 24 bytes
    unk061 = 0
    unk062 = 0
    unk063 = 0
    unk064 = 0
    unk065 = 0
    unk066 = 0
    unk067 = 0
    unk068 = 0
    unk069 = 0
    unk070 = 0
    unk071 = 0
    unk072 = 0
    unk073 = 0
    unk074 = 0
    unk075 = 0
    unk076 = 0

    def read_info_cmd_table1(self, f=fopen()):
        self.unk061 = readByte(f, unsigned)
        self.unk062 = readShort(f, unsigned)
        self.unk063 = readLong(f, unsigned)
        self.unk064 = readByte(f, unsigned)
        self.unk065 = readShort(f, unsigned)
        self.unk066 = readLong(f, unsigned)
        self.unk067 = readByte(f, unsigned)
        self.unk068 = readByte(f, unsigned)
        self.unk069 = readByte(f, unsigned)
        self.unk070 = readByte(f, unsigned)
        self.unk071 = readByte(f, unsigned)
        self.unk072 = readByte(f, unsigned)
        self.unk073 = readByte(f, unsigned)
        self.unk074 = readByte(f, unsigned)
        self.unk075 = readByte(f, unsigned)
        self.unk076 = readByte(f, unsigned)


class ndxr_entry_info_cmd:
    unk041 = 0
    unk042 = 0
    unk043 = 0
    unk044 = 0
    unk045 = 0
    table1_count = 0  # count
    unk046 = 0
    unk047 = 0
    unk048 = 0
    unk049 = 0
    unk050 = 0
    table1 = []
    unk051 = 0
    unk052 = 0
    unk053 = 0
    table2 = []

    def read_info_cmd(self, strings_addr=0, f=fopen()):
        self.unk041 = readByte(f, unsigned)
        self.unk042 = readShort(f, unsigned)
        self.unk043 = readByte(f, unsigned)
        self.unk044 = readShort(f, unsigned)
        self.unk045 = readLong(f, unsigned)
        self.table1_count = readShort(f, unsigned)
        self.unk046 = readShort(f, unsigned)
        self.unk047 = readLong(f, unsigned)
        self.unk048 = readLong(f, unsigned)
        self.unk049 = readByte(f, unsigned)
        self.unk050 = readShort(f, unsigned)
        if self.table1_count > 0:
            self.table1 = [ndxr_entry_info_cmd_table1] * self.table1_count
            for i in range(0, self.table1_count):
                self.table1[i] = ndxr_entry_info_cmd_table1()
                self.table1[i].read_info_cmd_table1(f)
                self.unk051 = readByte(f, unsigned)
                self.unk052 = readShort(f, unsigned)
                self.unk053 = readLong(f, unsigned)
                i = -1
                while True:
                    i += 1
                    append(self.table2, (ndxr_entry_info_cmd_table2()))
                    self.table2[i].read_info_cmd_table2(strings_addr, f)
                    if self.table2[i].unk081 != 0x20: break


class ndxr_entry_info:
    face_addr = 0  # face buffer addr?
    vert_addr = 0  # vert buffer addr?
    unk033 = 0  # 0
    vert_count = 0  # vertex count?
    unk036 = 0  # 6
    unk037 = 0  # ? vertex format? 17=28bytes, 16=20bytes
    cmd_addr = 0
    cmd = ndxr_entry_info_cmd()
    unk038 = 0  # 0
    unk039 = 0  # 0
    unk040 = 0  # 0
    face_count = 0  # face count?
    unk042 = 0  # 0
    unk043 = 0  # 0
    unk044 = 0  # 0
    unk045 = 0  # 0

    def read_info(self, pos=0, strings_addr=0, f=fopen(), addr_off = 0):
        self.face_addr = readLong(f, unsigned)
        self.vert_addr = readLong(f, unsigned)
        self.unk033 = readLong(f, unsigned)
        self.vert_count = readShort(f, unsigned)
        self.unk036 = readByte(f, unsigned)
        self.unk037 = readByte(f, unsigned)
        self.cmd_addr = readLong(f, unsigned) - addr_off
        self.unk038 = readLong(f, unsigned)
        self.unk039 = readLong(f, unsigned)
        self.unk040 = readLong(f, unsigned)
        self.face_count = readShort(f, unsigned)
        self.unk042 = readShort(f, unsigned)
        self.unk043 = readLong(f, unsigned)
        self.unk044 = readLong(f, unsigned)
        self.unk045 = readLong(f, unsigned)
        if self.cmd_addr > 0:
            fseek(f, pos + self.cmd_addr, seek_set)
            self.cmd.read_info_cmd(strings_addr, f)


class ndxr_entry:
    unk011 = 0.0
    unk012 = 0.0
    unk013 = 0.0
    unk014 = 0.0
    unk015 = 0.0
    unk016 = 0.0
    unk017 = 0.0
    unk018 = 0
    name_addr = 0
    name = ""
    unk019 = 0
    unk020 = 0
    unk021 = 0
    unk022 = 0  # info count
    info_addr = 0
    info = []

    def read_entry(self, pos=0, strings_addr=0, f=fopen(), addr_off = 0):
        self.unk011 = readFloat(f)
        self.unk012 = readFloat(f)
        self.unk013 = readFloat(f)
        self.unk014 = readFloat(f)
        self.unk015 = readFloat(f)
        self.unk016 = readFloat(f)
        self.unk017 = readFloat(f)
        self.unk018 = readLong(f, unsigned)
        self.name_addr = readLong(f, unsigned)
        self.unk019 = readShort(f, unsigned)
        self.unk020 = readShort(f, unsigned)
        self.unk021 = readShort(f, unsigned)
        self.unk022 = readShort(f, unsigned)
        self.info_addr = readLong(f, unsigned) - addr_off
        fseek(f, (strings_addr + self.name_addr), seek_set)
        self.name = readString(f)
        if self.unk022 > 0 and self.info_addr > 0:
            self.info = [ndxr_entry_info] * self.unk022
            for i in range(0, self.unk022):
                fseek(f, (pos + self.info_addr + (i * 48)), seek_set)
                self.info[i] = ndxr_entry_info()
                self.info[i].read_info(pos, strings_addr, f, addr_off)


class ndxr_file:
    fileid = 0
    unk001 = 0
    unk002 = 0
    count = 0
    unk007 = 0
    unk003 = 0
    face_addr = 0
    face_size = 0
    vert_size = 0
    unk004 = 0
    unk005 = 0
    unk006 = [0.0, 0.0, 0.0]
    entries = []

    def read(self, f=fopen(), mscale = 1.0, col_name = "", addr_off = 0):
        pos = ftell(f)
        header_size = 48
        self.fileid = readLong(f, unsigned)
        self.unk001 = readLong(f, unsigned)
        self.unk002 = readShort(f, unsigned)
        self.count = readShort(f, unsigned)
        self.unk007 = readShort(f, unsigned)
        self.unk003 = readShort(f, unsigned)
        self.face_addr = readLong(f, unsigned)
        self.face_size = readLong(f, unsigned)
        self.vert_size = readLong(f, unsigned)
        self.unk004 = readLong(f, unsigned)
        self.unk005 = readLong(f, unsigned)
        self.unk006 = [readFloat(f), readFloat(f), readFloat(f)]

        self.face_addr += pos + header_size
        vert_addr = self.face_addr + self.face_size
        strings_addr = vert_addr + self.vert_size
        entry_size = 48
        vertArray = []
        normArray = []
        faceArray = []
        matidArray = []
        tvertArray = []
        msh = None
        tmp = []
        vertex_stride = 0
        face = [0, 0, 0]
        facePosition = 0
        faceCW = True
        maxIndex = 0

        if self.count > 0:
            self.entries = [ndxr_entry] * self.count
            for i in range(0, self.count):
                fseek(f, (pos + header_size + (i * entry_size)), seek_set)
                self.entries[i] = ndxr_entry()
                self.entries[i].read_entry(pos, strings_addr, f, addr_off)
       
       
           
       
        # Generate Size List to Estimate the Vertex Stride
        for i in range(0, self.count):
            for ii in range(0, self.entries[i].unk022):
                appendIfUnique(tmp, (self.entries[i].info[ii].vert_addr + vert_addr))

        append(tmp, strings_addr)
        tmp.sort()
       
        for i in range(0, self.count):  # mesh entry
            vertArray = []
            tvertArray = []
            faceArray = []
            normArray = []
            matidArray = []
            facePosition = 0
            for ii in range(0, self.entries[i].unk022):  # level of detail meshes?

                # Read Faces
                fseek(f, (self.face_addr + self.entries[i].info[ii].face_addr), seek_set)
                v = 0
               
                while v < self.entries[i].info[ii].face_count:
                    faceCW = True
                    face[0] = readShort(f, unsigned)
                    face[1] = readShort(f, unsigned)
                    v += 2
                    while v < self.entries[i].info[ii].face_count:
                        face[2] = readShort(f, unsigned)
                        v += 1
                        if face[0] == 0xFFFF or face[1] == 0xFFFF or face[2] == 0xFFFF: break
                        if face[0] != face[1] and face[1] != face[2] and face[0] != face[2]:
                            if faceCW:
                                append(faceArray, [face[0] + facePosition, face[1] + facePosition, face[2] + facePosition])
                            else:
                                append(faceArray, [face[0] + facePosition, face[2] + facePosition, face[1] + facePosition])
                            append(matidArray, ii)
                        faceCW = not faceCW
                        face = [face[1], face[2], face[0]]
                       
                facePosition += self.entries[i].info[ii].vert_count
                vertex_stride = 20
                # Reading Vertices
                if self.entries[i].info[ii].vert_count != 0:
                    vertex_stride = int(
                        (tmp[(findItem(tmp, self.entries[i].info[ii].vert_addr + vert_addr)) + 1] -
                        (self.entries[i].info[ii].vert_addr + vert_addr)) / self.entries[i].info[ii].vert_count
                        )
                    vertex_stride = int(vertex_stride - (vertex_stride % 4))

                # format "Vertex Addr:\t%\n" (entries[i].info[ii].vert_addr + vert_addr)
                # format "Vertex Count:\t%\n" entries[i].info[ii].vert_count

                # format "Vertex Format:\t%\n" entries[i].info[ii].unk037
                if self.entries[i].info[ii].unk036 == 0:
                    vertex_stride = 20
                elif self.entries[i].info[ii].unk036 == 6:
                    vertex_stride = 28
                elif self.entries[i].info[ii].unk036 == 7:
                    vertex_stride = 44
                else:
                    print("Error:\tUnsupported Vertex Stride [%i]" % self.entries[i].info[ii].unk036)

                # format "Vertex Stride:\t%\n" vertex_stride
                # vertArray[entries[i].info[ii].vert_count] = [0.0, 0.0, 0.0]
                for v in range(0, self.entries[i].info[ii].vert_count):
                    fseek(f, (vert_addr + self.entries[i].info[ii].vert_addr + (v * vertex_stride)),
                          seek_set)
                    append(vertArray, [readFloat(f), readFloat(f), readFloat(f)])
                    if vertex_stride >= 28:
                        fseek(f, 8, seek_cur)  # append normArray ([readHalf f, readHalf f, readHalf f] * (readHalf f))
                        append(tvertArray, [readFloat(f), readFloat(f), 0.0])
                    else:
                        fseek(f, 4, seek_cur)  # readLong(f) # normal
                        append(tvertArray, [readHalf(f), readHalf(f), 0.0])
           
            mats = []
            mat = None
            for ii in range(0, self.entries[i].unk022):
                mat = StandardMaterial()
                mats.append(mat.data)
           
            msh = mesh(
                vertices=vertArray,
                faces=faceArray,
                tverts=[tvertArray],
                materialIDs=matidArray,
                materials=mats,
                obj_name=self.entries[i].name,
                flipAxis=True,
                lay_name=col_name,
                mscale=mscale
                )
        return None

class fhm_table_addr_entry:
    unk021 = 0
    addr = 0
    def read_addr_entry(self, f=fopen()):
        self.unk021 = readLong(f, unsigned)
        self.addr = readLong(f, unsigned)


class fhm_table_entry:
    unk031=0
    unk032 = 0
    unk033 = 0
    addr = 0
    size = 0
    def read_entry(self, f=fopen()):
        self.unk031 = readShort(f)
        self.unk032 = readShort(f)
        self.unk033 = readLong(f, unsigned)
        self.addr = readLong(f, unsigned)
        self.size = readLong(f, unsigned)


class fhm_file:
    fileid=0
    unk001 = 0
    unk002 = 0
    unk003 = 0
    unk004 = 0
    unk005 = 0
    unk006 = 0
    unk007 = 0
    unk008 = 0
    unk009 = 0
    unk010 = 0
    unk011 = 0
    file_count = 0
    file_addr_table =[]
    file_table =[]
    def read(self, f=fopen()):
        self.fileid = readLong(f, unsigned)
        if self.fileid != 0x004D4846:
            print("Error:\tInvalid File Type\n")
            return False
       
        self.unk001 = readLong(f, unsigned)
        self.unk002 = readLong(f, unsigned)
        self.unk003 = readLong(f, unsigned)
        self.unk004 = readLong(f, unsigned)
        self.unk005 = readLong(f, unsigned)
        self.unk006 = readLong(f, unsigned)
        self.unk007 = readLong(f, unsigned)
        self.unk008 = readLong(f, unsigned)
        self.unk009 = readLong(f, unsigned)
        self.unk010 = readLong(f, unsigned)
        self.unk011 = readLong(f, unsigned)
        self.file_count = readLong(f, unsigned)
        if self.file_count > 0:
            self.file_addr_table = [fhm_table_addr_entry] * self.file_count
            self.file_table = [fhm_table_entry] * self.file_count
            for i in range(0, self.file_count):
                self.file_addr_table[i] = fhm_table_addr_entry()
                self.file_addr_table[i].read_addr_entry(f)
   
            for i in range(0, self.file_count):
                fseek(f, (0x30 + self.file_addr_table[i].addr), seek_set)
                self.file_table[i] = fhm_table_entry()
                self.file_table[i].read_entry(f)
        return True

def dump_fhm(file=""):
    f = fopen(file, "rb")
    s = None

    fseek(f, 0x30, seek_set)
    count = readLong(f)

    fseek(f, (count * 8), seek_cur)

    pos = ftell(f)
    addr = 0
    size = 0
    type = ""
    for i in range(0, count):
        fseek(f, (pos + (i * 16)), seek_set)

    readLong(f)
    readLong(f)
    addr = readLong(f)
    size = readLong(f)

    fseek(f, (addr + 0x30), seek_set)
    type = "."
    for x in range(0, 4):
        type += chr(readByte(f))

    s = fopen((file + "_" + str(i) + type), "wb")

    fseek(f, (addr + 0x30), seek_set)
    for x in range(0, size):
        writeByte(s, (readByte(f)))

    fclose(s)
    fclose(f)

def read(file="", mscale = 1.0):
    fhm = fhm_file()
    type = 0
    ndxr = ndxr_file()
    fileID = 0
   
    f = fopen(file, "rb")
    if f.isGood:
        fname = getFilenameFile(file)
        fileID = readLong(f, unsigned)
        fseek(f, 0, seek_set)

        if fileID == 0x004D4846:  # FHM
            fhm.read(f)

            for i in range(0, len(fhm.file_table)):
                fseek(f, (fhm.file_table[i].addr + 0x30), seek_set)
                type = readLong(f, unsigned)
                fseek(f, (fhm.file_table[i].addr + 0x30), seek_set)
                if type == 0x5258444E:
                    ndxr = ndxr_file()
                    ndxr.read(f, mscale, fname + "_" + str(i + 1))
                else:
                    print("#%i\tUnknown Block:\t%i\t@ 0x%s\n" % (i, type, hex(fhm.file_table[i].addr + 0x30)))


        elif fileID == 0x5258444E:  # NDXR
            ndxr = ndxr_file()
            ndxr.read(f, mscale)
           
        elif fileID == 0x3350444E:  # NDP3 (Memory Dump)
           
            # a sample was provided from a memory dump
            # in which the addresses are assigned to memory
            # and are out of bounds of the supplied file sample
            # For this we need to try and derive the address
            # relative to the file and not the PS3 Memory block
           
            f.set_endian(False)
            fseek(f, 0x0A, seek_set)
            count = readShort(f)
            fseek(f, 0x5C, seek_set)
            addr_off = readLong(f) - (48 + (count * 0x30))
            print("addr_off:\t%i" % addr_off)
            fseek(f, 0, seek_set)
           
           
            ndxr = ndxr_file()
            ndxr.read(f, mscale, "", addr_off)
        else:
            print("Error:\tUnsupported File Type\n")

        fclose(f)

    else:
        print("Error:\tFailed to Read File\n")


# Callback when file(s) are selected
def acecombat_ah_imp_callback(fpath="", files=[], clearScene=True, mscale = 1.0):
    if len(files) > 0 and clearScene: deleteScene(['MESH', 'ARMATURE'])
    for file in files:
        read(fpath + file.name, mscale)
    if len(files) > 0:
        messageBox("Done!")
        return True
    else:
        return False


# Wrapper that Invokes FileSelector to open files from blender
def acecombat_ah_imp(reload=False):
    # Un-Register Operator
    if reload and hasattr(bpy.types, "IMPORTHELPER_OT_acecombat_ah_imp"):  # print(bpy.ops.importhelper.acecombat_ah_imp.idname())

        try:
            bpy.types.TOPBAR_MT_file_import.remove(
                bpy.types.Operator.bl_rna_get_subclass_py('IMPORTHELPER_OT_acecombat_ah_imp').menu_func_import)
        except:
            print("Failed to Unregister2")

        try:
            bpy.utils.unregister_class(bpy.types.Operator.bl_rna_get_subclass_py('IMPORTHELPER_OT_acecombat_ah_imp'))
        except:
            print("Failed to Unregister1")

    # Define Operator
    class ImportHelper_acecombat_ah_imp(bpy.types.Operator):

        # Operator Path
        bl_idname = "importhelper.acecombat_ah_imp"
        bl_label = "Select File"

        # Operator Properties
        # filter_glob: bpy.props.StringProperty(default='*.jpg;*.jpeg;*.png;*.tif;*.tiff;*.bmp', options={'HIDDEN'})
        filter_glob: bpy.props.StringProperty(default='*.fhm;*.ndxr;*.ndp3', options={'HIDDEN'}, subtype='FILE_PATH')

        # Variables
        filepath: bpy.props.StringProperty(subtype="FILE_PATH")  # full path of selected item (path+filename)
        filename: bpy.props.StringProperty(subtype="FILE_NAME")  # name of selected item
        directory: bpy.props.StringProperty(subtype="FILE_PATH")  # directory of the selected item
        files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement)  # a collection containing all the selected items as filenames

        # Controls
        my_float1: bpy.props.FloatProperty(name="Scale", default=1.0, description="Changes Scale of the imported Mesh")
        my_bool1: bpy.props.BoolProperty(name="Clear Scene", default=True, description="Deletes everything in the scene prior to importing")

        # Runs when this class OPENS
        def invoke(self, context, event):

            # Retrieve Settings
            try: self.filepath = bpy.types.Scene.acecombat_ah_imp_filepath
            except: bpy.types.Scene.acecombat_ah_imp_filepath = bpy.props.StringProperty(subtype="FILE_PATH")

            try: self.directory = bpy.types.Scene.acecombat_ah_imp_directory
            except: bpy.types.Scene.acecombat_ah_imp_directory = bpy.props.StringProperty(subtype="FILE_PATH")

            try: self.my_float1 = bpy.types.Scene.acecombat_ah_imp_my_float1
            except: bpy.types.Scene.acecombat_ah_imp_my_float1 = bpy.props.FloatProperty(default=1.0)

            try: self.my_bool1 = bpy.types.Scene.acecombat_ah_imp_my_bool1
            except: bpy.types.Scene.acecombat_ah_imp_my_bool1 = bpy.props.BoolProperty(default=False)


            # Open File Browser
            # Set Properties of the File Browser
            context.window_manager.fileselect_add(self)
            context.area.tag_redraw()

            return {'RUNNING_MODAL'}

        # Runs when this Window is CANCELLED
        def cancel(self, context): print("run *SPAM*")

        # Runs when the class EXITS
        def execute(self, context):

            # Save Settings
            bpy.types.Scene.acecombat_ah_imp_filepath = self.filepath
            bpy.types.Scene.acecombat_ah_imp_directory = self.directory
            bpy.types.Scene.acecombat_ah_imp_my_float1 = self.my_float1
            bpy.types.Scene.acecombat_ah_imp_my_bool1 = self.my_bool1

            # Run Callback
            acecombat_ah_imp_callback(self.directory + "\\", self.files, self.my_bool1, self.my_float1)

            return {"FINISHED"}

            # Window Settings

        def draw(self, context):

            self.layout.row().label(text="Import Settings")

            self.layout.separator()
            self.layout.row().prop(self, "my_bool1")
            self.layout.row().prop(self, "my_float1")

            self.layout.separator()

            col = self.layout.row()
            col.alignment = 'RIGHT'
            col.label(text="  Author:", icon='QUESTION')
            col.alignment = 'LEFT'
            col.label(text="mariokart64n")

            col = self.layout.row()
            col.alignment = 'RIGHT'
            col.label(text="Release:", icon='GRIP')
            col.alignment = 'LEFT'
            col.label(text="March 23, 2021")

        def menu_func_import(self, context):
            self.layout.operator("importhelper.acecombat_ah_imp", text="Ace Combat Assault Horizon (*.fhm)")

    # Register Operator
    bpy.utils.register_class(ImportHelper_acecombat_ah_imp)
    bpy.types.TOPBAR_MT_file_import.append(ImportHelper_acecombat_ah_imp.menu_func_import)

    # Call ImportHelper
    bpy.ops.importhelper.acecombat_ah_imp('INVOKE_DEFAULT')



if not useOpenDialog:
    deleteScene(['MESH', 'ARMATURE'])  # Clear Scene
    clearListener()  # clears out console
    read(
        #"C:\\Users\\Corey\\Desktop\\AuditionOnlien\\nozomi\\model_id\\mech\\airp\\d_tnd4\\d_tnd4_pcom.fhm"
        #"C:\\Users\\Corey\\Desktop\\AuditionOnlien\\nozomi\\model_id\\mech\\airp\\d_tnd4\\d_tnd4_pcom\\d_tnd4_pcom.fhm_13.NDXR"
        "G:\\WA3_memory\\f16xl.ndp3"
        )
    messageBox("Done!")
else: acecombat_ah_imp(True)
enmacderm
Posts: 19
Joined: Wed Nov 02, 2016 4:15 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by enmacderm »

Alright, did some tests with the plugin, mostly i also upgrade to Blender 2.92, but seems the plugin also works fine with 2.90 too. mostly about the NDP3 imports, the most obvious thing i noticed is unlike the NDXR, they are imported without their name structure nor in separated collections, i could understand because of the difference of Big Endian to Little Endian. by far, some meshes seems to import really fine, some with missing elements like the nozzle of the F-16XL, but other suprising ones, like the weapon pylons and the machine gun SWP of ADA-01B(which are hidden by default at least on hangar modes), is interresing due on their current game, they use a same structure for storing separate meshes interconected, but mostly on ACAH and ACI is made by a FHM with groups of NDXR/NDP3, when on AC7 they separated the meshes on uassets instead. i noticed the UV maps seems to be missing too, despite with some leftovers, i noticed is also a side effect on the NDXR too, as some manages to get some maps intact and merged with other meshes which i noticed it could be separated ones, but other ones lack the coordinates, as you may read on Shatokay post, the UV for both are stored in half-float UV maps, seeing it was one of the coords i took a while to make it work manually, but then finally understood the structure.
Image
Image
Image
Image
Image

In general for the NDXR files, it turns to be a impressive work, despite mostly of the work was based on the o_xxxx and d_xxxx meshes(which checking by my eyes now, seems to be both CPU and damage models), trying to import the player models(p_xxxx) result in a gamble of parts by originally begin separated meshes with skeleton bone influences, not sure exactly if the ones of the player turns to be more complex of the CPU ones(despite the helos somehow got very intact), as i noticed they also are separated, and aren't in wrong axis either for instruments and the "steel carnage" divisions. i would say part of that turns the script was made on May 22, and updated on May 23, and implementing the correction for the player models would take more time for delievering the script.
Image
Image
Image
Image
Image
Image
Image
Image

Not only for the aircraft CPU models, but the script also works really good with the weapon props, ground and sea units too like vessels or SAMs(obvious exceptions turns into map chunks, human characters, and scenario props), which tends to work fine by them also using the NDXR structure.
Image
Image
Image
Image
Image
Image
Image

In general, even for the earlier stages, the script got huge potential, i know working at one script for all models won't work 100% at the time, as some specific models tend to glitch their pivot position without having them as reference, but mostly if you would still have interrest in updating it, adding the UV coords for the ACI dumps, fixing the wrong axis of both player models(even if the variable wings turns to be glitched but with plyons intact on the wing, is easy to fix their orientation by re-rigging the model), and doing corrections to optimize the glitched imports of some of the CPU models. would be a huge step for preservating and maybe porting those models into other games, or just using them as reference on Blender when making custom texture skins, due the UVs are a good advantage. aside of the ACAH ones, unless some models turns to be hard at fixing, i will be sending more ACI memory dumps for a better reference and also for further testing. as a bonus, i also included some samples of AC6 dumps too, their structure seems to be the same on the NDXR format basics, despite i can't import by certain differences it got by begin from an early version of the engine. so considering the way i dumped them is a bit different due i unsure which end point the main model files would end unlike the ACAH/ACI ones, but i know mostly general NDXR mesh data ends between the next header at times by grouping, but is a hottake in parts.
mariokart64n
Posts: 12
Joined: Fri Aug 08, 2014 12:59 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by mariokart64n »

yeah I noticed that the positions of some of the objects are bad, there is transform data for each mesh but I wasn't able to decode it. There seems to be 7 floats, so I thought maybe position X,Y,Z and then rotation X, Y, Z, W but it doesnt work

I was sort of hoping someone else knew about it, if you figure it out of course that'll be easy to implement into the script...

Also half floats are being used, but the kicker is that they use a flexible vertex format which means that some use half floats, and some don't... so I was only able to implement the FVF specs for the provided samples.

I will look at the new samples later and add support for those aswell and I'll double check why the names are not working... but again I can only work with what is provided to me
enmacderm
Posts: 19
Joined: Wed Nov 02, 2016 4:15 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by enmacderm »

Thanks for the reply, yeah, i know some of the transformation axis would be managed with floats, i may try to check it if i could find them, but can't guarantee results too early, as it may take a while. also thanks for the information about the UV issues, i through by begin mostly UV coordinates, i didn't expected they would behave like that with the script, seeing the Hex2obj sample i did manages to import it fine, with the script import, i noticed the corrupted UV coords keeps into the shape of a polygonal cross, which was the previous results i was getting before by begin stuck of how the floats worked.
Image
enmacderm
Posts: 19
Joined: Wed Nov 02, 2016 4:15 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by enmacderm »

And as a sort of correction of a errata i did, of course, if you did double checked during the script writing, the PS3 ones are the ones in Big Endian, when the AC6(i guess) and the ACAH ones are written in Big Endian
mariokart64n
Posts: 12
Joined: Fri Aug 08, 2014 12:59 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by mariokart64n »

the script can read either big or little endian, it shouldnt matter

Edit: in ACAH, the fhm is a file container, that holds multiple files in it which some are NDXR which are models.

But the files you sent are NDP3's which should be 1 model, however I notice with NDP3 you sent has multiple NDP3's inside of it self. Uhm its going to be hard to read those without the FHM file table, but I'll see what I can do
enmacderm
Posts: 19
Joined: Wed Nov 02, 2016 4:15 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by enmacderm »

Yeah, mostly it was one of the differences i noticed, i know on PC, FHM got a sort of table of contents, but it wasn't something that i could managed to isolate easily, as some FHM headers were too short, along begin interconected with NTP3 texture headers, at least comparing even with the PS3 version of ACAH, i couldn't find the same headers, as sort if they didn't existed at first place.
mariokart64n
Posts: 12
Joined: Fri Aug 08, 2014 12:59 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by mariokart64n »

Change Log:
- Major update to work better with PS3 memory Dumps
- Added Scan option in import dialog to scan for any nested mesh blocks
- Added support for fvf type 7 (44 bytes per vertex)
- Added fix to read names from memory dumps

**script is attached below as a text file
enmacderm
Posts: 19
Joined: Wed Nov 02, 2016 4:15 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by enmacderm »

Hello once again, as i said, at some point i would be trying to research and parse the model for making some tests, despite it wasn't easier either as Saturday i went into a strong thunderstorm. i also managed to test some functions of the new version of the script in general. as a overall review, the file scan in fact turns to be useful when searching NDXR/NDP3 of Project Aces games it can be unsure if their structure is the same or not afterall. with the option toggled, the player models of ACAH somehow will be restricted at importing the cockpit mesh always, which can be fixed by toggling off the file scan, but still, for some mesh samples i dumped and tested, i also managed to make a better research from the overall fhm format. in fact, i was correct that the FHM serves more as a in-house container for storing multiple data, so the FHM on the model question works for structuring their data, the first bytes of the FHM NDXR containers i would say it works as a table of contents, despite unsure in fact, i know that the last time i tried porting the NDP3 files, the PC version of ACAH crashed and close by finding an incompability of those bytes not begin tweaked and endianess i would say. the general dump i did for the NDP3 files works at the same way as the ACAH rips except of the main FHM header, at the last mesh set, they will end with a sort of ID moniker indentification bytes(the plain p_f16x you may noticed at some), but that isn't the main ruleset for importing ND formats, in general, a FHM container with model parts for the player aircrafts would look more or less like this :

Code: Select all

01 : Main mesh
02 : Cockpit mesh
03 : Exterior(Cockpit View)
04 : Nozzle mesh(Falken Z.O.E 'zoex' and Adler 'adlr' will include more meshes, which can be imported by the script, but one-mesh nozzles won't import, ex : f16xl)
05 : Landing Gear mesh
06 : Hangar placement dummy
*07-11 : dupes of the model order(secondary LOD mesh, as it got a few lower filesize and polycount, despite similar structure), main difference is the Exterior mesh includes the nozzle by default, along ACAH NDXR seems to lack the nozzle dupe, along it also have issues to import the main nozzle too
*Piston Aircraft(5 in total) will include 01-02 by default, but will be reduced to 03 and 05, having 4 mesh sets only, some uncoventional aircraft(X-49 and R-101) will lack some mesh groups also


So in general, searching for NDP3 on my dumps, or NDXR on the ACAH samples, you'll notice that following with the next search, you will get the next ND header below the last bits of the NU engine information like the NU_HASH and NU_FLAG's, so the end of those models obviously will go until the next ND header pops up. the way i split those for understanding better was thanks to a python unix script, it was a bit hard to make it to work due i using a windows system, and the script was made with linux in mind. but it was helpful to understand better how the structure of those meshes work, and begin suprised cockpit models are also preloaded, which i through they would be impossible, but partially i hoped they would work, seeing ACAH and ACI got their engine functions based on AC6, and on 6, they got a function for vieweing in cockpit view, so in parts, this data bytes exist still, despite not used on the hangars anymore. the only set of meshes i found issues at importing, are related to the "nozl" and "shnozl" sets(04 and 09), the only ones i managed to import, are the ones from Falken Z.O.E(zoex) and Adler(adlr), which by getting two pairs of trust vectoring nozzles, the script manages to import them without issues, which isn't the case of the nozzle of the F-16XL for example, which got simply one mesh influenciated by vertex morphs(i suppose they would be related with some of the KFM1 and MOP2 headers you may find). but yeah, unfortunately i don't got lucky either at getting those FHM headers, i may try once again seeing it was interresing to find out some models i was ripping wrong without knowing why, due trying to find them on the PC version seems to not work either, even by searching the exact 4 byte structures, but mostly implementing the way it imports the FHM files without scan(which imports each group in order, except for nozzle meshes) would work despite lacking this first section of bytes.
Image
Image
Image
Image
-----------------------------------------------------------------------------------
Image
Image

Right, going into the second topic, i fired up ACI to make some tests with the F-16XL model, mostly using CE for doing RTM edits due is mostly the tool i use for dumping the assets. from the tests i did by NOPing the values into 00, i could be probably wrong, but the down section of the NDXR file during offset (0x20) seems to be my main suspect, i know some other Bandai Namco games which makes use of the ND formats and NU Engine architetures in general, they could be using the structure of 3x4 or 4x4 matrices with inverted right hand coordinate system. the ND formats in general they include skeletons by default, but mostly isn't something i could look seeing isn't a priority on hex2obj, but by NOPing this section, i seem to suspect they may include relevant data for the bones, as some manages the parenting of them on the sample pictures. on my memory dumps also, aside of NDP3 models, i dumped also some files with the MNT header, and some of them make callbacks to bone structures. but yeah, NOPing the area of the edges would result in vertex bomb exploding, and NOPing the vertex area would make them disapear slowly, with the tidbit you mentioned of dynamic half floats, that one section will simply result in UV corruptions.
Image
Image
Image

the last topic, mostly begin minimum stuff, would be the reminder that some of the nozzle meshes isn't begin imported with the script currently, the AC6 rips i did before, mostly of Gyges and F-16, i did by mistake of sending simple parts of them only, seeing i managed to understood their structure better with the ND formats in general, they wouldn't be the issue due i can import them without hassle. however there's a model englobed into the map props that isn't working with the script either, the mesh in question, included on this sample pack, is from a cruise missile warhead called Stauros, which is fired by a huge electromagnetic ground railgun called "Chandelier" on the universe of AC6, during ripping the missiles only, i noticed the warhead shell was refered as a map prop, due the eml on the name gave me the explicit clue, and i found out strange due is originally a air-to-air/weaponry prop, but it was begin refered as a map prop instead. so if you could add at least the provisory support to the layout of those meshes it would be helpful too, there are also some map props and objects i would have interrest, not sure if patching with the Stauros dump would be enough for making them compatible, but seeing ACI is impossible to play in-game, the only ground mesh i could be ripping at some point would be Chandelier itself. but yeah, that's everything from my infodump today, looking forward for the future improvements of the script, despite of course, research would be necessary, which i'll be seeking at doing when possible, due i got my occupations also.
Image
Image
mariokart64n
Posts: 12
Joined: Fri Aug 08, 2014 12:59 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by mariokart64n »

Hello, I'm not sure what's happening with the positions or bones, or animation data as that is outside my expertise..

However I have looked at the other issues, here is the change log

2021-03-30
fixed mesh import function to ignore missing UV's to import mesh
byte used for vertex stride failed to work on 'stauroswarhead.fhm' As a work-around a 'Guess Vertex Stride' option was added to the dialog



in accordance with the second issue which is that the vertex stride is incorrect and makes the mesh appear corrupt;
that is because of the byte I am using to identify the different mesh types seems to not be working with the one sample you have provided. Without digging deeper into it, I have just added a simple work-around (which is not full proof) but does work for the one sample you have provided.

This here is the struct of the mesh information in the binary, the variable 'unk036' is currently being used to identify the vertex definition type. this may determine if the mesh contains UV's or normals, bones, weights etc...

Code: Select all

struct mesh_info {
   uint32_t   face_addr
   uint32_t   vert_addr
   uint32_t   unk033
   uint16_t   vert_count
   uint8_t    unk036   // ? Vertex Definition Type
   uint8_t    unk037
   uint32_t   cmd_addr
   uint32_t   unk038
   uint32_t   unk039
   uint32_t   unk040
   uint16_t   face_count
   uint16_t   unk042
   uint32_t   unk043
   uint32_t   unk044
   uint32_t   unk045
};


however it doesn't to hold up against the one sample so another byte may control it, or possible we just need another strategy....

regardless a 'Guess Vertex Stride' was added to the import dialog, and should work on your 'stauroswarhead.fhm' sample
read ndp3 and nxdr from ps3 mem dumps v2.txt
enmacderm
Posts: 19
Joined: Wed Nov 02, 2016 4:15 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by enmacderm »

Alright, i went AFK for 38 days in total without reporting progress by focusing on IRL stuff, but yeah, posting it due i always took to wait some days before reporting, despite i'm doing a bit late.

Tested with Stauros, and it worked fine, despite the lack of UVs, but i could understand it was mostly a quick fix seeing fixing would need some in-depth rewrite on the script code, however with both guess vertex and without it, the single nozzle meshes couldn't be imported yet, mostly the will break like at the first example picture, which i find weird considering the structure is almost the same, and R-101 and X-49 got a instance of single mesh for their cockpit models which is readable, seen on the second.
Image
Image

Other stuff i tested from the models, assembling them manually turns to be possible with some knowledge at working with Blender, despite isn't such a quick task either. the good thing of the models, despite missing the bones, the axis root is preserved, not sure if by the origins of the bone, or they are common propreties of the model originally, but in general, assembling them was a interresing part-time activity i enjoyed, despite for landing gears turns almost into a nightmare, it would if i didn't had the Renderdoc rips as reference of correct placement.

Aside from minimum stuff that mostly i found out that the KFM1 and MOP2 are listed below the last NDP3/NDXR mesh info, i could comfirm that their bone data and structure are indeed baked at the container, unfortunately, seeing the lack of documentation and you're unexperienced on that aspect, for now i'll leave this thread into a hiatus, if someone would gain some interrest, and would like to check and give the documentation of the bone/skeleton structures to improve the script, don't hesitate at interacting at the thread, but yeah, mostly i don't want to give the impression of a spolied brat which is interacting at the post, but not bringing any useful information or help. and considering people got their own interrests and priorities, i always show some respect at not 'necroposting' this thread with guesses by quick researches without the in depth understanding of how really the structure works. but yeah, mostly i give huge thanks for mario developing a proof of concept for importing this model format.
enmacderm
Posts: 19
Joined: Wed Nov 02, 2016 4:15 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by enmacderm »

Alright, so it was some months since the last activity of this thread, and considering the AC fans from time got the curiosity thanks to me, but seeing the dumping process wasn't very detailed, i decided to release a mini guide i made of the whole process during August, so for people that would like to give a try, despite the obstacles the process got, and still has, now you're free to try themselves

https://1drv.ms/u/s!ApONtI1SBUFngso5VYA ... g?e=KO7xyJ

(̶B̶T̶W̶,̶ ̶t̶h̶i̶s̶ ̶w̶i̶l̶l̶ ̶b̶e̶ ̶m̶y̶ ̶l̶a̶s̶t̶ ̶a̶c̶t̶i̶v̶i̶t̶y̶ ̶f̶o̶r̶ ̶n̶o̶w̶ ̶i̶n̶ ̶e̶n̶t̶e̶r̶i̶n̶g̶ ̶X̶e̶n̶t̶a̶x̶/̶Z̶e̶n̶h̶a̶x̶,̶ ̶c̶u̶r̶r̶e̶n̶t̶l̶y̶ ̶i̶'̶m̶ ̶b̶u̶s̶y̶ ̶w̶i̶t̶h̶ ̶r̶e̶a̶l̶ ̶l̶i̶f̶e̶ ̶i̶s̶s̶u̶e̶s̶ ̶o̶n̶ ̶m̶y̶ ̶e̶n̶d̶,̶ ̶d̶e̶c̶i̶d̶e̶d̶ ̶t̶o̶ ̶g̶i̶v̶e̶ ̶t̶h̶e̶m̶ ̶p̶r̶i̶o̶r̶i̶t̶y̶ ̶a̶n̶d̶ ̶s̶a̶c̶r̶i̶f̶i̶c̶e̶ ̶o̶v̶e̶r̶ ̶m̶y̶ ̶h̶o̶b̶b̶y̶ ̶r̶o̶u̶t̶i̶n̶e̶,̶ ̶t̶h̶e̶r̶e̶'̶s̶ ̶n̶o̶ ̶E̶T̶A̶ ̶o̶f̶ ̶w̶h̶e̶n̶ ̶i̶'̶l̶l̶ ̶b̶e̶ ̶g̶e̶t̶t̶i̶n̶g̶ ̶t̶h̶o̶s̶e̶ ̶s̶o̶l̶v̶e̶d̶,̶ ̶s̶o̶ ̶i̶t̶'̶l̶l̶ ̶t̶a̶k̶e̶ ̶t̶h̶e̶ ̶t̶i̶m̶e̶ ̶i̶t̶ ̶n̶e̶e̶d̶s̶)̶
Last edited by enmacderm on Fri Jul 22, 2022 6:41 am, edited 1 time in total.
wood5568
Posts: 14
Joined: Sat Jun 02, 2018 4:55 pm

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by wood5568 »

Sorry the necro. But I figured the documentations from Smash Forge could help out for future researches not only for ACAH, but for some amount of Namco developed games using the same engine.

[NUT Texture format]
[NUD Model format]

The game engine used here is called the 'NU Library' and Namco had used it for a number of their games (Tekken 6 & Tag2, Time Crisis 4/Razing Storm, Naruto Ninja Storm and etc.) , especially on the 7th gen console era.

From what I checked so far, some formats aren't that far off, the documentations provided there should be able to save up some work (For the 2nd and 3rd revision of this engine atleast)

Here is a list of games I dug through so far that uses this game engine with some extra notes :

Code: Select all

========================================================================
   List of games that uses the Namco NU LIBRARY Game Engine
========================================================================

Formats : Extension (HEADER_STRING) [HEX_VALUE] (MODEL -> TEXTURE -> Sound format -> BONE/ANIMS)
========================================================================
            1st Gen
========================================================================

1. Yumeria
Year :2003
Platform : PS2
Notes : First NU Engine game (According to the Japanese Game Engine article on Wikipedia)
Formats : ????

2. R:Racing Evolution
Year : Late 2003
Platform : PS2
Formats : NUD (NUDP) [0x45454450], TM2 (TIM2) [0x54494D32],

3. Tekken 5 / Tekken 5 : Dark Resurrection (Arcade version)
Year : 2004 / Late 2005
Platform : PS2
Formats : NUD (NUDP) [0x45454450], TM2 (TIM2) [0x54494D32], NPS (NPSF) {NUSound Format} [0x4E505346]

4. Starfox Assault
Year : 2005
Platform : Gamecube
Formats : NUD (NUDC) [0x4E455443], NUT (NUTC) [0x4E555443], IDSP

========================================================================
      NU Next Generation {NUNG} (2nd Gen)
========================================================================
Game engine notes : - Starting from this engine and the 3rd gen, Model and Texture headers are written in this format
          - NDxx or NTxx (Models and Textures respectively)
         where xx represents the platform its compiled for

        List of known platforms :
      - PSP (PP)
      - Vita (VI)
      - P3 (PS3)
      - XR (XBOX360)
      - X3 (XBOX360)
      - Wii U (WU)
      - LX (Linux)
      - WD (Windows)

1. Tekken 5 : Dark Resurrection PS3 Port
Year : 2006
Platform : PS3
Formats : NUD (NDP3) [0x4E445033], NUT (NTP3) [0x4E545033]

2. Tekken 6
Year : 2007
Platform : PS3 & Xbox360
NOTES : Haven't check the PSP version, but a user mention it also uses the NU file extensions that headers ends with PP
Format : PS3 - NUD (NDP3) [0x4E445033], NUT (NTP3) [0x4E545033]
    XBOX360 - NUD (NDXR) [0x4E445852], NUT (NTP3) [0x4E545852], NUB (Header unknown) {NU2Sound format}

3. Time Crisis 4 PS3 Port
Year : 2007
Platform : PS3
NOTES : - Some models like characters cant be loaded
   - Textures works however
Formats : NUD (NDP3) [0x4E445033], NUT (NTP3) [0x4E545033]

4. Razing Storm
Year : 2008
Platform : PS3
NOTES : - Model can't be loaded on SF 'System.OutOfMemoryException'
   - Textures works however
Formats : NUD (NDP3) [0x4E445033], NUT (NTP3) [0x4E545033]

5. Mobile Suit Gundam : Extreme VS
Year : 2010
Platform : PS3
NOTES : -VBNs from this game is incompatible
Formats : NUD (NDP3) [0x4E445033], NUT (NTP3) [0x4E545033], VBN

6. Tekken Tag Tournament 2
Year : 2011
Platform : PS3, XBOX360, Wii U
NOTES :   - Wii U still mostly used P3 headers, WU was only used for very few asset like textures
   - Wii U assets are encrypted, needs to be ramdumped when the game is running
Format : PS3 - NUD (NDP3) [0x4E445033], NUT (NTP3) [0x4E545033]
    XBOX360 - NUD (NDXR) [0x4E445852], NUT (NTP3) [0x4E545852]
    Wii U - NUT (NUWU) [0x4E545755]

7. Wangan Midnight Maximum Tune 4
Year : Late 2011
Platform : Linux
NOTES  : - Game contained development files such as the NUD export logs generated by their Maya plugin
    - Also contained assets that were available as a base for the NU Engine SDK.
Format : NUD (NDLX) [0x4E444C58], NUT (NTLX) [0x4E54C58]

8. Ridge Racer Vita
Year : Late 2011
Platform : Vita
Format : NUD (NDVI) [0x4E445649], NUT (NTVI) [0x4E545649]


========================================================================
      NU Cyber Conenect {NUCC}
========================================================================
NOTES : - Seems to be based off the 2nd gen engine
   - Used for CyberConnect developed games
   - Haven't explored much but from Xentax threads started from seems to be started on Ultimate Ninja Storm 2
Formats : NUD (NDP3) [0x4E445033], NUT (NTP3) [0x4E545033]

========================================================================
      NU 3rd Gen {NU3G}
========================================================================
1. Wangan Midnight Maxmimum Tune 5/5DX/5DX+/6/6R/6RR
Year : 2014
Platform : Windows
Format : NUD (NDWD) [0x4E445033], NUT (NTWD) [0x4E545033], nub (Header Unknown) {NU2Sound Format}

2. Smash Wii U
Year : 2014
Platform : Wii U
Formats : NUD (NDP3) [0x4E445033], NUD (NDWU)[0x4E445755] ,NUT (NTP3) [0x4E545033], NUT (NTWU) [0x4E545755], NUS3BANK (NUS3BANK) {NUS3 Sound Format}, VBN

3. Pokken Tournament
Year : 2015
Platform : Windows, Wii U, Switch
NOTES : Regardless of which platform they all use the Windows header
Format : NUD (NDWD) [0x4E445033], NUT (NTWD) [0x4E545033], NUS3BANK (NUS3BANK) {NUS3 Sound Format}

========================================================================
      NU 4.0 {NU 4.0}
========================================================================
1. Smash Ultimate
Year : 2016
Platform : Switch
Format : NUMDLB , NUMSHB, NUTEXB,  nu3audio {NUSound3.1}

2. Taiko Nijiro (AKA Taiko No Tatsujin 2020 Arcade)
Year : 2020
Platform : Windows (ARCADE ONLY)
NOTES : - Not to be mistaken with the recent release, this is the arcade version with 120 fps support
   - PC Ports are built on Unity Engine
Format : NUMDLB , NUSRCMDLB , NUMSHB , NUHLPB , NUTEXB,

3. New Pokemon Snap
Year : 2021
Platform : Switch
Format : NUMDLB , NUTEXB, NUMSHB, NUS3Bank {NUS3BANK}

There is still some Namco games that uses this engine but isn't listed here.

Again sorry for using this thread to post it, I was going to make a thread about this but don't know where this would fit into.
enmacderm
Posts: 19
Joined: Wed Nov 02, 2016 4:15 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by enmacderm »

Again sorry for using this thread to post it, I was going to make a thread about this but don't know where this would fit into.

Don't worry, your post indeed is welcome as this thread would be still open for improvements of the research of the model format(even if from the last post i may sounded a bit rude, i'm already sorry for that at the time, but i was clueless indeed and didn't wanted to spam the topic)

Smash Forge was indeed one of my inspiration points when checking for more information about the NDP3/NDXR formats, i even went suprised Brawlbox would get a sucessor, Sm4sh was indeed one of the major/mainstream game that made use of the format, however the format still went distinct for not reading most of the models in a universal way, tried out importing before the samples from both ACAH and ACI, but no luck either

At the time i was still with social network, i tried to enter in contact with the creator, but no luck either, at the same time i did the contributions to the post of our friend at the OP, there's also Random Talking Bush's 3DS Max script for Sm4sh and Pokken, which may contain some good reference tools, unfortunately downloading Autodesk trials went a bit of a headache for me just for model imports, when most scripts migrated to Blender, Ninjaripper as a example, but nice you did the homework already

About the Ace Combat games at least, the NU Library made for them is called "Flight Engine", this name is referenced on the datatable of the bone groups of Ace Combat 7 as most of the name basis went reused in UE4(but the models and most stuff are now handled by UE4 default formats), Tekken indeed also used the format, despite part of the data needs to be extracted first before reaching to the NUD data, on a Tekken thread also, one guy mentioned about the distinctions of the headers :

Code: Select all

The NDP3 (model), NTP3 (texture) format is also used in Smash Wii U, so I don't suppose the model format would be similar with the Wii U version?

Miscellaneous notes
The NU Engine has a sort of naming scheme to their models and textures headers, ND(XX) and NT(XX) respectively where the (XX) varies depending on platform but data type remains mostly the same (with some exception like the Xbox ones).
The (XX) can be as :
P3 - Mainly PS3 (also Wii U and some PC ports)
XR - Xbox
LX - Linux
WD- Windows

(also gosh i hate how Zenhax is displaying some bbcode, unsure if is Firefox's fault)

The format is universal, however with the current tools for each game, we didn't reached into a universal state that the same tool can parse or import each model fine with their attributes, as the headers aren't consistent either, even with NDWD existing and Wangan uses it, ACAH on his PC release used NDXR, along there are different types of external assets that vary from the developed game also, Sm4sh uses one way to animation, however unless i'm wrong, ACAH uses a whole different from my analysis

If mario turns out to be interesed again, or any of the 3D researchers that knows how to do Blender scripts, the minimum goal would be improving the script that it could import the all over meshes with bones and armature, as currently is the only main issue that is bothering most people, from viewing 3D models or making game mods even if manual reassemble works, but is very tedious summed doing for each of the "50" models, would be really glad if from one of the complete samples is enough for reconstructing the animation data, as i guess they are dumpable, and from ACAH mod testing they indeed affect the aircraft selected

There are other different NDP3 models too aside from the aircraft ones i send, but didn't sent them before after the Strauros shell as i didn't wanted to spam the thread asking for compability of each model, Vertex Stride helps, despite for some models it lacks UV, and others the materials are all merged in one, but if a considerable progress begins for the script, i wouldn't mind offering the samples of those other NDP3 samples, the dream goal, and probably the most improbable, would be a "Smash Forge" for the NUD formats in general, not like a tool exactly, due a blender script could work well already, but yeah, once again repeating, i understand the rules of game research, for someone getting interesed in developing tools, begging isn't a option, as the person needs to like the subject game, and know about how to develop or reverse engineering, but if the information turns out good, hope it catches the interest of someone

Also visting this thread from time when it get notifications, nice i went to check today when relogging at Zenhax, about myself, i'm better compared to before, but i still focusing on IRL issues from time
enmacderm
Posts: 19
Joined: Wed Nov 02, 2016 4:15 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by enmacderm »

Finally finished at reading the information of the game list(had to transfer into a notepad), indeed a very interesing intel of the engine, also i didn't noticed at first, but you indeed is that guy who imported the Tekken models at Smash Forge along you wrote the bit i mentioned at the code section, nice to see you coming back after two years later, unsure if you got any luck with the ACAH or my ACI samples, but would be curious to see if you got some results
enmacderm
Posts: 19
Joined: Wed Nov 02, 2016 4:15 am

Re: Ace Combat Assault Horizon FHM meshes and IMG textures

Post by enmacderm »

Yeah, it was noticeable this article went into a long radio silence since, but at the meantime, things went interessing on Xentax, so decided to share the current sitrep of what happend since

To August to September, now not only AcAH models, but ACI and soon AC6 skeletal meshes can now be imported with their correct pivots and also sockets, as previously, the last considerable progress was made by mariokart64n, which was the first kick of the whole project after the data i shared from the NDP3 dump, even if it was already satisfactory to check most of those models, the previous roadblock was to retrieving the motion data of the armatures, resulting in misplaced meshes around, even that most of the pivot data could be retrieved, making easy for some parts, landing gears, and complex aircraft which features diagonal parts, dual layered instruments, weapon bays or stuff like Falken or Adler's "mouth", was one of the nightmares at the whole progress

Part of this incredible work during those 3 years of research and contribution was thanks to GreenTrafficLight, which turned out to be the MVP of the advancements of this year(but also honourable mention to wood, which shared information of Smash Forge and the games that uses NUD/NUT files), and currently, is the most far the project went since, depending of next goals we may acheive with the time, at least is satisfactory to begin able to view the models in their original fidelity

Along as some bonus, the current script support most of the structure of my memory dumps and ACAH's file structure, meaning it can read most models, as to remind, some stuff on ACI like the superweapons and exclusive models aren't still possible to begin extracted due there isn't a way yet to access them on both the memory dump way, or in container extraction, but the stuff that still can be retrieved, can be viewed fine by both complete memory dumps, as well with standalone model dumps which was the old way i did them(focused on the model data only)

If you want to check out and follow the updates of the new script, which now works as a import addon instead of a text script, you can found out below, which also, if you're interesed in other aviation media, you'd like to check out the Macross 30 script also made by Green, which is what kicked out this new progression and had similar issues
https://github.com/GreenTrafficLight/Ace-Combat-Blender-Addon
https://github.com/GreenTrafficLight/Macross-30-Blender-Addon

I know some stuff from ACI isn't still available yet aside of "doing the dumps manually", but i'm working to change that due i'll be working on memory dumps to share at the Onedrive repository, along to update the guides following the new features, won't give an ETA, but when finished, i'll annouce on both Xentax/Zenhax
Image
(PS : The Onedrive documentation from my repository hotlink also changed for people that still got the old hotlink, but aside of the edit label, Zenhax won't notify edits compared to new posts)
(https://1drv.ms/u/s!ApONtI1SBUFngso5VYASFX-R7wSJCg?e=KO7xyJ)