sabrina 0.5.5

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,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