rom-distillery 0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|