From efc9c2f15036b901fa3a47f0f89688d30298b4fe Mon Sep 17 00:00:00 2001 From: Cyberarm Date: Tue, 8 Sep 2026 11:41:35 -0500 Subject: [PATCH] Added ICO and MIX libraries --- lib/ico.rb | 144 +++++++++++++++++++++++++ lib/ww_mix.rb | 287 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 lib/ico.rb create mode 100644 lib/ww_mix.rb diff --git a/lib/ico.rb b/lib/ico.rb new file mode 100644 index 0000000..492ccde --- /dev/null +++ b/lib/ico.rb @@ -0,0 +1,144 @@ +require "stringio" + +module W3DHubLauncher + class ICO + PNG_IDENT = "\211PNG\r\n\032\n".force_encoding("ASCII-8BIT").freeze + + IconDirectory = Struct.new(:reserved, :type, :image_count) + + IconDirectoryEntity = Struct.new( + :width, + :height, + :palette_size, + :reserved, + :color_planes, + :bit_depth, + :image_size, + :image_offset + ) + + + def initialize(file:) + @file = StringIO.new(File.binread(file)) + + @images = [] + + parse + end + + def directory + @icon_directory + end + + def images + @images + end + + # SEE: https://en.wikipedia.org/wiki/ICO_(file_format) + def parse + # Parse IconDirectory + @icon_directory = IconDirectory.new( + read_u16, read_u16, read_u16 + ) + + @icon_directory.image_count.times do + @images << IconDirectoryEntity.new( + read_u8, + read_u8, + read_u8, + read_u8, + read_u16, + read_u16, + read_u32, + read_u32 + ) + + @images.last.width = 256 if @images.last.width == 0 + @images.last.height = 256 if @images.last.height == 0 + end + end + + def read_u8 + @file.read(1).unpack1("C") + end + + def read_u16 + @file.read(2).unpack1("v") + end + + def read_u32 + @file.read(4).unpack1("V") + end + + def select_pngs + @images.select do |image| + image_png?(image) + end + end + + def image_png?(image) + @file.pos = image.image_offset + buf = @file.read(8).unpack1("a*") + + buf == PNG_IDENT + end + + def select_bmps + @images.select do |image| + image_bmp?(image) && image.palette_size.zero? && image.bit_depth == 32 + end + end + + def image_bmp?(image) + !image_png?(image) + end + + def to_rgba32_blob(image) + @file.pos = image.image_offset + buf = StringIO.new(@file.read(image.image_size)) + + raise NotImplementedError "Cannot parse png based icons!" unless image_bmp?(image) + + raise NotImplementedError "Cannot parse #{image.bit_depth}" unless image.bit_depth == 32 + + blob = "".force_encoding("ASCII-8BIT") + + width = image.width + height = image.height - 1 + + image.height.times do |y| + image.width.times do |x| + buf.pos = ((height - y) * width + x + 10) * 4 + + blue = buf.read(1) + green = buf.read(1) + red = buf.read(1) + alpha = buf.read(1) + + blob << red + blob << green + blob << blue + blob << alpha + end + end + + Gosu::Image.from_blob(image.width, image.height, blob) + end + + def image_data(image) + @file.pos = image.image_offset + StringIO.new(@file.read(image.image_size)).string + end + + def save(image, filename) + puts filename + if image_bmp?(image) + puts :gosu_image + to_rgba32_blob(image).save(filename) + else + puts :png + File.open(filename, "wb") { |f| f.write(image_data(image)) } + end + end + end +end diff --git a/lib/ww_mix.rb b/lib/ww_mix.rb new file mode 100644 index 0000000..a0c17f5 --- /dev/null +++ b/lib/ww_mix.rb @@ -0,0 +1,287 @@ +require "digest" +require "stringio" + +module W3DHubLauncher + # Reimplementating MIX1 reader/writer with years more + # experience working with these formats and having the + # 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:, replace: false) + return false unless File.exist?(path) + return false if File.directory?(path) + + entry = Entry.new(name: File.basename(path), path: path, info: EntryInfoHeader.new(0, 0, File.size(path))) + add_entry(entry: entry, replace: replace) + end + + def add_blob(path:, blob:, replace: false) + info = EntryInfoHeader.new(0, 0, blob.size) + entry = Entry.new(name: File.basename(path), path: path, info: info, blob: blob) + into.crc32 = @entries.last.calculate_crc32 + + add_entry(entry: entry, replace: replace) + end + + def add_entry(entry:, replace: false) + duplicate = @entries.find { |e| e.name.upcase == entry.name.upcase } + + if duplicate + if replace + @entries.delete(duplicate) + else + return false + end + end + + @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