sabrina 0.5.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,302 @@
1
+ module Sabrina
2
+ # A class for handling low-level read and write operations upon a ROM file.
3
+ # Beyond creating a new {Rom} from filename for passing to
4
+ # {Bytestream}-family objects, you should not need to deal with it
5
+ # or any of its methods directly.
6
+ class Rom
7
+ # The position in the ROM file from which to read the 4-byte
8
+ # identifier string. This is relied upon to pull the ROM type
9
+ # data from the {Config}.
10
+ ID_OFFSET = 172
11
+
12
+ # The 4-byte ID of the ROM.
13
+ #
14
+ # @return [String]
15
+ # @see Config.rom_params
16
+ attr_reader :id
17
+
18
+ # The full path and filename of the ROM file.
19
+ #
20
+ # @return [String]
21
+ attr_reader :path
22
+
23
+ # Just the filename of the ROM file.
24
+ #
25
+ # @return [String]
26
+ attr_reader :filename
27
+
28
+ # The ROM file object.
29
+ #
30
+ # @return [File]
31
+ attr_reader :file
32
+
33
+ class << self
34
+ # Converts a numerical offset to a GBA-compliant,
35
+ # reverse 3-byte pointer.
36
+ #
37
+ # @return [Integer]
38
+ def offset_to_pointer(offset)
39
+ format('%06X', offset).scan(/../).reverse.map { |x| x.hex.chr }.join('')
40
+ end
41
+
42
+ # Converts a reverse 3-byte pointer to a numerical offset.
43
+ #
44
+ # @return [Integer]
45
+ def pointer_to_offset(pointer)
46
+ Bytestream.from_bytes(pointer.reverse).to_i
47
+ end
48
+ end
49
+
50
+ # Creates a new Rom object from the supplied ROM image file.
51
+ #
52
+ # @return [Rom]
53
+ def initialize(rom_file)
54
+ @path = rom_file
55
+ @file = File.new(rom_file, 'r+b')
56
+
57
+ @filename = @path.rpartition('/').last.rpartition('.').first
58
+ @id = load_id
59
+
60
+ @params = Config.rom_params(@id)
61
+
62
+ @params.each_key do |key|
63
+ m = key.downcase.to_sym
64
+ define_singleton_method(m) { @params[key] } unless respond_to?(m)
65
+ end
66
+ end
67
+
68
+ # Returns the numerical offset associated with the +name+ in the
69
+ # current ROM {Config} data.
70
+ #
71
+ # @param [String, Symbol] name
72
+ # @return [Integer]
73
+ def table(name)
74
+ Bytestream.parse_offset(param(name))
75
+ end
76
+
77
+ # Fetches a specific key from the ROM {Config} data.
78
+ #
79
+ # @param [String, Symbol] p the key to look for.
80
+ # @see Config
81
+ def param(p)
82
+ s = p.to_sym
83
+ @params.fetch(s) { fail "No parameter #{s} for ROM type #{@id}." }
84
+ end
85
+
86
+ # Gets the name of the monster identified by +real_index+ from
87
+ # the ROM file.
88
+ #
89
+ # @param [Integer] real_index
90
+ # @return [String]
91
+ # @see Monster.parse_index
92
+ def monster_name(real_index)
93
+ read_string_from_table(:name_table, real_index, name_length)
94
+ end
95
+
96
+ # Takes a table name and index and returns a byte offset.
97
+ #
98
+ # @param [String, Symbol] name the name of the table as specified
99
+ # in the ROM {Config} data.
100
+ # @param [Integer] index in the case of a monster, the real index
101
+ # of the monster.
102
+ # @param [Integer] index_length The number of bytes occupied by each
103
+ # index in +table+. If absent, will search {Config} for a +_length+
104
+ # param associated with the table.
105
+ # @return [String]
106
+ def table_to_offset(name, index, index_length = nil)
107
+ index_length ||= param(name.to_s.sub('_table', '_length'))
108
+
109
+ table(name) + index * index_length
110
+ end
111
+
112
+ # Reads a numerical offset associated with +index+ from the table
113
+ # +name+. This assumes the table contains 3-byte GBA pointers.
114
+ #
115
+ # @param [String, Symbol] name the name of the table as specified
116
+ # in the ROM {Config} data.
117
+ # @param [Integer] index in the case of a monster, the real index
118
+ # of the monster.
119
+ # @return [Integer]
120
+ # @see offset_to_pointer
121
+ def read_offset_from_table(name, index)
122
+ pointer = read_table(name, index, 8, 3)
123
+ self.class.pointer_to_offset(pointer)
124
+ end
125
+
126
+ # Reads a stream expected to be an 0xFF-terminated GBA string from the
127
+ # table. This assumes the entries before +index+ are each +index_length+
128
+ # long.
129
+ #
130
+ # @param [String, Symbol] name the name of the table as specified
131
+ # in the ROM {Config} data.
132
+ # @param [Integer] index in the case of a monster, the real index
133
+ # of the monster.
134
+ # @param [Integer] index_length The number of bytes occupied by each
135
+ # index in +table+. If absent, will search {Config} for a +_length+
136
+ # param associated with the table.
137
+ # @return [String]
138
+ # @see GBAString
139
+ def read_string_from_table(name, index, index_length = nil)
140
+ index_length ||= param(name.to_s.sub('_table', '_length'))
141
+ s = read_string(table(name) + index * index_length)
142
+ GBAString.from_bytes(s).to_s
143
+ end
144
+
145
+ # Reads +length+ bytes of data from the table. This assumes the
146
+ # entries before +index+ are each +index_length+ long.
147
+ #
148
+ # @param [String, Symbol] name the name of the table as specified
149
+ # in the ROM {Config} data.
150
+ # @param [Integer] index in the case of a monster, the real index
151
+ # of the monster.
152
+ # @param [Integer] index_length The number of bytes occupied by each
153
+ # index in +table+. If absent, will search {Config} for a +_length+
154
+ # param associated with the table.
155
+ # @param [Integer] length how many bytes to read. Will assume +index_length+
156
+ # if absent.
157
+ # @return [String]
158
+ # @see Bytestream
159
+ def read_table(name, index, index_length = nil, length = nil)
160
+ index_length ||= param(name.to_s.sub('_table', '_length'))
161
+ length ||= index_length
162
+ read(table(name) + index * index_length, length)
163
+ end
164
+
165
+ # Returns the position of the first occurence of +length+ 0xFF
166
+ # bytes, assumed to be free space available for writing.
167
+ # If +start+ is +nil+, the search will begin at the +:free_space_start+
168
+ # offset specified in the ROM {Config}.
169
+ #
170
+ # @param [Integer] length
171
+ # @return [Integer] the first found offset.
172
+ def find_free(length, start = nil)
173
+ query = ("\xFF" * length).force_encoding('ASCII-8BIT')
174
+ start ||= Bytestream.parse_offset(free_space_start)
175
+
176
+ @file.seek(start)
177
+ match = start + @file.read.index(query)
178
+
179
+ return match if match % 4 == 0 || !match
180
+
181
+ match += 1 until match % 4 == 0
182
+ find_free(length, match)
183
+ end
184
+
185
+ # Reads the data from +offset+, assuming it to be {Lz77}-compressed.
186
+ #
187
+ # @param [Integer] offset
188
+ # @return [Hash] contains the uncompressed data as +:stream+ and the
189
+ # estimated original compressed length as +:original_length+.
190
+ # @see Lz77.uncompress
191
+ def read_lz77(offset)
192
+ Lz77.uncompress(self, offset)
193
+ end
194
+
195
+ # Reads a stream expected to be an 0xFF-terminated GBA string from +offset+.
196
+ #
197
+ # @param [Integer] offset
198
+ # @return [String]
199
+ # @see GBAString
200
+ def read_string(offset)
201
+ term = "\xFF".force_encoding('ASCII-8BIT')
202
+
203
+ @file.seek(offset)
204
+ @file.gets(term)
205
+ end
206
+
207
+ # Reads +length+ bytes from +offset+, or the entire rest of the file
208
+ # if +length+ is not specified.
209
+ #
210
+ # @param [Integer] offset
211
+ # @param [Integer] length
212
+ # @return [String]
213
+ def read(offset, length = nil)
214
+ @file.seek(offset)
215
+ length ? @file.read(length) : @file.read
216
+ end
217
+
218
+ # Writes a stream of bytes at the provided offset.
219
+ #
220
+ # @param [Integer] offset
221
+ # @return [String] a debug message.
222
+ def write(offset, b)
223
+ @file.seek(offset)
224
+ @file.write(b.force_encoding('ASCII-8BIT'))
225
+
226
+ "Rom#write: Wrote #{b.length} bytes at #{offset}" \
227
+ " (#{ format('%06X', offset) })."
228
+ end
229
+
230
+ # Writes the +offset+ associated with +index+ to the table
231
+ # +name+. This assumes the table contains 3-byte GBA pointers.
232
+ #
233
+ # @param [Integer] offset the offset. It will be converted to a
234
+ # GBA-compliant 3-byte pointer before writing.
235
+ # @param [String, Symbol] name the name of the table as specified
236
+ # in the ROM {Config} data.
237
+ # @param [Integer] index in the case of a monster, the real index
238
+ # of the monster.
239
+ # @return [String] a debug message.
240
+ # @see offset_to_pointer
241
+ def write_offset_to_table(name, index, offset)
242
+ write(
243
+ table(name) + index * 8,
244
+ self.class.offset_to_pointer(offset)
245
+ )
246
+ end
247
+
248
+ # Writes a stream of +length+ 0xFF bytes at the provided +offset+.
249
+ # This ought to be recognized as free space available for writing.
250
+ # Unless +force+ is set to true, the method will do nothing
251
+ # if there may be multiple pointers referencing the given offset
252
+ # within the ROM file.
253
+ #
254
+ # @param [Integer] offset
255
+ # @param [Integer] length
256
+ # @param [Boolean] force whether to force wiping even if the offset
257
+ # appears to be pointed at multiple times.
258
+ # @return [String] a debug message.
259
+ def wipe(offset, length, force = false)
260
+ unless force
261
+ pointer = self.class.offset_to_pointer(offset)
262
+
263
+ @file.rewind
264
+ hits = @file.read.scan(pointer).length
265
+ if hits > 1
266
+ return "Rom.wipe: Offset #{offset} (#{ format('%06X', offset) })" \
267
+ " appears to be referenced by multiple pointers (#{hits})," \
268
+ ' not wiping. Use wipe(offset, length, true) to override.'
269
+ end
270
+ end
271
+
272
+ write(offset, "\xFF" * length)
273
+
274
+ "Rom#wipe: Wiped #{length} bytes at #{offset}" \
275
+ "(#{ format('%06X', offset) })."
276
+ end
277
+
278
+ # Closes the ROM file.
279
+ #
280
+ # @return [0]
281
+ def close
282
+ @file.close
283
+ 0
284
+ end
285
+
286
+ # Returns a blurb consisting of the ROM title and ID.
287
+ #
288
+ # @return [String]
289
+ def to_s
290
+ "#{ param(:title) } [#{@id}]"
291
+ end
292
+
293
+ private
294
+
295
+ # Reads the type ID from the ROM file.
296
+ #
297
+ # @return [String]
298
+ def load_id
299
+ read(ID_OFFSET, 4)
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,312 @@
1
+ module Sabrina
2
+ # A class tailored towards dealing with graphical (sprite) data.
3
+ #
4
+ # Note that a sprite generated from ROM data does not contain color data by
5
+ # default. Pass a {Palette} as :palette in the option hash, or to
6
+ # {#palette=}, to specify a default palette for the RGB output methods.
7
+ class Sprite < Bytestream
8
+ # Gets or sets the default palette for RGB output. Note that this will not
9
+ # cause the palette to also be automatically written to ROM on sprite
10
+ # {RomOperations#write_to_rom}.
11
+ attr_accessor :palette
12
+
13
+ class << self
14
+ # Same as {ByteInput#from_table_as_pointer}, but also supports a
15
+ # width parameter for the resulting picture.
16
+ #
17
+ # @param [Integer] width
18
+ # @param [Hash] h see {Bytestream#initialize}
19
+ # @return [Sprite]
20
+ # @see ByteInput#from_table_as_pointer
21
+ def from_table(rom, table, index, width = 64, h = {})
22
+ h.merge!(width: width)
23
+ from_table_as_pointer(rom, table, index, h)
24
+ end
25
+
26
+ # Same as {ByteInput#from_rom}, but supplies no +length+ (due to
27
+ # implicit {Lz77} mode) and supports a width parameter for the resulting
28
+ # picture.
29
+ #
30
+ # @param [Integer] width
31
+ # @param [Hash] h see {Bytestream#initialize}
32
+ # @return [Sprite]
33
+ # @see ByteInput#from_rom
34
+ def from_rom(rom, offset, width = 64, h = {})
35
+ h.merge!(width: width)
36
+ super(rom, offset, nil, h)
37
+ end
38
+
39
+ # Generates a sprite from a PNG file and optionally attempts to match the
40
+ # colors to the provided {Palette}.
41
+ #
42
+ # Internally, this creates a
43
+ # {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
44
+ # object from the provided file and then passes the extracted RGB stream
45
+ # and width to {from_rgb} along with the palette.
46
+ #
47
+ # @param [String] file
48
+ # @param [Palette] palette
49
+ # @param [Hash] h see {Bytestream#initialize}
50
+ # @return [Sprite]
51
+ def from_png(file, palette = Palette.empty, h = {})
52
+ c = ChunkyPNG::Canvas.from_file(file)
53
+
54
+ from_canvas(c, palette, h)
55
+ end
56
+
57
+ # Generates a sprite from a
58
+ # {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
59
+ # and optionally attempts to match the
60
+ # colors to the provided {Palette}.
61
+ #
62
+ # @param [Canvas] c
63
+ # @param [Palette] palette
64
+ # @param [Hash] h see {Bytestream#initialize}
65
+ # @return [Sprite]
66
+ def from_canvas(c, palette = Palette.empty, h = {})
67
+ from_rgb(c.to_rgb_stream, c.width, palette, h)
68
+ end
69
+
70
+ # Generates a sprite from a stream of bytes following the +0xRRGGBB+
71
+ # format with the provided +width+, optionally matching the colors to the
72
+ # supplied {Palette} (and failing if the sprite dimensions are not
73
+ # multiples of 8 or the total number of colors in the image and the
74
+ # palette exceeds 16).
75
+ #
76
+ # It is important to remember that while the resulting image will be ready
77
+ # for saving to PNG, writing it to a ROM will not save the color data by
78
+ # itself. The generated palette (accessible via {#palette}) should be
79
+ # written separately. The {Plugins::Spritesheet Spritesheet} plugin should
80
+ # take care of that for you.
81
+ #
82
+ # @param [String] rgb
83
+ # @param [Integer] width
84
+ # @param [Palette] palette
85
+ # @param [Hash] h see {Bytestream#initialize}
86
+ # @return [Sprite]
87
+ def from_rgb(rgb, width = 64, palette = Palette.empty, h = {})
88
+ fail 'RGB stream length must divide by 3.' unless rgb.length % 3 == 0
89
+
90
+ unless width % 8 == 0 && (rgb.length / 3 / width) % 8 == 0
91
+ fail 'Sprite dimensions must be divisible by 8.'
92
+ end
93
+
94
+ out_array = []
95
+
96
+ until rgb.empty?
97
+ pixel = rgb.slice!(0, 3).unpack('CCC')
98
+ palette.add(pixel) unless palette.index_of(pixel)
99
+ out_array << palette.index_of(pixel).to_s(16).upcase
100
+ end
101
+
102
+ h.merge!(
103
+ representation: out_array,
104
+ width: width,
105
+ palette: palette
106
+ )
107
+ new(h)
108
+ end
109
+ end
110
+
111
+ # Same as {Bytestream#initialize}, but with +:lz77+ and
112
+ # +:pointer_mode+ set to true by default and support for the following extra
113
+ # options.
114
+ #
115
+ # @param [Hash] h
116
+ # @option h [Integer] :width The width of the picture.
117
+ # @option h [Palette] :palette The default palette to use
118
+ # with the RGB and Canvas output methods.
119
+ # @see Bytestream#initialize
120
+ def initialize(h = {})
121
+ @lz77 = true
122
+ @pointer_mode = true
123
+
124
+ @width = 64
125
+ @palette = nil
126
+
127
+ super
128
+ end
129
+
130
+ # Returns an array of characters that represent the palette index of each
131
+ # pixel in hexadecimal format.
132
+ #
133
+ # @return [Array] an array of characters from 0 through F.
134
+ def present
135
+ return @representation if @representation
136
+ # return nil if to_bytes.empty?
137
+
138
+ in_array = []
139
+ to_hex.scan(/(.)(.)/) { |x, y| in_array += [y, x] }
140
+
141
+ column_num = @width / 8
142
+
143
+ blocks = []
144
+ blocks << in_array.slice!(0, 64) until in_array.empty?
145
+
146
+ out_array = []
147
+ i = 0
148
+ loop do
149
+ loop do
150
+ break if blocks[i % column_num].empty?
151
+ out_array += blocks[i % column_num].slice!(0, 8)
152
+ i += 1
153
+ end
154
+ blocks.slice!(0, column_num)
155
+ break if blocks.empty?
156
+ end
157
+
158
+ @representation = out_array
159
+ end
160
+
161
+ alias_method :to_a, :present
162
+
163
+ # @todo Some breakage with number 360, is this the culprit?
164
+ # Converts the internal representation to a GBA-compatible stream of bytes.
165
+ #
166
+ # @return [String]
167
+ # @see ByteOutput#generate_bytes
168
+ def generate_bytes
169
+ in_array = []
170
+ present.join('').scan(/(.)(.)/) { |x, y| in_array += [y, x] }
171
+
172
+ column_num = @width / 8
173
+ out_array = []
174
+
175
+ loop do
176
+ break if in_array.empty?
177
+ columns = [[]] * column_num
178
+
179
+ until columns[0].length == 8 * 8
180
+ column_num.times { |i| columns[i] += in_array.slice!(0, 8) }
181
+ end # Filled one block
182
+
183
+ out_array += columns.slice!(0) until columns.empty?
184
+ end
185
+
186
+ Bytestream.from_hex(out_array.join('')).to_b
187
+ end
188
+
189
+ # Converts the internal representation to a stream of +0xRRGGBB+ bytes using
190
+ # the default palette or the provided one (and failing if neither is
191
+ # present.)
192
+ #
193
+ # @param [Palette] pal
194
+ # @return [String]
195
+ # @see #palette=
196
+ def to_rgb(pal = @palette)
197
+ fail 'A palette must be specified for conversion to RGB.' unless pal
198
+
199
+ rgb = present.map do |x|
200
+ color = pal.present[x.hex]
201
+ fail "No such entry in the palette: #{x.hex}" unless color
202
+ color.map(&:chr).join('')
203
+ end
204
+
205
+ rgb.join('')
206
+ end
207
+
208
+ # Crops or repeats the sprite vertically until it meets the current ROM's
209
+ # frame count, assuming 64x64 pixels per frame.
210
+ #
211
+ # @return [self]
212
+ def justify
213
+ frame_count =
214
+ if @table.to_sym == :front_table
215
+ @rom.special_frames.fetch(@index, @rom.frames).first
216
+ else
217
+ @rom.special_frames.fetch(@index, @rom.frames).last
218
+ end
219
+
220
+ h = { lz77: false }
221
+ target = frame_count * 64 * 64
222
+
223
+ old_rep = @representation.dup
224
+ if @representation.length < target
225
+ @representation += old_rep until @representation.length >= target
226
+ h = { lz77: true }
227
+ elsif @representation.length > target
228
+ @representation.slice!(0, target)
229
+ h = { lz77: true }
230
+ end
231
+
232
+ clear_cache(h)
233
+ end
234
+
235
+ # @see Bytestream#rom=
236
+ def rom=(p_rom)
237
+ @rom = p_rom
238
+ justify
239
+ @rom
240
+ end
241
+
242
+ # Converts the internal representation to a
243
+ # {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
244
+ # object using the default palette or the provided one (and failing if
245
+ # neither is present.)
246
+ #
247
+ # @param [Palette] pal
248
+ # @return [Canvas] a
249
+ # {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
250
+ # object.
251
+ # @see #palette=
252
+ def to_canvas(pal = @palette)
253
+ ChunkyPNG::Canvas.from_rgb_stream(
254
+ @width,
255
+ present.length / @width,
256
+ to_rgb(pal)
257
+ )
258
+ end
259
+
260
+ # Saves the internal representation to a PNG file (appending the file
261
+ # extension if absent) using the default palette or the provided one (and
262
+ # failing if neither is present.)
263
+ #
264
+ # @param [String] file the file to save to. A +.png+ extension is optional.
265
+ # @param [Palette] pal
266
+ # @see #palette=
267
+ def to_png(file, dir = '', pal = @palette)
268
+ dir << '/' unless dir.empty? || dir.end_with?('/')
269
+ FileUtils.mkpath(dir) unless Dir.exist?(dir)
270
+
271
+ path = dir << file
272
+ path << '.png' unless path.downcase.end_with?('.png')
273
+
274
+ to_canvas(pal).save(path)
275
+ end
276
+
277
+ alias_method :to_file, :to_png
278
+
279
+ # Outputs the sprite as ASCII art with each pixel represented by the
280
+ # hexadecimal value of its palette index.
281
+ #
282
+ # @param [Integer] width_multiplier each pixel will be repeated this
283
+ # many times horizontally. Defaults to 2 for better proportions
284
+ # with typical monospace fonts.
285
+ # @return [String]
286
+ # @see #palette=
287
+ def to_ascii(width_multiplier = 2)
288
+ output = ''
289
+ present.each_index do |i|
290
+ output << present[i] * width_multiplier
291
+ output << "\n" if (i + 1) % @width == 0
292
+ end
293
+ output
294
+ end
295
+
296
+ # A blurb showing the sprite dimensions.
297
+ #
298
+ # @return [String]
299
+ def to_s
300
+ "Sprite (#{ @width }x#{ present.length / @width })"
301
+ end
302
+
303
+ private
304
+
305
+ # @see {Bytestream#load_settings}
306
+ def load_settings(h)
307
+ @width = h.fetch(:width, @width)
308
+ @palette = h.fetch(:palette, @palette)
309
+ super
310
+ end
311
+ end
312
+ end