mirror of
https://github.com/cyberarm/w3d_hub_linux_launcher.git
synced 2026-03-21 19:56:14 +00:00
288 lines
7.0 KiB
Ruby
288 lines
7.0 KiB
Ruby
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:, 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
|