diff --git a/lib/ww_mix.rb b/lib/ww_mix.rb new file mode 100644 index 0000000..97f7d18 --- /dev/null +++ b/lib/ww_mix.rb @@ -0,0 +1,280 @@ +require "digest" +require "stringio" + +class W3DHub + # Reimplementating MIX1 reader/writer with years more + # experience working with these formats and having then + # advantage of being able to reference the renegade source + # code :) + class WWMix + MIX1_HEADER = 0x3158494D + MIX2_HEADER = 0x3258494D + + MixHeader = Struct.new( + :mime_type, # int32 + :file_data_offset, # int32 + :file_names_offset, # int32 + :_reserved # int32 + ) + + EntryInfoHeader = Struct.new( + :crc32, # uint32 + :content_offset, # uint32 + :content_length # uint32 + ) + + class Entry + attr_accessor :path, :name, :info, :blob, :is_blob + + def initialize(name:, path:, info:, blob: nil) + @name = name + @path = path + @info = info + @blob = blob + + @info.content_length = blob.size if blob? + end + + def blob? + @blob + end + + def calculate_crc32 + Digest::CRC32.hexdigest(@name.upcase).upcase.to_i(16) + end + + # Write entry's data to stream. + # Caller is responsible for ensuring stream is valid for writing + def copy_to(stream) + if blob? + return false if @blob.size.zero? + + stream.write(blob) + return true + else + if read + stream.write(@blob) + @blob = nil + return true + end + end + + false + end + + def read + return false unless File.exist?(@path) + return false if File.directory?(@path) + return false if File.size(@path) < @info.content_offset + @info.content_length + + @blob = File.binread(@path, @info.content_length, @info.content_offset) + + true + end + end + + attr_reader :path, :encrypted, :entries, :error_reason + + def initialize(path:, encrypted: false) + @path = path + @encrypted = encrypted + @entries = [] + + @error_reason = "" + end + + # Load entries from MIX file. Entry data is NOT loaded. + # @return true on success or false on failure. Check m_error_reason for why. + def load + unless File.exist?(@path) + @error_reason = format("Path does not exist: %s", @path) + return false + end + + if File.directory?(@path) + @error_reason = format("Path is a directory: %s", @path) + return false + end + + File.open(@path, "rb") do |f| + header = MixHeader.new(0, 0, 0, 0) + header.mime_type = read_i32(f) + header.file_data_offset = read_i32(f) + header.file_names_offset = read_i32(f) + header._reserved = read_i32(f) + + unless header.mime_type == MIX1_HEADER || header.mime_type == MIX2_HEADER + @error_reason = format("Invalid mime type: %d", header.mime_type) + return false + end + + @encrypted = header.mime_type == MIX2_HEADER + + # Read entry info + f.pos = header.file_data_offset + file_count = read_i32(f) + + file_count.times do |i| + entry_info = EntryInfoHeader.new(0, 0, 0) + entry_info.crc32 = read_u32(f) + entry_info.content_offset = read_u32(f) + entry_info.content_length = read_u32(f) + + @entries << Entry.new(name: "", path: @path, info: entry_info) + end + + # Read entry names + f.pos = header.file_names_offset + file_count = read_i32(f) + + file_count.times do |i| + @entries[i].name = read_string(f) + end + end + + true + end + + def save + unless @entries.size.positive? + @error_reason = "No entries to write." + return false + end + + if File.directory?(@path) + @error_reason = format("Path is a directory: %s", @path) + return false + end + + File.open(@path, "wb") do |f| + header = MixHeader.new(encrypted? ? MIX2_HEADER : MIX1_HEADER, 0, 0, 0) + + # write mime type + write_i32(f, header.mime_type) + + f.pos = 16 + + # sort entries by crc32 of their name + sort_entries + + # write file blobs + @entries.each do |entry| + # store current io position + pos = f.pos + + # copy entry to stream + entry.copy_to(f) + + # update entry with new offset + entry.info.content_offset = pos + + # add alignment padding + padding = (-f.pos & 7) + padding.times do |i| + write_u8(f, 0) + end + end + + # Save file data offset + header.file_data_offset = f.pos + + # write number of entries + write_i32(f, @entries.size) + + # write entries file data + @entries.each do |entry| + write_u32(f, entry.info.crc32) + write_u32(f, entry.info.content_offset) + write_u32(f, entry.info.content_length) + end + + # save file names offset + header.file_names_offset = f.pos + + # write number of entries + write_i32(f, @entries.size) + + # write entry names + @entries.each do |entry| + write_string(f, entry.name) + end + + # jump to io_position 4 + f.pos = 4 + # write rest of header + + write_i32(f, header.file_data_offset) + write_i32(f, header.file_names_offset) + write_i32(f, header._reserved) + end + + true + end + + def valid? + # ALL entries MUST have unique case-insensitive names + @entries.each do |a| + @entries.each do |b| + next if a == b + + return false if a.name.upcase == b.name.upcase + end + end + + true + end + + def encrypted? + @encrypted + end + + def add_file(path:) + return false unless File.exist?(path) + return false if File.directory?(path) + + info = EntryInfoHeader.new(0, 0, File.size(path)) + @entries << Entry.new(name: File.basename(path), path: path, info: info) + + true + end + + def add_blob(path:, blob:) + info = EntryInfoHeader.new(0, 0, blob.size) + @entries << Entry.new(name: File.basename(path), path: path, info: info, blob: blob) + into.crc32 = @entries.last.calculate_crc32 + + true + end + + def add_entry(entry:) + @entries << entry + + true + end + + def sort_entries + return false if @entries.any? { |e| e.info.crc32 == 0 } + + @entries.sort! { |a, b| a.info.crc32 <=> b.info.crc32 } + + true + end + + def read_i32(f) = f.read(4).unpack1("l") + def read_u32(f) = f.read(4).unpack1("L") + def read_u8(f) = f.read(1).unpack1("c") + def read_string(f) + f.read(read_u8(f)).strip + end + + def write_i32(f, value) = f.write([value].pack("l")) + def write_u32(f, value) = f.write([value].pack("L")) + def write_u8(f, value) = f.write([value].pack("c")) + def write_string(f, string) + length = string.size + 1 # include null byte + write_u8(f, length) + f.write(string) + write_u8(f, 0) # null byte + end + end +end