Skip to content

Instantly share code, notes, and snippets.

@Zoxc
Last active January 4, 2024 02:50
Show Gist options
  • Star 26 You must be signed in to star a gist
  • Fork 12 You must be signed in to fork a gist
  • Save Zoxc/2393192 to your computer and use it in GitHub Desktop.
Save Zoxc/2393192 to your computer and use it in GitHub Desktop.
GGPK Defragmenter
require 'bindata'
require 'benchmark'
require 'win32/registry'
require 'io/console'
class GGPK
class UTF16String < BinData::String
def snapshot
super.force_encoding('UTF-16LE')
end
end
class RecordBase < BinData::Record
endian :little
uint32 :record_length
string :tag, :length => 4
end
class GrindingGearRecord < BinData::Record
endian :little
record_base :base
uint32 :child_count, :value => lambda { children.length }
array :children, :type => :uint64, :initial_length => :child_count
end
class DirectoryEntry < BinData::Record
endian :little
uint32 :timestamp
uint64 :absolute_offset
end
class FileRecord < BinData::Record
endian :little
uint32 :name_length, :value => lambda { name.length }
string :digest, :length => 32 # hash of the following data
utf16_string :name, :read_length => lambda { name_length * 2 }
def utf8_name
name.encode('UTF-8').chomp("\0")
end
end
class DirectoryRecord < BinData::Record
endian :little
uint32 :name_length, :value => lambda { name.length }
uint32 :child_count, :value => lambda { children.length }
string :digest, :length => 32
utf16_string :name, :read_length => lambda { name_length * 2 }
array :children, :type => :directory_entry, :initial_length => :child_count
def utf8_name
name.encode('UTF-8').chomp("\0")
end
end
class FreeRecord < BinData::Record
endian :little
uint64 :next_record
end
def initialize(file)
@file = file
@root = GrindingGearRecord.read(@file)
@free_space = 0
raise "Invalid input file" unless @root.base.tag == "GGPK"
end
def traverse_children(path, children)
children.each do |child|
@file.pos = child
base = RecordBase.read(@file)
if base.tag == 'PDIR'
dir = DirectoryRecord.read(@file)
traverse_children(path + dir.utf8_name + '/', dir.children.map(&:absolute_offset))
elsif base.tag == 'FILE'
file = FileRecord.read(@file)
puts path + file.utf8_name
elsif base.tag == 'FREE'
@free_space += base.record_length - base.num_bytes
else
raise "Invalid tag #{base.tag}"
end
end
end
def list
traverse_children('', @root.children)
puts @free_space
end
def defragment(other)
@dest = other
dest_root = GrindingGearRecord.new
dest_root.children = [0] * @root.children.size
dest_root.base.tag = 'GGPK'
dest_root.base.record_length = dest_root.num_bytes
@dest.seek dest_root.num_bytes
puts "This will likely take a while..."
dest_root.children = copy_children(@root, @root.children.map do |child|
entry = DirectoryEntry.new
entry.absolute_offset = child
entry
end).map(&:absolute_offset)
@dest.seek 0
dest_root.write(@dest)
end
def copy_children(root, children)
children.map do |child|
@file.pos = child.absolute_offset
result = DirectoryEntry.new
result.timestamp = child.timestamp
base = RecordBase.read(@file)
dest_base = RecordBase.new
dest_base.tag = base.tag
if base.tag == 'PDIR'
dir = DirectoryRecord.read(@file)
dest_dir = DirectoryRecord.new
dest_dir.name = dir.name
children = copy_children(dir, dir.children)
dest_dir.children = children
dest_dir.digest = dir.digest
dest_base.record_length = dest_base.num_bytes + dest_dir.num_bytes
result.absolute_offset = @dest.pos
dest_base.write(@dest)
dest_dir.write(@dest)
elsif base.tag == 'FILE'
file = FileRecord.read(@file)
remaining = base.record_length - (base.num_bytes + file.num_bytes)
dest_file = FileRecord.new
dest_base.record_length = base.record_length
dest_file.name = file.name
dest_file.digest = file.digest
result.absolute_offset = @dest.pos
dest_base.write(@dest)
dest_file.write(@dest)
data = @file.read(remaining)
@dest.write(data)
elsif base.tag == 'FREE'
dest_free = FreeRecord.new
dest_free.next_record = 0
dest_base.record_length = dest_base.num_bytes + dest_free.num_bytes
result.absolute_offset = @dest.pos
dest_base.write(@dest)
dest_free.write(@dest)
else
raise "Invalid tag #{base.tag}"
end
result
end
end
end
def finish(msg)
puts msg, "\nPress a key to close this application."
STDIN.getch
exit
end
input = ARGV.first
unless input
begin
Win32::Registry::HKEY_CURRENT_USER.open('Software\GrindingGearGames\Path of Exile') do |reg|
input = File.join(reg['InstallLocation'].to_s, 'Content.ggpk')
input = nil unless File.exists?(input)
end
rescue Win32::Registry::Error
end
end
finish "No GGPK given and no Path of Exile installation detected. Please drag and drop the GGPK to defragment on this executable." unless input
finish "The given GGPK file '#{input}' doesn't exist" unless File.exists?(input)
puts "Defragmenting #{input}"
new = input + '.new'
begin
fin = File.open(input, 'r+b')
rescue SystemCallError
finish "The GGPK appears to be in use. Please close Path of Exile."
end
old_size = fin.size
new_size = nil
time = Benchmark.realtime do
begin
File.open(new, 'w+b') do |fnew|
fnew.seek 0
GGPK.new(fin).defragment(fnew)
new_size = fnew.size
end
fin.close
rescue Exception
File.delete(new)
raise
end
File.delete(input)
File.rename(new, input)
end
puts "Elapsed time: #{(time / 60.0).round(2)} minute(s)"
puts "File reduction: #{((old_size - new_size) / 1024.0 / 1024.0).round(2)} MB(s)"
finish "Successfully defragmented the GGPK."
@kevinmccaughey
Copy link

