diff options
Diffstat (limited to 'Scripts/libunity.py')
| -rw-r--r-- | Scripts/libunity.py | 1356 |
1 files changed, 1356 insertions, 0 deletions
diff --git a/Scripts/libunity.py b/Scripts/libunity.py new file mode 100644 index 0000000..f9e9e28 --- /dev/null +++ b/Scripts/libunity.py @@ -0,0 +1,1356 @@ +#!/usr/bin/env python3 + +import argparse +import copy +import enum +import math +import os +import pickle +import random +import sys +# python3 -m pip install pyyaml +# License: MIT. +import yaml + +import multiprocessing as mp + +WRITE_DEFAULTS_ANIM_TEMPLATE = """ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!74 &7400000 +AnimationClip: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: TaSTT_Reset_Animations + serializedVersion: 6 + m_Legacy: 0 + m_Compressed: 0 + m_UseHighQualityCurve: 1 + m_RotationCurves: [] + m_CompressedRotationCurves: [] + m_EulerCurves: [] + m_PositionCurves: [] + m_ScaleCurves: [] + m_FloatCurves: + - curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 0 + inSlope: 0 + outSlope: 0 + tangentMode: 136 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + attribute: REPLACEME_ATTRIBUTE + path: REPLACEME_PATH + classID: 137 + script: {fileID: 0} + m_PPtrCurves: [] + m_SampleRate: 60 + m_WrapMode: 0 + m_Bounds: + m_Center: {x: 0, y: 0, z: 0} + m_Extent: {x: 0, y: 0, z: 0} + m_ClipBindingConstant: + genericBindings: + - serializedVersion: 2 + path: 2794480623 + attribute: 2284639795 + script: {fileID: 0} + typeID: 137 + customType: 22 + isPPtrCurve: 0 + pptrCurveMapping: [] + m_AnimationClipSettings: + serializedVersion: 2 + m_AdditiveReferencePoseClip: {fileID: 0} + m_AdditiveReferencePoseTime: 0 + m_StartTime: 0 + m_StopTime: 0 + m_OrientationOffsetY: 0 + m_Level: 0 + m_CycleOffset: 0 + m_HasAdditiveReferencePose: 0 + m_LoopTime: 1 + m_LoopBlend: 0 + m_LoopBlendOrientation: 0 + m_LoopBlendPositionY: 0 + m_LoopBlendPositionXZ: 0 + m_KeepOriginalOrientation: 0 + m_KeepOriginalPositionY: 1 + m_KeepOriginalPositionXZ: 0 + m_HeightFromFeet: 0 + m_Mirror: 0 + m_EditorCurves: + - curve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 0 + inSlope: 0 + outSlope: 0 + tangentMode: 136 + weightedMode: 0 + inWeight: 0 + outWeight: 0 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + attribute: REPLACEME_ATTRIBUTE + path: REPLACEME_PATH + classID: 137 + script: {fileID: 0} + m_EulerEditorCurves: [] + m_HasGenericRootTransform: 0 + m_HasMotionFloatCurves: 0 + m_Events: [] +"""[1:][:-1] + +METADATA_TEMPLATE = """ +fileFormatVersion: 2 +guid: REPLACEME_GUID +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 7400000 + userData: + assetBundleName: + assetBundleVariant: +"""[1:][:-1] + +ANIMATION_STATE_TEMPLATE = """ +--- !u!1102 &110200000 +AnimatorState: + serializedVersion: 6 + m_ObjectHideFlags: 1 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: REPLACEME_ANIMATION_NAME + m_Speed: 1 + m_CycleOffset: 0 + m_Transitions: [] + m_StateMachineBehaviours: [] + m_Position: {x: 50, y: 50, z: 0} + m_IKOnFeet: 0 + m_WriteDefaultValues: 0 + m_Mirror: 0 + m_SpeedParameterActive: 0 + m_MirrorParameterActive: 0 + m_CycleOffsetParameterActive: 0 + m_TimeParameterActive: 0 + m_Motion: {} + m_Tag: + m_SpeedParameter: + m_MirrorParameter: + m_CycleOffsetParameter: + m_TimeParameter: +"""[1:][:-1] + +TRANSITION_TEMPLATE = """ +--- !u!1101 &110100000 +AnimatorStateTransition: + m_ObjectHideFlags: 1 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: + m_Conditions: [] + m_DstStateMachine: {fileID: 0} + m_DstState: {fileID: 0} + m_Solo: 0 + m_Mute: 0 + m_IsExit: 0 + serializedVersion: 3 + m_TransitionDuration: 0 + m_TransitionOffset: 0 + m_ExitTime: 0.0 + m_HasExitTime: 0 + m_HasFixedDuration: 1 + m_InterruptionSource: 2 + m_OrderedInterruption: 1 + m_CanTransitionToSelf: 1 +"""[1:][:-1] + +BLEND_TREE_TEMPLATE = """ +--- !u!206 &1071664566462684110 +BlendTree: + m_ObjectHideFlags: 1 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: REPLACEME_BLEND_TREE_NAME + m_Childs: + - serializedVersion: 2 + m_Motion: {fileID: 7400000, guid: REPLACEME_GUID_LO, type: 2} + m_Threshold: -1 + m_Position: {x: 0, y: 0} + m_TimeScale: 1 + m_CycleOffset: 0 + m_DirectBlendParameter: REPLACEME_BLEND_PARAMETER + m_Mirror: 0 + - serializedVersion: 2 + m_Motion: {fileID: 7400000, guid: REPLACEME_GUID_HI, type: 2} + m_Threshold: 1 + m_Position: {x: 0, y: 0} + m_TimeScale: 1 + m_CycleOffset: 0 + m_DirectBlendParameter: REPLACEME_BLEND_PARAMETER + m_Mirror: 0 + m_BlendParameter: REPLACEME_BLEND_PARAMETER + m_BlendParameterY: REPLACEME_BLEND_PARAMETER + m_MinThreshold: -1 + m_MaxThreshold: 1 + m_UseAutomaticThresholds: 0 + m_NormalizedBlendValues: 0 + m_BlendType: 0 +"""[1:][:-1] + +class Metadata: + def __init__(self): + self.guid = "%032x" % random.randrange(16 ** 32) + + def load(self, path): + if not path.endswith(".meta"): + path = path + ".meta" + + self.guid = None + with open(path, "r") as f: + for line in f: + if line.startswith("guid"): + self.guid = line.split()[1] + + def __str__(self): + return METADATA_TEMPLATE.replace("REPLACEME_GUID", self.guid) + +class Node: + def __init__(self): + # Optional. In Unity, this is the fileID of an object. Not all YAML + # mappings have an anchor. + self.anchor = None + + # Pointer to the Node containing this one. + self.parent = None + +class Sequence(Node): + def __init__(self): + super().__init__() + self.sequence = [] + + def copy(self): + new = Sequence() + new.anchor = self.anchor + new.parent = self.parent + + for v in self.sequence: + if hasattr(v, "copy"): + new.sequence.append(v.copy()) + new.sequence[-1].parent = new + else: + new.sequence.append(v) + + return new + + def prettyPrint(self, first_indent=None, leading_newline=None): + depth = 0 + p = self.parent + while p != None: + depth += 1 + p = p.parent + indent = " " * depth + + lines = [] + first = True + for item in self.sequence: + cur_indent = indent + if first: + if first_indent != None: + cur_indent = first_indent + first = False + if hasattr(item, "prettyPrint"): + lines.append("{}- {}".format(cur_indent, item.prettyPrint(first_indent="", leading_newline=False))) + else: + lines.append("{}- {}".format(cur_indent, item)) + + if len(lines) == 0: + return "[]" + + return "\n" + '\n'.join(lines) + + def __str__(self): + return self.prettyPrint() + + def addChildMapping(self, anchor = None, add_to_head = False): + child = Mapping() + child.anchor = anchor + child.parent = self + child.sequence = [] + + if add_to_head: + self.sequence = [child] + self.sequence + else: + self.sequence.append(child) + + return child + + def addChildSequence(self, anchor = None): + child = Sequence() + child.anchor = anchor + child.parent = self + child.sequence = [] + + self.sequence.append(child) + + return child + + def forEach(self, cb): + for k in self.sequence: + cb(k) + +class Mapping(Node): + def __init__(self): + super().__init__() + self.mapping = {} + + def copy(self): + new = Mapping() + new.anchor = self.anchor + new.parent = self.parent + + for k, v in self.mapping.items(): + if hasattr(v, "copy"): + new.mapping[k] = v.copy() + new.mapping[k].parent = new + else: + new.mapping[k] = v + + return new + + def prettyPrint(self, first_indent=None, leading_newline=True): + depth = 0 + p = self.parent + while p != None: + depth += 1 + p = p.parent + indent = " " * depth + + lines = [] + first = True + for k, v in self.mapping.items(): + cur_indent = indent + if first: + if first_indent != None: + cur_indent = first_indent + first = False + lines.append("{}{}: {}".format(cur_indent, k, v)) + + result = '\n'.join(lines) + + # Inline 1-item mappings, matching Unity behavior. + if len(self.mapping.keys()) == 1 and len(result.split("\n")) == 1: + if first_indent == None: + return self.prettyPrint(first_indent="") + return "{" + lines[0] + "}" + + # Empty mappings are represented by '{}'. If we don't do this, Unity + # will assume that they are Sequences and get very sad. + if len(self.mapping.keys()) == 0: + return "{}" + + if leading_newline: + result = "\n" + result + + return result + + def __str__(self): + return self.prettyPrint() + + def addChildMapping(self, key, anchor = None): + child = Mapping() + child.anchor = anchor + child.parent = self + child.mapping = {} + + self.mapping[key] = child + + return child + + def addChildSequence(self, key, anchor = None): + child = Sequence() + child.anchor = anchor + child.parent = self + child.mapping = {} + + self.mapping[key] = child + + return child + + def forEach(self, cb): + for k, v in self.mapping.items(): + cb(v) + +class UnityDocument(Mapping): + def __init__(self): + super().__init__() + self.class_id = None + + def __str__(self): + return super().__str__() + + def copy(self): + result = super().copy() + result.class_id = self.class_id + return result + +# Class representing a Unity AnimatorController. Implements manipulations, like +# merging and reanchoring. +class UnityAnimator(): + def __init__(self): + self.nodes = [] + self.id_to_node = {} + self.next_id = 1000 * 1000 + + def __str__(self): + return unityYamlToString(self.nodes) + + def addNodes(self, nodes): + for node in nodes: + self.nodes.append(node) + anchor = node.anchor + if anchor == None: + raise Exception("Node is missing anchor: {}".format(str(node))) + if anchor in self.id_to_node: + raise Exception("Duplicate anchor: {}, node 1: {}, node 2: {}".format(anchor, str(node), str(self.id_to_node[anchor]))) + self.id_to_node[anchor] = node + + if int(anchor) > self.next_id: + self.next_id = int(anchor) + 1 + # I don't know why but this fixes a bug in the `fixWriteDefaults` + # codepath: two documents wind up with the same anchor. + self.next_id += 1 + + def allocateId(self) -> int: + result = self.next_id + self.next_id += 1 + return result + + # Checks if `old_id` is in `self.id_mapping`, and if so, returns the + # already-generated ID. Otherwise this allocates a new ID and + # records it in `self.id_mapping`. + def mapId(self, old_id: str) -> int: + new_id = None + if old_id in self.id_mapping.keys(): + new_id = self.id_mapping[old_id] + else: + new_id = self.allocateId() + self.id_mapping[old_id] = new_id + return new_id + + # Recursively iterate every mapping under `node` and assign new IDs to + # every identifier. Mappings are recorded in `self.id_mapping`. + def mergeIterator(self, node): + if hasattr(node, "mapping"): + # Don't relabel anything that's defined in an external file. + # TODO(yum) do this. + if 'fileID' in node.mapping and not 'guid' in node.mapping: + if node.mapping['fileID'] != '0': + old_id = node.mapping['fileID'] + new_id = self.mapId(old_id) + node.mapping['fileID'] = str(new_id) + if hasattr(node, "forEach"): + node.forEach(self.mergeIterator) + + def peekNodeOfClass(self, classId): + for node in self.nodes: + if node.class_id == classId: + return node + return None + + def popNodeOfClass(self, classId): + result = None + for node in self.nodes: + if node.class_id == classId: + result = node + self.nodes.remove(result) + break + if result: + del self.id_to_node[result.anchor] + return result + + def pushNode(self, node): + self.nodes.append(node) + self.id_to_node[node.anchor] = node + + # Merges two animator controllers and returns the result. Any identifiers + # in the animators are reassigned in a new namespace. The mappings from old + # identifiers to new identifiers are recorded in `self.id_mapping0` and + # `self.id_mapping1`. + def mergeAnimatorControllers(self, ctrl0, ctrl1): + ctrl0 = copy.deepcopy(ctrl0) + ctrl1 = copy.deepcopy(ctrl1) + + self.id_mapping0 = {} + self.id_mapping1 = {} + + p0 = ctrl0.mapping['AnimatorController'].mapping['m_AnimatorParameters'] + p1 = ctrl1.mapping['AnimatorController'].mapping['m_AnimatorParameters'] + + a0 = ctrl0.mapping['AnimatorController'].mapping['m_AnimatorLayers'] + a1 = ctrl1.mapping['AnimatorController'].mapping['m_AnimatorLayers'] + + self.id_mapping = self.id_mapping0 + p0.forEach(self.mergeIterator) + a0.forEach(self.mergeIterator) + + # Hack to prevent ctrl1 from getting a new ID for the animator. + # TODO(yum) delete this? + #del self.class_to_next_id['91'] + + self.id_mapping = self.id_mapping1 + p1.forEach(self.mergeIterator) + a1.forEach(self.mergeIterator) + + p0.sequence += p1.sequence + a0.sequence += a1.sequence + + for elm in p0.sequence: + elm.mapping['m_Controller'].mapping['fileID'] = ctrl0.anchor + for elm in a0.sequence: + elm.mapping['m_Controller'].mapping['fileID'] = ctrl0.anchor + + return ctrl0 + + def merge(self, other): + ctrl0 = self.popNodeOfClass('91') + ctrl1 = other.popNodeOfClass('91') + # Merge animators and populate `self.id_mapping0` and + # `self.id_mapping1. + merged_anim = self.mergeAnimatorControllers(ctrl0, ctrl1) + + # Mapping from class ID (string) to new class ID (int) + self.id_mapping = self.id_mapping0 + for node in self.nodes: + new_id = self.mapId(node.anchor) + node.anchor = str(new_id) + node.forEach(self.mergeIterator) + + self.id_mapping = self.id_mapping1 + for node in other.nodes: + new_id = self.mapId(node.anchor) + node.anchor = str(new_id) + node.forEach(self.mergeIterator) + + nodes = self.nodes + self.nodes = [] + self.id_to_node = {} + self.pushNode(merged_anim) + self.addNodes(nodes) + self.addNodes(other.nodes) + + # TODO(yum) support overwriting duplicates + def addParameter(self, param_name, param_type): + unity_type = None + if param_type == float: + unity_type = '1' + elif param_type == int: + unity_type = '3' + elif param_type == bool: + unity_type = '4' + anim = self.peekNodeOfClass('91') + params = anim.mapping['AnimatorController'].mapping['m_AnimatorParameters'] + param = params.addChildMapping() + param.mapping['m_Name'] = param_name + param.mapping['m_Type'] = unity_type + param.mapping['m_DefaultFloat'] = '0' + param.mapping['m_DefaultInt'] = '0' + param.mapping['m_DefaultBool'] = '0' + ctrl = param.addChildMapping('m_Controller') + ctrl.mapping['fileID'] = anim.anchor + + def addLayer(self, layer_name, add_to_head = False) -> UnityDocument: + # Add layer to controller + anim = self.peekNodeOfClass('91') + layers = anim.mapping['AnimatorController'].mapping['m_AnimatorLayers'] + layer = layers.addChildMapping(add_to_head = add_to_head) + layer.mapping['serializedVersion'] = '5' + layer.mapping['m_Name'] = layer_name + new_id = self.allocateId() + layer.addChildMapping('m_StateMachine').mapping['fileID'] = str(new_id) + layer.addChildMapping('m_Mask').mapping['fileID'] = '0' + layer.addChildSequence('m_Motions') + layer.addChildSequence('m_Behaviours') + layer.mapping['m_BlendingMode'] = '0' + layer.mapping['m_SyncedLayerIndex'] = '-1' + layer.mapping['m_DefaultWeight'] = '1' + layer.mapping['m_IKPass'] = '0' + layer.mapping['m_SyncedLayerAffectsTiming'] = '0' + layer.addChildMapping('m_Controller').mapping['fileID'] = anim.anchor + + # Create layer object + layer = UnityDocument() + layer.class_id = "1107" + layer.anchor = str(new_id) + mach = layer.addChildMapping('AnimatorStateMachine') + + mach.mapping['serializedVersion'] = '6' + + mach.mapping['m_ObjectHideFlags'] = '1' + mach.addChildMapping('m_CorrespondingSourceObject').mapping['fileID'] = '0' + mach.addChildMapping('m_PrefabInstance').mapping['fileID'] = '0' + mach.addChildMapping('m_PrefabAsset').mapping['fileID'] = '0' + mach.mapping['m_Name'] = layer_name + mach.addChildSequence('m_ChildStates') + mach.addChildSequence('m_ChildStateMachines') + mach.addChildSequence('m_AnyStateTransitions') + mach.addChildSequence('m_EntryTransitions') + mach.addChildMapping('m_StateMachineTransitions') + mach.addChildSequence('m_StateMachineBehaviours') + pos = mach.addChildMapping('m_AnyStatePosition') + pos.mapping['x'] = '50' + pos.mapping['y'] = '20' + pos.mapping['z'] = '0' + pos = mach.addChildMapping('m_EntryPosition') + pos.mapping['x'] = '50' + pos.mapping['y'] = '120' + pos.mapping['z'] = '0' + pos = mach.addChildMapping('m_ExitPosition') + pos.mapping['x'] = '800' + pos.mapping['y'] = '120' + pos.mapping['z'] = '0' + pos = mach.addChildMapping('m_ParentStateMachinePosition') + pos.mapping['x'] = '800' + pos.mapping['y'] = '20' + pos.mapping['z'] = '0' + mach.addChildMapping('m_DefaultState') + + self.nodes.append(layer) + return layer + + def addAnimatorState(self, layer, state_name, is_default_state = False, + dx = 0, dy = 0) -> UnityDocument: + # Create animation state + parser = UnityParser() + parser.parse(ANIMATION_STATE_TEMPLATE) + new_anim = UnityAnimator() + new_anim.addNodes(parser.nodes) + node = new_anim.nodes[0] + + new_id = self.allocateId() + node.class_id = "1102" + node.anchor = str(new_id) + state = node.mapping['AnimatorState'] + state.mapping['m_Name'] = state_name + #state.mapping['m_Motion'].mapping['guid'] = anim_guid + self.nodes.append(node) + + # Add state to layer + child_state = layer.mapping['AnimatorStateMachine'].mapping['m_ChildStates'].addChildMapping() + child_state.mapping['serializedVersion'] = '1' + child_state.addChildMapping('m_State').mapping['fileID'] = str(new_id) + state_pos = child_state.addChildMapping('m_Position') + state_pos.mapping['x'] = str(280 + dx) + state_pos.mapping['y'] = str(80 + dy) + state_pos.mapping['z'] = '0' + + if is_default_state: + layer.mapping['AnimatorStateMachine'].mapping['m_DefaultState'].mapping['fileID'] = str(new_id) + + return node + + def setAnimatorStateAnimation(self, anim_state, anim_guid): + anim_state.mapping['AnimatorState'].mapping['m_Motion'].mapping['guid'] = anim_guid + anim_state.mapping['AnimatorState'].mapping['m_Motion'].mapping['fileID'] = '7400000' + anim_state.mapping['AnimatorState'].mapping['m_Motion'].mapping['type'] = '2' + + # Adds a blend tree which uses the parameter named `param_name` to blend + # between anim_lo and anim_hi. Also creates the corresponding animation + # state. + def addAnimatorBlendTree(self, layer, state_name, param_name, + anim_guid_lo, anim_guid_hi, dx = 0, dy = 0, + lo_threshold = -1.0, hi_threshold = 1.0, + is_default_state = False) -> UnityDocument: + # Create the blend tree. + parser = UnityParser() + parser.parse(BLEND_TREE_TEMPLATE) + new_anim = UnityAnimator() + new_anim.addNodes(parser.nodes) + node = new_anim.nodes[0] + + new_id = self.allocateId() + node.class_id = "206" + node.anchor = str(new_id) + tree = node.mapping['BlendTree'] + tree.mapping['m_Name'] = state_name + # Low animation + tree.mapping['m_Childs'].sequence[0].mapping['m_Motion'].mapping['guid'] = anim_guid_lo + tree.mapping['m_Childs'].sequence[0].mapping['m_DirectBlendParameter'] = param_name + tree.mapping['m_Childs'].sequence[0].mapping['m_Threshold'] = str(lo_threshold) + # High animation + tree.mapping['m_Childs'].sequence[1].mapping['m_Motion'].mapping['guid'] = anim_guid_hi + tree.mapping['m_Childs'].sequence[1].mapping['m_DirectBlendParameter'] = param_name + tree.mapping['m_Childs'].sequence[1].mapping['m_Threshold'] = str(hi_threshold) + + tree.mapping['m_BlendParameter'] = param_name + tree.mapping['m_BlendParameterY'] = param_name + + self.nodes.append(node) + + # Create the corresponding animation state. + anim_state = self.addAnimatorState(layer, state_name, is_default_state, dx = dx, dy = + dy) + anim_state.mapping['AnimatorState'].mapping['m_Motion'].mapping['fileID'] = node.anchor + + return anim_state + + def addTransition(self, dst_state): + # Create animation state + parser = UnityParser() + parser.parse(TRANSITION_TEMPLATE) + new_transition = UnityAnimator() + new_transition.addNodes(parser.nodes) + node = new_transition.nodes[0] + + new_id = self.allocateId() + node.class_id = "1101" + node.anchor = str(new_id) + state = node.mapping['AnimatorStateTransition'] + state.mapping['m_DstState'].mapping['fileID'] = dst_state.anchor + self.nodes.append(node) + + return node + + def fixWriteDefaults(self, guid_map, generated_anim_path): + # TODO(yum) we should have an Animation class which encapsulates all + # this stuff. + parser = UnityParser() + parser.parse(WRITE_DEFAULTS_ANIM_TEMPLATE) + new_anim = UnityAnimator() + new_anim.addNodes(parser.nodes) + + new_clip = new_anim.peekNodeOfClass('74').mapping['AnimationClip'] + curve_template = new_clip.mapping['m_FloatCurves'].sequence[0] + new_clip.mapping['m_FloatCurves'].sequence = [] + new_clip.mapping['m_EditorCurves'].sequence = [] + + # Keep track of the (attribute, path) tuples we've already set to avoid + # animating the same thing twice. + attributes_set = set() + + animator_state_id = '1102' + for node in self.nodes: + if node.class_id != animator_state_id: + continue + + # Looking at an animator state. + if node.mapping['AnimatorState'].mapping['m_WriteDefaultValues'] != '1': + continue + + # Disable write defaults. + node.mapping['AnimatorState'].mapping['m_WriteDefaultValues'] = '0' + + # Looking at an animator state with write defaults. + motion = node.mapping['AnimatorState'].mapping['m_Motion'] + # Some animations have write defaults but don't trigger an + # animation. No idea what that's about. For now, just ignore. + if not 'guid' in motion.mapping: + continue + guid = motion.mapping['guid'] + + # Again, not really sure what's going on here, just ignore and + # revisit if we hit problems. + if not guid in guid_map.keys(): + continue + + # OK, we found an animation with write defaults, and we know where + # the animation lives. Crack it open and see what it's writing. + animation_path = guid_map[guid] + print("Animation has write defaults: {}".format(animation_path), file=sys.stderr) + parser = UnityParser() + parser.parseFile(animation_path) + anim = UnityAnimator() + anim.addNodes(parser.nodes) + + clip = anim.peekNodeOfClass('74') + + for curve in clip.mapping['AnimationClip'].mapping['m_FloatCurves'].sequence: + attr = curve.mapping['attribute'] + path = curve.mapping['path'] + if (attr, path) in attributes_set: + continue + #print("Fix attr/path {}/{}".format(attr, path), file=sys.stderr) + attributes_set.add((attr, path)) + + new_curve = curve_template.copy() + new_curve.mapping['attribute'] = attr + new_curve.mapping['path'] = path + + new_clip.mapping['m_FloatCurves'].sequence.append(new_curve) + new_clip.mapping['m_EditorCurves'].sequence.append(new_curve) + + #print("len float curves: {}".format(len(new_clip.mapping['m_FloatCurves'].sequence)), file=sys.stderr) + + def generateOffAnimationForGuid(self, guid_map, generated_anim_dir, guid): + # Looking at an animation. + if not guid in guid_map.keys(): + return + + animation_path = guid_map[guid] + print("Checking animation at {}".format(animation_path), file=sys.stderr) + parser = UnityParser() + parser.parseFile(animation_path) + anim = UnityAnimator() + anim.addNodes(parser.nodes) + + clip = anim.peekNodeOfClass('74') + + has_nonzero = False + curve_members = ["m_FloatCurves", "m_EditorCurves"] + for memb in curve_members: + for curve in clip.mapping['AnimationClip'].mapping[memb].sequence: + attr = curve.mapping['attribute'] + path = curve.mapping['path'] + + for m_curve in curve.mapping['curve'].mapping['m_Curve'].sequence: + if m_curve.mapping['value'] != '0': + has_nonzero = True + m_curve.mapping['value'] = '0' + + if not has_nonzero: + print("Animation does not set anything nonzero") + return + + print("Animation sets things nonzero, fixing") + + new_anim_path = "OFF_{}".format(os.path.basename(animation_path)) + new_anim_path = "{}/{}".format(generated_anim_dir, new_anim_path) + + with open(new_anim_path, "w") as f: + f.write(str(anim)) + + meta = Metadata() + with open(new_anim_path + ".meta", "w") as f: + f.write(str(meta)) + + def generateOffAnimationsAnimStates(self, guid_map, generated_anim_dir): + animator_state_id = '1102' + for node in self.nodes: + if node.class_id != animator_state_id: + continue + + # Looking at an animation state. + motion = node.mapping['AnimatorState'].mapping['m_Motion'] + if not 'guid' in motion.mapping: + continue + guid = motion.mapping['guid'] + self.generateOffAnimationForGuid(guid_map, generated_anim_dir, guid) + + + def generateOffAnimationsBlendTrees(self, guid_map, generated_anim_dir): + animator_state_id = '206' + for node in self.nodes: + if node.class_id != animator_state_id: + continue + + # Looking at an animation state. + for child in node.mapping['BlendTree'].mapping['m_Childs'].sequence: + motion = child.mapping['m_Motion'] + + if not 'guid' in motion.mapping: + continue + guid = motion.mapping['guid'] + self.generateOffAnimationForGuid(guid_map, generated_anim_dir, guid) + + def generateOffAnimations(self, guid_map, generated_anim_dir): + self.generateOffAnimationsAnimStates(guid_map, generated_anim_dir) + self.generateOffAnimationsBlendTrees(guid_map, generated_anim_dir) + + def addTransitionBooleanCondition(self, from_state, trans, param, branch): + # Populate the transition's condition logic. + cond = trans.mapping['AnimatorStateTransition'].mapping['m_Conditions'].addChildMapping() + if branch: + cond.mapping['m_ConditionMode'] = '1' + else: + cond.mapping['m_ConditionMode'] = '2' + cond.mapping['m_ConditionEvent'] = param + cond.mapping['m_EventThreshold'] = '0' + # Register the transition with the `from_state`. + if from_state: + from_state_trans = from_state.mapping['AnimatorState'].mapping['m_Transitions'].addChildMapping() + from_state_trans.mapping['fileID'] = trans.anchor + + def addTransitionIntegerEqualityCondition(self, from_state, trans, param, param_val): + # Populate the transition's condition logic. + cond = trans.mapping['AnimatorStateTransition'].mapping['m_Conditions'].addChildMapping() + cond.mapping['m_ConditionMode'] = '6' + cond.mapping['m_ConditionEvent'] = param + # Curiously, the typo ("treshold" only has 1 'h') is needed for this to + # work, but not for boolean conditions to work. + cond.mapping['m_EventTreshold'] = str(param_val) + # Register the transition with the `from_state`. + if from_state: + from_state_trans = from_state.mapping['AnimatorState'].mapping['m_Transitions'].addChildMapping() + from_state_trans.mapping['fileID'] = trans.anchor + + def addTransitionIntegerGreaterCondition(self, from_state, trans, param, param_val): + # Populate the transition's condition logic. + cond = trans.mapping['AnimatorStateTransition'].mapping['m_Conditions'].addChildMapping() + cond.mapping['m_ConditionMode'] = '3' + cond.mapping['m_ConditionEvent'] = param + cond.mapping['m_EventThreshold'] = str(param_val) + # Register the transition with the `from_state`. + if from_state: + from_state_trans = from_state.mapping['AnimatorState'].mapping['m_Transitions'].addChildMapping() + from_state_trans.mapping['fileID'] = trans.anchor + + # TODO(yum) this should be factored out into generate_fx.py + def addTasttToggle(self, off_anim_path, on_anim_path, toggle_param): + self.addParameter(toggle_param, bool) + + off_anim_meta = Metadata() + off_anim_meta.load(off_anim_path) + + on_anim_meta = Metadata() + on_anim_meta.load(on_anim_path) + + layer = self.addLayer('TaSTT_Toggle') + off_anim = self.addAnimatorState(layer, 'TaSTT_Toggle_Off', is_default_state = True) + self.setAnimatorStateAnimation(off_anim, off_anim_meta.guid) + on_anim = self.addAnimatorState(layer, 'TaSTT_Toggle_On') + self.setAnimatorStateAnimation(on_anim, on_anim_meta.guid) + + # TODO(yum) make a Transition class with methods for adding boolean + # conditions + off_to_on = self.addTransition(on_anim) + self.addTransitionBooleanCondition(off_anim, off_to_on, toggle_param, True) + + on_to_off = self.addTransition(off_anim) + self.addTransitionBooleanCondition(on_anim, on_to_off, toggle_param, False) + + def setNoopAnimations(self, guid_map, noop_anim_path): + noop_anim_meta = Metadata() + noop_anim_meta.load(noop_anim_path) + + for node in self.nodes: + if node.class_id != "1102": + continue + motion = node.mapping['AnimatorState'].mapping['m_Motion'] + replace = False + + if "fileID" in motion.mapping.keys() and \ + motion.mapping["fileID"] != "0": + continue + + if "guid" in motion.mapping.keys() and \ + motion.mapping["guid"] in guid_map: + continue + + motion.mapping["fileID"] = "7400000" + motion.mapping["guid"] = noop_anim_meta.guid + motion.mapping["type"] = "2" + +def unityYamlToString(nodes): + lines = [] + preamble = """ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +"""[1:][:-1] + lines.append(preamble) + for doc in nodes: + lines.append("--- !u!" + doc.class_id + " &" + doc.anchor) + lines.append(str(doc)) + result = '\n'.join(lines) + + for i in range(0,10): + result = result.replace("\n\n", "\n") + + return result + +class UnityParser: + STREAM_START = 100 + STREAM_END = 199 + + DOCUMENT_START = 200 + DOCUMENT_END = 299 + + MAPPING_START = 300 + MAPPING_KEY = 301 + + SEQUENCE_VALUE = 400 + + def __init__(self): + self.state = self.STREAM_START + self.cur_scalar = None + self.cur_node = None + + # Simple list of parsed documents. Populated by parse(). + self.nodes = [] + self.prev_states = [] + + def __str__(self): + return unityYamlToString(self.nodes) + + def pushState(self, state): + self.prev_states.append(self.state) + self.state = state + #print("state {} ({})".format(self.state, len(self.prev_states))) + + def popState(self): + self.state = self.prev_states[-1] + self.prev_states = self.prev_states[0:len(self.prev_states) - 1] + #print("state {} ({})".format(self.state, len(self.prev_states))) + return self.state + + def cleanYaml(self, yaml_str): + lines = [] + first_document = True + got_document = False + for line in yaml_str.split("\n"): + # Add end-of-document indicators. + if line.startswith("---"): + got_document = True + if not first_document: + lines.append("...\n") + first_document = False + + # Remove class ID tag from each block. + if line.startswith("---"): + parts = line.split() + lines.append(parts[0] + " " + parts[2] + "\n") + continue + lines.append(line) + + if got_document: + lines.append("...\n") + return '\n'.join(lines) + + def getClassIds(self, yaml_str): + anchor_to_class_id = {} + for line in yaml_str.split("\n"): + if not line.startswith("---"): + continue + + parts = line.split() + class_id = parts[1][3:] + anchor = parts[2][1:] + anchor_to_class_id[anchor] = class_id + + return anchor_to_class_id + + def parseFile(self, yaml_file): + yaml_str = "" + with open(yaml_file, "r") as f: + yaml_str = f.read() + return self.parse(yaml_str) + + def parse(self, yaml_str): + anchor_to_class_id = self.getClassIds(yaml_str) + yaml_str = self.cleanYaml(yaml_str) + + for event in yaml.parse(yaml_str): + if isinstance(event, yaml.StreamStartEvent): + if len(self.prev_states) > 0: + raise Exception("Multiple StreamStartEvents received") + self.pushState(self.STREAM_START) + + elif isinstance(event, yaml.StreamEndEvent): + if self.state != self.STREAM_START: + raise Exception("Document end received after state {}".format(self.state)) + self.popState() + if len(self.prev_states) > 0: + raise Exception("Extra states at stream end") + + elif isinstance(event, yaml.DocumentStartEvent): + if self.state != self.STREAM_START and self.state != self.DOCUMENT_END: + raise Exception("Document start received after state {}".format(self.state)) + self.pushState(self.DOCUMENT_START) + + elif isinstance(event, yaml.DocumentEndEvent): + if self.state != self.DOCUMENT_START: + raise Exception("Document end received after state {}".format(self.state)) + self.popState() + self.nodes.append(self.cur_node) + self.cur_node = None + + elif isinstance(event, yaml.MappingStartEvent): + if self.cur_node == None: + self.cur_node = UnityDocument() + self.cur_node.anchor = event.anchor + self.cur_node.class_id = anchor_to_class_id[event.anchor] + else: + self.cur_node = self.cur_node.addChildMapping(self.cur_scalar) + self.pushState(self.MAPPING_START) + + elif isinstance(event, yaml.MappingEndEvent): + if self.state != self.MAPPING_START: + raise Exception("Mapping end received after state {}".format(self.state)) + self.popState() + if self.state == self.MAPPING_KEY: + self.popState() + if self.cur_node.parent != None: + self.cur_node = self.cur_node.parent + + elif isinstance(event, yaml.SequenceStartEvent): + self.cur_node = self.cur_node.addChildSequence(self.cur_scalar) + self.pushState(self.SEQUENCE_VALUE) + + elif isinstance(event, yaml.SequenceEndEvent): + if self.state != self.SEQUENCE_VALUE: + raise Exception("Sequence end received after state {}".format(self.state)) + self.popState() + if self.state == self.MAPPING_KEY: + self.popState() + self.cur_node = self.cur_node.parent + + elif isinstance(event, yaml.ScalarEvent): + if self.state == self.MAPPING_START: + self.cur_scalar = event.value + self.pushState(self.MAPPING_KEY) + elif self.state == self.MAPPING_KEY: + self.cur_node.mapping[self.cur_scalar] = event.value + self.popState() + elif self.state == self.SEQUENCE_VALUE: + self.cur_node.sequence.append(event.value) + else: + raise Exception("Scalar event received after state {}".format(self.state)) + else: + raise Exception("Unhandled event {}".format(event)) + continue + +class MulticoreUnityParser: + def parseFile(self, yaml_file): + yaml_str = "" + with open(yaml_file, "r") as f: + yaml_str = f.read() + return self.parse(yaml_str) + + def parse(self, yaml_str): + lines = [] + documents = [] + first = True + n_lines = 0 + for line in yaml_str.split("\n"): + n_lines += 1 + if line.startswith("---"): + if not first: + documents.append("\n".join(lines)) + lines = [] + first = False + lines.append(line) + if len(lines) > 0: + documents.append("\n".join(lines)) + lines = [] + print("Got {} documents out of {} lines".format(len(documents), n_lines), file=sys.stderr) + + # Divide the work evenly among the # of CPUs we have available. + n_threads = os.cpu_count() + window_size = int(math.ceil(len(documents) / n_threads)) + merge_window = [] + merged_documents = [] + for i in range(0, len(documents)): + if i > 0 and i % window_size == 0: + merged_documents.append("\n".join(merge_window)) + merge_window = [] + merge_window.append(documents[i]) + if len(merge_window) > 0: + merged_documents.append("\n".join(merge_window)) + merge_window = [] + documents = merged_documents + + mgr = mp.Manager() + + print("Spawning {} threads".format(len(documents)), file=sys.stderr) + threads = [] + for document in documents: + res = mgr.dict() + thread = mp.Process(target = self.parseOneSerial, args = (document, res,)) + threads.append((thread, res)) + thread.start() + + print("Joining threads", file=sys.stderr) + nodes = [] + for thread, res in threads: + thread.join() + nodes += res['nodes'] + + print("Creating animator", file=sys.stderr) + result = UnityAnimator() + result.addNodes(nodes) + + return result + + def parseOneSerial(self, document, res): + parser = UnityParser() + parser.parse(document) + res['nodes'] = parser.nodes + + def parseFile(self, yaml_file): + yaml_str = "" + with open(yaml_file, "r") as f: + yaml_str = f.read() + return self.parse(yaml_str) + +def getGuidMap(d): + result = {} + for f in os.scandir(d): + path = f.path + if f.is_dir(): + result.update(getGuidMap(path)) + if not f.is_file(): + continue + suffix = ".meta" + if path.endswith(suffix): + with open(path, "r") as f: + for line in f: + if line.startswith("guid"): + guid = line.split()[1] + result[guid] = path[:-len(suffix)] + return result + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("cmd", type=str, help="One of merge, guid_map, fix_write_defaults") + parser.add_argument("--fx0", type=str, help="The first animator to merge") + parser.add_argument("--fx1", type=str, help="The second animator to merge") + parser.add_argument("--project_root", type=str, help="The path to the " + + "Unity project Assets folder") + parser.add_argument("--save_to", type=str, help="The path to save the " + + "result of the computation") + parser.add_argument("--guid_map", type=str, help="Path to guid.map, " + + "generated by a previous call to `guid_map`") + parser.add_argument("--guid_map_append", type=bool, help="If set, " + + "append to GUID map instead of overwriting.") + args = parser.parse_args() + + if args.cmd == "merge": + if not args.fx0 or not args.fx1: + print("--fx0 and --fx1 required", file=sys.stderr) + parser.print_help() + parser.exit(1) + + print("Parsing {}".format(args.fx0), file=sys.stderr) + parser0 = MulticoreUnityParser() + anim0 = parser0.parseFile(args.fx0) + + arg1 = "TaSTT_fx.controller" + print("Parsing {}".format(args.fx1), file=sys.stderr) + parser1 = MulticoreUnityParser() + anim1 = parser1.parseFile(args.fx1) + + print("Merging animators", file=sys.stderr) + anim0.merge(anim1) + + print("Serializing", file=sys.stderr) + print(unityYamlToString(anim0.nodes)) + + elif args.cmd == "guid_map": + if not args.project_root or not args.save_to: + print("--project_root and --save_to required") + parser.print_help() + parser.exit(1) + + print("Looking up GUIDs under {}".format(args.project_root), + file=sys.stderr) + guid_map = getGuidMap(args.project_root) + if args.guid_map_append: + tmp_map = {} + with open(args.save_to, "rb") as f: + tmp_map = pickle.load(f) + # combine guid_map and tmp_map + guid_map = {**guid_map, **tmp_map} + print("Saving to {}".format(args.save_to), file=sys.stderr) + with open(args.save_to, 'wb') as f: + pickle.dump(guid_map, f) + elif args.cmd == "fix_write_defaults": + if not args.fx0 or not args.guid_map: + print("--fx0 and --guid_map required") + parser.print_help() + parser.exit(1) + + guid_map = {} + with open(args.guid_map, 'rb') as f: + guid_map = pickle.load(f) + + print("Parsing {}".format(args.fx0), file=sys.stderr) + parser0 = MulticoreUnityParser() + anim = parser0.parseFile(args.fx0) + + print("Fixing write defaults", file=sys.stderr) + anim_dir = "generated/animations/" + os.makedirs(anim_dir, exist_ok=True) + anim.fixWriteDefaults(guid_map, anim_dir + "TaSTT_Reset_Animation.anim") + print(str(anim)) + + elif args.cmd == "gen_off_anims": + if not args.fx0 or not args.guid_map: + print("--fx0 and --guid_map required") + parser.print_help() + parser.exit(1) + + guid_map = {} + with open(args.guid_map, 'rb') as f: + guid_map = pickle.load(f) + + print("Parsing {}".format(args.fx0), file=sys.stderr) + parser0 = MulticoreUnityParser() + anim = parser0.parseFile(args.fx0) + + print("Generating off animations", file=sys.stderr) + anim_dir = "generated/animations/" + os.makedirs(anim_dir, exist_ok=True) + anim.generateOffAnimations(guid_map, "generated/animations") + + elif args.cmd == "add_toggle": + if not args.fx0: + print("--fx0 required") + parser.print_help() + parser.exit(1) + + print("Parsing {}".format(args.fx0), file=sys.stderr) + parser0 = MulticoreUnityParser() + anim = parser0.parseFile(args.fx0) + + print("Adding toggle", file=sys.stderr) + anim.addTasttToggle("Animations/TaSTT_Toggle_Off.anim", + "Animations/TaSTT_Toggle_On.anim", "TaSTT_Toggle") + print(str(anim)) + + elif args.cmd == "fast_parse_test": + if not args.fx0: + print("--fx0 required") + parser.print_help() + parser.exit(1) + + print("Parsing {}".format(args.fx0), file=sys.stderr) + parser0 = MulticoreUnityParser() + anim = parser0.parseFile(args.fx0) + print(str(anim)) + + elif args.cmd == "set_noop_anim": + if not args.fx0 or not args.guid_map: + print("--fx0 and --guid_map required") + parser.print_help() + parser.exit(1) + + guid_map = {} + with open(args.guid_map, 'rb') as f: + guid_map = pickle.load(f) + + print("Parsing {}".format(args.fx0), file=sys.stderr) + parser = MulticoreUnityParser() + anim = parser.parseFile(args.fx0) + + anim.setNoopAnimations(guid_map, "Animations/TaSTT_Do_Nothing.anim") + + print(str(anim)) + + else: + print("Unrecognized command: {}".format(args.cmd)) + |
