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.
@@ -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