pyjmap 1.1.0__py3-none-any.whl
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.
- pyjmap/__init__.py +4 -0
- pyjmap/__main__.py +58 -0
- pyjmap/jmap.py +997 -0
- pyjmap/lookup_donkeykongjunglebeat.txt +81 -0
- pyjmap/lookup_luigismansion.txt +133 -0
- pyjmap/lookup_supermariogalaxy.txt +1470 -0
- pyjmap/lookup_supermariosunshine.txt +3 -0
- pyjmap-1.1.0.dist-info/METADATA +171 -0
- pyjmap-1.1.0.dist-info/RECORD +13 -0
- pyjmap-1.1.0.dist-info/WHEEL +5 -0
- pyjmap-1.1.0.dist-info/entry_points.txt +2 -0
- pyjmap-1.1.0.dist-info/licenses/LICENSE +674 -0
- pyjmap-1.1.0.dist-info/top_level.txt +1 -0
pyjmap/jmap.py
ADDED
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
"""
|
|
2
|
+
High-level implementation of Nintendo's homemade BCSV / JMap container format that provides methods to construct,
|
|
3
|
+
analyze, serialize, deserialize and manipulate JMapInfo data. As the name BCSV suggests, the data is laid out in a
|
|
4
|
+
table-like structure. Each column (JMapField) has a name and binary storage information that describes how individual
|
|
5
|
+
entries (JMapEntry) are to be interpreted. All integral data types are interpreted as signed data due to the lack of
|
|
6
|
+
information about a field's signedness. The reverse-engineered specifications of this format can be accessed on the
|
|
7
|
+
Luma's Workshop wiki: https://luma.aurumsmods.com/wiki/BCSV_(File_format)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
Different games use different hash algorithms, string encodings and endianness:
|
|
11
|
+
|
|
12
|
+
Game Hash Algorithm Endianness Encoding
|
|
13
|
+
--------------------------------------------------------------------------
|
|
14
|
+
Luigi's Mansion (GameCube) Old big shift_jisx0213
|
|
15
|
+
Luigi's Mansion (3DS) Old little utf-8(?)
|
|
16
|
+
Super Mario Sunshine Old big shift_jisx0213
|
|
17
|
+
Donkey Kong Jungle Beat New (JGadget) big shift_jisx0213
|
|
18
|
+
Super Mario Galaxy (Wii) New (JGadget) big shift_jisx0213
|
|
19
|
+
Super Mario Galaxy (Switch) New (JGadget) little utf-8
|
|
20
|
+
Super Mario Galaxy 2 New (JGadget) big shift_jisx0213
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"JMapException", "calc_old_hash", "calc_jgadget_hash", "JMapHashTable", "SuperMarioGalaxyHashTable",
|
|
25
|
+
"JungleBeatHashTable", "SuperMarioSunshineHashTable", "LuigisMansionHashTable", "JMapFieldType", "JMapField",
|
|
26
|
+
"JMapEntry", "JMapInfo", "from_buffer", "pack_buffer", "from_file", "write_file", "from_csv", "dump_csv"
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
import csv
|
|
30
|
+
import enum
|
|
31
|
+
import os
|
|
32
|
+
import struct
|
|
33
|
+
import warnings
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ----------------------------------------------------------------------------------------------------------------------
|
|
37
|
+
# Exception for JMap-related actions
|
|
38
|
+
# ----------------------------------------------------------------------------------------------------------------------
|
|
39
|
+
class JMapException(Exception):
|
|
40
|
+
"""
|
|
41
|
+
Signals that an error occurred during any JMap-related action.
|
|
42
|
+
"""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ----------------------------------------------------------------------------------------------------------------------
|
|
47
|
+
# Hash lookup table implementations
|
|
48
|
+
# ----------------------------------------------------------------------------------------------------------------------
|
|
49
|
+
def calc_old_hash(field_name: str) -> int:
|
|
50
|
+
"""
|
|
51
|
+
The old hash function that is used in Luigi's Mansion. The resulting hash is a 32-bit value. The field name is
|
|
52
|
+
expected to be an ASCII-string.
|
|
53
|
+
|
|
54
|
+
:param field_name: the field name to be hashed.
|
|
55
|
+
:returns: the 32-bit hash value.
|
|
56
|
+
"""
|
|
57
|
+
field_hash = 0
|
|
58
|
+
|
|
59
|
+
for ch in field_name.encode("ascii"):
|
|
60
|
+
ch |= ~0xFF if ch & 0x80 else 0 # Signed char
|
|
61
|
+
field_hash = (((field_hash << 8) & 0xFFFFFFFF) + ch) % 33554393
|
|
62
|
+
|
|
63
|
+
return field_hash
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def calc_jgadget_hash(field_name: str) -> int:
|
|
67
|
+
"""
|
|
68
|
+
The JGadget hash function that is used in the Super Mario Galaxy games and Donkey Kong Jungle Beat. The resulting
|
|
69
|
+
hash is a 32-bit value. The field name is expected to be an ASCII-string.
|
|
70
|
+
|
|
71
|
+
:param field_name: the field name to be hashed.
|
|
72
|
+
:returns: the 32-bit hash value.
|
|
73
|
+
"""
|
|
74
|
+
field_hash = 0
|
|
75
|
+
|
|
76
|
+
for ch in field_name.encode("ascii"):
|
|
77
|
+
ch |= ~0xFF if ch & 0x80 else 0 # Signed char
|
|
78
|
+
field_hash = (field_hash * 31) + ch
|
|
79
|
+
|
|
80
|
+
return field_hash & 0xFFFFFFFF
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class JMapHashTable:
|
|
84
|
+
"""
|
|
85
|
+
A hash lookup table implementation for known field names. This stores a string for a given hash. The actual hashing
|
|
86
|
+
algorithm differs between the games and needs to be specified first. A file that lists known field names can be used
|
|
87
|
+
to initialize the hash table.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, hash_func, lookup_file_path):
|
|
91
|
+
"""
|
|
92
|
+
Constructs a new hash table using the specified hash function and list file of known field names. Each line
|
|
93
|
+
corresponds to a field that should be included. Lines that start with a '#' will be ignored and are treated as
|
|
94
|
+
comments. This raises a FileNotFoundError if the lookup file does not exist.
|
|
95
|
+
|
|
96
|
+
:param hash_func: the hashing algorithm to be used.
|
|
97
|
+
:param lookup_file_path: the path to the file containing the known field names.
|
|
98
|
+
:raises FileNotFoundError: when the lookup file cannot be found.
|
|
99
|
+
"""
|
|
100
|
+
self._hash_func_ = hash_func
|
|
101
|
+
self._lookup_ = dict()
|
|
102
|
+
|
|
103
|
+
if os.path.exists(lookup_file_path):
|
|
104
|
+
for field in open(lookup_file_path, "r", encoding="utf-8").readlines():
|
|
105
|
+
# Comment line?
|
|
106
|
+
if field.startswith("#"):
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
field = field.strip("\r\n")
|
|
110
|
+
self._lookup_[self.calc(field)] = field
|
|
111
|
+
else:
|
|
112
|
+
raise FileNotFoundError(f"Lookup names file \"{lookup_file_path}\" cannot be found!")
|
|
113
|
+
|
|
114
|
+
def calc(self, field_name: str) -> int:
|
|
115
|
+
"""
|
|
116
|
+
Calculates the hash value over a given field name. The resulting hash is a 32-bit value.
|
|
117
|
+
|
|
118
|
+
:param field_name: the field name to be hashed.
|
|
119
|
+
:returns: the 32-bit hash value.
|
|
120
|
+
"""
|
|
121
|
+
return self._hash_func_(field_name)
|
|
122
|
+
|
|
123
|
+
def find(self, field_hash: int) -> str:
|
|
124
|
+
"""
|
|
125
|
+
Attempts to retrieve a valid field name for the specified hash value. If the hash is not found in the lookup
|
|
126
|
+
table, it returns a hexadecimal representation of the hash. For example, if the hash value 0xDEADBEEF could not
|
|
127
|
+
be found, this returns "[DEADBEEF]".
|
|
128
|
+
|
|
129
|
+
:param field_hash: the hash to find the field name for.
|
|
130
|
+
:returns: the field name if it exists, otherwise a hexadecimal string representation.
|
|
131
|
+
"""
|
|
132
|
+
if field_hash in self._lookup_:
|
|
133
|
+
return self._lookup_[field_hash]
|
|
134
|
+
else:
|
|
135
|
+
return f"[{field_hash:08X}]"
|
|
136
|
+
|
|
137
|
+
def add(self, field_name: str) -> int:
|
|
138
|
+
"""
|
|
139
|
+
Calculates the hash value for the specified field name tries to add the hash-name pair to the hash lookup table
|
|
140
|
+
if it does not exist already. The hash will be returned as well.
|
|
141
|
+
|
|
142
|
+
:param field_name: the string to calculate the hash for.
|
|
143
|
+
:returns: the 32-bit hash value.
|
|
144
|
+
"""
|
|
145
|
+
field_hash = self.calc(field_name)
|
|
146
|
+
|
|
147
|
+
if field_hash not in self._lookup_:
|
|
148
|
+
self._lookup_[field_hash] = field_name
|
|
149
|
+
|
|
150
|
+
return field_hash
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class SuperMarioGalaxyHashTable(JMapHashTable):
|
|
154
|
+
"""A hash table implementation for Super Mario Galaxy 1/2."""
|
|
155
|
+
|
|
156
|
+
def __init__(self):
|
|
157
|
+
"""Constructs a new hash lookup table using the known field names from Super Mario Galaxy 1/2."""
|
|
158
|
+
super().__init__(calc_jgadget_hash, os.path.join(os.path.dirname(__file__), "lookup_supermariogalaxy.txt"))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class JungleBeatHashTable(JMapHashTable):
|
|
162
|
+
"""A hash table implementation for Donkey Kong Jungle Beat."""
|
|
163
|
+
|
|
164
|
+
def __init__(self):
|
|
165
|
+
"""Constructs a new hash lookup table using the known field names from Donkey Kong Jungle Beat."""
|
|
166
|
+
super().__init__(calc_jgadget_hash, os.path.join(os.path.dirname(__file__), "lookup_donkeykongjunglebeat.txt"))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class SuperMarioSunshineHashTable(JMapHashTable):
|
|
170
|
+
"""A hash table implementation for Super Mario Sunshine."""
|
|
171
|
+
|
|
172
|
+
def __init__(self):
|
|
173
|
+
"""Constructs a new hash lookup table using the known field names from Super Mario Sunshine."""
|
|
174
|
+
super().__init__(calc_old_hash, os.path.join(os.path.dirname(__file__), "lookup_supermariosunshine.txt"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class LuigisMansionHashTable(JMapHashTable):
|
|
178
|
+
"""A hash table implementation for Luigi's Mansion."""
|
|
179
|
+
|
|
180
|
+
def __init__(self):
|
|
181
|
+
"""Constructs a new hash lookup table using the known field names from Luigi's Mansion."""
|
|
182
|
+
super().__init__(calc_old_hash, os.path.join(os.path.dirname(__file__), "lookup_luigismansion.txt"))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ----------------------------------------------------------------------------------------------------------------------
|
|
186
|
+
# Proper field type declarations
|
|
187
|
+
# ----------------------------------------------------------------------------------------------------------------------
|
|
188
|
+
class JMapFieldType(enum.Enum):
|
|
189
|
+
"""
|
|
190
|
+
Declarations of all data types that are supported by JMapInfo containers. Every type holds information about their
|
|
191
|
+
size in bytes, bitmask storage order, default value and data type.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
def __new__(cls, *args, **kwargs):
|
|
195
|
+
idx = len(cls.__members__)
|
|
196
|
+
obj = object.__new__(cls)
|
|
197
|
+
obj._value_ = idx
|
|
198
|
+
return obj
|
|
199
|
+
|
|
200
|
+
def __init__(self, size: int, mask: int, order: int, data_type, defval):
|
|
201
|
+
self._size_ = size
|
|
202
|
+
self._mask_ = mask
|
|
203
|
+
self._order_ = order
|
|
204
|
+
self._data_type_ = data_type
|
|
205
|
+
self._default_ = defval
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def size(self) -> int:
|
|
209
|
+
"""The data size in bytes."""
|
|
210
|
+
return self._size_
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def mask(self) -> int:
|
|
214
|
+
"""The data mask."""
|
|
215
|
+
return self._mask_
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def order(self) -> int:
|
|
219
|
+
"""The sorting order index."""
|
|
220
|
+
return self._order_
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def data_type(self):
|
|
224
|
+
"""The data type."""
|
|
225
|
+
return self._data_type_
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def default(self):
|
|
229
|
+
"""The default value."""
|
|
230
|
+
return self._default_
|
|
231
|
+
|
|
232
|
+
LONG = 4, 0xFFFFFFFF, 2, int, 0
|
|
233
|
+
STRING = 32, 0x00000000, 0, str, ""
|
|
234
|
+
FLOAT = 4, 0xFFFFFFFF, 1, float, 0.0
|
|
235
|
+
UNSIGNED_LONG = 4, 0xFFFFFFFF, 3, int, 0
|
|
236
|
+
SHORT = 2, 0x0000FFFF, 4, int, 0
|
|
237
|
+
CHAR = 1, 0x000000FF, 5, int, 0
|
|
238
|
+
STRING_OFFSET = 4, 0xFFFFFFFF, 6, str, ""
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ----------------------------------------------------------------------------------------------------------------------
|
|
242
|
+
# JMap field and entry definitions
|
|
243
|
+
# ----------------------------------------------------------------------------------------------------------------------
|
|
244
|
+
class JMapField:
|
|
245
|
+
"""
|
|
246
|
+
Represents the information for a field (or column) of a JMapInfo container. This specifies the name, hash, type,
|
|
247
|
+
mask, shift amount and offset for an individual field. Actual fields should be created by the JMapInfo instance
|
|
248
|
+
itself. All entries in a JMapInfo should contain data for every field in the container.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
# Structures for parsing and packing
|
|
252
|
+
__STRUCT_BE__ = struct.Struct(">2IH2b") # Big-endian
|
|
253
|
+
__STRUCT_LE__ = struct.Struct("<2IH2b") # Little-endian
|
|
254
|
+
|
|
255
|
+
def __init__(self, jmap, field_hash: int = 0, field_type: JMapFieldType = None,
|
|
256
|
+
mask: int = 0xFFFFFFFF, shift_amount: int = 0, offset: int = 0, defval=None):
|
|
257
|
+
"""
|
|
258
|
+
Low-level constructor for a new JMapField. The given JMapInfo container will be assigned to the field. This
|
|
259
|
+
should not be called directly. Instead, use JMapInfo's methods for creating new fields.
|
|
260
|
+
|
|
261
|
+
:param jmap: the JMapInfo container that this field belongs to.
|
|
262
|
+
:param field_hash: the field's hash.
|
|
263
|
+
:param field_type: the data type of this field.
|
|
264
|
+
:param mask: the bitmask.
|
|
265
|
+
:param shift_amount: the amount of bits to be shifted.
|
|
266
|
+
:param offset: the data offset into an entry.
|
|
267
|
+
:param defval: the default value for the field.
|
|
268
|
+
"""
|
|
269
|
+
self._jmap_ = jmap
|
|
270
|
+
self._hash_ = field_hash
|
|
271
|
+
self._type_ = field_type
|
|
272
|
+
self.mask = mask
|
|
273
|
+
self.shift = shift_amount
|
|
274
|
+
self._default_ = defval
|
|
275
|
+
self._offset_ = offset
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def jmap(self):
|
|
279
|
+
"""The JMapInfo container that the field belongs to."""
|
|
280
|
+
return self._jmap_
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def name(self) -> str:
|
|
284
|
+
"""The field's name."""
|
|
285
|
+
return self._jmap_.hash_table.find(self._hash_)
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def hash(self) -> int:
|
|
289
|
+
"""The field's hash."""
|
|
290
|
+
return self._hash_
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def type(self) -> JMapFieldType:
|
|
294
|
+
"""The field's data type."""
|
|
295
|
+
return self._type_
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def offset(self) -> int:
|
|
299
|
+
"""The field's offset. -1 indicates that the container will calculate the offset automatically."""
|
|
300
|
+
return self._offset_ if self._jmap_.manual_offsets else -1
|
|
301
|
+
|
|
302
|
+
@offset.setter
|
|
303
|
+
def offset(self, val: int):
|
|
304
|
+
"""Sets the field's offset. Should always be specified for containers with manually assigned field offsets."""
|
|
305
|
+
self._offset_ = val
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def default(self):
|
|
309
|
+
"""The field's default value."""
|
|
310
|
+
return self._default_
|
|
311
|
+
|
|
312
|
+
def __repr__(self):
|
|
313
|
+
return self._jmap_.hash_table.find(self._hash_)
|
|
314
|
+
|
|
315
|
+
def _unpack_(self, data, off: int, is_big_endian: bool):
|
|
316
|
+
strct = self.__STRUCT_BE__ if is_big_endian else self.__STRUCT_LE__
|
|
317
|
+
self._hash_, self.mask, self._offset_, self.shift, raw_type = strct.unpack_from(data, off)
|
|
318
|
+
|
|
319
|
+
if raw_type < 0 or raw_type >= 7: # Invalid or unknown type
|
|
320
|
+
raise JMapException(f"Invalid JMap field type with ID 0x{raw_type:02X} found!")
|
|
321
|
+
|
|
322
|
+
self._type_ = JMapFieldType(raw_type)
|
|
323
|
+
self._default_ = self._type_.default
|
|
324
|
+
|
|
325
|
+
def _pack_(self, data, off: int, is_big_endian: bool):
|
|
326
|
+
strct = self.__STRUCT_BE__ if is_big_endian else self.__STRUCT_LE__
|
|
327
|
+
strct.pack_into(data, off, self._hash_, self.mask, self._offset_, self.shift, self._type_.value)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class JMapEntry:
|
|
331
|
+
"""
|
|
332
|
+
An entry (or row) of a JMapInfo container that holds the actual data. Every entry should contain data for all fields
|
|
333
|
+
in a JMap container. The data is stored as hash-value pairs. Data can be accessed using the field hash or name.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
def __init__(self, jmap):
|
|
337
|
+
"""
|
|
338
|
+
Constructs a new JMapEntry for the given JMapInfo container. This should not be called outside JMapInfo itself.
|
|
339
|
+
Instead, use JMapInfo's methods for creating new entries.
|
|
340
|
+
|
|
341
|
+
:param jmap: The JMapInfo container that this field belongs to.
|
|
342
|
+
"""
|
|
343
|
+
self._jmap_ = jmap
|
|
344
|
+
self._data_ = dict()
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def jmap(self):
|
|
348
|
+
"""The JMapInfo container that the entry belongs to."""
|
|
349
|
+
return self._jmap_
|
|
350
|
+
|
|
351
|
+
def data(self):
|
|
352
|
+
"""Returns a view of hash-value pairs."""
|
|
353
|
+
return self._data_.items()
|
|
354
|
+
|
|
355
|
+
def __repr__(self):
|
|
356
|
+
string = "{"
|
|
357
|
+
first = True
|
|
358
|
+
|
|
359
|
+
for field_hash, value in self._data_.items():
|
|
360
|
+
if not first:
|
|
361
|
+
string += ", "
|
|
362
|
+
else:
|
|
363
|
+
first = False
|
|
364
|
+
string += repr(self._jmap_.hash_table.find(field_hash)) + ": " + repr(value)
|
|
365
|
+
|
|
366
|
+
return string + "}"
|
|
367
|
+
|
|
368
|
+
def __len__(self):
|
|
369
|
+
return len(self._data_)
|
|
370
|
+
|
|
371
|
+
def __getitem__(self, field_key):
|
|
372
|
+
if isinstance(field_key, str):
|
|
373
|
+
field_hash = self._jmap_.hash_table.calc(field_key)
|
|
374
|
+
|
|
375
|
+
if field_hash not in self._data_:
|
|
376
|
+
raise KeyError(f"Entry does not contain the field \"{field_key}\"")
|
|
377
|
+
else:
|
|
378
|
+
return self._data_[field_hash]
|
|
379
|
+
elif isinstance(field_key, int):
|
|
380
|
+
if field_key not in self._data_:
|
|
381
|
+
raise KeyError(f"Entry does not contain the field [{field_key:08X}]")
|
|
382
|
+
else:
|
|
383
|
+
return self._data_[field_key]
|
|
384
|
+
else:
|
|
385
|
+
raise TypeError("Key must be a str or int!")
|
|
386
|
+
|
|
387
|
+
def __setitem__(self, field_key, value):
|
|
388
|
+
if isinstance(field_key, str):
|
|
389
|
+
field_hash = self._jmap_.hash_table.calc(field_key)
|
|
390
|
+
|
|
391
|
+
if field_hash not in self._data_:
|
|
392
|
+
raise KeyError(f"Entry does not contain the field \"{field_key}\"")
|
|
393
|
+
elif type(self._data_[field_hash]) != type(value):
|
|
394
|
+
raise TypeError(f"Wrong data type for field \"{field_key}\": Expected {str(type(self._data_[field_hash]))}, found {type(value)} instead.")
|
|
395
|
+
else:
|
|
396
|
+
self._data_[field_hash] = value
|
|
397
|
+
elif isinstance(field_key, int):
|
|
398
|
+
if field_key not in self._data_:
|
|
399
|
+
raise KeyError(f"Entry does not contain the field [{field_key:08X}]")
|
|
400
|
+
elif type(self._data_[field_key]) != type(value):
|
|
401
|
+
raise TypeError(f"Wrong data type for field [{field_key:08X}]: Expected {str(type(self._data_[field_key]))}, found {type(value)} instead.")
|
|
402
|
+
else:
|
|
403
|
+
self._data_[field_key] = value
|
|
404
|
+
else:
|
|
405
|
+
raise TypeError("Key must be a str or int!")
|
|
406
|
+
|
|
407
|
+
def __contains__(self, field_key):
|
|
408
|
+
if isinstance(field_key, str):
|
|
409
|
+
return self._jmap_.hash_table.calc(field_key) in self._data_
|
|
410
|
+
elif isinstance(field_key, int):
|
|
411
|
+
return field_key in self._data_
|
|
412
|
+
else:
|
|
413
|
+
raise TypeError("Key must be a str or int!")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ----------------------------------------------------------------------------------------------------------------------
|
|
417
|
+
# JMapInfo implementation according to the BCSV / JMap format
|
|
418
|
+
# ----------------------------------------------------------------------------------------------------------------------
|
|
419
|
+
class JMapInfo:
|
|
420
|
+
"""
|
|
421
|
+
The table-like JMap container that consists of individual fields and entries. Provides high-level access to entries.
|
|
422
|
+
A hash lookup table is used to retrieve proper names for fields.
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
# Structures for parsing and packing
|
|
426
|
+
__STRUCT_BE__ = struct.Struct(">4I") # Big-endian
|
|
427
|
+
__STRUCT_LE__ = struct.Struct("<4I") # Little-endian
|
|
428
|
+
__U16_BE__ = struct.Struct(">H")
|
|
429
|
+
__U16_LE__ = struct.Struct("<H")
|
|
430
|
+
__U32_BE__ = struct.Struct(">I")
|
|
431
|
+
__U32_LE__ = struct.Struct("<I")
|
|
432
|
+
__F32_BE__ = struct.Struct(">f")
|
|
433
|
+
__F32_LE__ = struct.Struct("<f")
|
|
434
|
+
|
|
435
|
+
def __init__(self, hash_table: JMapHashTable):
|
|
436
|
+
"""
|
|
437
|
+
Constructs a new JMapInfo container with no fields or entries. The specified lookup hash table will be used to
|
|
438
|
+
retrieve proper names for hashes.
|
|
439
|
+
|
|
440
|
+
:param hash_table: the hash lookup table to be used.
|
|
441
|
+
"""
|
|
442
|
+
self._fields_ = dict() # Maps fields to hashes for quick access.
|
|
443
|
+
self._entries_ = list() # List of actual entries.
|
|
444
|
+
self._hash_table_ = hash_table # The lookup hash table that is used to retrieve proper field names.
|
|
445
|
+
self._entry_size_ = -1 # Size of a single entry.
|
|
446
|
+
self.manual_offsets = False # Requires manually-specified field offsets. Necessary for PA collision data.
|
|
447
|
+
|
|
448
|
+
@property
|
|
449
|
+
def hash_table(self):
|
|
450
|
+
"""
|
|
451
|
+
Returns the hash lookup table used by this container.
|
|
452
|
+
|
|
453
|
+
:return: the hash lookup table.
|
|
454
|
+
"""
|
|
455
|
+
return self._hash_table_
|
|
456
|
+
|
|
457
|
+
@property
|
|
458
|
+
def fields(self) -> tuple:
|
|
459
|
+
"""
|
|
460
|
+
Returns a tuple of all fields in this container.
|
|
461
|
+
|
|
462
|
+
:return: the tuple of all fields.
|
|
463
|
+
"""
|
|
464
|
+
return tuple(self._fields_.values())
|
|
465
|
+
|
|
466
|
+
def __iter__(self):
|
|
467
|
+
return iter(self._entries_)
|
|
468
|
+
|
|
469
|
+
def __reversed__(self):
|
|
470
|
+
return reversed(self._entries_)
|
|
471
|
+
|
|
472
|
+
def __repr__(self):
|
|
473
|
+
return repr(self._entries_)
|
|
474
|
+
|
|
475
|
+
def __len__(self):
|
|
476
|
+
return len(self._entries_)
|
|
477
|
+
|
|
478
|
+
def __getitem__(self, key):
|
|
479
|
+
return self._entries_[key]
|
|
480
|
+
|
|
481
|
+
def __delitem__(self, key):
|
|
482
|
+
if isinstance(key, slice):
|
|
483
|
+
for i in reversed(range(*key.indices(len(self._entries_)))):
|
|
484
|
+
self._entries_[i]._jmap_ = None
|
|
485
|
+
del self._entries_[i]
|
|
486
|
+
else:
|
|
487
|
+
self._entries_[key]._jmap_ = None
|
|
488
|
+
del self._entries_[key]
|
|
489
|
+
|
|
490
|
+
def __contains__(self, field_key):
|
|
491
|
+
if isinstance(field_key, str):
|
|
492
|
+
return self._hash_table_.calc(field_key) in self._fields_
|
|
493
|
+
elif isinstance(field_key, int):
|
|
494
|
+
return field_key in self._fields_
|
|
495
|
+
else:
|
|
496
|
+
raise TypeError("Key must be a str or int!")
|
|
497
|
+
|
|
498
|
+
def get_field(self, field_key) -> JMapField:
|
|
499
|
+
"""
|
|
500
|
+
Retrieves the field using the specified key (hash or name) and remove's the field's data from all entries.
|
|
501
|
+
|
|
502
|
+
:param field_key: the field's key (hash or name).
|
|
503
|
+
:return: the field that corresponds to the key.
|
|
504
|
+
"""
|
|
505
|
+
if isinstance(field_key, str):
|
|
506
|
+
field_hash = self._hash_table_.calc(field_key)
|
|
507
|
+
|
|
508
|
+
if field_hash not in self._fields_:
|
|
509
|
+
raise KeyError(f"Entry does not contain the field \"{field_key}\"")
|
|
510
|
+
else:
|
|
511
|
+
return self._fields_[field_hash]
|
|
512
|
+
elif isinstance(field_key, int):
|
|
513
|
+
if field_key not in self._fields_:
|
|
514
|
+
raise KeyError(f"Entry does not contain the field [{field_key}]")
|
|
515
|
+
else:
|
|
516
|
+
return self._fields_[field_key]
|
|
517
|
+
else:
|
|
518
|
+
raise TypeError("Key must be a str or int!")
|
|
519
|
+
|
|
520
|
+
def create_field(self, field_name, field_type: JMapFieldType, defval, mask: int = -1, shift_amount: int = 0, offset: int = 0):
|
|
521
|
+
"""
|
|
522
|
+
Creates a new field with the given name, type, mask and shift amount. This also sets the field's data to the
|
|
523
|
+
default value for every entry. If a field with the same hash already exists, a JMapException will be raised. The
|
|
524
|
+
field's offset should only be provided when the JMapInfo container uses manually-specified field offsets.
|
|
525
|
+
|
|
526
|
+
:param field_name: the new field's name.
|
|
527
|
+
:param field_type: the new field's data type.
|
|
528
|
+
:param defval: the new field's default value.
|
|
529
|
+
:param mask: the new field's bitmask. If negative, the field type's default mask will be used.
|
|
530
|
+
:param shift_amount: the new field's shift amount.
|
|
531
|
+
:param offset: the field's offset.
|
|
532
|
+
:raises JMapException: if a field with the same hash already exists.
|
|
533
|
+
"""
|
|
534
|
+
if not isinstance(defval, field_type.data_type):
|
|
535
|
+
raise TypeError(f"Default value type {repr(type(defval))} is not expected type {repr(field_type.data_type)}!")
|
|
536
|
+
|
|
537
|
+
# Calculate hash and check if field with same hash already exists
|
|
538
|
+
field_hash = self._hash_table_.add(field_name)
|
|
539
|
+
|
|
540
|
+
if field_hash in self._fields_:
|
|
541
|
+
raise JMapException(f"Field \"{field_name}\" already exists!")
|
|
542
|
+
|
|
543
|
+
# Create the actual field
|
|
544
|
+
mask = field_type.mask if mask < 0 else mask
|
|
545
|
+
field = JMapField(self, field_hash, field_type, mask, shift_amount, offset, defval)
|
|
546
|
+
self._fields_[field_hash] = field
|
|
547
|
+
|
|
548
|
+
# Set default values for all entries
|
|
549
|
+
for entry in self._entries_:
|
|
550
|
+
entry._data_[field_hash] = field.default
|
|
551
|
+
|
|
552
|
+
def drop_field(self, field_key):
|
|
553
|
+
"""
|
|
554
|
+
Drops the field with the specified key (hash or name) and remove's the field's data from all entries.
|
|
555
|
+
|
|
556
|
+
:param field_key: the field's key (hash or name).
|
|
557
|
+
"""
|
|
558
|
+
def dropfield0(field_hash):
|
|
559
|
+
field = self._fields_[field_hash]
|
|
560
|
+
field._jmap_ = None # Unlink
|
|
561
|
+
del self._fields_[field_hash]
|
|
562
|
+
|
|
563
|
+
for entry in self._entries_:
|
|
564
|
+
del entry._data_[field_hash]
|
|
565
|
+
|
|
566
|
+
if isinstance(field_key, str):
|
|
567
|
+
field_hash = self._hash_table_.calc(field_key)
|
|
568
|
+
|
|
569
|
+
if field_hash not in self._fields_:
|
|
570
|
+
raise KeyError(f"Field \"{field_key}\" does not exist!")
|
|
571
|
+
else:
|
|
572
|
+
dropfield0(field_hash)
|
|
573
|
+
elif isinstance(field_key, int):
|
|
574
|
+
if field_key not in self._fields_:
|
|
575
|
+
raise KeyError(f"Field [{field_key:08X}] does not exist!")
|
|
576
|
+
else:
|
|
577
|
+
dropfield0(field_key)
|
|
578
|
+
else:
|
|
579
|
+
raise TypeError("Key must be a str or int!")
|
|
580
|
+
|
|
581
|
+
def create_entry(self) -> JMapEntry:
|
|
582
|
+
"""
|
|
583
|
+
Creates a new entry, populates it with default values for all fields and appends it at the end of the container.
|
|
584
|
+
|
|
585
|
+
:return: the newly created entry.
|
|
586
|
+
"""
|
|
587
|
+
entry = JMapEntry(self)
|
|
588
|
+
|
|
589
|
+
for field in self._fields_.values():
|
|
590
|
+
entry._data_[field.hash] = field.default
|
|
591
|
+
|
|
592
|
+
self._entries_.append(entry)
|
|
593
|
+
|
|
594
|
+
return entry
|
|
595
|
+
|
|
596
|
+
def remove_entry(self, index: int):
|
|
597
|
+
"""
|
|
598
|
+
Removes and unlinks the entry at the given index.
|
|
599
|
+
|
|
600
|
+
:param index: the entry's index.
|
|
601
|
+
"""
|
|
602
|
+
entry = self._entries_[index]
|
|
603
|
+
entry._jmap_ = None
|
|
604
|
+
del self._entries_[index]
|
|
605
|
+
|
|
606
|
+
def clear_entries(self):
|
|
607
|
+
"""
|
|
608
|
+
Removes and unlinks all entries from this container.
|
|
609
|
+
"""
|
|
610
|
+
for entry in self._entries_:
|
|
611
|
+
entry._jmap_ = None
|
|
612
|
+
|
|
613
|
+
self._entries_.clear()
|
|
614
|
+
|
|
615
|
+
def sort_entries(self, key, reverse: bool = False):
|
|
616
|
+
"""
|
|
617
|
+
Sorts the entries using the given sorting key.
|
|
618
|
+
|
|
619
|
+
:param key: the sorting key function.
|
|
620
|
+
:param reverse: reverse sorting order.
|
|
621
|
+
"""
|
|
622
|
+
self._entries_.sort(key=key, reverse=reverse)
|
|
623
|
+
|
|
624
|
+
def copy(self):
|
|
625
|
+
clone = JMapInfo(self._hash_table_)
|
|
626
|
+
clone._entry_size_ = self._entry_size_
|
|
627
|
+
|
|
628
|
+
for field_hash, field in self._fields_.items():
|
|
629
|
+
clone_field = JMapField(clone, field_hash, field.type, field.mask, field.shift, field.default)
|
|
630
|
+
clone._fields_[field_hash] = clone_field
|
|
631
|
+
|
|
632
|
+
for entry in self._entries_:
|
|
633
|
+
clone_entry = JMapEntry(clone)
|
|
634
|
+
clone_entry._data_ = entry._data_.copy()
|
|
635
|
+
clone._entries_.append(clone_entry)
|
|
636
|
+
|
|
637
|
+
return clone
|
|
638
|
+
|
|
639
|
+
__copy__ = copy
|
|
640
|
+
__deepcopy__ = copy
|
|
641
|
+
|
|
642
|
+
def _unpack_(self, data, off: int, is_big_endian: bool, encoding: str):
|
|
643
|
+
# Unpack header and calculate string pool offset
|
|
644
|
+
strct = self.__STRUCT_BE__ if is_big_endian else self.__STRUCT_LE__
|
|
645
|
+
num_entries, num_fields, off_data, self._entry_size_ = strct.unpack_from(data, off)
|
|
646
|
+
off_strings = off + off_data + (num_entries * self._entry_size_)
|
|
647
|
+
|
|
648
|
+
# Unpack fields
|
|
649
|
+
off_tmp = off + 0x10
|
|
650
|
+
|
|
651
|
+
for i in range(num_fields):
|
|
652
|
+
field = JMapField(self)
|
|
653
|
+
field._unpack_(data, off_tmp, is_big_endian)
|
|
654
|
+
self._fields_[field.hash] = field
|
|
655
|
+
off_tmp += 0xC
|
|
656
|
+
|
|
657
|
+
# Unpack entries
|
|
658
|
+
off_tmp = off + off_data
|
|
659
|
+
|
|
660
|
+
strct_u16 = self.__U16_BE__ if is_big_endian else self.__U16_LE__
|
|
661
|
+
strct_u32 = self.__U32_BE__ if is_big_endian else self.__U32_LE__
|
|
662
|
+
strct_f32 = self.__F32_BE__ if is_big_endian else self.__F32_LE__
|
|
663
|
+
|
|
664
|
+
for i in range(num_entries):
|
|
665
|
+
entry = JMapEntry(self)
|
|
666
|
+
|
|
667
|
+
for field in self._fields_.values():
|
|
668
|
+
field_type = field.type
|
|
669
|
+
off_val = off_tmp + field._offset_
|
|
670
|
+
val = None
|
|
671
|
+
|
|
672
|
+
# Read long
|
|
673
|
+
if field_type == JMapFieldType.LONG or field_type == JMapFieldType.UNSIGNED_LONG:
|
|
674
|
+
val = (strct_u32.unpack_from(data, off_val)[0] & field.mask) >> field.shift
|
|
675
|
+
val |= ~0xFFFFFFFF if val & 0x80000000 else 0
|
|
676
|
+
|
|
677
|
+
# Read string
|
|
678
|
+
elif field_type == JMapFieldType.STRING:
|
|
679
|
+
# Read 32 bytes maximum
|
|
680
|
+
end = data.index(0x00, off_val, off_val + 32)
|
|
681
|
+
end = 32 if end < 0 else end
|
|
682
|
+
val = data[off_val:end].decode(encoding)
|
|
683
|
+
|
|
684
|
+
# Read float
|
|
685
|
+
elif field_type == JMapFieldType.FLOAT:
|
|
686
|
+
val = strct_f32.unpack_from(data, off_val)[0]
|
|
687
|
+
|
|
688
|
+
# Read short
|
|
689
|
+
elif field_type == JMapFieldType.SHORT:
|
|
690
|
+
val = (strct_u16.unpack_from(data, off_val)[0] & field.mask) >> field.shift
|
|
691
|
+
val |= ~0xFFFF if val & 0x8000 else 0
|
|
692
|
+
|
|
693
|
+
# Read char
|
|
694
|
+
elif field_type == JMapFieldType.CHAR:
|
|
695
|
+
val = (data[off_val] & field.mask) >> field.shift
|
|
696
|
+
val |= ~0xFF if val & 0x80 else 0
|
|
697
|
+
|
|
698
|
+
# Read string at offset
|
|
699
|
+
elif field_type == JMapFieldType.STRING_OFFSET:
|
|
700
|
+
off_val = off_strings + strct_u32.unpack_from(data, off_val)[0]
|
|
701
|
+
end_str = off_val
|
|
702
|
+
|
|
703
|
+
while data[end_str]:
|
|
704
|
+
end_str += 1
|
|
705
|
+
|
|
706
|
+
val = data[off_val:end_str].decode(encoding)
|
|
707
|
+
|
|
708
|
+
entry._data_[field.hash] = val
|
|
709
|
+
|
|
710
|
+
self._entries_.append(entry)
|
|
711
|
+
off_tmp += self._entry_size_
|
|
712
|
+
|
|
713
|
+
def makebin(self, is_big_endian: bool, encoding: str) -> bytearray:
|
|
714
|
+
"""
|
|
715
|
+
Packs the container's contents according to the BCSV format and returns the resulting bytearray buffer.
|
|
716
|
+
|
|
717
|
+
:param is_big_endian: the endianness of the data.
|
|
718
|
+
:param encoding: the encoding for strings.
|
|
719
|
+
:return: the packed bytearray buffer.
|
|
720
|
+
"""
|
|
721
|
+
# Prepare header information
|
|
722
|
+
num_entries = len(self._entries_)
|
|
723
|
+
num_fields = len(self._fields_)
|
|
724
|
+
off_data = 0x10 + num_fields * 0xC
|
|
725
|
+
|
|
726
|
+
# Calculate entry size and field offsets
|
|
727
|
+
len_data_entry = 0
|
|
728
|
+
|
|
729
|
+
if self.manual_offsets:
|
|
730
|
+
for field in self._fields_.values():
|
|
731
|
+
potential_len = field.offset + field.type.size
|
|
732
|
+
|
|
733
|
+
if potential_len > len_data_entry:
|
|
734
|
+
len_data_entry = potential_len
|
|
735
|
+
else:
|
|
736
|
+
for field in sorted(self._fields_.values(), key=lambda k: k.type.order):
|
|
737
|
+
field._offset_ = len_data_entry
|
|
738
|
+
len_data_entry += field.type.size
|
|
739
|
+
|
|
740
|
+
# Align total entry size to 4 bytes
|
|
741
|
+
self._entry_size_ = len_data_entry + 3 & ~3
|
|
742
|
+
|
|
743
|
+
# Prepare output buffer and write header
|
|
744
|
+
buffer = bytearray(off_data + num_entries * self._entry_size_)
|
|
745
|
+
strct = self.__STRUCT_BE__ if is_big_endian else self.__STRUCT_LE__
|
|
746
|
+
strct.pack_into(buffer, 0, num_entries, num_fields, off_data, self._entry_size_)
|
|
747
|
+
|
|
748
|
+
# Pack fields
|
|
749
|
+
off_tmp = 0x10
|
|
750
|
+
|
|
751
|
+
for field in self._fields_.values():
|
|
752
|
+
field._pack_(buffer, off_tmp, is_big_endian)
|
|
753
|
+
off_tmp += 0xC
|
|
754
|
+
|
|
755
|
+
# Pack entries and prepare the string pool
|
|
756
|
+
off_strings = len(buffer)
|
|
757
|
+
string_offsets = dict()
|
|
758
|
+
|
|
759
|
+
strct_u16 = self.__U16_BE__ if is_big_endian else self.__U16_LE__
|
|
760
|
+
strct_u32 = self.__U32_BE__ if is_big_endian else self.__U32_LE__
|
|
761
|
+
strct_f32 = self.__F32_BE__ if is_big_endian else self.__F32_LE__
|
|
762
|
+
|
|
763
|
+
for entry in self._entries_:
|
|
764
|
+
for field in self._fields_.values():
|
|
765
|
+
field_type = field.type
|
|
766
|
+
off_val = off_tmp + field._offset_
|
|
767
|
+
val = entry._data_[field.hash]
|
|
768
|
+
|
|
769
|
+
# Pack long
|
|
770
|
+
if field_type == JMapFieldType.LONG or field_type == JMapFieldType.UNSIGNED_LONG:
|
|
771
|
+
prev = strct_u32.unpack_from(buffer, off_val)[0] & ~field.mask
|
|
772
|
+
val = ((val << field.shift) & field.mask) | prev
|
|
773
|
+
strct_u32.pack_into(buffer, off_val, val)
|
|
774
|
+
|
|
775
|
+
# Pack string
|
|
776
|
+
elif field_type == JMapFieldType.STRING:
|
|
777
|
+
enc_string = val.encode(encoding)
|
|
778
|
+
if len(enc_string) >= 32:
|
|
779
|
+
warnings.warn("String is too long to be embedded. String will be chopped to fit 32 bytes!")
|
|
780
|
+
len_string = min(len(enc_string), 32)
|
|
781
|
+
buffer[off_val:off_val + len_string] = enc_string[:len_string]
|
|
782
|
+
|
|
783
|
+
# Pack float
|
|
784
|
+
elif field_type == JMapFieldType.FLOAT:
|
|
785
|
+
strct_f32.pack_into(buffer, off_val, val)
|
|
786
|
+
|
|
787
|
+
# Pack short
|
|
788
|
+
elif field_type == JMapFieldType.SHORT:
|
|
789
|
+
prev = strct_u16.unpack_from(buffer, off_val)[0] & ~field.mask
|
|
790
|
+
val = ((val << field.shift) & field.mask) | prev
|
|
791
|
+
strct_u16.pack_into(buffer, off_val, val)
|
|
792
|
+
|
|
793
|
+
# Pack char
|
|
794
|
+
elif field_type == JMapFieldType.CHAR:
|
|
795
|
+
buffer[off_val] = (val << field.shift) & field.mask
|
|
796
|
+
|
|
797
|
+
# Pack string at offset
|
|
798
|
+
elif field_type == JMapFieldType.STRING_OFFSET:
|
|
799
|
+
if val in string_offsets:
|
|
800
|
+
off_string = string_offsets[val]
|
|
801
|
+
else:
|
|
802
|
+
off_string = len(buffer) - off_strings
|
|
803
|
+
string_offsets[val] = off_string
|
|
804
|
+
|
|
805
|
+
# shift_jis appears to truncate the zero terminator sometimes...
|
|
806
|
+
buffer += val.encode(encoding)
|
|
807
|
+
buffer += "\0".encode(encoding)
|
|
808
|
+
|
|
809
|
+
strct_u32.pack_into(buffer, off_val, off_string)
|
|
810
|
+
|
|
811
|
+
off_tmp += self._entry_size_
|
|
812
|
+
|
|
813
|
+
# Align buffer to 32 bytes
|
|
814
|
+
len_buf = len(buffer)
|
|
815
|
+
buffer += bytearray([0x40] * ((len_buf + 31 & ~31) - len_buf))
|
|
816
|
+
|
|
817
|
+
return buffer
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
# ----------------------------------------------------------------------------------------------------------------------
|
|
821
|
+
# Helper I/O functions
|
|
822
|
+
# ----------------------------------------------------------------------------------------------------------------------
|
|
823
|
+
def from_buffer(hashtable: JMapHashTable, buffer, offset: int, big_endian: bool = True, encoding: str = "shift_jisx0213") -> JMapInfo:
|
|
824
|
+
"""
|
|
825
|
+
Creates and returns a new JMapInfo container by unpacking the content from the specified buffer. The data is
|
|
826
|
+
expected to be stored in the JMap / BCSV format.
|
|
827
|
+
|
|
828
|
+
:param hashtable: the hash lookup table to be used.
|
|
829
|
+
:param buffer: the byte buffer.
|
|
830
|
+
:param offset: the offset into the buffer.
|
|
831
|
+
:param big_endian: the endianness of the data.
|
|
832
|
+
:param encoding: the encoding for strings.
|
|
833
|
+
:return: the unpacked JMapInfo container.
|
|
834
|
+
"""
|
|
835
|
+
jmap = JMapInfo(hashtable)
|
|
836
|
+
jmap._unpack_(buffer, offset, big_endian, encoding)
|
|
837
|
+
return jmap
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def pack_buffer(jmap: JMapInfo, big_endian: bool = True, encoding: str = "shift_jisx0213") -> bytearray:
|
|
841
|
+
"""
|
|
842
|
+
Packs the given JMapInfo's contents according to the BCSV format and returns the resulting bytearray buffer.
|
|
843
|
+
|
|
844
|
+
:param jmap: the JMapInfo container.
|
|
845
|
+
:param big_endian: the endianness of the data.
|
|
846
|
+
:param encoding: the encoding for strings.
|
|
847
|
+
:return: the buffer containing the stored data.
|
|
848
|
+
"""
|
|
849
|
+
return jmap.makebin(big_endian, encoding)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def from_file(hashtable: JMapHashTable, file_path: str, big_endian: bool = True, encoding: str = "shift_jisx0213") -> JMapInfo:
|
|
853
|
+
"""
|
|
854
|
+
Creates and returns a new JMapInfo container by unpacking the contents from the given file path. The data is
|
|
855
|
+
expected to be stored in the JMap / BCSV format.
|
|
856
|
+
|
|
857
|
+
:param hashtable: the hash lookup table to be used.
|
|
858
|
+
:param file_path: the file path to the JMap / BCSV file.
|
|
859
|
+
:param big_endian: the endianness of the data.
|
|
860
|
+
:param encoding: the encoding for strings.
|
|
861
|
+
:return: the unpacked JMapInfo container.
|
|
862
|
+
"""
|
|
863
|
+
jmap = JMapInfo(hashtable)
|
|
864
|
+
with open(file_path, "rb") as f:
|
|
865
|
+
jmap._unpack_(f.read(), 0, big_endian, encoding)
|
|
866
|
+
return jmap
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def write_file(jmap: JMapInfo, file_path: str, big_endian: bool = True, encoding: str = "shift_jisx0213"):
|
|
870
|
+
"""
|
|
871
|
+
Packs the given JMapInfo's contents according to the BCSV format and writes the resulting buffer's contents to the
|
|
872
|
+
specified file.
|
|
873
|
+
|
|
874
|
+
:param jmap: the JMapInfo container.
|
|
875
|
+
:param file_path: the file path to write the contents to.
|
|
876
|
+
:param big_endian: the endianness of the data.
|
|
877
|
+
:param encoding: the encoding for strings.
|
|
878
|
+
"""
|
|
879
|
+
buffer = jmap.makebin(big_endian, encoding)
|
|
880
|
+
|
|
881
|
+
with open(file_path, "wb") as f:
|
|
882
|
+
f.write(buffer)
|
|
883
|
+
f.flush()
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
__CSV_FIELD_TYPES__ = ["Int", "EmbeddedString", "Float", "UnsignedInt", "Short", "Char", "String"]
|
|
887
|
+
__CSV_FIELD_DEFAULTS__ = ["0", "0", "0.0", "0", "0", "0", "0"]
|
|
888
|
+
__CSV_FIELD_PRIMARIES__ = [int, str, float, int, int, int, str]
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def from_csv(hashtable: JMapHashTable, file_path: str, encoding: str = "utf-8") -> JMapInfo:
|
|
892
|
+
"""
|
|
893
|
+
Creates a new JMapInfo container using the raw CSV data found in the specified file. The CSV files have to be comma-
|
|
894
|
+
delimited and may use quote marks for quotes strings. The information of each field consists of three components
|
|
895
|
+
that are separated by double-colons. Example: GroupName:String:0. The components describe the field's name, type and
|
|
896
|
+
default value, respectively.
|
|
897
|
+
|
|
898
|
+
:param hashtable: the hash lookup table to be used.
|
|
899
|
+
:param file_path: the file path to the CSV file.
|
|
900
|
+
:param encoding: the CSV file's encoding, expects utf-8 by default.
|
|
901
|
+
:return: the created JMapInfo container.
|
|
902
|
+
"""
|
|
903
|
+
jmap = JMapInfo(hashtable)
|
|
904
|
+
|
|
905
|
+
with open(file_path, "r", encoding=encoding, newline="") as f:
|
|
906
|
+
csvreader = csv.reader(f, delimiter=",", quotechar='"')
|
|
907
|
+
|
|
908
|
+
# Create fields
|
|
909
|
+
field_descs = next(csvreader, None)
|
|
910
|
+
|
|
911
|
+
if field_descs is None:
|
|
912
|
+
raise SyntaxError("CSV file is empty.")
|
|
913
|
+
|
|
914
|
+
for field_desc in field_descs:
|
|
915
|
+
# Get field descriptor information
|
|
916
|
+
field_desc = field_desc.split(":")
|
|
917
|
+
|
|
918
|
+
if len(field_desc) != 3:
|
|
919
|
+
raise SyntaxError("Number of field descriptor details is not 3!")
|
|
920
|
+
|
|
921
|
+
field_name, field_type, field_default = field_desc
|
|
922
|
+
|
|
923
|
+
if len(field_name) == 0:
|
|
924
|
+
raise SyntaxError("Field name cannot be empty!")
|
|
925
|
+
|
|
926
|
+
# Get proper JMapFieldType and default value from descriptor
|
|
927
|
+
if field_type == "Int":
|
|
928
|
+
actual_type = JMapFieldType.LONG
|
|
929
|
+
actual_default = int(field_default)
|
|
930
|
+
elif field_type == "EmbeddedString":
|
|
931
|
+
actual_type = JMapFieldType.STRING
|
|
932
|
+
actual_default = ""
|
|
933
|
+
elif field_type == "Float":
|
|
934
|
+
actual_type = JMapFieldType.FLOAT
|
|
935
|
+
actual_default = float(field_default)
|
|
936
|
+
elif field_type == "UnsignedInt":
|
|
937
|
+
actual_type = JMapFieldType.UNSIGNED_LONG
|
|
938
|
+
actual_default = int(field_default)
|
|
939
|
+
elif field_type == "Short":
|
|
940
|
+
actual_type = JMapFieldType.SHORT
|
|
941
|
+
actual_default = int(field_default)
|
|
942
|
+
elif field_type == "Char":
|
|
943
|
+
actual_type = JMapFieldType.CHAR
|
|
944
|
+
actual_default = int(field_default)
|
|
945
|
+
elif field_type == "String":
|
|
946
|
+
actual_type = JMapFieldType.STRING_OFFSET
|
|
947
|
+
actual_default = ""
|
|
948
|
+
else:
|
|
949
|
+
raise SyntaxError(f"Unknown CSV field type {field_type} for field {field_name}")
|
|
950
|
+
|
|
951
|
+
# Check if field name is a hash
|
|
952
|
+
if field_name[0] == "[" and field_name[-1] == "]":
|
|
953
|
+
field_hash = int(field_name[1:-1], 16)
|
|
954
|
+
else:
|
|
955
|
+
field_hash = hashtable.add(field_name)
|
|
956
|
+
|
|
957
|
+
field = JMapField(jmap, field_hash, actual_type, mask=actual_type.mask, defval=actual_default)
|
|
958
|
+
jmap._fields_[field.hash] = field
|
|
959
|
+
|
|
960
|
+
# Create entries
|
|
961
|
+
for entry_row in csvreader:
|
|
962
|
+
entry = JMapEntry(jmap)
|
|
963
|
+
jmap._entries_.append(entry)
|
|
964
|
+
|
|
965
|
+
for i, field in enumerate(jmap._fields_.values()):
|
|
966
|
+
if len(entry_row[i]) == 0:
|
|
967
|
+
entry._data_[field.hash] = field.default
|
|
968
|
+
else:
|
|
969
|
+
entry._data_[field.hash] = __CSV_FIELD_PRIMARIES__[field.type.value](entry_row[i])
|
|
970
|
+
|
|
971
|
+
return jmap
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def dump_csv(jmap: JMapInfo, file_path: str, encoding: str = "utf-8"):
|
|
975
|
+
"""
|
|
976
|
+
Dumps the JMapInfo's data to the specified CSV file. The CSV file is comma-delimited and may use quote marks for
|
|
977
|
+
quoted strings.
|
|
978
|
+
|
|
979
|
+
:param jmap: the JMapInfo container.
|
|
980
|
+
:param file_path: the file path to the CSV file.
|
|
981
|
+
:param encoding: the CSV file's encoding, expects utf-8 by default.
|
|
982
|
+
"""
|
|
983
|
+
with open(file_path, "w", encoding=encoding, newline="") as f:
|
|
984
|
+
csv_writer = csv.writer(f, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL)
|
|
985
|
+
|
|
986
|
+
# Write fields header
|
|
987
|
+
field_descs = [
|
|
988
|
+
f"{field.name}:{__CSV_FIELD_TYPES__[field.type.value]}:{__CSV_FIELD_DEFAULTS__[field.type.value]}"
|
|
989
|
+
for field in jmap._fields_.values()
|
|
990
|
+
]
|
|
991
|
+
csv_writer.writerow(field_descs)
|
|
992
|
+
|
|
993
|
+
# Write entries
|
|
994
|
+
for entry in jmap:
|
|
995
|
+
csv_writer.writerow([str(entry[field.hash]) for field in jmap._fields_.values()])
|
|
996
|
+
|
|
997
|
+
f.flush()
|