lib/mikunyan/asset.rb in mikunyan-3.9.6 vs lib/mikunyan/asset.rb in mikunyan-3.9.7

- old
+ new

@@ -1,340 +1,313 @@ +# frozen_string_literal: true + +require 'mikunyan/type_tree' +require 'mikunyan/constants' +require 'mikunyan/object_value' +require 'mikunyan/base_object' + module Mikunyan - # Class for representing Unity Asset - # @attr_reader [String] name Asset name - # @attr_reader [Integer] format file format number - # @attr_reader [String] generator_version version string of generator - # @attr_reader [Integer] target_platform target platform number - # @attr_reader [Symbol] endian data endianness (:little or :big) - # @attr_reader [Array<Mikunyan::Asset::Klass>] klasses defined classes - # @attr_reader [Array<Mikunyan::Asset::ObjectData>] objects included objects - # @attr_reader [Array<Integer>] add_ids ? - # @attr_reader [Array<Mikunyan::Asset::Reference>] references reference data - class Asset - attr_reader :name, :format, :generator_version, :target_platform, :endian, :klasses, :objects, :add_ids, :references, :res_s + # Class for representing Unity Asset + # @attr_reader [String] name Asset name + # @attr_reader [Integer] format file format number + # @attr_reader [String] generator_version version string of generator + # @attr_reader [Integer] target_platform target platform number + # @attr_reader [Symbol] endian data endianness (:little or :big) + # @attr_reader [Array<Mikunyan::Asset::Klass>] klasses defined classes + # @attr_reader [Array<Mikunyan::Asset::ObjectEntry>] objects contained objects + # @attr_reader [Array<Mikunyan::Asset::LocalObjectEntry>] add_ids ? + # @attr_reader [Array<Mikunyan::Asset::Reference>] references reference data + class Asset + attr_reader :name, :format, :generator_version, :target_platform, :endian, :klasses, :objects, :add_ids, :references - # Struct for representing Asset class definition - # @attr [Integer] class_id class ID - # @attr [Integer,nil] script_id script ID - # @attr [String] hash hash value (16 or 32 bytes) - # @attr [Mikunyan::TypeTree, nil] type_tree given TypeTree - Klass = Struct.new(:class_id, :script_id, :hash, :type_tree) + # Struct for representing Asset class definition + # @attr [Integer] class_id class ID + # @attr [Boolean] stripped? + # @attr [Integer,nil] script_id script ID + # @attr [String] hash hash value (16 or 32 bytes) + # @attr [Mikunyan::TypeTree, nil] type_tree given TypeTree + Klass = Struct.new(:class_id, :stripped?, :script_id, :hash, :type_tree) - # Struct for representing Asset object information - # @attr [Integer] path_id path ID - # @attr [Integer] offset data offset - # @attr [Integer] size data size - # @attr [Integer,nil] type_id type ID - # @attr [Integer,nil] class_id class ID - # @attr [Integer,nil] class_idx class definition index - # @attr [Boolean] destroyed? destroyed or not - # @attr [String] data binary data of object - ObjectData = Struct.new(:path_id, :offset, :size, :type_id, :class_id, :class_idx, :destroyed?, :data) + # Struct for representing Asset object information + # @attr [Integer] path_id path ID + # @attr [Integer] offset data offset + # @attr [Integer] size data size + # @attr [Integer,nil] type_id type ID + # @attr [Integer,nil] class_id class ID + # @attr [Integer,nil] class_idx class definition index + # @attr [Boolean] destroyed? destroyed or not + # @attr [String] data binary data of object + # @attr [Mikunyan::Asset] parent_asset + # @attr [Klass] klass + ObjectEntry = Struct.new( + :path_id, :offset, :size, :type_id, :class_id, :class_idx, :destroyed?, :stripped?, + :data, :parent_asset, :klass, + keyword_init: true + ) do + # Alias to {Asset#parse_object} + def parse + parent_asset.parse_object(self) + end - # Struct for representing Asset reference information - # @attr [String] path path - # @attr [String] guid GUID (16 bytes) - # @attr [Integer] type ? - # @attr [String] file_path Asset name - Reference = Struct.new(:path, :guid, :type, :file_path) + # Alias to {Asset#parse_object_simple} + def parse_simple + parent_asset.parse_object_simple(self) + end - # Load Asset from binary string - # @param [String] bin binary data - # @param [String] name Asset name - # @param [String] res_s resS data - # @return [Mikunyan::Asset] deserialized Asset object - def self.load(bin, name, res_s = nil) - r = Asset.new(name, res_s) - r.send(:load, bin) - r - end + # Returns object type name string + # @return [String,nil] type name + def type + klass&.type_tree&.tree&.type || Mikunyan::Constants::CLASS_ID2NAME[class_id] + end + end - # Load Asset from file - # @param [String] file file name - # @param [String] name Asset name (automatically generated if not specified) - # @return [Mikunyan::Asset] deserialized Asset object - def self.file(file, name=nil) - name = File.basename(name, '.*') unless name - Asset.load(File.binread(file), name) - end + LocalObjectEntry = Struct.new(:file_id, :local_id) - # Returns list of all path IDs - # @return [Array<Integer>] list of all path IDs - def path_ids - @objects.map{|e| e.path_id} - end + # Struct for representing Asset reference information + # @attr [String] path path + # @attr [String] guid GUID (16 bytes) + # @attr [Integer] type ? + # @attr [String] file_path Asset name + Reference = Struct.new(:path, :guid, :type, :file_path) - # Returns list of containers - # @return [Array<Hash>,nil] list of all containers - def containers - obj = parse_object(1) - return nil unless obj && obj.m_Container && obj.m_Container.array? - obj.m_Container.value.map do |e| - {:name => e.first.value, :preload_index => e.second.preloadIndex.value, :path_id => e.second.asset.m_PathID.value} - end - end + # Strcut for container information + # @attr [String] name + # @attr [Integer] preload_index + # @attr [Integer] path_id + ContainerInfo = Struct.new(:name, :preload_index, :preload_size, :file_id, :path_id) - # Parse object of given path ID - # @param [Integer,ObjectData] path_id path ID or object - # @return [Mikunyan::ObjectValue,nil] parsed object - def parse_object(path_id) - if path_id.class == Integer - obj = @objects.find{|e| e.path_id == path_id} - return nil unless obj - elsif path_id.class == ObjectData - obj = path_id - else - return nil - end + # Load Asset from binary string + # @param [String,IO] bin binary data + # @param [String] name Asset name + # @param [Mikunyan::AssetBundle] parent_bundle Parent AssetBundle + # @return [Mikunyan::Asset] deserialized Asset object + def self.load(bin, name, parent_bundle = nil) + r = Asset.new(name, parent_bundle) + r.send(:load, bin) + r + end - klass = (obj.class_idx ? @klasses[obj.class_idx] : @klasses.find{|e| e.class_id == obj.class_id} || @klasses.find{|e| e.class_id == obj.type_id}) - type_tree = Asset.parse_type_tree(klass) - return nil unless type_tree + # Load Asset from file + # @param [String] file file name + # @param [String] name Asset name (automatically generated if not specified) + # @return [Mikunyan::Asset] deserialized Asset object + def self.file(file, name = nil) + name ||= File.basename(name, '.*') + File.open(file, 'rb') do |io| + Asset.load(io, name) + end + end - parse_object_private(BinaryReader.new(obj.data, @endian), type_tree) - end + # Same as objects.each + # @return [Enumerator<Mikunyan::Asset::ObjectEntry>,Array<Mikunyan::Asset::ObjectEntry>] + def each_object(&block) + @objects.each(&block) + end - # Parse object of given path ID and simplify it - # @param [Integer,ObjectData] path_id path ID or object - # @return [Hash,nil] parsed object - def parse_object_simple(path_id) - Asset.object_simplify(parse_object(path_id)) - end + # Returns list of all path IDs + # @return [Array<Integer>] list of all path IDs + def path_ids + @objects.map(&:path_id) + end - # Returns object type name string - # @param [Integer,ObjectData] path_id path ID or object - # @return [String,nil] type name - def object_type(path_id) - if path_id.class == Integer - obj = @objects.find{|e| e.path_id == path_id} - return nil unless obj - elsif path_id.class == ObjectData - obj = path_id - else - return nil - end - klass = (obj.class_idx ? @klasses[obj.class_idx] : @klasses.find{|e| e.class_id == obj.class_id} || @klasses.find{|e| e.class_id == obj.type_id}) - if klass && klass.type_tree && klass.type_tree.nodes[0] - klass.type_tree.nodes[0].type - elsif klass - Mikunyan::CLASS_ID[klass.class_id] - else - nil - end - end + # Returns list of containers + # @return [Array<Hash>,nil] list of all containers + def containers + obj = @path_id_table[1] + return nil unless obj.klass&.type_tree&.tree&.type == 'AssetBundle' + parse_object(obj).m_Container.value.map do |e| + ContainerInfo.new(e.first.value, e.second.preloadIndex.value, e.second.preloadSize.value, e.second.asset.m_FileID.value, e.second.asset.m_PathID.value) + end + end - private + # Parse object of given path ID + # @param [Integer,ObjectEntry] obj path ID or object + # @return [Mikunyan::BaseObject,nil] parsed object + def parse_object(obj) + obj = @path_id_table[obj] if obj.class == Integer + return nil unless obj.klass&.type_tree + value_klass = Mikunyan::CustomTypes.get_custom_type(obj.klass.type_tree.tree.type, obj.class_id) + ret = parse_object_private(BinaryReader.new(obj.data, @endian), obj.klass.type_tree.tree, value_klass) + ret.object_entry = obj + ret + end - def initialize(name, res_s = nil) - @name = name - @endian = :big - @res_s = res_s - end + # Parse object of given path ID and simplify it + # @param [Integer,ObjectEntry] obj path ID or object + # @return [Hash,nil] parsed object + def parse_object_simple(obj) + parse_object(obj)&.simplify + end - def load(bin) - br = BinaryReader.new(bin) - metadata_size = br.i32u - size = br.i32u - @format = br.i32u - data_offset = br.i32u + # Returns object type name string + # @param [Integer,ObjectEntry] obj path ID or object + # @return [String,nil] type name + def object_type(obj) + obj = @path_id_table[obj] if obj.class == Integer + obj&.type + end - if @format >= 9 - @endian = :little if br.i32 == 0 - br.endian = @endian - end + # Alias to {ObjectValue#simplify} (for compatibility) + def self.object_simplify(obj) + obj.is_a?(ObjectValue) ? obj.simplify : obj + end - @generator_version = br.cstr - @target_platform = br.i32 - @klasses = [] + private - if @format >= 17 - has_type_trees = (br.i8 != 0) - type_tree_count = br.i32u - type_tree_count.times do - class_id = br.i32 - br.adv(1) - script_id = br.i16 - if class_id < 0 || class_id == 114 - hash = br.read(32) - else - hash = br.read(16) - end - @klasses << Klass.new(class_id, script_id, hash, has_type_trees ? TypeTree.load(br) : TypeTree.load_default(hash)) - end - elsif @format >= 13 - has_type_trees = (br.i8 != 0) - type_tree_count = br.i32u - type_tree_count.times do - class_id = br.i32 - if class_id < 0 - hash = br.read(32) - else - hash = br.read(16) - end - @klasses << Klass.new(class_id, nil, hash, has_type_trees ? TypeTree.load(br) : TypeTree.load_default(hash)) - end - else - @type_trees = {} - type_tree_count = br.i32u - type_tree_count.times do - class_id = br.i32 - @klasses << Klass.new(class_id, nil, nil, @format == 10 || @format == 12 ? TypeTree.load(br) : TypeTree.load_legacy(br)) - end - end + # @param [Mikunyan::AssetBundle] bundle + def initialize(name, bundle = nil) + @name = name + @endian = :big + @bundle = bundle + end - long_object_ids = (@format >= 14 || (7 <= @format && @format <= 13 && br.i32 != 0)) + def load(bin) + br = BinaryReader.new(bin) - @objects = [] - object_count = br.i32u - object_count.times do - br.align(4) if @format >= 14 - path_id = long_object_ids ? br.i64 : br.i32 - offset = br.i32u - size = br.i32u - if @format >= 17 - @objects << ObjectData.new(path_id, offset, size, nil, nil, br.i32u, @format <= 10 && br.i16 != 0) - else - @objects << ObjectData.new(path_id, offset, size, br.i32, br.i16, nil, @format <= 10 && br.i16 != 0) - end - br.adv(2) if 11 <= @format && @format <= 16 - br.adv(1) if 15 <= @format && @format <= 16 - end + meta_size = br.i32u + file_size = br.i32u + @format = br.i32u + data_offset = br.i32u - if @format >= 11 - @add_ids = [] - add_id_count = br.i32u - add_id_count.times do - br.align(4) if @format >= 14 - @add_ids << [(long_object_ids ? br.i64 : br.i32), br.i32] - end - end + if @format >= 9 + @endian = br.bool ? :big : :little + br.adv(3) + else + br.pos = file_size - meta_size + @endian = br.bool ? :big : :little + end + br.endian = @endian - if @format >= 6 - @references = [] - reference_count = br.i32u - reference_count.times do - @references << Reference.new(br.cstr, br.read(16), br.i32, br.cstr) - end - end + @generator_version = br.cstr if @format >= 7 + @target_platform = br.i32 if @format >= 8 + has_type_trees = @format >= 13 ? br.bool : true + type_count = br.i32u - @objects.each do |e| - br.jmp(data_offset + e.offset) - e.data = br.read(e.size) - end - end + @klasses = Array.new(type_count) do + class_id = br.i32s + stripped = br.bool if @format >= 16 + script_id = br.i16 if @format >= 17 + hash = br.read(@format < 16 && class_id < 0 || @format >= 16 && class_id == 114 ? 32 : 16) if @format >= 13 + type_tree = has_type_trees ? TypeTree.load(br, @format) : TypeTree.load_default(class_id, hash) + Klass.new(class_id, stripped, script_id, hash, type_tree) + end - def parse_object_private(br, type_tree) - r = nil - node = type_tree[:node] - children = type_tree[:children] + wide_path_id = @format >= 14 || @format >= 7 && br.i32 != 0 - if node.array? - data = nil - size = parse_object_private(br, children.find{|e| e[:name] == 'size'}).value - data_type_tree = children.find{|e| e[:name] == 'data'} - if node.type == 'TypelessData' - data = br.read(size * data_type_tree[:node].size) - else - data = size.times.map{ parse_object_private(br, data_type_tree) } - end - r = ObjectValue.new(node.name, node.type, br.endian, data) - elsif node.size == -1 - r = ObjectValue.new(node.name, node.type, br.endian) - if children.size == 1 && children[0][:name] == 'Array' && children[0][:node].type == 'Array' && children[0][:node].array? - if node.type == 'string' - size = parse_object_private(br, children[0][:children].find{|e| e[:name] == 'size'}).value - r.value = br.read(size * children[0][:children].find{|e| e[:name] == 'data'}[:node].size).force_encoding("utf-8") - br.align(4) if children[0][:node].flags & 0x4000 != 0 - else - r.value = parse_object_private(br, children[0]).value - end - elsif node.type == 'StreamingInfo' - children.each{|child| r[child[:name]] = parse_object_private(br, child)} - r.value = @res_s.byteslice(r['offset'].value, r['size'].value) if r['path'].value == "archive:/#{name}/#{name}.resS" - else - children.each do |child| - r[child[:name]] = parse_object_private(br, child) - end - end - elsif children.size > 0 - pos = br.pos - r = ObjectValue.new(node.name, node.type, br.endian) - r.is_struct = true - children.each do |child| - r[child[:name]] = parse_object_private(br, child) - end - else - pos = br.pos - value = nil - case node.type - when 'bool' - value = (br.i8 != 0) - when 'SInt8' - value = br.i8s - when 'UInt8', 'char' - value = br.i8u - when 'SInt16', 'short' - value = br.i16s - when 'UInt16', 'unsigned short' - value = br.i16u - when 'SInt32', 'int' - value = br.i32s - when 'UInt32', 'unsigned int' - value = br.i32u - when 'SInt64', 'long long' - value = br.i64s - when 'UInt64', 'unsigned long long' - value = br.i64u - when 'float' - value = br.float - when 'double' - value = br.double - when 'ColorRGBA' - value = [br.i8u, br.i8u, br.i8u, br.i8u] - else - value = br.read(node.size) - end - br.jmp(pos + node.size) - r = ObjectValue.new(node.name, node.type, br.endian, value) - end - br.align(4) if node.flags & 0x4000 != 0 - r + object_count = br.i32u + @objects = Array.new(object_count) do + br.align(4) if @format >= 14 + if @format >= 16 + ObjectEntry.new( + path_id: wide_path_id ? br.i64s : br.i32s, offset: br.i32u, size: br.i32u, + class_idx: br.i32u, stripped?: @format == 16 ? br.bool : nil, + parent_asset: self + ) + else + ObjectEntry.new( + path_id: wide_path_id ? br.i64s : br.i32s, offset: br.i32u, size: br.i32u, + type_id: br.i32, class_id: br.i16, destroyed?: br.i16 == 1, stripped?: @format == 15 ? br.bool : nil, + parent_asset: self + ) end + end - def self.object_simplify(obj) - if obj.class != ObjectValue - obj - elsif obj.type == 'pair' - [object_simplify(obj['first']), object_simplify(obj['second'])] - elsif obj.type == 'map' && obj.array? - obj.value.map{|e| [object_simplify(e['first']), object_simplify(e['second'])] }.to_h - elsif obj.value? - object_simplify(obj.value) - elsif obj.array? - obj.value.map{|e| object_simplify(e)} - else - hash = {} - obj.keys.each do |key| - hash[key] = object_simplify(obj[key]) - end - hash - end + @path_id_table = @objects.map{|e| [e.path_id, e]}.to_h + + if @format >= 11 + add_id_count = br.i32u + @add_ids = Array.new(add_id_count) do + br.align(4) if @format >= 14 + LocalObjectEntry.new(br.i32u, wide_path_id ? br.i64s : br.i32s) end + end - def self.parse_type_tree(klass) - return nil unless klass.type_tree - nodes = klass.type_tree.nodes - tree = {} - stack = [] - nodes.each do |node| - this = {:name => node.name, :node => node, :children => []} - if node.depth == 0 - tree = this - else - stack[node.depth - 1][:children] << this - end - stack[node.depth] = this + reference_count = br.i32u + @references = Array.new(reference_count) do + Reference.new(@format >= 6 ? br.cstr : nil, @format >= 5 ? br.read(16) : nil, @format >= 5 ? br.i32s : nil, br.cstr) + end + + @comment = br.cstr if @format >= 5 + + @objects.each do |e| + br.jmp(data_offset + e.offset) + e.data = br.read(e.size) + e.klass = e.class_idx ? @klasses[e.class_idx] : @klasses.find{|e2| e2.class_id == e.class_id} || @klasses.find{|e2| e2.class_id == e.type_id} + end + end + + # @param [Mikunyan::BinaryReader] br + # @param [Mikunyan::TypeTree::Node] node + def parse_object_private(br, node, klass = ObjectValue) + ret = klass.new(node.name, node.type, br.endian) + children = node.children + + if children.empty? + pos = br.pos + ret.value = + case node.type + when 'bool' + br.bool + when 'SInt8' + br.i8s + when 'UInt8' + br.i8u + when 'SInt16', 'short' + br.i16s + when 'UInt16', 'unsigned short' + br.i16u + when 'SInt32', 'int' + br.i32s + when 'UInt32', 'unsigned int', 'Type*' + br.i32u + when 'SInt64', 'long long' + br.i64s + when 'UInt64', 'unsigned long long' + br.i64u + when 'float' + br.float + when 'double' + br.double + else + br.read(node.size) + end + br.jmp(pos + node.size) if node.size >= 0 + elsif node.array? + children.each do |child| + next ret[child.name] = parse_object_private(br, child) unless child.name == 'data' + size = ret['size']&.value || raise('`size` node must appear before `data` node in array node') + ret.value = + if child.children.empty? && (!child.need_align? || br.pos % 4 == 0 && child.size % 4 == 0) + if node.type == 'TypelessData' + br.read(size * child.size) + elsif child.type == 'char' + # string + br.read(size * child.size).force_encoding('utf-8') + end end - tree + ret.value ||= Array.new(size){parse_object_private(br, child)} + ret['data'] = ret.value end + elsif children.size == 1 && children[0].array? && children[0].type == 'Array' && children[0].name == 'Array' + ret = parse_object_private(br, children[0]) + ret.name = node.name + ret.type = node.type + else + ret.attr = children.map{|c| [c.name, parse_object_private(br, c)]}.to_h + if node.type == 'StreamingInfo' + ret.value = get_stream_blob(ret['path'].value, ret['offset'].value, ret['size'].value) + else + ret.is_struct = true + end + end + br.align(4) if node.need_align? + ret end + + def get_stream_blob(path, offset, size) + return nil unless path && @bundle + return nil if path.empty? + path["archive:/#{@name}/"] = '' if path.start_with?("archive:/#{@name}/") + @bundle.blobs[path]&.byteslice(offset, size) + end + end end