rom-distillery 0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +6 -0
- data/LICENSE +287 -0
- data/README.md +24 -0
- data/bin/rhum +6 -0
- data/distillery.gemspec +38 -0
- data/lib/distillery.rb +10 -0
- data/lib/distillery/archiver.rb +372 -0
- data/lib/distillery/archiver/archive.rb +102 -0
- data/lib/distillery/archiver/external.rb +182 -0
- data/lib/distillery/archiver/external.yaml +31 -0
- data/lib/distillery/archiver/libarchive.rb +105 -0
- data/lib/distillery/archiver/zip.rb +88 -0
- data/lib/distillery/cli.rb +234 -0
- data/lib/distillery/cli/check.rb +100 -0
- data/lib/distillery/cli/clean.rb +60 -0
- data/lib/distillery/cli/header.rb +61 -0
- data/lib/distillery/cli/index.rb +65 -0
- data/lib/distillery/cli/overlap.rb +39 -0
- data/lib/distillery/cli/rebuild.rb +47 -0
- data/lib/distillery/cli/rename.rb +34 -0
- data/lib/distillery/cli/repack.rb +113 -0
- data/lib/distillery/cli/validate.rb +171 -0
- data/lib/distillery/datfile.rb +180 -0
- data/lib/distillery/error.rb +13 -0
- data/lib/distillery/game.rb +70 -0
- data/lib/distillery/game/release.rb +40 -0
- data/lib/distillery/refinements.rb +41 -0
- data/lib/distillery/rom-archive.rb +266 -0
- data/lib/distillery/rom.rb +585 -0
- data/lib/distillery/rom/path.rb +110 -0
- data/lib/distillery/rom/path/archive.rb +103 -0
- data/lib/distillery/rom/path/file.rb +100 -0
- data/lib/distillery/rom/path/virtual.rb +70 -0
- data/lib/distillery/storage.rb +170 -0
- data/lib/distillery/vault.rb +433 -0
- data/lib/distillery/version.rb +7 -0
- metadata +192 -0
@@ -0,0 +1,180 @@
|
|
1
|
+
# SPDX-License-Identifier: EUPL-1.2
|
2
|
+
|
3
|
+
require 'set'
|
4
|
+
require 'nokogiri'
|
5
|
+
|
6
|
+
require_relative 'error'
|
7
|
+
require_relative 'game'
|
8
|
+
require_relative 'rom'
|
9
|
+
require_relative 'vault'
|
10
|
+
|
11
|
+
module Distillery
|
12
|
+
|
13
|
+
# Handle information from DAT file
|
14
|
+
class DatFile
|
15
|
+
class ContentError < Error
|
16
|
+
end
|
17
|
+
|
18
|
+
# List of ROM with loosely defined (ie: with some missing checksum)
|
19
|
+
#
|
20
|
+
# @return [Array<ROM>,nil]
|
21
|
+
#
|
22
|
+
def with_partial_checksum
|
23
|
+
@roms.with_partial_checksum
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
# Get Games to which this ROM belongs.
|
28
|
+
# @note This only works for ROMs created by the DatFile
|
29
|
+
#
|
30
|
+
# @param rom [Rom]
|
31
|
+
#
|
32
|
+
# @return [Array<Game>]
|
33
|
+
#
|
34
|
+
def getGames(rom)
|
35
|
+
@roms_game[rom.object_id]
|
36
|
+
end
|
37
|
+
|
38
|
+
|
39
|
+
# Create DatFile representation from file.
|
40
|
+
#
|
41
|
+
# @param datfile [String]
|
42
|
+
#
|
43
|
+
def initialize(datfile)
|
44
|
+
@games = Set.new
|
45
|
+
@roms = Vault::new
|
46
|
+
@roms_game = {}
|
47
|
+
|
48
|
+
if !FileTest.file?(datfile)
|
49
|
+
raise ArgumentError, "DAT file is missing or not a regular file"
|
50
|
+
end
|
51
|
+
|
52
|
+
# Get datafile as XML document
|
53
|
+
dat = Nokogiri::XML(File.read(datfile))
|
54
|
+
|
55
|
+
dat.xpath('//header').each {|hdr|
|
56
|
+
@name = hdr.xpath('name' )&.first&.content
|
57
|
+
@description = hdr.xpath('description')&.first&.content
|
58
|
+
@url = hdr.xpath('url' )&.first&.content
|
59
|
+
@date = hdr.xpath('date' )&.first&.content
|
60
|
+
@version = hdr.xpath('version' )&.first&.content
|
61
|
+
}
|
62
|
+
|
63
|
+
# Process each game elements
|
64
|
+
dat.xpath('//game').each {|g|
|
65
|
+
releases = g.xpath('release').map {|r|
|
66
|
+
Release::new(r[:name], region: r[:region].upcase)
|
67
|
+
}
|
68
|
+
roms = g.xpath('rom').map {|r|
|
69
|
+
path = File.join(r[:name].split('\\'))
|
70
|
+
ROM::new(ROM::Path::Virtual.new(path),
|
71
|
+
:size => Integer(r[:size]),
|
72
|
+
:crc32 => r[:crc ],
|
73
|
+
:md5 => r[:md5 ],
|
74
|
+
:sha1 => r[:sha1])
|
75
|
+
}
|
76
|
+
game = Game::new(g['name'], *roms, releases: releases,
|
77
|
+
cloneof: g['cloneof'])
|
78
|
+
|
79
|
+
roms.each {|rom|
|
80
|
+
(@roms_game[rom.object_id] ||= []) << game
|
81
|
+
@roms << rom
|
82
|
+
}
|
83
|
+
|
84
|
+
if @games.add?(game).nil?
|
85
|
+
raise ContentError,
|
86
|
+
"Game '#{game}' defined multiple times in DAT file"
|
87
|
+
end
|
88
|
+
}
|
89
|
+
end
|
90
|
+
|
91
|
+
# Identify ROM which have the same fullname/name but are different
|
92
|
+
#
|
93
|
+
# @param type [:fullname, :name] Check by fullname or name
|
94
|
+
#
|
95
|
+
# @return [Hash{String => Array<ROM>}]
|
96
|
+
#
|
97
|
+
def clash(type = :fullname)
|
98
|
+
grp = case type
|
99
|
+
when :fullname then @roms.each.group_by {|rom| rom.fullname }
|
100
|
+
when :name then @roms.each.group_by {|rom| rom.name }
|
101
|
+
else raise ArgumentError
|
102
|
+
end
|
103
|
+
|
104
|
+
grp.select {|_, roms| roms.size > 1 }
|
105
|
+
.transform_values {|roms|
|
106
|
+
lst = []
|
107
|
+
while rom = roms.first do
|
108
|
+
t, f = roms.partition {|r| r.same?(rom) }
|
109
|
+
lst << t.first
|
110
|
+
roms = f
|
111
|
+
end
|
112
|
+
lst
|
113
|
+
}
|
114
|
+
end
|
115
|
+
|
116
|
+
|
117
|
+
# @return [Vault]
|
118
|
+
attr_reader :roms
|
119
|
+
|
120
|
+
|
121
|
+
# Iterate over each ROM
|
122
|
+
#
|
123
|
+
# @yieldparam rom [ROM]
|
124
|
+
#
|
125
|
+
# @return [self,Enumerator]
|
126
|
+
#
|
127
|
+
def each_rom
|
128
|
+
block_given? ? @roms.each {|r| yield(r) }
|
129
|
+
: @roms.each
|
130
|
+
end
|
131
|
+
|
132
|
+
|
133
|
+
# @return [Set<Games>]
|
134
|
+
attr_reader :games
|
135
|
+
|
136
|
+
|
137
|
+
# Iterate over each game
|
138
|
+
#
|
139
|
+
# @yieldparam game [Game]
|
140
|
+
#
|
141
|
+
# @return [self,Enumerator]
|
142
|
+
#
|
143
|
+
def each_game
|
144
|
+
block_given? ? @games.each {|g| yield(g) }
|
145
|
+
: @games.each
|
146
|
+
end
|
147
|
+
|
148
|
+
|
149
|
+
# Datfile name
|
150
|
+
#
|
151
|
+
# @return [String,nil]
|
152
|
+
attr_reader :name
|
153
|
+
|
154
|
+
|
155
|
+
# Datfile description
|
156
|
+
#
|
157
|
+
# @return [String,nil]
|
158
|
+
attr_reader :description
|
159
|
+
|
160
|
+
|
161
|
+
# Datfile url
|
162
|
+
#
|
163
|
+
# @return [String,nil]
|
164
|
+
attr_reader :url
|
165
|
+
|
166
|
+
|
167
|
+
# Datfile date
|
168
|
+
#
|
169
|
+
# @return [String,nil]
|
170
|
+
attr_reader :date
|
171
|
+
|
172
|
+
|
173
|
+
# Datfile version
|
174
|
+
#
|
175
|
+
# @return [String,nil]
|
176
|
+
attr_reader :version
|
177
|
+
|
178
|
+
end
|
179
|
+
|
180
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# SPDX-License-Identifier: EUPL-1.2
|
2
|
+
|
3
|
+
require_relative 'game/release'
|
4
|
+
|
5
|
+
module Distillery
|
6
|
+
|
7
|
+
# Information about game
|
8
|
+
#
|
9
|
+
class Game
|
10
|
+
# Create a new instance of Game.
|
11
|
+
#
|
12
|
+
# @param name [String]
|
13
|
+
# @param roms [ROM]
|
14
|
+
# @param releases [Array<Game::Release>,nil]
|
15
|
+
# @param cloneof [String,nil]
|
16
|
+
def initialize(name, *roms, releases: nil, cloneof: nil)
|
17
|
+
raise ArgumentError if name.nil?
|
18
|
+
|
19
|
+
@name = name
|
20
|
+
@roms = roms
|
21
|
+
@releases = releases
|
22
|
+
@cloneof = cloneof
|
23
|
+
end
|
24
|
+
|
25
|
+
|
26
|
+
# @return [Integer]
|
27
|
+
def hash
|
28
|
+
@name.hash
|
29
|
+
end
|
30
|
+
|
31
|
+
def eql?(o)
|
32
|
+
@name.eql?(o.name)
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
# String representation
|
37
|
+
# @return [String]
|
38
|
+
def to_s
|
39
|
+
@name
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
# @return [String]
|
44
|
+
attr_reader :name
|
45
|
+
|
46
|
+
# @return [Array<ROM>]
|
47
|
+
attr_reader :roms
|
48
|
+
|
49
|
+
# @return [Array<Release>]
|
50
|
+
attr_reader :releases
|
51
|
+
|
52
|
+
# @return [String]
|
53
|
+
attr_reader :cloneof
|
54
|
+
|
55
|
+
|
56
|
+
# Iterate over ROMs used be the game
|
57
|
+
#
|
58
|
+
# @yieldparam rom [ROM]
|
59
|
+
#
|
60
|
+
# @return [self,Enumerator]
|
61
|
+
#
|
62
|
+
def each_rom
|
63
|
+
block_given? ? @roms.each {|r| yield(r) }
|
64
|
+
: @roms.each
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# SPDX-License-Identifier: EUPL-1.2
|
2
|
+
|
3
|
+
module Distillery
|
4
|
+
|
5
|
+
# Information about release
|
6
|
+
#
|
7
|
+
class Release
|
8
|
+
# @!visibility private
|
9
|
+
@@regions = Set.new
|
10
|
+
|
11
|
+
# List all assigned region code.
|
12
|
+
#
|
13
|
+
# @return [Set<String>] set of region code
|
14
|
+
#
|
15
|
+
def self.regions
|
16
|
+
@@regions
|
17
|
+
end
|
18
|
+
|
19
|
+
# Create a new instance of Release.
|
20
|
+
#
|
21
|
+
# @param name [String] release name
|
22
|
+
# @param region [String] region of release
|
23
|
+
#
|
24
|
+
def initialize(name, region:)
|
25
|
+
@name = name
|
26
|
+
@region = region
|
27
|
+
|
28
|
+
@@regions.add(region)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Release name
|
32
|
+
# @return [String]
|
33
|
+
attr_reader :name
|
34
|
+
|
35
|
+
# Region of release
|
36
|
+
# @return [String]
|
37
|
+
attr_reader :region
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# SPDX-License-Identifier: EUPL-1.2
|
2
|
+
|
3
|
+
module Distillery
|
4
|
+
|
5
|
+
module StringY
|
6
|
+
refine ::String do
|
7
|
+
def ellipsize(width, position = :end, ellipsis: '...')
|
8
|
+
# Sanity check
|
9
|
+
unless [ :begin, :middle, :end ].include?(position)
|
10
|
+
raise ArgumentError, "unsupported position (#{position})"
|
11
|
+
end
|
12
|
+
|
13
|
+
# Is there a need to ellipsize ?
|
14
|
+
return self if self.size <= width
|
15
|
+
|
16
|
+
# Ellipsis too big?
|
17
|
+
if ellipsis&.size > width
|
18
|
+
return ellipsis
|
19
|
+
end
|
20
|
+
|
21
|
+
# Deal with nil-ellipsis
|
22
|
+
ellipsis ||= ''
|
23
|
+
|
24
|
+
# Perform ellipsis
|
25
|
+
str = self.dup
|
26
|
+
delsize = self.size - width + ellipsis.size
|
27
|
+
|
28
|
+
case position
|
29
|
+
when :begin then str[0, delsize] = ellipsis
|
30
|
+
when :middle then str[width/2, delsize] = ellipsis
|
31
|
+
when :end then str[-delsize .. -1] = ellipsis
|
32
|
+
end
|
33
|
+
|
34
|
+
# Return ellipsized string
|
35
|
+
str
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
@@ -0,0 +1,266 @@
|
|
1
|
+
# SPDX-License-Identifier: EUPL-1.2
|
2
|
+
|
3
|
+
require 'set'
|
4
|
+
|
5
|
+
begin
|
6
|
+
require 'mimemagic'
|
7
|
+
rescue LoadError
|
8
|
+
end
|
9
|
+
|
10
|
+
require_relative 'archiver'
|
11
|
+
require_relative 'archiver/external'
|
12
|
+
require_relative 'archiver/zip'
|
13
|
+
require_relative 'rom'
|
14
|
+
|
15
|
+
Distillery::Archiver.registering
|
16
|
+
|
17
|
+
|
18
|
+
|
19
|
+
module Distillery
|
20
|
+
|
21
|
+
# Deal with ROM embedded in an archive file.
|
22
|
+
#
|
23
|
+
class ROMArchive
|
24
|
+
include Enumerable
|
25
|
+
|
26
|
+
# Prefered
|
27
|
+
PREFERED = '7z'
|
28
|
+
|
29
|
+
# Allowed extension names
|
30
|
+
EXTENSIONS = Set[ '7z', 'zip' ]
|
31
|
+
|
32
|
+
|
33
|
+
# Set buffer size used when processing archive content
|
34
|
+
#
|
35
|
+
# @param size [Integer] size in kbytes
|
36
|
+
#
|
37
|
+
def self.bufsize=(size)
|
38
|
+
@@bufsize = size << 10
|
39
|
+
end
|
40
|
+
|
41
|
+
|
42
|
+
# Check using extension if file is an archive
|
43
|
+
#
|
44
|
+
# @param file [String] file to test
|
45
|
+
#
|
46
|
+
# @return [Boolean]
|
47
|
+
#
|
48
|
+
def self.archive?(file, archives: EXTENSIONS)
|
49
|
+
return false if archives.nil?
|
50
|
+
archives.include?(File.extname(file)[1..-1])
|
51
|
+
end
|
52
|
+
|
53
|
+
|
54
|
+
# Read ROM archive from file
|
55
|
+
#
|
56
|
+
# @param file [String] path to archive file
|
57
|
+
# @param headers [Array,nil,false] header definition list
|
58
|
+
#
|
59
|
+
# @return [ROMArchive]
|
60
|
+
#
|
61
|
+
def self.from_file(file, headers: nil)
|
62
|
+
# Create archive object
|
63
|
+
archive = self.new(file)
|
64
|
+
|
65
|
+
#
|
66
|
+
Distillery::Archiver.for(file).each {|entry, i|
|
67
|
+
path = ROM::Path::Archive.new(archive, entry)
|
68
|
+
archive[entry] = ROM.new(path, **ROM.info(i, headers: headers))
|
69
|
+
}
|
70
|
+
|
71
|
+
archive
|
72
|
+
end
|
73
|
+
|
74
|
+
|
75
|
+
# Create an empty archive
|
76
|
+
#
|
77
|
+
# @param file [String] archive file
|
78
|
+
#
|
79
|
+
def initialize(file)
|
80
|
+
@file = file
|
81
|
+
@roms = {}
|
82
|
+
end
|
83
|
+
|
84
|
+
|
85
|
+
# String representation of the archive
|
86
|
+
# @return [String]
|
87
|
+
def to_s
|
88
|
+
@file
|
89
|
+
end
|
90
|
+
|
91
|
+
|
92
|
+
# Assign a ROM to the archive
|
93
|
+
#
|
94
|
+
# @param entry [String] archive entry name
|
95
|
+
# @param rom [ROM] ROM
|
96
|
+
#
|
97
|
+
# @return [ROM] the assigned ROM
|
98
|
+
#
|
99
|
+
def []=(entry, rom)
|
100
|
+
@roms.merge!(entry => rom) {|key, old_rom, new_rom|
|
101
|
+
warn "replacing ROM entry \"#{key}\" (#{to_s})"
|
102
|
+
new_rom
|
103
|
+
}
|
104
|
+
rom
|
105
|
+
end
|
106
|
+
|
107
|
+
|
108
|
+
# Same archive file
|
109
|
+
#
|
110
|
+
# @param o [ROMArchive] other archive
|
111
|
+
# @return [Boolean]
|
112
|
+
#
|
113
|
+
def same_file?(o)
|
114
|
+
self.file == o.file
|
115
|
+
end
|
116
|
+
|
117
|
+
|
118
|
+
# Test if archive is identical (same file, same content)
|
119
|
+
#
|
120
|
+
# @param o [ROMArchive] other archive
|
121
|
+
# @return [Boolean]
|
122
|
+
#
|
123
|
+
def ==(o)
|
124
|
+
o.kind_of?(ROMArchive) &&
|
125
|
+
(self.entries.to_set == o.entries.to_set) &&
|
126
|
+
self.entries.all? {|entry| self[entry].same?(o[entry]) }
|
127
|
+
end
|
128
|
+
|
129
|
+
|
130
|
+
# Archive size (number of entries)
|
131
|
+
# @return [Integer]
|
132
|
+
def size
|
133
|
+
@roms.size
|
134
|
+
end
|
135
|
+
|
136
|
+
|
137
|
+
# Iterate over each ROM
|
138
|
+
#
|
139
|
+
# @yieldparam rom [ROM]
|
140
|
+
#
|
141
|
+
# @return [self,Enumerator]
|
142
|
+
#
|
143
|
+
def each
|
144
|
+
block_given? ? @roms.each_value {|r| yield(r) }
|
145
|
+
: @roms.each_value
|
146
|
+
end
|
147
|
+
|
148
|
+
# List of ROMs
|
149
|
+
#
|
150
|
+
# @return [Array<ROM>]
|
151
|
+
#
|
152
|
+
def roms
|
153
|
+
@roms.values
|
154
|
+
end
|
155
|
+
|
156
|
+
|
157
|
+
# List of archive entries
|
158
|
+
#
|
159
|
+
# @return [Array<String>]
|
160
|
+
#
|
161
|
+
def entries
|
162
|
+
@roms.keys
|
163
|
+
end
|
164
|
+
|
165
|
+
|
166
|
+
# Get ROM by entry
|
167
|
+
#
|
168
|
+
# @param entry [String] archive entry
|
169
|
+
# @return [ROM]
|
170
|
+
#
|
171
|
+
def [](entry)
|
172
|
+
@roms[entry]
|
173
|
+
end
|
174
|
+
|
175
|
+
|
176
|
+
# Delete entry
|
177
|
+
#
|
178
|
+
# @param entry [String] archive entry
|
179
|
+
#
|
180
|
+
# @return [Boolean] operation status
|
181
|
+
#
|
182
|
+
def delete!(entry)
|
183
|
+
Distillery::Archiver.for(@file) {|archive|
|
184
|
+
if archive.delete!(entry)
|
185
|
+
if archive.empty?
|
186
|
+
File.unlink(@file)
|
187
|
+
end
|
188
|
+
true
|
189
|
+
else
|
190
|
+
false
|
191
|
+
end
|
192
|
+
}
|
193
|
+
end
|
194
|
+
|
195
|
+
|
196
|
+
# Read ROM.
|
197
|
+
# @note Can be costly, to be avoided.
|
198
|
+
#
|
199
|
+
# @param entry [String] archive entry
|
200
|
+
#
|
201
|
+
# @yieldparam [#read] io stream for reading
|
202
|
+
#
|
203
|
+
# @return block value
|
204
|
+
#
|
205
|
+
def reader(entry, &block)
|
206
|
+
Distillery::Archiver.for(@file).reader(entry, &block)
|
207
|
+
end
|
208
|
+
|
209
|
+
|
210
|
+
# Extract rom to the filesystem
|
211
|
+
#
|
212
|
+
# @param entry [String] entry (rom) to extract
|
213
|
+
# @param to [String] destination
|
214
|
+
# @param length [Integer,nil] data length to be copied
|
215
|
+
# @param offset [Integer] data offset
|
216
|
+
# @param force [Boolean] remove previous file if necessary
|
217
|
+
#
|
218
|
+
# @return [Boolean] operation status
|
219
|
+
#
|
220
|
+
def extract(entry, to, length = nil, offset = 0, force: false)
|
221
|
+
Distillery::Archiver.for(@file).reader(entry) {|i|
|
222
|
+
# Copy file
|
223
|
+
begin
|
224
|
+
op = force ? File::TRUNC : File::EXCL
|
225
|
+
File.open(to, File::CREAT|File::WRONLY|op) {|o|
|
226
|
+
while (skip = [ offset, @@bufsize ].min) > 0
|
227
|
+
i.read(skip)
|
228
|
+
offset -= skip
|
229
|
+
end
|
230
|
+
|
231
|
+
if length.nil?
|
232
|
+
while data = i.read(@@bufsize)
|
233
|
+
o.write(data)
|
234
|
+
end
|
235
|
+
else
|
236
|
+
while ((sz = [ length, @@bufsize ].min) > 0) &&
|
237
|
+
(data = i.read(sz))
|
238
|
+
o.write(data)
|
239
|
+
length -= sz
|
240
|
+
end
|
241
|
+
end
|
242
|
+
}
|
243
|
+
rescue Errno::EEXIST
|
244
|
+
return false
|
245
|
+
end
|
246
|
+
|
247
|
+
# Assuming entries are unique
|
248
|
+
return true
|
249
|
+
}
|
250
|
+
|
251
|
+
# Was not found
|
252
|
+
return false
|
253
|
+
end
|
254
|
+
|
255
|
+
|
256
|
+
# Archive file
|
257
|
+
# @return [String]
|
258
|
+
attr_reader :file
|
259
|
+
end
|
260
|
+
|
261
|
+
|
262
|
+
# Set default buffer size to 32k
|
263
|
+
ROMArchive.bufsize = 32
|
264
|
+
|
265
|
+
|
266
|
+
end
|