#!/usr/bin/env python3 import argparse import copy import enum import os import pickle import sys # python3 -m pip install pyyaml import yaml 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 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): child = Mapping() child.anchor = anchor child.parent = self child.sequence = [] 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 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] + "}" 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) def classId(anchor): # AnimatorController if anchor.startswith("91"): return "91" # MonoBehaviour if anchor.startswith("114"): return "114" # BlendTree if anchor.startswith("206"): return "206" # AnimatorStateTransition if anchor.startswith("1101"): return "1101" # AnimatorState if anchor.startswith("1102"): return "1102" # AnimatorStateMachine if anchor.startswith("1107"): return "1107" # AnimatorTransition if anchor.startswith("1109"): return "1109" # IDK what this is lmao if anchor.startswith("74"): return "74" if anchor.startswith("115"): return "115" raise Exception("Unrecognized object: {}".format(anchor)) class UnityDocument(Mapping): def __str__(self): return super().__str__() def classId(self): return classId(self.anchor) class UnityAnimator(): def __init__(self): self.nodes = [] self.id_to_node = {} def addNodes(self, nodes): for node in nodes: self.nodes.append(node) if node.anchor == None: raise Exception("Node is missing anchor: {}".format(str(node))) if node.anchor in self.id_to_node: raise Exception("Duplicate anchor: {}, node 1: {}, node 2: {}".format(node.anchor, str(node), str(self.id_to_node[node.anchor]))) self.id_to_node[node.anchor] = node def getUniqueId(self, anchor): if anchor in self.id_mapping.keys(): return self.id_mapping[anchor] if classId(anchor) in self.class_to_next_id: new_id = self.class_to_next_id[classId(anchor)] self.class_to_next_id[classId(anchor)] += 1 self.id_mapping[anchor] = new_id else: new_id = int("%s%05d" % (classId(anchor), 0)) next_id = new_id + 1 self.class_to_next_id[classId(anchor)] = next_id self.id_mapping[anchor] = new_id #print("Map {} to {}".format(anchor, self.id_mapping[anchor]), file=sys.stderr) return self.id_mapping[anchor] def mergeIterator(self, v): if hasattr(v, "mapping"): # Don't relabel anything that's defined in an external file. # TODO(yum) do this. if 'fileID' in v.mapping and not 'guid' in v.mapping: if v.mapping['fileID'] != '0': old_id = v.mapping['fileID'] new_id = self.getUniqueId(old_id) v.mapping['fileID'] = str(new_id) if hasattr(v, "forEach"): v.forEach(self.mergeIterator) def popNodeOfClass(self, classId): result = None for node in self.nodes: if node.classId() == classId: result = node self.nodes.remove(result) break del self.id_to_node[result.anchor] return result def pushNode(self, node): self.nodes.append(node) self.id_to_node[node.anchor] = node 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. 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 return ctrl0 def merge(self, other): # Mapping from class ID (string) to next ID (int) self.class_to_next_id = {} ctrl0 = self.popNodeOfClass('91') ctrl1 = other.popNodeOfClass('91') 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.getUniqueId(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.getUniqueId(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) def unityAnimatorToString(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.classId() + " &" + 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 unityAnimatorToString(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 for line in yaml_str.split("\n"): # Add end-of-document indicators. if line.startswith("---"): 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) lines.append("...\n") return '\n'.join(lines) def parse(self, yaml_file): yaml_str = "" with open(yaml_file, "r") as f: yaml_str = f.read() 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 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 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[path[:-len(suffix)]] = guid return result if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("cmd", type=str) 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`") args = parser.parse_args() guid_map = {} if args.guid_map: with open(args.guid_map, 'rb') as f: guid_map = pickle.load(f) if args.cmd == "merge": if not args.fx0 or not args.fx1: print("Usage: ./libunity.py merge --fx0 my_fx.controller --fx1 " + "my_other_fx.controller") sys.exit(1) print("Parsing {}".format(args.fx0), file=sys.stderr) parser0 = UnityParser() try: parser0.parse(args.fx0) except Exception as e: print("exception: {}".format(e)) anim0 = UnityAnimator() anim0.addNodes(parser0.nodes) arg1 = "TaSTT_fx.controller" print("Parsing {}".format(args.fx1), file=sys.stderr) parser1 = UnityParser() try: parser1.parse(args.fx1) except Exception as e: print("exception: {}".format(e)) anim1 = UnityAnimator() anim1.addNodes(parser1.nodes) print("Merging animators", file=sys.stderr) anim0.merge(anim1) print("Serializing", file=sys.stderr) print(unityAnimatorToString(anim0.nodes)) elif args.cmd == "guid_map": if not args.project_root or not args.save_to: print("Usage: ./libunity.py guid_map --project_root " + "/path/to/unity/assets --save_to /path/to/guid.map") sys.exit(1) print("Looking up GUIDs under {}".format(args.project_root), file=sys.stderr) guid_map = getGuidMap(args.project_root) print("Saving to {}".format(args.save_to), file=sys.stderr) with open(args.save_to, 'wb') as f: pickle.dump(guid_map, f) else: print("Unrecognized command: {}".format(args.cmd))