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,112 @@
|
|
1
|
+
module Sabrina
|
2
|
+
class Bytestream
|
3
|
+
# Methods that allow {Bytestream} to output the raw byte data
|
4
|
+
# or export it to various formats. Subclasses should override {#to_bytes}
|
5
|
+
# to allow generation from an internal representation if present,
|
6
|
+
# and add to the list of output formats if necessary.
|
7
|
+
module ByteOutput
|
8
|
+
# Outputs a raw byte string. ROM and either table and index (recommended)
|
9
|
+
# or offset should have been specified, otherwise the return string will
|
10
|
+
# be empty. This method is relied on for write and save operations.
|
11
|
+
#
|
12
|
+
# This method uses an internal cache. The cache should be wiped
|
13
|
+
# automatically on changes to ROM or internal data, otherwise it can
|
14
|
+
# be wiped manually with {RomOperations#clear_cache}.
|
15
|
+
#
|
16
|
+
# Subclasses should define {#generate_bytes} to create the bytestream
|
17
|
+
# from the internal representation instead and only read from the
|
18
|
+
# ROM if the internal representation is absent.
|
19
|
+
#
|
20
|
+
# @return [String]
|
21
|
+
def to_bytes
|
22
|
+
return @bytes_cache if @bytes_cache
|
23
|
+
return @bytes_cache = generate_bytes if @representation
|
24
|
+
|
25
|
+
l_offset = offset
|
26
|
+
if @rom && offset
|
27
|
+
if @lz77
|
28
|
+
data = @rom.read_lz77(l_offset)
|
29
|
+
@length_cache = data[:original_length]
|
30
|
+
@lz77_cache = data[:original_stream]
|
31
|
+
return @bytes_cache = data[:stream]
|
32
|
+
end
|
33
|
+
return @bytes_cache = @rom.read_string(l_offset) if @is_gba_string
|
34
|
+
return @bytes_cache = @rom.read(l_offset, @length) if @length
|
35
|
+
end
|
36
|
+
|
37
|
+
return ''
|
38
|
+
end
|
39
|
+
|
40
|
+
# Subclasses should override this to return a string of bytes generated
|
41
|
+
# from the representation.
|
42
|
+
def generate_bytes
|
43
|
+
@bytes_cache = present
|
44
|
+
end
|
45
|
+
|
46
|
+
# Subclasses should override this to update the representation from
|
47
|
+
# byte data.
|
48
|
+
def present
|
49
|
+
@representation ||= to_bytes
|
50
|
+
end
|
51
|
+
|
52
|
+
# Same as {#to_bytes}.
|
53
|
+
def to_b
|
54
|
+
to_bytes
|
55
|
+
end
|
56
|
+
|
57
|
+
# Returns a hexadecimal representation of the byte data, optionally
|
58
|
+
# grouping it into quartets if passed +true+ as a parameter.
|
59
|
+
#
|
60
|
+
# @return [String]
|
61
|
+
def to_hex(pretty = false)
|
62
|
+
h = to_bytes.each_byte.to_a.map { |x| format('%02x', x) }.join('')
|
63
|
+
return h unless pretty
|
64
|
+
|
65
|
+
h.scan(/......../).map { |x| x.scan(/../).join(':') }.join(' ')
|
66
|
+
end
|
67
|
+
|
68
|
+
# Same as {#to_hex}, but reverses the bytes before conversion.
|
69
|
+
#
|
70
|
+
# @return [String]
|
71
|
+
def to_hex_reverse
|
72
|
+
to_bytes.each_byte.to_a.map { |x| format('%02x', x) }.reverse.join('')
|
73
|
+
end
|
74
|
+
|
75
|
+
# Returns the byte data converted to a base-16 integer.
|
76
|
+
# @return [Integer]
|
77
|
+
def to_i
|
78
|
+
to_hex.hex
|
79
|
+
end
|
80
|
+
|
81
|
+
# Outputs the byte data as a GBA-compatible {Lz77}-compressed
|
82
|
+
# stream, raising an error when the data is empty.
|
83
|
+
# {RomOperations#write_to_rom} relies on this
|
84
|
+
# method when :lz77 is set to true.
|
85
|
+
#
|
86
|
+
# This method uses an internal cache. The cache should be wiped
|
87
|
+
# automatically on changes to ROM or internal data, otherwise it can
|
88
|
+
# be wiped manually with {RomOperations#clear_cache}.
|
89
|
+
#
|
90
|
+
# Subclasses should clear the internal cache by calling
|
91
|
+
# {RomOperations#clear_cache} whenever the internal
|
92
|
+
# representation has changed.
|
93
|
+
# @return [String]
|
94
|
+
def to_lz77
|
95
|
+
return @lz77_cache if @lz77_cache
|
96
|
+
b = to_bytes
|
97
|
+
fail 'Cannot compress empty data.' if b.empty?
|
98
|
+
@lz77_cache = Lz77.compress(b)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Returns the output of {#to_hex}.
|
102
|
+
#
|
103
|
+
# Subclasses should override this to provide a concise textual
|
104
|
+
# representation of the internal data.
|
105
|
+
#
|
106
|
+
# @return [String]
|
107
|
+
def to_s
|
108
|
+
"#{to_hex(true)}"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
module Sabrina
|
2
|
+
class Bytestream
|
3
|
+
# Methods related to writing and retrieving various ROM data.
|
4
|
+
module RomOperations
|
5
|
+
# @todo Return key +:original_length+ for {Lz77.uncompress} is currently
|
6
|
+
# unreliable. Wipe and overwrite in place for table data is disabled.
|
7
|
+
#
|
8
|
+
# Writes the byte data to the ROM, trying to use first the
|
9
|
+
# table index and then the offset (and failing if neither is
|
10
|
+
# known).
|
11
|
+
#
|
12
|
+
# If the table index is known, the data will be written to an
|
13
|
+
# available free space on the ROM and the table will be updated.
|
14
|
+
#
|
15
|
+
# If the table data is unknown, the data will be written in place
|
16
|
+
# at the offset.
|
17
|
+
#
|
18
|
+
# This method will call {#reload_from_rom} and update and return
|
19
|
+
# the {#last_write} array.
|
20
|
+
#
|
21
|
+
# @return [Array] see {#last_write}.
|
22
|
+
def write_to_rom
|
23
|
+
old_length = calculate_length
|
24
|
+
b = (@lz77 ? to_lz77 : to_bytes)
|
25
|
+
new_length = b.length
|
26
|
+
old_offset = offset
|
27
|
+
|
28
|
+
unless @rom && old_offset
|
29
|
+
fail 'Rom and offset or table/index must be set before writing.'
|
30
|
+
end
|
31
|
+
fail 'Byte string is empty. Aborting.' if b.empty?
|
32
|
+
fail "Byte string too long #{new_length}. Aborting." if new_length > 10_000
|
33
|
+
|
34
|
+
@last_write = []
|
35
|
+
|
36
|
+
new_offset =
|
37
|
+
if !@pointer_mode || !(@table && @index)
|
38
|
+
@last_write << 'Bytestream#repoint: Overwriting in place.' \
|
39
|
+
" (#{old_offset})"
|
40
|
+
old_offset
|
41
|
+
# Wipe and overwrite disabled due to
|
42
|
+
# Lz77.uncompress[:original_length] inaccuracy.
|
43
|
+
#
|
44
|
+
# elsif new_length <= old_length
|
45
|
+
# @last_write << 'Repoint: New length less than or equal to old' \
|
46
|
+
# 'length, overwriting in place.'
|
47
|
+
# @last_write << @rom.wipe(old_offset, old_length, true)
|
48
|
+
# old_offset
|
49
|
+
else
|
50
|
+
o = repoint
|
51
|
+
@last_write << 'Bytestream#repoint: Wiping disabled in this' \
|
52
|
+
"version. Leaving #{old_length} bytes at #{old_offset}" \
|
53
|
+
" (#{ format('%06X', old_offset) }) in #{@rom} intact."
|
54
|
+
@last_write << "Bytestream#repoint: Repointed to #{o}" \
|
55
|
+
" (#{ format('%06X', o) })."
|
56
|
+
# @last_write << @rom.wipe(old_offset, old_length)
|
57
|
+
o
|
58
|
+
end
|
59
|
+
|
60
|
+
@last_write << @rom.write(new_offset, b)
|
61
|
+
clear_cache(lz77: false)
|
62
|
+
|
63
|
+
@last_write
|
64
|
+
end
|
65
|
+
|
66
|
+
# Wipes the internal cache AND the representation so that the data
|
67
|
+
# may be synced from ROM if present.
|
68
|
+
#
|
69
|
+
# @return [self]
|
70
|
+
def reload_from_rom
|
71
|
+
@representation = nil
|
72
|
+
clear_cache
|
73
|
+
present
|
74
|
+
self
|
75
|
+
end
|
76
|
+
|
77
|
+
# Wipes the internal cache so that the data may be reloaded from ROM if
|
78
|
+
# present.
|
79
|
+
#
|
80
|
+
# Subclasses should call this whenever the internal representation of
|
81
|
+
# data is changed, and define {ByteOutput#generate_bytes} to
|
82
|
+
# regenerate the byte data from the internal representation.
|
83
|
+
#
|
84
|
+
# @param [Hash] h
|
85
|
+
# @option h [Boolean] :lz77 whether to clear the internal representation
|
86
|
+
# as well (defaults to +true+).
|
87
|
+
# @return [self]
|
88
|
+
def clear_cache(h = { lz77: true })
|
89
|
+
@lz77_cache = nil if h.fetch(:lz77, true)
|
90
|
+
@length_cache = nil if h.fetch(:lz77, true) && !@lz77
|
91
|
+
@bytes_cache = nil
|
92
|
+
self
|
93
|
+
end
|
94
|
+
|
95
|
+
# @todo Return key +:original_length+ for {Lz77.uncompress} is currently
|
96
|
+
# unreliable. Wipe and overwrite in place for table data is disabled.
|
97
|
+
#
|
98
|
+
# Returns the length of the {Lz77} data stored on the ROM if +:lz77+
|
99
|
+
# mode is on and a table of offset is provided. Otherwise, returns
|
100
|
+
# the length of the byte data.
|
101
|
+
#
|
102
|
+
# @return [Integer]
|
103
|
+
def calculate_length
|
104
|
+
return @length_cache if @length_cache
|
105
|
+
if @lz77 && offset
|
106
|
+
return @length_cache = @rom.read_lz77(offset)[:original_length]
|
107
|
+
end
|
108
|
+
to_bytes.length
|
109
|
+
end
|
110
|
+
|
111
|
+
private
|
112
|
+
|
113
|
+
# Assigns the data to a suitable free space on the ROM and updates
|
114
|
+
# the table accordingly.
|
115
|
+
#
|
116
|
+
# This will clear the internal cache.
|
117
|
+
#
|
118
|
+
# @return [Integer] The offset found.
|
119
|
+
def repoint
|
120
|
+
unless @rom && @table && @index
|
121
|
+
fail 'Rom, table and index must be set before repointing.'
|
122
|
+
end
|
123
|
+
|
124
|
+
l = (@lz77 ? to_lz77.length : to_bytes.length)
|
125
|
+
f_offset = @rom.find_free(l)
|
126
|
+
|
127
|
+
unless f_offset
|
128
|
+
fail "Bytestream#repoint: Could not find #{l} bytes of free data in" \
|
129
|
+
" #{@rom}. Consider using a clean rombase."
|
130
|
+
end
|
131
|
+
|
132
|
+
@rom.write_offset_to_table(@table, @index, f_offset)
|
133
|
+
clear_cache
|
134
|
+
f_offset
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module Sabrina
|
2
|
+
# Implements +attr_children+, an +attr_accessor+-like macro that allows
|
3
|
+
# passing down attribute write calls to an arbitrary array of objects.
|
4
|
+
# Obviously, the child objects should support writing to the specified
|
5
|
+
# attributes.
|
6
|
+
#
|
7
|
+
# Classes should override +#children+ to return a meaningful array of
|
8
|
+
# child objects.
|
9
|
+
module ChildrenManager
|
10
|
+
# Adds a macro for attribute with children update.
|
11
|
+
module AttrChildren
|
12
|
+
private
|
13
|
+
|
14
|
+
# Takes any number of symbols and creates writers that will also
|
15
|
+
# pass the value down to each value in +children+.
|
16
|
+
def attr_writer_children(*args)
|
17
|
+
args.each do |x|
|
18
|
+
x_var = "@#{x}".to_sym
|
19
|
+
x_setter = "#{x}=".to_sym
|
20
|
+
|
21
|
+
lamb = generate_writer_children(x_setter, x_var)
|
22
|
+
|
23
|
+
define_method(x_setter, lamb) unless respond_to?(x_setter)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Takes any number of symbols and creates accessors that will also
|
28
|
+
# pass the value down to each value in +children+.
|
29
|
+
def attr_children(*args)
|
30
|
+
attr_reader(*args)
|
31
|
+
attr_writer_children(*args)
|
32
|
+
end
|
33
|
+
|
34
|
+
def generate_writer_children(name, variable)
|
35
|
+
lambda do |value|
|
36
|
+
children.each do |c|
|
37
|
+
next c.method(name).call(value) if c.respond_to?(name)
|
38
|
+
fail "Child #{c} has no method #{name}, yet attr_children called."
|
39
|
+
end
|
40
|
+
instance_variable_set(variable, value)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
class << self
|
46
|
+
# Magic for adding class methods when included.
|
47
|
+
def included(mod)
|
48
|
+
mod.extend(AttrChildren)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Classes should override this to provide a meaningful array
|
53
|
+
# of children.
|
54
|
+
#
|
55
|
+
# @return [Array]
|
56
|
+
def children
|
57
|
+
instance_variable_defined?(:@children) ? @children : []
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
module Sabrina
|
2
|
+
# This utility module handles the {Sabrina} configuration. You should not
|
3
|
+
# need to deal with it directly, except perhaps for ad-hoc runtime config
|
4
|
+
# loading.
|
5
|
+
#
|
6
|
+
# Each key present in the config can also be called as a module method,
|
7
|
+
# for example: +Sabrina::Config.rom_defaults+.
|
8
|
+
#
|
9
|
+
# Upon the first load of the library, a directory called +.sabrina+ should
|
10
|
+
# have been created in your home directory (a level above +My+ +Documents+ in
|
11
|
+
# Windows XP, or your Cygwin home if you are running Cygwin). This directory
|
12
|
+
# should contain a +sample.json+ config file.
|
13
|
+
#
|
14
|
+
# Any +.json+ files in the directory except +sample.json+ will be
|
15
|
+
# automatically loaded and merged into the default config. Any config file
|
16
|
+
# may contain only some of the necessary keys as long as the key hierarchy
|
17
|
+
# tree is preserved.
|
18
|
+
#
|
19
|
+
# The most obvious use of the above would likely be to supply the library with
|
20
|
+
# easily distributable config files for specific ROMs.
|
21
|
+
#
|
22
|
+
# Keep in mind that user config files will not be auto-updated on library
|
23
|
+
# updates. New versions might break support for old features or add new ones.
|
24
|
+
# If you run into errors, try moving the files away from +.sabrina+ and let
|
25
|
+
# the library generate a new sample config, then look at it to see what has
|
26
|
+
# changed.
|
27
|
+
module Config
|
28
|
+
# The user config directory.
|
29
|
+
USER_CONFIG_DIR = Dir.home + '/.sabrina/'
|
30
|
+
|
31
|
+
# A sample config file to create. This will not actually be loaded and
|
32
|
+
# should not be modified.
|
33
|
+
USER_CONFIG_SAMPLE = 'sample.json'
|
34
|
+
|
35
|
+
class << self
|
36
|
+
# A simple function for recursive merging of nested hashes.
|
37
|
+
#
|
38
|
+
# @param [Hash] h1
|
39
|
+
# @param [Hash] h2
|
40
|
+
# @return [Hash]
|
41
|
+
def deep_merge(h1, h2)
|
42
|
+
h1.merge(h2) do |_key, x, y|
|
43
|
+
x.is_a?(Hash) && y.is_a?(Hash) ? deep_merge(x, y) : y
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Loads a hash into the internal config.
|
48
|
+
#
|
49
|
+
# @param [Hash] h the option hash to be merged.
|
50
|
+
# @return [0]
|
51
|
+
def load(h)
|
52
|
+
@sabrina_config ||= {}
|
53
|
+
@sabrina_config = deep_merge(@sabrina_config, h)
|
54
|
+
|
55
|
+
@sabrina_config.each_key do |key|
|
56
|
+
m = key.downcase.to_sym
|
57
|
+
next if respond_to?(m)
|
58
|
+
define_singleton_method(m) { @sabrina_config[key] }
|
59
|
+
end
|
60
|
+
|
61
|
+
0
|
62
|
+
end
|
63
|
+
|
64
|
+
# Creates {USER_CONFIG_DIR}, and {USER_CONFIG_SAMPLE} if the directory
|
65
|
+
# is empty.
|
66
|
+
#
|
67
|
+
# @return [0]
|
68
|
+
def create_user_config
|
69
|
+
FileUtils.mkpath(USER_CONFIG_DIR) unless Dir.exist?(USER_CONFIG_DIR)
|
70
|
+
|
71
|
+
c = @sabrina_config
|
72
|
+
c.delete_if { |key, _val| key.to_s.start_with?('charmap') }
|
73
|
+
|
74
|
+
f = File.new(USER_CONFIG_DIR + USER_CONFIG_SAMPLE, 'w+b')
|
75
|
+
f.write(JSON.pretty_generate(c), symbolize_names: true)
|
76
|
+
f.close
|
77
|
+
0
|
78
|
+
end
|
79
|
+
|
80
|
+
# Loads all .json files from {USER_CONFIG_DIR}, exempting
|
81
|
+
# {USER_CONFIG_SAMPLE}.
|
82
|
+
#
|
83
|
+
# @return [0]
|
84
|
+
def load_user_config
|
85
|
+
files = Dir[USER_CONFIG_DIR + '*.json']
|
86
|
+
|
87
|
+
# create_user_config if files.empty?
|
88
|
+
|
89
|
+
files.each do |x|
|
90
|
+
next if x.end_with?(USER_CONFIG_SAMPLE)
|
91
|
+
load(JSON.parse(File.read(x)))
|
92
|
+
end
|
93
|
+
0
|
94
|
+
end
|
95
|
+
|
96
|
+
# Returns a hash of all config keys for a ROM type identified by the
|
97
|
+
# 4-byte +id+.
|
98
|
+
#
|
99
|
+
# @param [String] id
|
100
|
+
# @return [Hash]
|
101
|
+
def rom_params(id)
|
102
|
+
defs = @sabrina_config[:rom_defaults]
|
103
|
+
|
104
|
+
params = @sabrina_config[:rom_data].fetch(id.to_sym) do
|
105
|
+
fail "Unsupported ROM type: \'#{id}\'."
|
106
|
+
end
|
107
|
+
|
108
|
+
defs.merge(params)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
Sabrina::Config.load(
|
2
|
+
charmap_in: {
|
3
|
+
' ' => '00',
|
4
|
+
'<' => '85',
|
5
|
+
'>' => '86',
|
6
|
+
'0' => 'A1',
|
7
|
+
'1' => 'A2',
|
8
|
+
'2' => 'A3',
|
9
|
+
'3' => 'A4',
|
10
|
+
'4' => 'A5',
|
11
|
+
'5' => 'A6',
|
12
|
+
'6' => 'A7',
|
13
|
+
'7' => 'A8',
|
14
|
+
'8' => 'A9',
|
15
|
+
'9' => 'AA',
|
16
|
+
'!' => 'AB',
|
17
|
+
'?' => 'B6',
|
18
|
+
'.' => 'AD',
|
19
|
+
'-' => 'AE',
|
20
|
+
"\'" => 'B4',
|
21
|
+
'♂' => 'B5',
|
22
|
+
'♀' => 'B6',
|
23
|
+
'$' => 'B7',
|
24
|
+
',' => 'B8',
|
25
|
+
'*' => 'B9',
|
26
|
+
'/' => 'BA',
|
27
|
+
'A' => 'BB',
|
28
|
+
'B' => 'BC',
|
29
|
+
'C' => 'BD',
|
30
|
+
'D' => 'BE',
|
31
|
+
'E' => 'BF',
|
32
|
+
'F' => 'C0',
|
33
|
+
'G' => 'C1',
|
34
|
+
'H' => 'C2',
|
35
|
+
'I' => 'C3',
|
36
|
+
'J' => 'C4',
|
37
|
+
'K' => 'C5',
|
38
|
+
'L' => 'C6',
|
39
|
+
'M' => 'C7',
|
40
|
+
'N' => 'C8',
|
41
|
+
'O' => 'C9',
|
42
|
+
'P' => 'CA',
|
43
|
+
'Q' => 'CB',
|
44
|
+
'R' => 'CC',
|
45
|
+
'S' => 'CD',
|
46
|
+
'T' => 'CE',
|
47
|
+
'U' => 'CF',
|
48
|
+
'V' => 'D0',
|
49
|
+
'W' => 'D1',
|
50
|
+
'X' => 'D2',
|
51
|
+
'Y' => 'D3',
|
52
|
+
'Z' => 'D4',
|
53
|
+
'a' => 'D5',
|
54
|
+
'b' => 'D6',
|
55
|
+
'c' => 'D7',
|
56
|
+
'd' => 'D8',
|
57
|
+
'e' => 'D9',
|
58
|
+
'f' => 'DA',
|
59
|
+
'g' => 'DB',
|
60
|
+
'h' => 'DC',
|
61
|
+
'i' => 'DD',
|
62
|
+
'j' => 'DE',
|
63
|
+
'k' => 'DF',
|
64
|
+
'l' => 'E0',
|
65
|
+
'm' => 'E1',
|
66
|
+
'n' => 'E2',
|
67
|
+
'o' => 'E3',
|
68
|
+
'p' => 'E4',
|
69
|
+
'q' => 'E5',
|
70
|
+
'r' => 'E6',
|
71
|
+
's' => 'E7',
|
72
|
+
't' => 'E8',
|
73
|
+
'u' => 'E9',
|
74
|
+
'v' => 'EA',
|
75
|
+
'w' => 'EB',
|
76
|
+
'x' => 'EC',
|
77
|
+
'y' => 'ED',
|
78
|
+
'z' => 'EE',
|
79
|
+
"\n" => 'FE'
|
80
|
+
}
|
81
|
+
)
|