Nice!

@bobbycvi
Copy link

very nice!

@rubsilence
Copy link

nasıl yükleniyor bilgisayara .yardımcı olurmusunuz

@Reithan
Copy link

Reithan commented May 15, 2020

Is this still useful/needed? 🤔

@Zoxc
Copy link
Author

Zoxc commented May 20, 2020

@Reithan Not if you use Steam, but I don't know if this is needed or works with the standalone client still.

@Marikhen
Copy link

Still works and is still needed with the stand-alone client. After the recent update, perhaps the third since I last ran it, I ran ggpk_defragment and it reduced content.ggpk's file size by 9720.15MB.

@LordBlick
Copy link

For now with 3.11.2 Patch it has stopped working, throws EOF Error.

PS D:\prg\rb> ruby ggpk_defragment.rb
Defragmenting D:\Gry\Grinding Gear Games\Path of Exile\Content.ggpk
This will likely take a while...
Traceback (most recent call last):
        19: from ggpk_defragment.rb:214:in `<main>'
        18: from C:/msys64/mingw64/lib/ruby/2.6.0/benchmark.rb:308:in `realtime'
        17: from ggpk_defragment.rb:216:in `block in <main>'
        16: from ggpk_defragment.rb:216:in `open'
        15: from ggpk_defragment.rb:218:in `block (2 levels) in <main>'
        14: from ggpk_defragment.rb:104:in `defragment'
        13: from ggpk_defragment.rb:116:in `copy_children'
        12: from ggpk_defragment.rb:116:in `map'
        11: from ggpk_defragment.rb:121:in `block in copy_children'
        10: from C:/msys64/mingw64/lib/ruby/gems/2.6.0/gems/bindata-2.4.4/lib/bindata/base.rb:21:in `read'
         9: from C:/msys64/mingw64/lib/ruby/gems/2.6.0/gems/bindata-2.4.4/lib/bindata/base.rb:145:in `read'
         8: from C:/msys64/mingw64/lib/ruby/gems/2.6.0/gems/bindata-2.4.4/lib/bindata/base.rb:254:in `start_read'
         7: from C:/msys64/mingw64/lib/ruby/gems/2.6.0/gems/bindata-2.4.4/lib/bindata/base.rb:147:in `block in read'
         6: from C:/msys64/mingw64/lib/ruby/gems/2.6.0/gems/bindata-2.4.4/lib/bindata/struct.rb:139:in `do_read'
         5: from C:/msys64/mingw64/lib/ruby/gems/2.6.0/gems/bindata-2.4.4/lib/bindata/struct.rb:139:in `each'
         4: from C:/msys64/mingw64/lib/ruby/gems/2.6.0/gems/bindata-2.4.4/lib/bindata/struct.rb:139:in `block in do_read'
         3: from C:/msys64/mingw64/lib/ruby/gems/2.6.0/gems/bindata-2.4.4/lib/bindata/base_primitive.rb:129:in `do_read'
         2: from (eval):23:in `read_and_return_value'
         1: from C:/msys64/mingw64/lib/ruby/gems/2.6.0/gems/bindata-2.4.4/lib/bindata/io.rb:276:in `readbytes'
C:/msys64/mingw64/lib/ruby/gems/2.6.0/gems/bindata-2.4.4/lib/bindata/io.rb:314:in `read': End of file reached (EOFError)
PS D:\prg\rb>

@axet
Copy link

axet commented Jan 11, 2021

3.11.2 Patch Notes:

  • We have massively overhauled how Path of Exile stores its internal data files. This will result in faster patching in the future, preloading improvements (especially beneficial for players without an SSD) and improved texture quality. Please note that due to the nature of these changes, this patch includes a full re-download of all Path of Exile data.

https://www.pathofexile.com/forum/view-thread/2934311

diff --git a/ggpk_compact.rb b/ggpk_compact.rb
index 9e6e1a0..d0a9afe 100755
--- a/ggpk_compact.rb
+++ b/ggpk_compact.rb
@@ -23,8 +23,8 @@ class GGPK
class GrindingGearRecord < BinData::Record
endian :little
record_base :base
- uint32 :child_count, :value => lambda { children.length }
- array :children, :type => :uint64, :initial_length => :child_count
+ uint32 :version, :value => 3
+ array :children, :type => :uint64, :initial_length => 2
end

class DirectoryEntry < BinData::Record

@iagosousadev
Copy link

does this still help in any way ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment