sabrina 0.5.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/lib/sabrina.rb +46 -0
- data/lib/sabrina/bytestream.rb +266 -0
- data/lib/sabrina/bytestream/byte_input.rb +126 -0
- data/lib/sabrina/bytestream/byte_output.rb +112 -0
- data/lib/sabrina/bytestream/rom_operations.rb +138 -0
- data/lib/sabrina/children_manager.rb +60 -0
- data/lib/sabrina/config.rb +112 -0
- data/lib/sabrina/config/charmap_in.rb +81 -0
- data/lib/sabrina/config/charmap_out.rb +144 -0
- data/lib/sabrina/config/charmap_out_special.rb +28 -0
- data/lib/sabrina/config/main.rb +105 -0
- data/lib/sabrina/gba_string.rb +156 -0
- data/lib/sabrina/lz77.rb +161 -0
- data/lib/sabrina/meta.rb +33 -0
- data/lib/sabrina/monster.rb +147 -0
- data/lib/sabrina/palette.rb +216 -0
- data/lib/sabrina/plugin.rb +145 -0
- data/lib/sabrina/plugin/load.rb +43 -0
- data/lib/sabrina/plugin/register.rb +32 -0
- data/lib/sabrina/plugins/spritesheet.rb +196 -0
- data/lib/sabrina/plugins/stats.rb +257 -0
- data/lib/sabrina/rom.rb +302 -0
- data/lib/sabrina/sprite.rb +312 -0
- metadata +113 -0
@@ -0,0 +1,43 @@
|
|
1
|
+
module Sabrina
|
2
|
+
class Plugin
|
3
|
+
# Allows to load plugins. Add +load_plugins+ at the end of your class's
|
4
|
+
# +initialize+ call.
|
5
|
+
module Load
|
6
|
+
class << self
|
7
|
+
# Exposes +#append_features+.
|
8
|
+
def include_in(obj)
|
9
|
+
append_features(obj) unless obj.include?(self)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :plugins
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
# Generates plugin instances. This should be called from
|
18
|
+
# the target class's init.
|
19
|
+
#
|
20
|
+
# @return [0]
|
21
|
+
def load_plugins
|
22
|
+
self.class.plugins.each do |plugin|
|
23
|
+
n = plugin.short_name
|
24
|
+
|
25
|
+
plugin.features.each do |f|
|
26
|
+
feature_all_sym = f.to_sym
|
27
|
+
feature_this_sym = "#{f}_#{n}"
|
28
|
+
unless respond_to?(feature_all_sym)
|
29
|
+
define_singleton_method(feature_all_sym, plugin.feature_all(f))
|
30
|
+
end
|
31
|
+
unless respond_to?(feature_this_sym)
|
32
|
+
define_singleton_method(feature_this_sym, plugin.feature_this(f, n))
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
define_singleton_method(n, -> { @plugins[n] }) unless respond_to?(n)
|
37
|
+
@plugins[n] = plugin.new(self)
|
38
|
+
end
|
39
|
+
0
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Sabrina
|
2
|
+
class Plugin
|
3
|
+
# Allows to register plugins.
|
4
|
+
module Register
|
5
|
+
# Lists all currently registered plugins.
|
6
|
+
#
|
7
|
+
# @return [Set]
|
8
|
+
def plugins
|
9
|
+
@plugins.to_a
|
10
|
+
end
|
11
|
+
|
12
|
+
# Registers a new plugin for handling a specific subset of monster
|
13
|
+
# data.
|
14
|
+
#
|
15
|
+
# @return [0]
|
16
|
+
# @see Plugin
|
17
|
+
def register_plugin(plugin)
|
18
|
+
@plugins ||= Set.new
|
19
|
+
@plugins << plugin
|
20
|
+
end
|
21
|
+
|
22
|
+
# Lists all currently available features.
|
23
|
+
#
|
24
|
+
# @return [Set]
|
25
|
+
def features
|
26
|
+
s = Set.new
|
27
|
+
@plugins.each { |x| s += x.features }
|
28
|
+
s
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,196 @@
|
|
1
|
+
module Sabrina
|
2
|
+
module Plugins
|
3
|
+
# This {Plugin} provides an abstraction for manipulating all
|
4
|
+
# {Sprite Sprites} and {Palette Palettes} associated with a {Monster}.
|
5
|
+
#
|
6
|
+
# @see Plugin
|
7
|
+
class Spritesheet < Plugin
|
8
|
+
include ChildrenManager
|
9
|
+
|
10
|
+
# @see Plugin::ENHANCES
|
11
|
+
ENHANCES = Monster
|
12
|
+
|
13
|
+
# @see Plugin::PLUGIN_NAME
|
14
|
+
PLUGIN_NAME = 'Spritesheet'
|
15
|
+
|
16
|
+
# @see Plugin::SHORT_NAME
|
17
|
+
SHORT_NAME = 'spritesheet'
|
18
|
+
|
19
|
+
# @see Plugin::FEATURES
|
20
|
+
FEATURES = Set.new [:reread, :write, :save, :load]
|
21
|
+
|
22
|
+
# @!attribute rom
|
23
|
+
# The current working ROM file.
|
24
|
+
# @return [Rom]
|
25
|
+
# @!method rom=(r)
|
26
|
+
# The current working ROM file.
|
27
|
+
# @param [Rom] r
|
28
|
+
# @return [Rom]
|
29
|
+
# @!attribute index
|
30
|
+
# The real index of the monster.
|
31
|
+
# @return [Integer]
|
32
|
+
# @!method index=(i)
|
33
|
+
# The real index of the monster.
|
34
|
+
# @param [Integer] i
|
35
|
+
# @return [Integer]
|
36
|
+
attr_children :rom, :index
|
37
|
+
|
38
|
+
# The palettes used by the sprites.
|
39
|
+
#
|
40
|
+
# @return [Array]
|
41
|
+
attr_reader :palettes
|
42
|
+
|
43
|
+
# The sprites.
|
44
|
+
#
|
45
|
+
# @return [Array]
|
46
|
+
attr_reader :sprites
|
47
|
+
|
48
|
+
# Generates a new Spritesheet object.
|
49
|
+
#
|
50
|
+
# @return [Spritesheet]
|
51
|
+
def initialize(monster)
|
52
|
+
@monster = monster
|
53
|
+
@rom = monster.rom
|
54
|
+
@index = monster.index
|
55
|
+
|
56
|
+
@palettes = [
|
57
|
+
Palette.from_table(@monster.rom, :palette_table, @monster.index),
|
58
|
+
Palette.from_table(@monster.rom, :shinypal_table, @monster.index)
|
59
|
+
]
|
60
|
+
|
61
|
+
@sprites = [
|
62
|
+
Sprite.from_table(@monster.rom, :front_table, @monster.index),
|
63
|
+
Sprite.from_table(@monster.rom, :back_table, @monster.index)
|
64
|
+
]
|
65
|
+
end
|
66
|
+
|
67
|
+
# @return [Array]
|
68
|
+
# @see ChildrenManager
|
69
|
+
def children
|
70
|
+
@sprites + @palettes
|
71
|
+
end
|
72
|
+
|
73
|
+
# Justify sprites to the target ROM count as per ROM {Config}, assuming
|
74
|
+
# 64x64 frame size.
|
75
|
+
def justify
|
76
|
+
@sprites.map(&:justify)
|
77
|
+
end
|
78
|
+
|
79
|
+
# Load data from a file.
|
80
|
+
#
|
81
|
+
# @return [Array] Any return data from child methods.
|
82
|
+
def load(file = @monster.filename, dir = @monster.work_dir, *_args)
|
83
|
+
path = get_path(file, dir)
|
84
|
+
a = split_canvas(ChunkyPNG::Canvas.from_file(path))
|
85
|
+
h = { rom: @rom, index: @index }
|
86
|
+
|
87
|
+
normal_rgb = a[0].to_rgb_stream + a[2].to_rgb_stream
|
88
|
+
shiny_rgb = a[1].to_rgb_stream + a[3].to_rgb_stream
|
89
|
+
|
90
|
+
@palettes = Palette.create_synced_palettes(
|
91
|
+
normal_rgb,
|
92
|
+
shiny_rgb,
|
93
|
+
h.merge(table: :palette_table),
|
94
|
+
h.merge(table: :shinypal_table)
|
95
|
+
)
|
96
|
+
|
97
|
+
frames = @rom.special_frames.fetch(@index, @rom.frames)
|
98
|
+
front = crop_canvas(a[0], frames.first * 64)
|
99
|
+
back = crop_canvas(a[2], frames.last * 64)
|
100
|
+
|
101
|
+
@sprites = [
|
102
|
+
Sprite.from_canvas(
|
103
|
+
front,
|
104
|
+
@palettes.first,
|
105
|
+
h.merge(table: :front_table)
|
106
|
+
),
|
107
|
+
Sprite.from_canvas(
|
108
|
+
back,
|
109
|
+
@palettes.first,
|
110
|
+
h.merge(table: :back_table)
|
111
|
+
)
|
112
|
+
]
|
113
|
+
|
114
|
+
justify
|
115
|
+
children
|
116
|
+
end
|
117
|
+
|
118
|
+
# Save data to a file.
|
119
|
+
#
|
120
|
+
# @return [Array] Any return data from child methods.
|
121
|
+
def save(file = @monster.filename, dir = @monster.work_dir, *_args)
|
122
|
+
a = []
|
123
|
+
@sprites.product(@palettes).each do |x|
|
124
|
+
a << x.first.to_canvas(x.last)
|
125
|
+
end
|
126
|
+
|
127
|
+
path = get_path(file, dir, mkdir: true)
|
128
|
+
|
129
|
+
combine_canvas(a).save(path)
|
130
|
+
end
|
131
|
+
|
132
|
+
private
|
133
|
+
|
134
|
+
# Crops the canvas to the specified height if taller.
|
135
|
+
def crop_canvas(c, h)
|
136
|
+
return c.crop(0, 0, c.width, h) if c.height > h
|
137
|
+
c
|
138
|
+
end
|
139
|
+
|
140
|
+
# Horizontally combine a one-dimensional array of
|
141
|
+
# {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
|
142
|
+
# objects into a single, wide canvas.
|
143
|
+
#
|
144
|
+
# @param [Array] a an array of Canvas objects.
|
145
|
+
# @return [Canvas] the combined canvas.
|
146
|
+
def combine_canvas(a)
|
147
|
+
out_c = ChunkyPNG::Canvas.new(a.first.width * a.length, a.first.height)
|
148
|
+
a.each_index { |i| out_c.replace!(a[i], a.first.width * i) }
|
149
|
+
out_c
|
150
|
+
end
|
151
|
+
|
152
|
+
# Vertically combine a one-dimensional array of
|
153
|
+
# {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
|
154
|
+
# objects into a single, tall canvas.
|
155
|
+
#
|
156
|
+
# @param [Array] a an array of Canvas objects.
|
157
|
+
# @return [Canvas] the combined canvas.
|
158
|
+
def combine_canvas_vert(a)
|
159
|
+
out_c = ChunkyPNG::Canvas.new(a.first.width, a.first.height * a.length)
|
160
|
+
a.each_index { |i| out_c.replace!(a[i], 0, a.first.height * i) }
|
161
|
+
out_c
|
162
|
+
end
|
163
|
+
|
164
|
+
# Split a {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
|
165
|
+
# into an array of canvases horizontally.
|
166
|
+
#
|
167
|
+
# @param [Canvas] c
|
168
|
+
# @param [Integer] w tile width.
|
169
|
+
# @return [Array] an array of Canvas objects.
|
170
|
+
def split_canvas(c, w = 64)
|
171
|
+
out_a = []
|
172
|
+
(c.width / w).times { |i| out_a << c.crop(i * w, 0, w, c.height) }
|
173
|
+
out_a
|
174
|
+
end
|
175
|
+
|
176
|
+
# Concatenate the file name and directory into a full path, optionally
|
177
|
+
# creating the directory if it doesn't exist.
|
178
|
+
#
|
179
|
+
# @param [String] file
|
180
|
+
# @param [String] dir
|
181
|
+
# @param [Hash] h
|
182
|
+
# @option h [Boolean] :mkdir If +true+, create +dir+ if it doesn't
|
183
|
+
# exist.
|
184
|
+
# @return [String]
|
185
|
+
def get_path(file, dir, h = {})
|
186
|
+
f, d = file.dup, dir.dup
|
187
|
+
d << '/' unless d.empty? || d.end_with?('/')
|
188
|
+
|
189
|
+
FileUtils.mkpath(d) if h.fetch(:mkdir, false) && !Dir.exist?(d)
|
190
|
+
|
191
|
+
path = d << f
|
192
|
+
path << '.png' unless path.downcase.end_with?('.png')
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
@@ -0,0 +1,257 @@
|
|
1
|
+
module Sabrina
|
2
|
+
module Plugins
|
3
|
+
# @todo Finish this class.
|
4
|
+
# This {Plugin} aids in manipulating the basic stats of any {Monster}.
|
5
|
+
#
|
6
|
+
# @see Plugin
|
7
|
+
class Stats < Plugin
|
8
|
+
# @see Plugin::ENHANCES
|
9
|
+
ENHANCES = Monster
|
10
|
+
|
11
|
+
# @see Plugin::PLUGIN_NAME
|
12
|
+
PLUGIN_NAME = 'Stats'
|
13
|
+
|
14
|
+
# @see Plugin::SHORT_NAME
|
15
|
+
SHORT_NAME = 'stats'
|
16
|
+
|
17
|
+
# @see Plugin::FEATURES
|
18
|
+
FEATURES = Set.new [:reread, :write]
|
19
|
+
|
20
|
+
# Describes the order in which various stats appear in the byte data.
|
21
|
+
# All of these will also be converted into attributes on runtime and
|
22
|
+
# can be modified directly.
|
23
|
+
STRUCTURE = [
|
24
|
+
:hp, :attack, :defense, :speed, :sp_atk, :sp_def, :type_1, :type_2,
|
25
|
+
:catch_rate, :exp_yield, :ev_yield, :item_1, :item_2, :gender,
|
26
|
+
:egg_cycles, :friendship, :level_curve, :egg_group_1, :egg_group_2,
|
27
|
+
:ability_1, :ability_2, :safari_rate, :color_flip
|
28
|
+
]
|
29
|
+
|
30
|
+
# Code names for level up types.
|
31
|
+
LEVEL_CURVES = [
|
32
|
+
'Medium-Fast', 'Erratic', 'Fluctuating', 'Medium-Slow', 'Fast', 'Slow'
|
33
|
+
]
|
34
|
+
|
35
|
+
# Code names for egg groups.
|
36
|
+
EGG_GROUPS = [
|
37
|
+
'None', 'Monster', 'Water 1', 'Bug', 'Flying', 'Field', 'Fairy',
|
38
|
+
'Grass', 'Human-Like', 'Water 3', 'Mineral', 'Amorphous', 'Water 2',
|
39
|
+
'Ditto', 'Dragon', 'Undiscovered'
|
40
|
+
]
|
41
|
+
|
42
|
+
# Where to pull descriptive names from if applicable, these can be arrays
|
43
|
+
# or ROM tables.
|
44
|
+
NAMES = {
|
45
|
+
type_1: :type_table,
|
46
|
+
type_2: :type_table,
|
47
|
+
item_1: :item_table,
|
48
|
+
item_2: :item_table,
|
49
|
+
level_curve: LEVEL_CURVES,
|
50
|
+
egg_group_1: EGG_GROUPS,
|
51
|
+
egg_group_2: EGG_GROUPS,
|
52
|
+
ability_1: :ability_table,
|
53
|
+
ability_2: :ability_table
|
54
|
+
}
|
55
|
+
|
56
|
+
# @!attribute [rw] rom
|
57
|
+
# The current working ROM file.
|
58
|
+
# @return [Rom]
|
59
|
+
# @!attribute [rw] index
|
60
|
+
# The real index of the monster.
|
61
|
+
# @return [Integer]
|
62
|
+
attr_accessor(:rom, :index, *STRUCTURE)
|
63
|
+
|
64
|
+
# Generates a new Stats object.
|
65
|
+
#
|
66
|
+
# @return [Stats]
|
67
|
+
def initialize(monster)
|
68
|
+
@monster = monster
|
69
|
+
@rom = monster.rom
|
70
|
+
@index = monster.index
|
71
|
+
|
72
|
+
@stream = Bytestream.from_table(
|
73
|
+
@rom,
|
74
|
+
:stats_table,
|
75
|
+
@monster.index,
|
76
|
+
@rom.stats_length
|
77
|
+
)
|
78
|
+
|
79
|
+
parse_stats
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns the base stats total.
|
83
|
+
#
|
84
|
+
# @return [Integer]
|
85
|
+
def total
|
86
|
+
@hp + @attack + @defense + @speed + @sp_atk + @sp_def
|
87
|
+
end
|
88
|
+
|
89
|
+
# Reloads the data from a ROM, dropping any changes.
|
90
|
+
#
|
91
|
+
# @return [self]
|
92
|
+
def reread
|
93
|
+
parse_stats
|
94
|
+
self
|
95
|
+
end
|
96
|
+
|
97
|
+
# Write data to the ROM.
|
98
|
+
def write
|
99
|
+
stream.write_to_rom
|
100
|
+
end
|
101
|
+
|
102
|
+
# Returns a {Bytestream} object representing the stats, ready to be
|
103
|
+
# written to the ROM.
|
104
|
+
#
|
105
|
+
# @return [Bytestream]
|
106
|
+
def stream
|
107
|
+
Bytestream.from_bytes(
|
108
|
+
unparse_stats,
|
109
|
+
rom: @rom,
|
110
|
+
table: :stats_table,
|
111
|
+
index: @index
|
112
|
+
)
|
113
|
+
end
|
114
|
+
|
115
|
+
# Returns a hash representation of the stats.
|
116
|
+
#
|
117
|
+
# @return [Hash]
|
118
|
+
def to_h
|
119
|
+
h = {}
|
120
|
+
|
121
|
+
STRUCTURE.each do |entry|
|
122
|
+
h[entry] = instance_variable_get('@' << entry.to_s)
|
123
|
+
end
|
124
|
+
|
125
|
+
{ @index => { stats: h } }
|
126
|
+
end
|
127
|
+
|
128
|
+
# Returns a pretty hexadecimal representation of the stats byte data.
|
129
|
+
#
|
130
|
+
# @return [String]
|
131
|
+
def to_hex
|
132
|
+
stream.to_hex(true)
|
133
|
+
end
|
134
|
+
|
135
|
+
# Returns a pretty JSON representation of the stats.
|
136
|
+
#
|
137
|
+
# @return [String]
|
138
|
+
def to_json
|
139
|
+
JSON.pretty_generate(to_h)
|
140
|
+
end
|
141
|
+
|
142
|
+
# Returns a blurb containing the base stats total.
|
143
|
+
#
|
144
|
+
# @return [String]
|
145
|
+
def to_s
|
146
|
+
"<Stats (#{total})>"
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
# Reads stats data from the ROM.
|
152
|
+
def parse_stats
|
153
|
+
b = @stream.to_bytes.dup
|
154
|
+
|
155
|
+
STRUCTURE.each do |entry|
|
156
|
+
value =
|
157
|
+
case entry
|
158
|
+
when :ev_yield
|
159
|
+
parse_evs(b.slice!(0, 2))
|
160
|
+
when :item_1, :item_2
|
161
|
+
b.slice!(0, 2).unpack('S').first
|
162
|
+
when :color_flip
|
163
|
+
parse_color_flip(b.slice!(0))
|
164
|
+
else
|
165
|
+
b.slice!(0).unpack('C').first
|
166
|
+
end
|
167
|
+
|
168
|
+
pretty_value = prettify_stat(entry, value)
|
169
|
+
instance_variable_set('@' << entry.to_s, pretty_value)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
# Converts stats data to a GBA-compatible byte string.
|
174
|
+
def unparse_stats
|
175
|
+
b = ''
|
176
|
+
STRUCTURE.each do |entry|
|
177
|
+
val = instance_variable_get('@' << entry.to_s)
|
178
|
+
case entry
|
179
|
+
when :ev_yield
|
180
|
+
b << unparse_evs(val)
|
181
|
+
when :item_1, :item_2
|
182
|
+
b << [val.to_i].pack('S')
|
183
|
+
when :color_flip
|
184
|
+
b << unparse_color_flip(val)
|
185
|
+
else
|
186
|
+
b << [val.to_i].pack('C')
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
b.ljust(28, "\x00")
|
191
|
+
end
|
192
|
+
|
193
|
+
# Attempts to annotate a numeric value with useful information.
|
194
|
+
def prettify_stat(entry, value)
|
195
|
+
return "#{value} (#{gender_info(value)})" if entry == :gender
|
196
|
+
|
197
|
+
return value unless NAMES.key?(entry) && value.is_a?(Numeric)
|
198
|
+
|
199
|
+
zero_is_valid = [:type_1, :type_2, :level_curve]
|
200
|
+
return value if value == 0 && !zero_is_valid.include?(entry)
|
201
|
+
|
202
|
+
return "#{value} (#{NAMES[entry][value]})" if NAMES[entry].is_a?(Array)
|
203
|
+
|
204
|
+
"#{value} (#{ @rom.read_string_from_table(NAMES[entry], value) })"
|
205
|
+
end
|
206
|
+
|
207
|
+
# Displays readable info about the gender distribution defined by the
|
208
|
+
# provided gender value.
|
209
|
+
def gender_info(i)
|
210
|
+
return 'Genderless' if i > 254
|
211
|
+
return 'Always Female' if i == 254
|
212
|
+
return 'Always Male' if i == 0
|
213
|
+
"#{(100.0 * i / 255).round}% Female"
|
214
|
+
end
|
215
|
+
|
216
|
+
# Converts a word (two bytes) into a hash of EV yield data.
|
217
|
+
def parse_evs(b)
|
218
|
+
a = b.unpack('b*').first.scan(/../).map { |x| x.reverse.to_i(2) }
|
219
|
+
h = {}
|
220
|
+
|
221
|
+
a.take(6).each_index do |i|
|
222
|
+
next if a[i] < 1
|
223
|
+
h[STRUCTURE[i]] = a[i]
|
224
|
+
end
|
225
|
+
|
226
|
+
h
|
227
|
+
end
|
228
|
+
|
229
|
+
# Converts a hash of EV yield data into a word (two bytes).
|
230
|
+
def unparse_evs(h)
|
231
|
+
a = []
|
232
|
+
|
233
|
+
STRUCTURE.take(6).each do |stat|
|
234
|
+
ev = h.fetch(stat, 0)
|
235
|
+
a << ev.to_s(2).rjust(2, '0').reverse
|
236
|
+
end
|
237
|
+
|
238
|
+
[a.join.ljust(16, '0')].pack 'b*'
|
239
|
+
end
|
240
|
+
|
241
|
+
# Converts a byte into an array of dex color and flip.
|
242
|
+
def parse_color_flip(b)
|
243
|
+
s = b.unpack('C').first.to_s(16).rjust(2, '0')
|
244
|
+
|
245
|
+
[
|
246
|
+
s[1].to_i,
|
247
|
+
s[0] == '8' ? true : false
|
248
|
+
]
|
249
|
+
end
|
250
|
+
|
251
|
+
# Converts an array of dex color and flip into a byte.
|
252
|
+
def unparse_color_flip(a)
|
253
|
+
((a.last ? '8' : '0') << a.first.to_s).hex.chr
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|