rom-distillery 0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,13 @@
1
+ # SPDX-License-Identifier: EUPL-1.2
2
+
3
+ module Distillery
4
+
5
+ # Assertion
6
+ class Assert < StandardError
7
+ end
8
+
9
+ # Error
10
+ class Error < StandardError
11
+ end
12
+
13
+ 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