diff --git a/README.md b/README.md index 8aee34a..7565b19 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # io_scene_lithtech -This addon is forked from [io_scene_abc](https://github.com/cmbasnett/io_scene_abc), renamed to io_scene_lithtech due to the increased scope. +This addon is forked from [io_scene_abc](https://github.com/cmbasnett/io_scene_abc), renamed to io_scene_lithtech due to the increased scope. -This addon provides limited support for importing and exporting various Lithtech models formats from [No One Lives Forever](https://en.wikipedia.org/wiki/The_Operative:_No_One_Lives_Forever) to and from Blender 2.8x. +This addon provides limited support for importing and exporting various Lithtech models formats from [various games](https://en.wikipedia.org/wiki/LithTech#Games_using_LithTech) such as [Shogo](https://en.wikipedia.org/wiki/Shogo:_Mobile_Armor_Division), and [No One Lives Forever](https://en.wikipedia.org/wiki/The_Operative:_No_One_Lives_Forever) to and from Blender 2.8x/2.9x. ## How To Install -Download or clone the repository, and zip up the `src` folder. Go to `Edit -> Preferences` in Blender 2.8x, select `install` and then select the zip file you just created. +Download or clone the repository, and zip up the contents of the `src` folder. Go to `Edit -> Preferences` in Blender 2.8x/2.9x, go to the `Add-ons` tab, select `install` and then select the zip file you just created. To download the respository, click the green `Code -> Download ZIP` at the top of the main page. @@ -16,7 +16,8 @@ To download the respository, click the green `Code -> Download ZIP` at the top o Format | Import | Export --- | --- | --- -ABC | Rigid and Skeletal | Limited +ABCv6 | Full | Full +ABCv13 | Rigid and Skeletal | Limited LTA | No | Rigid and Skeletal LTB (PS2) | Rigid and Skeletal | No LTB (PC) | Rigid and Skeletal | No @@ -31,10 +32,12 @@ Additional format information can be found in [here](https://github.com/haekb/io - Imported skeletal meshes are mirrored on the X axis (They're flipped back on export!) - Converters may not provide 1:1 source files - Converters don't convert lods! + - ABCv6 sometimes exports vertex animation nodes with an odd rotation ![](https://raw.githubusercontent.com/haekb/io_scene_lithtech/master/doc/readme/example.png) ## Credits * **Colin Basnett** - Programming * **ReindeerFloatilla** - Research -* **Haekb** - Programming / Research \ No newline at end of file +* **Haekb** - Programming / Research +* **Amphos** - Programming \ No newline at end of file diff --git a/doc/abc-v6-anim-engine-links.md b/doc/abc-v6-anim-engine-links.md new file mode 100644 index 0000000..71a7567 --- /dev/null +++ b/doc/abc-v6-anim-engine-links.md @@ -0,0 +1,254 @@ +## ABCv6 Node Names + +For dismemberment/locational damage system: + +- head +- neck +- torso +- pelvis +- ru_arm +- rl_arm +- r_hand +- lu_arm +- ll_arm +- l_hand +- lu_leg +- ll_leg +- l_ankle +- l_foot +- ru_leg +- rl_leg +- r_ankle +- r_foot +- obj +- l_gun +- r_gun + +## ABCv6 Animation Names + +For (multiplayer?) characters: +- idle1 +- idle2 +- idle3 +- idle4 +- talk1 +- talk2 +- talk3 +- talk4 +- talk5 +- walk_nogun +- walk_rifle +- walk_pistol +- walk_knife1 +- walk_irleg_rifle (injured right leg) +- walk_irleg_nogun (injured right leg) +- walk_illeg_rifle (injured left leg) +- walk_illeg_nogun (injured left leg) +- run_nogun +- run_rifle +- run_pistol +- run_knife1 +- jmp_rifle +- jmp_pistol +- jmp_knife +- crouch_1pistol +- crouch_rifle +- crouch_knife1 +- crawl_1pistol +- crawl_rifle +- crawl_knife1 +- swim_nogun +- swim_rifle +- swim_pistol +- swim_knife1 +- strafe_right_nogun +- strafe_right_1pistol +- strafe_right_2pistol +- strafe_right_rifle +- strafe_right_nogun +- strafe_right_1pistol +- strafe_right_2pistol +- strafe_right_rifle +- pickup_weapon +- switch_weapon_2pistol +- switch_weapon_rifle +- switch_weapon_knife +- switch_weapon_none +- fire_stand_rifle +- fire_stand_autorifle +- fire_stand_1pistol +- fire_stand_2pistol +- fire_stand_knife1 +- fire_stand_knife2 +- fire_stand_knife3 +- fire_stand_grenade +- fire_stand_magic +- fire_walk_rifle +- fire_walk_autorifle +- fire_walk_1pistol +- fire_walk_2pistol +- fire_walk_knife1 +- fire_walk_knife2 +- fire_walk_knife3 +- fire_walk_grenade +- fire_walk_magic +- fire_run_rifle +- fire_run_autorifle +- fire_run_1pistol +- fire_run_2pistol +- fire_run_knife1 +- fire_run_knife2 +- fire_run_knife3 +- fire_run_grenade +- fire_run_magic +- fire_jump_rifle +- fire_jump_autorifle +- fire_jump_1pistol +- fire_jump_2pistol +- fire_jump_knife1 +- fire_jump_knife2 +- fire_jump_knife3 +- fire_jump_grenade +- fire_jump_magic +- fire_crouch_rifle +- fire_crouch_autorifle +- fire_crouch_1pistol +- fire_crouch_2pistol +- fire_crouch_knife1 +- fire_crouch_knife2 +- fire_crouch_knife3 +- fire_crouch_grenade +- fire_crouch_magic +- fire_crawl_rifle +- fire_crawl_autorifle +- fire_crawl_1pistol +- fire_crawl_2pistol +- fire_crawl_knife1 +- fire_crawl_knife2 +- fire_crawl_knife3 +- fire_crawl_grenade +- fire_crawl_magic +- falling +- falling_uncontrol +- roll_forward +- roll_right +- roll_left +- roll_back +- handspring_forward +- handspring_right +- handspring_left +- handspring_back +- flip_forward +- flip_right +- flip_left +- flip_back +- dodge_right +- dodge_left +- recoil_head1 +- recoil_chest1 +- recoil_rchest1 +- recoil_lchest1 +- recoil_lleg1 +- recoil_rleg1 +- recoil_head2 +- recoil_chest2 +- recoil_rchest2 +- recoil_lchest2 +- recoil_lleg2 +- recoil_rleg2 +- taunt_dance1 +- taunt_dance2 +- taunt_dance3 +- taunt_dance4 +- taunt_flip +- taunt_wave +- taunt_beg +- spot_right +- spot_left +- spot_point +- death_head1 +- death_chest1 +- death_rchest1 +- death_lchest1 +- death_lleg1 +- death_rleg1 +- death_head2 +- death_chest2 +- death_rchest2 +- death_lchest2 +- death_lleg2 +- death_rleg2 +- humiliation_01 +- humiliation_02 +- humiliation_03 +- humiliation_04 +- humiliation_05 +- special1 +- special2 +- special3 +- special4 +- special5 +- special6 +- special7 +- special8 +- special9 +- corpse_head1 +- corpse_chest +- corpse_rchest +- corpse_lchest +- corpse_lleg1 +- corpse_rleg1 +- corpse_head2 +- corpse_chest2 +- corpse_rchest2 +- corpse_lchest2 +- corpse_lleg2 +- corpse_rleg2 + +For dismemberment system: +- limb_head +- limb_arm +- limb_leg + +Animation names for PV weapon models: +- static_model +- idle +- draw +- dh_draw +- holster +- dh_holster +- start_fire +- fire +- end_fire +- start_alt_fire +- alt_fire +- end_alt_fire + +For weapon pickup models: +- handheld + +## ABCv6 Frame String Commands + +For characters/enemies: +- fire_key:optional int attack_num +- show_weapon:int weapon_num +- extra_key:string extra_args - this could be anything, depending on the class using the model, see AI_Mgr::MC_Extra +- play_sound:string sound_file + - sound_random:int max + - sound_radius:int radius + - sound_volume:int volume + - sound_chance:int chance [0-100] + - sound_voice:bool is_voice + +For PV weapons: +- fire_key +- sound_key:string sound_file +- soundloop_key:string sound_file +- soundstop_key +- hide_key:string node_name +- show_key:string node_name + +# Examples: + +- For a gun's PV animation to deal damage the actual fire frame needs: fire_key +- To play mon_footstep_1.wav or mon_footstep_2.wav randomly whenever a footstep frame happens: [play_sound:mon_footstep_][sound_random:2][sound_volume:50] \ No newline at end of file diff --git a/doc/abc-v6-export-basics.md b/doc/abc-v6-export-basics.md new file mode 100644 index 0000000..cee785d --- /dev/null +++ b/doc/abc-v6-export-basics.md @@ -0,0 +1,49 @@ +The absolute minimum your Blender scene must have to export successfully to ABCv6 is: +- One Armature +- One Mesh with an active UV Map +- One Action with at least 1 Keyframe + +An example .blend can be found [here](./abc-v6-export-basics/minimal_abc-v6_no_anim.blend) + +![](./abc-v6-export-basics/minimum_scene.png) + +## Making a basic ABCv6 model +First make an armature, and in object mode set the X rotation to 90 degrees, and the X and Z scale to -1. + +![](./abc-v6-export-basics/armature_transform.png) + +Next, make a mesh, and set its parent to the armature you just made. Then add an armature modifier for the same armature. + +![](./abc-v6-export-basics/mesh_armature_modifier.png) + +For every bone in your armature, the mesh *must* have a corresponding vertex group with the same name. Assign vertices you want to be affected by the bone here with a weight 1. *Never* assign multiple nodes to the same vertex, that is not supported by the format. + +Finally create an action. Actions start at frame 0, and must have at least 1 keyframe. Actions named with a "d_" prefix will not be exported, it is expected these contain vertex animation keyframes. + +Each bone must have the same amount of keyframes. They should be at the same times as the root node's **W Quaternion Rotation lane**. Only **location** and **rotation_quaternion** (other rotation types are not currently supported) lanes will be exported, scaling is not supported by the format. + +![](./abc-v6-export-basics/action_editor.png) + +## Exporting a basic ABCv6 model +Finally we can export our model. First we need to make sure the mesh is *triangulated* (ctrl + T). + +Then we wind the triangles the correct way by *calculating the inside* (ctrl + shift + N). + +Now, go to File -> Export -> Lithtech ABC, select the armature you want to export (should be done automatically), and choose the version: ABC v6 (Lithtech 1.0), and click export. + +# Issues you may encounter +- If some of your bones try to converge at [0, 0, 0] after export, it means they have fewer keyframes than the root node. +- If you think your exported animation is missing some keyframes, check that you've set a rotation keyframe on your root node at the expected time. +- If the bounding boxes are too small or too large, make sure you aren't scaling any of your objects. +- If the export fails with an "active UV index 0 out of range" error, make sure you're not in mesh edit mode, select the mesh object, and click on the UV map you want to export, and try again. + +![](./abc-v6-export-basics/uv_error.png) + +## Final product +I named it BERETTA_PU.ABC, and put it in BLOOD2/MODELS/POWERUPS to make it appear in 3rd person and when dropped on the ground: + +![](./abc-v6-export-basics/replaced_beretta_pickup.png) + +## Misc. Blender +- There's no way to export a frame command string from Blender yet, so you'll have to put it in ModelEdit after export to do this. Make sure to press enter after typing to confirm the string. +- If making a first person weapon model it may be helpful to create a camera object. I've found a Z position of 1m, and a rotation of X -90 degrees, and Z 180 degrees, with a 90 degree FOV to look fairly close to the game (in 16:9, with widescreen patches). \ No newline at end of file diff --git a/doc/abc-v6-export-basics/action_editor.png b/doc/abc-v6-export-basics/action_editor.png new file mode 100644 index 0000000..e8caff9 Binary files /dev/null and b/doc/abc-v6-export-basics/action_editor.png differ diff --git a/doc/abc-v6-export-basics/armature_transform.png b/doc/abc-v6-export-basics/armature_transform.png new file mode 100644 index 0000000..c6f4995 Binary files /dev/null and b/doc/abc-v6-export-basics/armature_transform.png differ diff --git a/doc/abc-v6-export-basics/mesh_armature_modifier.png b/doc/abc-v6-export-basics/mesh_armature_modifier.png new file mode 100644 index 0000000..d69c6ce Binary files /dev/null and b/doc/abc-v6-export-basics/mesh_armature_modifier.png differ diff --git a/doc/abc-v6-export-basics/minimal_abc-v6_no_anim.blend b/doc/abc-v6-export-basics/minimal_abc-v6_no_anim.blend new file mode 100644 index 0000000..20f497f Binary files /dev/null and b/doc/abc-v6-export-basics/minimal_abc-v6_no_anim.blend differ diff --git a/doc/abc-v6-export-basics/minimal_abc-v6_skeletal_anim.blend b/doc/abc-v6-export-basics/minimal_abc-v6_skeletal_anim.blend new file mode 100644 index 0000000..072ada6 Binary files /dev/null and b/doc/abc-v6-export-basics/minimal_abc-v6_skeletal_anim.blend differ diff --git a/doc/abc-v6-export-basics/minimal_abc-v6_vert_anim.blend b/doc/abc-v6-export-basics/minimal_abc-v6_vert_anim.blend new file mode 100644 index 0000000..4d03c0e Binary files /dev/null and b/doc/abc-v6-export-basics/minimal_abc-v6_vert_anim.blend differ diff --git a/doc/abc-v6-export-basics/minimum_scene.png b/doc/abc-v6-export-basics/minimum_scene.png new file mode 100644 index 0000000..ad3a72d Binary files /dev/null and b/doc/abc-v6-export-basics/minimum_scene.png differ diff --git a/doc/abc-v6-export-basics/replaced_beretta_pickup.png b/doc/abc-v6-export-basics/replaced_beretta_pickup.png new file mode 100644 index 0000000..fc022a8 Binary files /dev/null and b/doc/abc-v6-export-basics/replaced_beretta_pickup.png differ diff --git a/doc/abc-v6-export-basics/uv_error.png b/doc/abc-v6-export-basics/uv_error.png new file mode 100644 index 0000000..2e42f48 Binary files /dev/null and b/doc/abc-v6-export-basics/uv_error.png differ diff --git a/doc/abc-v6-vert-anim-basics.md b/doc/abc-v6-vert-anim-basics.md new file mode 100644 index 0000000..00e3ae9 --- /dev/null +++ b/doc/abc-v6-vert-anim-basics.md @@ -0,0 +1,21 @@ +Vertex animations utilize the shape key feature of Blender. + +An example .blend can be found [here](./abc-v6-export-basics/minimal_abc-v6_vert_anim.blend) + +## Basics +The exporter will ignore vertex animations entirely if you have less than 2 shape keys. + +![](./abc-v6-vert-anim-basics/shape_keys.png) + +For every action, you need to add shape keys equal to the number of keyframes in that action. They should be named the same as the corresponding action, eg. if you have an alt_fire animation, your shape keys should be named alt_fire_0, alt_fire_1, alt_fire_2, and so on. +**Note:** the names after the action name don't matter, but the order does. If alt_fire_2 is the highest in the list it will be attached to the first keyframe. + +Even if you don't want vertex animations for a certain action you'll have to create shape keys for it. + +# Issues +- Currently there's a bug that affects few vertex animated nodes, they export with a slightly incorrect rotation or translation. + +## Misc. Tips +- I usually set the shape key to absolute and add "d_" prefixed actions to animate the shape keys, they don't do anything in the export but it's easier to preview with them. + +![](./abc-v6-vert-anim-basics/action_list.png) \ No newline at end of file diff --git a/doc/abc-v6-vert-anim-basics/action_list.png b/doc/abc-v6-vert-anim-basics/action_list.png new file mode 100644 index 0000000..1ee4e4f Binary files /dev/null and b/doc/abc-v6-vert-anim-basics/action_list.png differ diff --git a/doc/abc-v6-vert-anim-basics/shape_keys.png b/doc/abc-v6-vert-anim-basics/shape_keys.png new file mode 100644 index 0000000..c6695c6 Binary files /dev/null and b/doc/abc-v6-vert-anim-basics/shape_keys.png differ diff --git a/src/abc.py b/src/abc.py index 017cd3e..e46673a 100644 --- a/src/abc.py +++ b/src/abc.py @@ -7,7 +7,7 @@ https://web.archive.org/web/20080605043638/http://bop-mod.com:80/download/docs/ABC-Format-v6.html TODO LIST: - * Figure out what the [-1, 0, 18] flag is at the end of animation bounds. + * Figure out what the [-1, 0, 18] flag is at the end of animation bounds. * Add the ability to optionally merge import meshes * Add the ability to import textures automatically ''' @@ -71,6 +71,9 @@ class Face(object): def __init__(self): self.vertices = [] + # ABCv6 specific + self.normal = Vector() + class LOD(object): def __init__(self): @@ -98,11 +101,11 @@ def get_face_vertices(self, face_index): class Piece(object): - + @property def weight_count(self): return sum([len(vertex.weights) for lod in self.lods for vertex in lod.vertices]) - + def __init__(self): self.material_index = 0 self.specular_power = 0.0 @@ -115,13 +118,13 @@ def __init__(self): self.lod_min = 0.0 self.lod_max = 0.0 self.lod_distances = [] - + class Node(object): - + @property def is_removable(self): return (self.flags & 1) != 0 - + @is_removable.setter def is_removable(self, b): self.flags = (self.flags & ~1) | (1 if b else 0) @@ -129,7 +132,7 @@ def is_removable(self, b): @property def uses_relative_location(self): return (self.flags & 2) != 0 - + def __init__(self): self.name = '' self.index = 0 @@ -138,9 +141,12 @@ def __init__(self): self.child_count = 0 # Version 6 specific + self.bounds_min = Vector() + self.bounds_max = Vector() + self.md_vert_count = 0 self.md_vert_list = [] - + def __repr__(self): return self.name @@ -175,15 +181,15 @@ class Transform(object): def __init__(self): self.location = Vector() self.rotation = Quaternion((1, 0, 0, 0)) - + @property def matrix(self): return Matrix.Translation(self.location) * self.rotation.to_matrix().to_4x4() - + @matrix.setter def matrix(self, m): self.location, self.rotation, _ = m.decompose() - + def __init__(self): self.time = 0 self.string = '' @@ -197,17 +203,19 @@ def __init__(self): self.node_keyframe_transforms = [] # Version 6 specific - + self.bounds_min = Vector() + self.bounds_max = Vector() # Note this should line up with md_vert_list, and go on for md_vert_count * keyframe_count # List of 3 chars (verts) self.vertex_deformations = [] + self.vertex_deformation_bounds = dict() # List of Vector (verts) # Scaled by the animation bounding box self.transformed_vertex_deformations = [] # LTB specific self.compression_type = 0 - self.is_vetex_animation = 0 + self.is_vertex_animation = 0 class AnimBinding(object): @@ -238,7 +246,7 @@ def __init__(self): self.lod_distances = [] self.weight_sets = [] self.anim_bindings = [] - + # ABC v6 specific # By default it's true, this is only used when self.version == 6! @@ -251,7 +259,7 @@ def __init__(self): # LTB specific - + @property def keyframe_count(self): return sum([len(animation.keyframes) for animation in self.animations]) @@ -259,7 +267,7 @@ def keyframe_count(self): @property def face_count(self): #TODO: this is actually probably per LOD as well return sum([len(lod.faces) for piece in self.pieces for lod in piece.lods]) - + @property def vertex_count(self): return sum([len(lod.vertices) for piece in self.pieces for lod in piece.lods]) @@ -267,7 +275,7 @@ def vertex_count(self): @property def weight_count(self): return sum([len(vertex.weights) for piece in self.pieces for lod in piece.lods for vertex in lod.vertices]) - + @property def lod_count(self): return len(self.pieces[0].lods) diff --git a/src/builder.py b/src/builder.py index 7071c57..198b974 100644 --- a/src/builder.py +++ b/src/builder.py @@ -1,6 +1,8 @@ from .abc import * -from math import pi, radians +from math import pi, radians, floor from mathutils import Vector, Matrix, Quaternion, Euler +from .utils import get_framerate +from re import match import bpy class ModelBuilder(object): @@ -10,7 +12,7 @@ def __init__(self): # # Set Keyframe Timings # Handles setting up the dictionary struct if it's the first time through a particular bone. - # + # @staticmethod def set_keyframe_timings(keyframe_dictionary, bone, time, transform_type): # Set up the small struct, if it's not already done @@ -82,7 +84,7 @@ def from_armature(armature_object): for (vertex_index, vertex) in enumerate(mesh.vertices): weights = [] for vertex_group in mesh_object.vertex_groups: - + # Location is used in Lithtech 2.0 games, but is not in ModelEdit. try: bias = vertex_group.weight(vertex_index) @@ -102,9 +104,9 @@ def from_armature(armature_object): # Note: This corrects any rotation done on import - rot = Matrix.Rotation(radians(-180), 4, 'Z') @ Matrix.Rotation(radians(90), 4, 'X') + rot = Matrix.Rotation(radians(-180), 4, 'Z') @ Matrix.Rotation(radians(90), 4, 'X') - v = Vertex() + v = Vertex() v.location = vertex.co @ rot v.normal = vertex.normal v.weights.extend(weights) @@ -117,6 +119,8 @@ def from_armature(armature_object): raise Exception('Mesh \'{}\' is not triangulated.'.format( mesh.name)) # TODO: automatically triangulate the mesh, and have this be reversible face = Face() + face.normal = polygon.normal + for loop_index in polygon.loop_indices: uv = mesh.uv_layers.active.data[loop_index].uv.copy() # TODO: use "active"? uv.y = 1.0 - uv.y @@ -152,6 +156,15 @@ def from_armature(armature_object): node.bind_matrix = matrix + # ABC v6 specific + # FIXME: disgusting, can this be done better? + for piece in model.pieces: + for lod in piece.lods: + for vertex in lod.vertices: + if vertex.weights[0].node_index == bone_index: + node.bounds_min = Vector((min(node.bounds_min.x, vertex.location.x), min(node.bounds_min.y, vertex.location.y), min(node.bounds_min.z, vertex.location.z))) + node.bounds_max = Vector((max(node.bounds_min.x, vertex.location.x), max(node.bounds_min.y, vertex.location.y), max(node.bounds_min.z, vertex.location.z))) + #print("Processed", node.name, node.bind_matrix) node.child_count = len(bone.children) model.nodes.append(node) @@ -184,10 +197,13 @@ def from_armature(armature_object): ''' Animations ''' for action in bpy.data.actions: + # skip any actions prefixed with "d_"; they're vertex animation lanes, we don't want them in the ABCv6 output + if match(r"^d_", action.name): + continue + print("Processing animation %s" % action.name) animation = Animation() animation.name = action.name - animation.extents = Vector((10, 10, 10)) armature_object.animation_data.action = action @@ -218,7 +234,7 @@ def from_armature(armature_object): current_skip_count = 4 elif 'location' in fcurve.data_path: current_type = 'location' - current_skip_count = 3 + current_skip_count = 3 elif 'scale' in fcurve.data_path: current_skip_count = 3 fcurve_index += current_skip_count @@ -241,24 +257,46 @@ def from_armature(armature_object): # For now we can just use the first node! for time in keyframe_timings[model.nodes[0].name]['rotation_quaternion']: # Expand our time - scaled_time = time * (1.0/0.025) + scaled_time = time * (1.0 / get_framerate()) + + subframe_time = time - floor(time) + bpy.context.scene.frame_set(time, subframe = subframe_time) keyframe = Animation.Keyframe() keyframe.time = scaled_time + + # will using mesh_object here break if there's multiple mesh objects using the same armature as a parent? DON'T DO THAT + keyframe.bounds_min = Vector(mesh_object.bound_box[0]) # top left back corner + keyframe.bounds_max = Vector(mesh_object.bound_box[6]) # bottom right front corner + animation.keyframes.append(keyframe) + animation.bounds_min = Vector((float("inf"), float("inf"), float("inf"))) + animation.bounds_max = Vector((float("-inf"), float("-inf"), float("-inf"))) + + for keyframe in animation.keyframes: + animation.bounds_min.x = min(animation.bounds_min.x, keyframe.bounds_min.x) + animation.bounds_min.y = min(animation.bounds_min.y, keyframe.bounds_min.y) + animation.bounds_min.z = min(animation.bounds_min.z, keyframe.bounds_min.z) + + animation.bounds_max.x = max(animation.bounds_max.x, keyframe.bounds_max.x) + animation.bounds_max.y = max(animation.bounds_max.y, keyframe.bounds_max.y) + animation.bounds_max.z = max(animation.bounds_max.z, keyframe.bounds_max.z) + # Okay let's start processing our transforms! for node_index, (node, pose_bone) in enumerate(zip(model.nodes, armature_object.pose.bones)): transforms = list() keyframe_timing = keyframe_timings[pose_bone.name] - + # FIXME: In the future we may trim off timing with no rotation/location changes # So we'd have to loop through each keyframe timing, but for now this should work! for time in keyframe_timing['rotation_quaternion']: # Expand our time - scaled_time = time * (1.0/0.025) - bpy.context.scene.frame_set(time) + scaled_time = time * (1.0 / get_framerate()) + + subframe_time = time - floor(time) + bpy.context.scene.frame_set(time, subframe = subframe_time) transform = Animation.Keyframe.Transform() @@ -277,12 +315,93 @@ def from_armature(armature_object): # End For model.animations.append(animation) - # End For + + ''' Vertex Animations ''' + + # Then most trivially, you find min and max of each dimension, set scales to (maxes-mins), subtract mins from all points, then divide by scales. + # And the transform is set by doing the same subtract and divide to the origin + # Later on, to reduce artifacts, instead of just doing that and then scaling back up to 255 blindly, you could iterate over possible values UP to 255, and check sum of error^2 for each one, and choose the one with lowest total error + # Idea being that if you had like three evenly spaced things, then 240 may give you perfect accuracy, while 255 will not + # When you're compressing, you can choose to compress to less than 255, and just use a larger scale and transform to compensate + + vertex_tolerance = 1e-5 + + # TODO: check if each animation has associated shape keys and if no assume neutral pose for each frame + if mesh.shape_keys and len(mesh.shape_keys.key_blocks) > 1: + for animation in model.animations: + print("Processing vertex animation", animation.name) + + animation.vertex_deformations = dict() + + shape_keys = [shape_key for shape_key in mesh.shape_keys.key_blocks if shape_key.name.startswith(animation.name)] + + for node_index, node in enumerate(model.nodes): + dirty_node = False + animation.vertex_deformations[node] = [] + + # get all vertices for this node + node_vertices = [vertex_index for vertex_index, vertex in enumerate(model.pieces[0].lods[0].vertices) if vertex.weights[0].node_index == node_index] + + node.bounds_min = Vector((float("inf"), float("inf"), float("inf"))) + node.bounds_max = Vector((float("-inf"), float("-inf"), float("-inf"))) + + if len(node_vertices) == 0: + node.bounds_min = Vector() + node.bounds_max = Vector() + + # get the bounds of the node + for keyframe_index, keyframe in enumerate(animation.keyframes): + for vertex_index in node_vertices: + temp_vert = shape_keys[keyframe_index].data[vertex_index] + + if (temp_vert.co - mesh.shape_keys.key_blocks[0].data[vertex_index].co).length > vertex_tolerance: + dirty_node = True + + temp_vert = (temp_vert.co @ mesh_object.matrix_world) @ node.bind_matrix.transposed().inverted() + + node.bounds_min.x = min(node.bounds_min.x, temp_vert.x) + node.bounds_min.y = min(node.bounds_min.y, temp_vert.y) + node.bounds_min.z = min(node.bounds_min.z, temp_vert.z) + + node.bounds_max.x = max(node.bounds_max.x, temp_vert.x) + node.bounds_max.y = max(node.bounds_max.y, temp_vert.y) + node.bounds_max.z = max(node.bounds_max.z, temp_vert.z) + + animation.vertex_deformation_bounds[node] = [node.bounds_min, node.bounds_max] + + node.md_vert_list.extend(node_vertices if dirty_node else []) + + scale = node.bounds_max - node.bounds_min + + # compress vertices + for keyframe_index, keyframe in enumerate(animation.keyframes): + for vertex_index in node_vertices: + temp_loc = (shape_keys[keyframe_index].data[vertex_index].co @ mesh_object.matrix_world) @ node.bind_matrix.transposed().inverted() - node.bounds_min + temp_vert = Vector((temp_loc.x / scale.x, temp_loc.y / scale.y, temp_loc.z / scale.z)) + + animation.vertex_deformations[node].append(temp_vert) + + for node in model.nodes: + # remove dupes, and count final + node.md_vert_list = list(dict.fromkeys(node.md_vert_list)) + node.md_vert_count = len(node.md_vert_list) + + # flag nodes + node.flags = 1 + for piece in model.pieces: + for lod in piece.lods: + for vertex in lod.vertices: + if vertex.weights[0].node_index == node.index: + node.flags = 2 + break + + if node.md_vert_count > 0: + node.flags |= 4 ''' AnimBindings ''' anim_binding = AnimBinding() anim_binding.name = 'base' - animation.extents = Vector((10, 10, 10)) + anim_binding.extents = Vector((10, 10, 10)) model.anim_bindings.append(anim_binding) return model \ No newline at end of file diff --git a/src/exporter.py b/src/exporter.py index 538934a..da94244 100644 --- a/src/exporter.py +++ b/src/exporter.py @@ -1,8 +1,9 @@ from bpy.types import Operator from bpy_extras.io_utils import ExportHelper -from bpy.props import StringProperty, EnumProperty +from bpy.props import StringProperty, EnumProperty, BoolProperty from .builder import ModelBuilder from .writer_abc_pc import ABCModelWriter +from .writer_abc_v6_pc import ABCV6ModelWriter from .writer_lta_pc import LTAModelWriter from .utils import ABCVersion, LTAVersion @@ -45,13 +46,24 @@ def item_abc_version(self, context): items = item_abc_version, ) + should_export_transform: BoolProperty( + name="Export V6 Transform Info (Not Implemented)", + description="When checked, will append TransformInfo section for Lithtech 1.5", + default=False, + ) + def execute(self, context): - if self.abc_version in [ABCVersion.ABC6.value, ABCVersion.ABC13.value]: + if self.abc_version in [ABCVersion.ABC13.value]: raise Exception('Not implemented ({}).'.format(ABCVersion.get_text(self.abc_version))) armature_object = context.scene.objects[self.armature] model = ModelBuilder().from_armature(armature_object) - ABCModelWriter().write(model, self.filepath, self.abc_version) + + if self.abc_version == ABCVersion.ABC6.value: + ABCV6ModelWriter().write(model, self.filepath, self.abc_version) + else: + ABCModelWriter().write(model, self.filepath, self.abc_version) + return {'FINISHED'} def menu_func_export(self, context): diff --git a/src/importer.py b/src/importer.py index b723d8f..197521b 100644 --- a/src/importer.py +++ b/src/importer.py @@ -3,11 +3,11 @@ import bmesh import os import math -from math import pi +from math import pi, ceil from mathutils import Vector, Matrix, Quaternion, Euler from bpy.props import StringProperty, BoolProperty, FloatProperty from .dtx import DTX -from .utils import show_message_box +from .utils import show_message_box, get_framerate # Format imports from .reader_abc_v6_pc import ABCV6ModelReader @@ -81,12 +81,12 @@ def import_model(model, options): # End If # End For - # FIXME: Make bones touch their parents. - # This however breaks the animations. - # - # I think I need to offset this in the animation processing, + # FIXME: Make bones touch their parents. + # This however breaks the animations. + # + # I think I need to offset this in the animation processing, # and animations might be a touch broken right now but it's not noticable...just ugly in blender. - # ---------------------------- + # ---------------------------- # for node in model.nodes: # bone = armature.edit_bones[node.name] @@ -95,7 +95,7 @@ def import_model(model, options): # # End If # for child in bone.children: - # bone.tail = child.head + # bone.tail = child.head # # End For Ops.object.mode_set(mode='OBJECT') @@ -131,7 +131,7 @@ def import_model(model, options): materials.append(material) ''' Create texture. ''' - + # Swapped over to nodes bsdf = material.node_tree.nodes["Principled BSDF"] texImage = material.node_tree.nodes.new('ShaderNodeTexImage') @@ -267,6 +267,9 @@ def import_model(model, options): ''' Add an armature modifier. ''' armature_modifier = mesh_object.modifiers.new(name='Armature', type='ARMATURE') armature_modifier.object = armature_object + # TODO: remove if we fix mesh neutral pose bug? + armature_modifier.show_in_editmode = True + armature_modifier.show_on_cage = True ''' Assign vertex weighting. ''' vertex_offset = 0 @@ -299,29 +302,35 @@ def import_model(model, options): armature_object.animation_data_create() for obj in armature_object.children: - obj.animation_data_create() + obj.shape_key_add(name="neutral_pose", from_mix=False) + # we'll animate using mesh.shape_keys.eval_time + mesh.shape_keys.animation_data_create() + mesh.shape_keys.use_relative = False actions = [] + md_actions = [] index = 0 + processed_frame_count = 1 # 1 for neutral_pose for animation in model.animations: - print("Processing ", animation.name) + print("Processing", animation.name) index = index + 1 # Create a new action with the animation name action = bpy.data.actions.new(name=animation.name) - + # Temp set armature_object.animation_data.action = action - for obj in [o for o in collection.objects if o.type in {'MESH'}]: - obj.animation_data.action = action + if options.should_import_vertex_animations: + # Create a new shape key action with d_ prefixed animation name + md_action = Data.actions.new(name="d_%s" % (animation.name)) + mesh.shape_keys.animation_data.action = md_action # For every keyframe for keyframe_index, keyframe in enumerate(animation.keyframes): # Set keyframe time - Scale it down to the default blender animation framerate (25fps) - Context.scene.frame_set(keyframe.time * 0.025) - + subframe_time = keyframe.time * get_framerate() ''' Recursively apply transformations to a nodes children Notes: It carries everything (nodes, pose_bones..) with it, because I expected it to not be a child of this scope...oops! @@ -350,7 +359,7 @@ def recursively_apply_transform(nodes, node_index, pose_bones, parent_matrix): # otherwise just use our matrix if parent_matrix != None: matrix = parent_matrix @ matrix - + pose_bone.matrix = matrix for _ in range(0, node.child_count): @@ -361,53 +370,65 @@ def recursively_apply_transform(nodes, node_index, pose_bones, parent_matrix): ''' Func End ''' - - - recursively_apply_transform(model.nodes, 0, armature_object.pose.bones, None) + if not (index == 1 and keyframe_index == 0): # this is a dumb hack to preserve the neutral pose + recursively_apply_transform(model.nodes, 0, armature_object.pose.bones, None) # For every bone for bone, node in zip(armature_object.pose.bones, model.nodes): - bone.keyframe_insert('location') - bone.keyframe_insert('rotation_quaternion') + bone.keyframe_insert('location', frame=subframe_time) + bone.keyframe_insert('rotation_quaternion', frame=subframe_time) # End For if options.should_import_vertex_animations: - # For every vert (Thanks animation_animall!) + # shape keys, here I go! for obj in armature_object.children: - for vert_index, vert in enumerate(obj.data.vertices): + # create our shape key + shape_key = obj.shape_key_add(name="%s_%d" % (animation.name, keyframe_index), from_mix=False) - # Let's hope they're in the same order! + for vert_index, vert in enumerate(obj.data.vertices): our_vert_index = vert_index node_index = model.pieces[0].lods[0].vertices[our_vert_index].weights[0].node_index node = model.nodes[node_index] if node.md_vert_count > 0: - # Find the position of the vertex we're going to deform - # It's laid out flat, so we'll need to add the result of the length of verts per frame * the framecount md_vert = node.md_vert_list.index(our_vert_index) + (keyframe_index * node.md_vert_count) - # Grab are transformed deformation vertex_transform = animation.vertex_deformations[node_index][md_vert].location - vert.co = node.bind_matrix @ vertex_transform - - vert.keyframe_insert('co', group="Vertex %s" % vert_index) + shape_key.data[vert_index].co = node.bind_matrix @ vertex_transform # End If # End For # End For - # End For + mesh.shape_keys.eval_time = (processed_frame_count + keyframe_index) * 10 + mesh.shape_keys.keyframe_insert("eval_time", frame=subframe_time) + # End For + + processed_frame_count += len(animation.keyframes) # Add to actions array actions.append(action) + if options.should_import_vertex_animations: + md_actions.append(md_action) # Add our actions to animation data armature_object.animation_data.action = actions[0] - - for obj in armature_object.children: - obj.animation_data.action = actions[0] - + if options.should_import_vertex_animations: + mesh.shape_keys.animation_data.action = md_actions[0] + + ''' Vertex Animations ''' + # TODO: move it all out of the animations section + # cleaner than adding more layers of loops + #if options.should_import_vertex_animations: + # for animation in model.animations: + # print("Processing vertex animations for ", animation.name) + + # Set almost sane defaults + Context.scene.frame_start = 0 + #Context.scene.frame_end = ceil(max([animation.keyframes[-1].time * get_framerate() for animation in model.animations])) # Set our keyframe time to 0 Context.scene.frame_set(0) + # Set this because almost 100% chance you're importing keyframes that aren't aligned to 25fps + Context.scene.show_subframe = True # TODO: make an option to convert to blender coordinate system armature_object.rotation_euler.x = math.radians(90) @@ -657,14 +678,14 @@ def execute(self, context): model = PCLTBModelReader().from_file(self.filepath) except Exception as e: model = PS2LTBModelReader().from_file(self.filepath) - + # Load the model #try: #model = PS2LTBModelReader().from_file(self.filepath) #except Exception as e: # show_message_box(str(e), "Read Error", 'ERROR') # return {'CANCELLED'} - + model.name = os.path.splitext(os.path.basename(self.filepath))[0] image = None if self.should_import_textures: diff --git a/src/reader_abc_v6_pc.py b/src/reader_abc_v6_pc.py index b33e9ea..8b57944 100644 --- a/src/reader_abc_v6_pc.py +++ b/src/reader_abc_v6_pc.py @@ -5,7 +5,7 @@ import copy # -# ABC Model Format Version 6 +# ABC Model Format Version 6 # Spec: https://web.archive.org/web/20170905023149/http://www.bop-mod.com/download/docs/LithTech-ABC-v6-File-Format.html # class ABCV6ModelReader(object): @@ -32,7 +32,7 @@ def __init__(self): # # Helpers # TODO: Move to utils - # + # def _read_matrix(self, f): data = unpack('16f', f) rows = [data[0:4], data[4:8], data[8:12], data[12:16]] @@ -50,7 +50,7 @@ def _read_string(self, f): # # Format Specific - # + # def _read_vertex(self, f): vertex = Vertex() @@ -153,8 +153,8 @@ def _read_node(self, f): node = Node() # These may be needed to calculate the position... - bounds_min = self._read_vector(f) - bounds_max = self._read_vector(f) + node.bounds_min = self._read_vector(f) + node.bounds_max = self._read_vector(f) # Bind matrix is set after we read in animations! @@ -204,14 +204,16 @@ def _read_animation(self, f): animation = Animation() animation.name = self._read_string(f) animation_length = unpack('I', f)[0] - bounds_min = self._read_vector(f) - bounds_max = self._read_vector(f) + animation.bounds_min = self._read_vector(f) + animation.bounds_max = self._read_vector(f) # ? - animation.extents = bounds_max + animation.extents = animation.bounds_max animation.keyframe_count = unpack('I', f)[0] animation.keyframes = [self._read_keyframe(f) for _ in range(animation.keyframe_count)] + + animation.vertex_deformations = [] for node_index in range(self._node_count): animation.node_keyframe_transforms.append( [self._read_transform(f) for _ in range(animation.keyframe_count)] ) @@ -322,7 +324,7 @@ def from_file(self, path): self._model.flip_geom = flip_geom self._model.flip_anim = flip_anim # End - + # Okay we're going to use the first animation's location and rotation data for our node's bind_matrix for node_index in range(len(self._model.nodes)): node = self._model.nodes[node_index] @@ -360,7 +362,7 @@ def from_file(self, path): # Find the position of the vertex we're going to deform md_vert = self._model.nodes[node_index].md_vert_list.index(vert_index) - + # Grab are transformed deformation vertex_transform = self._model.animations[0].vertex_deformations[node_index][md_vert].location diff --git a/src/utils.py b/src/utils.py index 6c391d3..4358fa8 100644 --- a/src/utils.py +++ b/src/utils.py @@ -2,6 +2,10 @@ import bmesh from enum import Enum +# Blender default: 25fps = frame 0-24 for our purposes +def get_framerate(): + return (bpy.context.window.scene.render.fps) / 1000 + # Enums class LTAVersion(Enum): TALON = 'lithtech-talon' @@ -25,9 +29,9 @@ def get_text(version): class ABCVersion(Enum): ABC12 = 'abc-12' + ABC6 = 'abc-6' # Not supported...yet ABC13 = 'abc-13' - ABC6 = 'abc-6' @staticmethod def get_text(version): @@ -36,7 +40,7 @@ def get_text(version): elif version == ABCVersion.ABC13.value: return 'ABC v13 (Not Supported)' elif version == ABCVersion.ABC6.value: - return 'ABC v6 (Not Supported)' + return 'ABC v6 (Lithtech 1.0)' #1.0/1.5? # End If return 'Unknown Version' diff --git a/src/writer_abc_v6_pc.py b/src/writer_abc_v6_pc.py new file mode 100644 index 0000000..094ec5d --- /dev/null +++ b/src/writer_abc_v6_pc.py @@ -0,0 +1,180 @@ +import struct +import itertools +from mathutils import Vector, Matrix, Quaternion + +class ABCV6ModelWriter(object): + @staticmethod + def _string_to_bytes(string): + return struct.pack('H{0}s'.format(len(string)), len(string), string.encode('ascii')) + + @staticmethod + def _vector_to_bytes(vector): + return struct.pack('3f', vector.x, vector.y, vector.z) + + @staticmethod + def _quaternion_to_bytes(quaternion): + return struct.pack('4f', quaternion.x, quaternion.y, quaternion.z, quaternion.w) + + def _transform_to_bytes(self, transform): + # TODO: this if fixed size, convert to bytes instead of bytearray + buffer = bytearray() + buffer.extend(self._vector_to_bytes(transform.location)) + buffer.extend(self._quaternion_to_bytes(transform.rotation)) + return bytes(buffer) + + @staticmethod + def _get_unique_strings(model): + strings = set() + strings.add(model.command_string) + strings.update([node.name for node in model.nodes]) + strings.update([child_model.name for child_model in model.child_models]) + strings.update([animation.name for animation in model.animations]) + strings.update([keyframe.string for animation in model.animations for keyframe in animation.keyframes]) + strings.discard('') + return strings + + def __init__(self): + self._version = 'not-set' + + # Copied from reader_abc_v6_pc.py, should probably be in an import + # Node Flags + self._flag_null = 1 + self._flag_tris = 2 # This node contains triangles + self._flag_deformation = 4 # This node contains deformation (vertex animation). Used in combo with flag_tris + # Might be Lithtech 1.5 only flags + self._flag_env_map = 8 + self._flag_env_map_only = 16 + self._flag_scroll_tex_u = 32 + self._flag_scroll_tex_v = 64 + + def write(self, model, path, version): + class Section(object): + def __init__(self, name, data): + self.name = name + self.data = data + + self._version = version + + ''' Reverse X Preprocess ''' + # TODO: reverse mesh and animations + + sections = [] + + ''' Header ''' + unique_strings = self._get_unique_strings(model) + + buffer = bytearray() + buffer.extend(self._string_to_bytes("MonolithExport Model File v6")) # version + buffer.extend(self._string_to_bytes(model.command_string)); + + sections.append(Section('Header', bytes(buffer))) + + ''' Geometry ''' + buffer = bytearray() + + buffer.extend(self._vector_to_bytes(Vector((-model.internal_radius / 2, -model.internal_radius / 2, -model.internal_radius / 2)))) # TODO: min bounds + buffer.extend(self._vector_to_bytes(Vector((model.internal_radius / 2, model.internal_radius / 2, model.internal_radius / 2)))) # TODO: max bounds + buffer.extend(struct.pack('Ih', 0, 0)) # TODO: lod count and lod array + '''buffer.extend(struct.pack('I', model.lod_count)) + for lod in model.lod_count + buffer.extend(struct.pack('H', lod))''' + + for piece in model.pieces: # TODO: error out on more than 1 piece? + for lod in piece.lods: + buffer.extend(struct.pack('I', model.face_count)) + for face in lod.faces: + buffer.extend(struct.pack('2f', face.vertices[0].texcoord.x, face.vertices[0].texcoord.y)) + buffer.extend(struct.pack('2f', face.vertices[1].texcoord.x, face.vertices[1].texcoord.y)) + buffer.extend(struct.pack('2f', face.vertices[2].texcoord.x, face.vertices[2].texcoord.y)) + buffer.extend(struct.pack('3H', face.vertices[0].vertex_index, face.vertices[1].vertex_index, face.vertices[2].vertex_index)) + normal = face.normal.normalized() * 127 + buffer.extend(struct.pack('3b', int(normal.x), int(-normal.y), int(normal.z))) + + buffer.extend(struct.pack('I', model.vertex_count)) + buffer.extend(struct.pack('I', len(lod.vertices))) # lod[0].vert_count + for vertex in lod.vertices: + buffer.extend(self._vector_to_bytes(vertex.weights[0].location)) + normal = vertex.normal.normalized() * 127 + buffer.extend(struct.pack('3b', int(normal.x), int(-normal.y), int(normal.z))) + buffer.extend(struct.pack('B', vertex.weights[0].node_index)) # TODO: error out on more than a single weight? + buffer.extend(struct.pack('2H', 0, 0)) # TODO: lod related, I think? + + sections.append(Section('Geometry', bytes(buffer))) + + ''' Nodes ''' + buffer = bytearray() + + for node in model.nodes: + buffer.extend(self._vector_to_bytes(node.bounds_min)) + buffer.extend(self._vector_to_bytes(node.bounds_max)) + buffer.extend(self._string_to_bytes(node.name)) + buffer.extend(struct.pack('H', node.index)) + buffer.extend(struct.pack('B', node.flags)) + buffer.extend(struct.pack('I', node.md_vert_count)) + for md_vert in node.md_vert_list: + buffer.extend(struct.pack('H', md_vert)) + buffer.extend(struct.pack('I', node.child_count)) + + sections.append(Section('Nodes', bytes(buffer))); + + ''' Animation ''' + buffer = bytearray() + + buffer.extend(struct.pack('I', len(model.animations))) + for anim_index, anim in enumerate(model.animations): + buffer.extend(self._string_to_bytes(anim.name)) + buffer.extend(struct.pack('I', int(anim.keyframes[-1].time))) # playing past final keyframe's time seems unpredictable + buffer.extend(self._vector_to_bytes(anim.bounds_min)) + buffer.extend(self._vector_to_bytes(anim.bounds_max)) + buffer.extend(struct.pack('I', len(anim.keyframes))) + for keyframe in anim.keyframes: + buffer.extend(struct.pack('I', int(keyframe.time))) + buffer.extend(self._vector_to_bytes(keyframe.bounds_min)) + buffer.extend(self._vector_to_bytes(keyframe.bounds_max)) + buffer.extend(self._string_to_bytes(keyframe.string)) + + for node_index, (node_transform_list, node) in enumerate(zip(anim.node_keyframe_transforms, model.nodes)): + for keyframe_transform in node_transform_list: + if model.flip_anim: + keyframe_transform.rotation.conjugate() + buffer.extend(self._transform_to_bytes(keyframe_transform)) + + for keyframe_index, keyframe in enumerate(anim.keyframes): + for md_vert_index, md_vert in enumerate(node.md_vert_list): + index = keyframe_index * node.md_vert_count + md_vert_index + buffer.extend(struct.pack('BBB', int(anim.vertex_deformations[node][index].x * 255), int(anim.vertex_deformations[node][index].y * 255), int(anim.vertex_deformations[node][index].z * 255))) + + scale = Vector((1, 1, 1)) + translation = Vector() + if node in anim.vertex_deformation_bounds: + scale = (anim.vertex_deformation_bounds[node][1] - anim.vertex_deformation_bounds[node][0]) / 255 + translation = anim.vertex_deformation_bounds[node][0] + + buffer.extend(self._vector_to_bytes(scale)) + buffer.extend(self._vector_to_bytes(translation)) + + sections.append(Section('Animation', bytes(buffer))) + + ''' Animation Dimensions ''' + buffer = bytearray() + + for anim in model.animations: + # use final frame's bounds because last frame is the same as first frame in a loop, and unique in a non-loop (the most likely candidate for collision in-engine) + buffer.extend(self._vector_to_bytes((-anim.keyframes[-1].bounds_min + anim.keyframes[-1].bounds_max) / 2)) + + sections.append(Section('AnimDims', bytes(buffer))) + + ''' Transform Information ''' + # TODO: I don't care about LithTech 1.5, conditional with UI toggle? + '''buffer = bytearray() + buffer.extend(struct.pack('II', model.flip_geom, model.flip_anim)) + sections.append(Section('TransformInfo', bytes(buffer)))''' + + with open(path, 'wb') as f: + for idx, section in enumerate(sections): + f.write(self._string_to_bytes(section.name)) + if idx + 1 == len(sections): + f.write(struct.pack('i', -1)) + else: + f.write(struct.pack('i', len(section.data) + f.tell() + 4)) + f.write(bytes(section.data))