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/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()