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.
- 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
|
+
)
|