PyREUser3 0.4.0__tar.gz → 0.5.0__tar.gz

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.
Files changed (46) hide show
  1. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/PKG-INFO +1 -1
  2. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/PyREUser3.egg-info/PKG-INFO +1 -1
  3. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyproject.toml +1 -1
  4. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/export/fields.py +7 -4
  5. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/export/metadata.py +3 -1
  6. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/export/tree.py +11 -0
  7. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/export/user3.py +14 -6
  8. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/pack/plan.py +23 -13
  9. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/pack/writer.py +2 -2
  10. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/schema.py +94 -2
  11. pyreuser3-0.5.0/tests/test_enum_binary.py +221 -0
  12. pyreuser3-0.4.0/tests/test_enum_binary.py +0 -92
  13. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/LICENSE +0 -0
  14. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/MANIFEST.in +0 -0
  15. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/PyREUser3.egg-info/SOURCES.txt +0 -0
  16. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/PyREUser3.egg-info/dependency_links.txt +0 -0
  17. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/PyREUser3.egg-info/entry_points.txt +0 -0
  18. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/PyREUser3.egg-info/requires.txt +0 -0
  19. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/PyREUser3.egg-info/top_level.txt +0 -0
  20. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/README.md +0 -0
  21. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/docs/PYPI.md +0 -0
  22. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/docs/README.zh-CN.md +0 -0
  23. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/__init__.py +0 -0
  24. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/__main__.py +0 -0
  25. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/api.py +0 -0
  26. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/cli.py +0 -0
  27. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/core.py +0 -0
  28. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/export/__init__.py +0 -0
  29. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/export/base.py +0 -0
  30. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/export/enums.py +0 -0
  31. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/export/postprocess.py +0 -0
  32. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/pack/__init__.py +0 -0
  33. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/pack/base.py +0 -0
  34. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/pack/models.py +0 -0
  35. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/rich_ui.py +0 -0
  36. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/web/__init__.py +0 -0
  37. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/web/__main__.py +0 -0
  38. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/web/handler.py +0 -0
  39. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/web/jobs.py +0 -0
  40. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/web/page.py +0 -0
  41. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/web/picker.py +0 -0
  42. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/web/runners.py +0 -0
  43. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/web/server.py +0 -0
  44. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/pyreuser3/web/settings.py +0 -0
  45. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/setup.cfg +0 -0
  46. {pyreuser3-0.4.0 → pyreuser3-0.5.0}/tests/test_export_enums.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyREUser3
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Pure Python tools for converting RE Engine .user.3 files to and from JSON.
5
5
  Author: Egg Targaryen
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyREUser3
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Pure Python tools for converting RE Engine .user.3 files to and from JSON.
5
5
  Author: Egg Targaryen
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "PyREUser3"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Pure Python tools for converting RE Engine .user.3 files to and from JSON."
9
9
  readme = "docs/PYPI.md"
10
10
  requires-python = ">=3.9"
@@ -74,7 +74,7 @@ class ExporterFieldParserMixin:
74
74
  return {"ref_instance_id": reader.read_s32()}
75
75
  if t in ("String", "Resource"):
76
76
  return read_len_utf16(reader)
77
- if t == "C8":
77
+ if t in ("C8", "RuntimeType"):
78
78
  return read_len_c8(reader)
79
79
  if t in ("Guid", "GameObjectRef", "Uri"):
80
80
  return read_guid_like(reader)
@@ -230,7 +230,7 @@ class ExporterFieldParserMixin:
230
230
  t = field.field_type
231
231
  if field.is_array:
232
232
  pos += 4
233
- elif t in ("String", "Resource", "C8"):
233
+ elif t in ("String", "Resource", "C8", "RuntimeType"):
234
234
  pos += 4
235
235
  elif t in ("Object", "UserData"):
236
236
  pos += 4
@@ -250,7 +250,9 @@ class ExporterFieldParserMixin:
250
250
  pos += max(field.size, 0)
251
251
  return max(pos, 1)
252
252
 
253
- def _parse_instance(self, reader: BinaryReader, class_hash: int) -> dict[str, Any]:
253
+ def _parse_instance(
254
+ self, reader: BinaryReader, class_hash: int, crc: int | None = None
255
+ ) -> dict[str, Any]:
254
256
  """Parse instance.
255
257
 
256
258
  The method keeps parsing, metadata lookup, and JSON shaping explicit so incomplete
@@ -259,6 +261,7 @@ class ExporterFieldParserMixin:
259
261
  Args:
260
262
  reader (BinaryReader): BinaryReader positioned at the value to parse.
261
263
  class_hash (int): RE_RSZ type hash for a class.
264
+ crc (int | None): Optional CRC used to choose among same-hash layout variants.
262
265
 
263
266
  Returns:
264
267
  dict[str, Any]: JSON-compatible dictionary for API or conversion callers.
@@ -266,7 +269,7 @@ class ExporterFieldParserMixin:
266
269
  Raises:
267
270
  ParseError: Binary data did not match the expected .user.3 or RSZ layout.
268
271
  """
269
- cls = self.typedb.get_class(class_hash)
272
+ cls = self.typedb.get_class(class_hash, crc)
270
273
  if cls is None:
271
274
  raise ParseError(f"class hash 0x{class_hash:08x} not found in schema")
272
275
  out: dict[str, Any] = {"_class": cls.name, "fields": {}}
@@ -337,7 +337,9 @@ class ExporterMetadataMixin:
337
337
  if typedb is None or not enum_lookup:
338
338
  return
339
339
 
340
- for class_def in typedb.classes.values():
340
+ iter_classes = getattr(typedb, "iter_classes", None)
341
+ class_defs = iter_classes() if callable(iter_classes) else typedb.classes.values()
342
+ for class_def in class_defs:
341
343
  field_map = self.class_field_fixed_types.setdefault(class_def.name, {})
342
344
  for field in class_def.fields:
343
345
  if field.field_type != "Enum":
@@ -245,6 +245,17 @@ class ExporterTreeMixin:
245
245
  }
246
246
  }
247
247
 
248
+ if inst.get("unparsed"):
249
+ class_name = inst.get("class_name", "Unknown Class")
250
+ class_name = self._normalize_to_fixed_enum_type(class_name)
251
+ return {
252
+ class_name: {
253
+ "ref_instance_id": idx,
254
+ "unparsed": True,
255
+ "reason": inst.get("reason", "unparsed"),
256
+ }
257
+ }
258
+
248
259
  data = inst.get("data", {})
249
260
  class_name = data.get("_class", inst.get("class_name", "Unknown Class"))
250
261
  class_name = self._normalize_to_fixed_enum_type(class_name)
@@ -56,11 +56,12 @@ class ExporterUser3ParserMixin:
56
56
  reader.seek(usr_header["userdata_info_tbl"])
57
57
  for idx in range(usr_header["userdata_count"]):
58
58
  class_hash = reader.read_u32()
59
- _crc = reader.read_u32()
59
+ crc = reader.read_u32()
60
60
  path_offset = reader.read_u64()
61
+ class_def = self.typedb.get_class(class_hash, crc)
61
62
  class_name = (
62
- self.typedb.get_class(class_hash).name
63
- if self.typedb.get_class(class_hash)
63
+ class_def.name
64
+ if class_def
64
65
  else "Unknown Class"
65
66
  )
66
67
  header_userdata_infos.append(
@@ -112,7 +113,7 @@ class ExporterUser3ParserMixin:
112
113
  # depend on these indexes remaining stable.
113
114
  class_hash = reader.read_u32()
114
115
  crc = reader.read_u32()
115
- class_def = self.typedb.get_class(class_hash)
116
+ class_def = self.typedb.get_class(class_hash, crc)
116
117
  instance_infos.append(
117
118
  {
118
119
  "index": idx,
@@ -175,7 +176,7 @@ class ExporterUser3ParserMixin:
175
176
  }
176
177
  )
177
178
  continue
178
- cls = self.typedb.get_class(class_hash)
179
+ cls = self.typedb.get_class(class_hash, int(info["crc"]))
179
180
  if cls is None:
180
181
  # Follow schema field layout exactly so alignment, padding, and unknown
181
182
  # data remain binary-compatible.
@@ -198,7 +199,14 @@ class ExporterUser3ParserMixin:
198
199
  start_pos = reader.tell()
199
200
  try:
200
201
  parsed_instances.append(
201
- {"index": idx, "data": self._parse_instance(reader, class_hash)}
202
+ {
203
+ "index": idx,
204
+ "data": self._parse_instance(
205
+ reader,
206
+ class_hash,
207
+ crc=int(info["crc"]),
208
+ ),
209
+ }
202
210
  )
203
211
  except Exception as exc:
204
212
  # Treat each file independently so one malformed resource is reported
@@ -95,16 +95,23 @@ class PackerPlanMixin:
95
95
  class_name = entry.get("_class")
96
96
  if not isinstance(class_name, str) or not class_name:
97
97
  raise PackError(f"instance {idx} is missing _class")
98
- class_hash = self.typedb.name_to_hash.get(class_name)
99
- if class_hash is None:
98
+ declared_crc = self._parse_optional_u32(entry.get("_crc"))
99
+ fields = entry.get("fields", {})
100
+ field_names = (
101
+ {key for key in fields if isinstance(key, str)}
102
+ if isinstance(fields, dict)
103
+ else None
104
+ )
105
+ resolved = self.typedb.get_class_for_fields(
106
+ class_name,
107
+ field_names=field_names,
108
+ crc=declared_crc,
109
+ )
110
+ if resolved is None:
100
111
  raise PackError(
101
112
  f"class not found in schema for instance {idx}: {class_name}"
102
113
  )
103
- class_def = self.typedb.get_class(class_hash)
104
- if class_def is None:
105
- raise PackError(
106
- f"class hash not found in schema for instance {idx}: {class_name}"
107
- )
114
+ class_hash, class_def = resolved
108
115
  self._validate_declared_hash(idx, entry, class_hash, class_def.crc)
109
116
  self.instances[idx] = InstanceSpec(
110
117
  class_hash=class_hash, class_def=class_def
@@ -363,12 +370,15 @@ class PackerPlanMixin:
363
370
  PackError: JSON input could not be represented safely as .user.3 binary data.
364
371
  """
365
372
  class_name, fields = self._unwrap_node(node, expected_class)
366
- class_hash = self.typedb.name_to_hash.get(class_name)
367
- if class_hash is None:
373
+ field_names = (
374
+ {key for key in fields if isinstance(key, str)}
375
+ if isinstance(fields, dict)
376
+ else None
377
+ )
378
+ resolved = self.typedb.get_class_for_fields(class_name, field_names=field_names)
379
+ if resolved is None:
368
380
  raise PackError(f"class not found in schema: {class_name}")
369
- class_def = self.typedb.get_class(class_hash)
370
- if class_def is None:
371
- raise PackError(f"class hash not found in schema: {class_name}")
381
+ class_hash, class_def = resolved
372
382
 
373
383
  spec = InstanceSpec(class_hash=class_hash, class_def=class_def)
374
384
  spec.fields = self._prepare_fields(class_def, fields)
@@ -602,7 +612,7 @@ class PackerPlanMixin:
602
612
  return False
603
613
  if field_def.field_type in {"F32", "F64"}:
604
614
  return 0.0
605
- if field_def.field_type in {"String", "Resource", "C8"}:
615
+ if field_def.field_type in {"String", "Resource", "C8", "RuntimeType"}:
606
616
  return ""
607
617
  if field_def.field_type in {"Guid", "GameObjectRef", "Uri"}:
608
618
  return "00000000-0000-0000-0000-000000000000"
@@ -215,8 +215,8 @@ class PackerWriterMixin:
215
215
  writer.write_struct("<I", len(raw) // 2)
216
216
  writer.write(raw)
217
217
  return
218
- if t == "C8":
219
- # C8 strings store UTF-8 byte length and keep a trailing null byte in the binary stream.
218
+ if t in {"C8", "RuntimeType"}:
219
+ # C8-style strings store UTF-8 byte length and keep a trailing null byte in the binary stream.
220
220
  writer.align(4)
221
221
  raw = f"{value or ''}\x00".encode("utf-8")
222
222
  writer.write_struct("<I", len(raw))
@@ -10,6 +10,11 @@ import json
10
10
  from dataclasses import dataclass
11
11
  from pathlib import Path
12
12
 
13
+ BTABLE_ORDER_LIST_CLASS_HASH = 0xD081F6C1
14
+ BTABLE_ORDER_LIST_NAME = "ace.btable.user_data.BTableOrderList"
15
+ BTABLE_ORDER_LIST_LEGACY_CRC = 0x6C85037C
16
+ BTABLE_ORDER_LIST_LEGACY_FIELDS = ("_OperatorFactories", "_CommandFactories")
17
+
13
18
 
14
19
  def murmur3_32(data: bytes, seed: int = 0xFFFFFFFF) -> int:
15
20
  """Compute the MurmurHash3 variant used by RE_RSZ type names.
@@ -101,7 +106,7 @@ class ClassDef:
101
106
 
102
107
 
103
108
  class TypeDB:
104
- """Index RE_RSZ class definitions by hash and by name for parser and packer lookups.
109
+ """Index RE_RSZ class definitions by hash, CRC, and name for parser and packer lookups.
105
110
  """
106
111
 
107
112
  def __init__(self, classes: dict[int, ClassDef]):
@@ -114,6 +119,11 @@ class TypeDB:
114
119
  None. The method performs its documented side effect in place and raises on invalid input.
115
120
  """
116
121
  self.classes = classes
122
+ self.class_variants: dict[tuple[int, int], ClassDef] = {
123
+ (class_hash, class_def.crc & 0xFFFFFFFF): class_def
124
+ for class_hash, class_def in classes.items()
125
+ }
126
+ self._add_builtin_class_variants()
117
127
  # Preserve the exported JSON structure so external scripts and hand-edited files
118
128
  # remain compatible across workflows.
119
129
  self.name_to_hash = {c.name: h for h, c in classes.items()}
@@ -164,17 +174,99 @@ class TypeDB:
164
174
  )
165
175
  return cls(classes)
166
176
 
167
- def get_class(self, class_hash: int) -> ClassDef | None:
177
+ def _add_builtin_class_variants(self) -> None:
178
+ """Register known CRC-specific layouts omitted by some public schema dumps."""
179
+ base_class = self.classes.get(BTABLE_ORDER_LIST_CLASS_HASH)
180
+ if base_class is None or base_class.name != BTABLE_ORDER_LIST_NAME:
181
+ return
182
+
183
+ fields_by_name = {field.name: field for field in base_class.fields}
184
+ legacy_fields = [
185
+ fields_by_name[name]
186
+ for name in BTABLE_ORDER_LIST_LEGACY_FIELDS
187
+ if name in fields_by_name
188
+ ]
189
+ if len(legacy_fields) != len(BTABLE_ORDER_LIST_LEGACY_FIELDS):
190
+ return
191
+
192
+ self.class_variants.setdefault(
193
+ (BTABLE_ORDER_LIST_CLASS_HASH, BTABLE_ORDER_LIST_LEGACY_CRC),
194
+ ClassDef(
195
+ name=base_class.name,
196
+ crc=BTABLE_ORDER_LIST_LEGACY_CRC,
197
+ fields=legacy_fields,
198
+ ),
199
+ )
200
+
201
+ def get_class(self, class_hash: int, crc: int | None = None) -> ClassDef | None:
168
202
  """Get class.
169
203
 
170
204
  Args:
171
205
  class_hash (int): RE_RSZ type hash for a class.
206
+ crc (int | None): Optional CRC used to choose among same-hash layout variants.
172
207
 
173
208
  Returns:
174
209
  ClassDef | None: Matching class definition when the schema contains the requested identifier.
175
210
  """
211
+ if crc is not None:
212
+ class_def = self.class_variants.get((class_hash, crc & 0xFFFFFFFF))
213
+ if class_def is not None:
214
+ return class_def
176
215
  return self.classes.get(class_hash)
177
216
 
217
+ def get_class_for_fields(
218
+ self,
219
+ class_name: str,
220
+ field_names: set[str] | None = None,
221
+ crc: int | None = None,
222
+ ) -> tuple[int, ClassDef] | None:
223
+ """Resolve a class by name, optionally preferring a CRC or exact field layout."""
224
+ class_hash = self.name_to_hash.get(class_name)
225
+ if class_hash is None:
226
+ return None
227
+ if crc is not None:
228
+ class_def = self.class_variants.get((class_hash, crc & 0xFFFFFFFF))
229
+ if class_def is not None:
230
+ return class_hash, class_def
231
+ if field_names is not None:
232
+ exact_matches: list[ClassDef] = []
233
+ for (variant_hash, _variant_crc), class_def in self.class_variants.items():
234
+ if variant_hash != class_hash or class_def.name != class_name:
235
+ continue
236
+ variant_fields = {field.name or "unnamed" for field in class_def.fields}
237
+ if variant_fields == field_names:
238
+ exact_matches.append(class_def)
239
+ if len(exact_matches) == 1:
240
+ class_def = exact_matches[0]
241
+ if crc is not None and (class_def.crc & 0xFFFFFFFF) != (
242
+ crc & 0xFFFFFFFF
243
+ ):
244
+ return (
245
+ class_hash,
246
+ ClassDef(
247
+ name=class_def.name,
248
+ crc=crc & 0xFFFFFFFF,
249
+ fields=class_def.fields,
250
+ ),
251
+ )
252
+ return class_hash, class_def
253
+ class_def = self.get_class(class_hash)
254
+ if class_def is None:
255
+ return None
256
+ return class_hash, class_def
257
+
258
+ def iter_classes(self) -> list[ClassDef]:
259
+ """Return base classes and CRC-specific variants without duplicate objects."""
260
+ out: list[ClassDef] = []
261
+ seen: set[int] = set()
262
+ for class_def in list(self.classes.values()) + list(self.class_variants.values()):
263
+ marker = id(class_def)
264
+ if marker in seen:
265
+ continue
266
+ seen.add(marker)
267
+ out.append(class_def)
268
+ return out
269
+
178
270
  def resolve_struct_hash(self, original_type: str) -> int | None:
179
271
  """Resolve struct hash.
180
272
 
@@ -0,0 +1,221 @@
1
+ import json
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ from pyreuser3.core import BinaryReader, RSZ_MAGIC, USR_MAGIC, enum_storage_type_from_size
7
+ from pyreuser3.export.fields import ExporterFieldParserMixin
8
+ from pyreuser3.pack.models import BinaryWriter
9
+ from pyreuser3.pack.plan import PackerPlanMixin
10
+ from pyreuser3.pack.writer import PackerWriterMixin
11
+ from pyreuser3.schema import FieldDef, TypeDB
12
+
13
+
14
+ class EnumFieldParser(ExporterFieldParserMixin):
15
+ def __init__(self, storage_type: str | None = None):
16
+ self.storage_type = storage_type
17
+
18
+ def _resolve_enum_storage_type(self, field: FieldDef) -> str:
19
+ return self.storage_type or enum_storage_type_from_size(field.size)
20
+
21
+
22
+ class EnumFieldWriter(PackerWriterMixin):
23
+ def __init__(self):
24
+ self.enum_underlying_types = {"via.motion.AxisDirection": "U8"}
25
+ self.member_lookup = {"via.motion.AxisDirection": {"Right": 2}}
26
+
27
+ @staticmethod
28
+ def _to_s32(value: int) -> int:
29
+ u32 = value & 0xFFFFFFFF
30
+ return u32 if u32 < 0x80000000 else u32 - 0x100000000
31
+
32
+
33
+ class EmptyBinaryWriter(PackerWriterMixin):
34
+ def __init__(self):
35
+ self.instances = [None]
36
+ self.user_magic = USR_MAGIC
37
+ self.rsz_magic = RSZ_MAGIC
38
+
39
+
40
+ class PlanDefaults(PackerPlanMixin):
41
+ pass
42
+
43
+
44
+ class EnumBinaryTests(unittest.TestCase):
45
+ def test_build_binary_uses_shared_alignment_helper(self):
46
+ writer_mixin = EmptyBinaryWriter()
47
+
48
+ data = writer_mixin._build_binary([])
49
+
50
+ self.assertEqual(data[:4], USR_MAGIC.to_bytes(4, "little"))
51
+ self.assertEqual(data[0x30:0x34], RSZ_MAGIC.to_bytes(4, "little"))
52
+
53
+ def test_enum_reader_uses_schema_size_fallback(self):
54
+ parser = EnumFieldParser()
55
+ reader = BinaryReader(b"\x02abcdef")
56
+ field = FieldDef(
57
+ name="v2_LegDirection",
58
+ field_type="Enum",
59
+ original_type="via.motion.AxisDirection",
60
+ size=1,
61
+ align=1,
62
+ is_array=False,
63
+ )
64
+
65
+ self.assertEqual(parser._parse_field_value(reader, field), 2)
66
+ self.assertEqual(reader.tell(), 1)
67
+
68
+ def test_enum_reader_uses_il2cpp_underlying_type(self):
69
+ parser = EnumFieldParser("U8")
70
+ reader = BinaryReader(b"\x02\x00\x00\x00")
71
+ field = FieldDef(
72
+ name="v2_LegDirection",
73
+ field_type="Enum",
74
+ original_type="via.motion.AxisDirection",
75
+ size=4,
76
+ align=4,
77
+ is_array=False,
78
+ )
79
+
80
+ self.assertEqual(parser._parse_field_value(reader, field), 2)
81
+ self.assertEqual(reader.tell(), 1)
82
+
83
+ def test_enum_writer_uses_il2cpp_underlying_type_for_labels(self):
84
+ writer_mixin = EnumFieldWriter()
85
+ writer = BinaryWriter()
86
+ field = FieldDef(
87
+ name="v2_LegDirection",
88
+ field_type="Enum",
89
+ original_type="via.motion.AxisDirection",
90
+ size=1,
91
+ align=1,
92
+ is_array=False,
93
+ )
94
+
95
+ writer_mixin._write_scalar(writer, field, "Right")
96
+ self.assertEqual(bytes(writer.data), b"\x02")
97
+
98
+ def test_runtime_type_reader_uses_c8_string_layout(self):
99
+ parser = EnumFieldParser()
100
+ reader = BinaryReader(b"\x04\x00\x00\x00abc\x00tail")
101
+ field = FieldDef(
102
+ name="_OrderType",
103
+ field_type="RuntimeType",
104
+ original_type="System.Type",
105
+ size=4,
106
+ align=4,
107
+ is_array=False,
108
+ )
109
+
110
+ self.assertEqual(parser._parse_field_value(reader, field), "abc")
111
+ self.assertEqual(reader.tell(), 8)
112
+
113
+ def test_runtime_type_writer_uses_c8_string_layout(self):
114
+ writer_mixin = EnumFieldWriter()
115
+ writer = BinaryWriter()
116
+ field = FieldDef(
117
+ name="_OrderType",
118
+ field_type="RuntimeType",
119
+ original_type="System.Type",
120
+ size=4,
121
+ align=4,
122
+ is_array=False,
123
+ )
124
+
125
+ writer_mixin._write_scalar(writer, field, "abc")
126
+
127
+ self.assertEqual(bytes(writer.data), b"\x04\x00\x00\x00abc\x00")
128
+
129
+ def test_runtime_type_default_is_empty_string(self):
130
+ field = FieldDef(
131
+ name="_OrderType",
132
+ field_type="RuntimeType",
133
+ original_type="System.Type",
134
+ size=4,
135
+ align=4,
136
+ is_array=False,
137
+ )
138
+
139
+ self.assertEqual(PlanDefaults()._default_value(field), "")
140
+
141
+ def test_typedb_selects_btable_order_list_crc_variant(self):
142
+ schema = {
143
+ "d081f6c1": {
144
+ "crc": "15afce2d",
145
+ "name": "ace.btable.user_data.BTableOrderList",
146
+ "fields": [
147
+ {
148
+ "name": "_OperatorFactories",
149
+ "type": "Object",
150
+ "original_type": "ace.btable.cOperatorFactory",
151
+ "size": 4,
152
+ "align": 4,
153
+ "array": True,
154
+ },
155
+ {
156
+ "name": "_CommandFactories",
157
+ "type": "Object",
158
+ "original_type": "ace.btable.cCommandFactory",
159
+ "size": 4,
160
+ "align": 4,
161
+ "array": True,
162
+ },
163
+ {
164
+ "name": "_OperatorHashList",
165
+ "type": "U64",
166
+ "original_type": "System.UInt64",
167
+ "size": 8,
168
+ "align": 8,
169
+ "array": True,
170
+ },
171
+ {
172
+ "name": "_CommandHashList",
173
+ "type": "U64",
174
+ "original_type": "System.UInt64",
175
+ "size": 8,
176
+ "align": 8,
177
+ "array": True,
178
+ },
179
+ ],
180
+ }
181
+ }
182
+ with tempfile.TemporaryDirectory() as tmpdir:
183
+ schema_path = Path(tmpdir) / "schema.json"
184
+ schema_path.write_text(json.dumps(schema), encoding="utf-8")
185
+
186
+ typedb = TypeDB.load(schema_path)
187
+
188
+ current = typedb.get_class(0xD081F6C1, 0x15AFCE2D)
189
+ legacy = typedb.get_class(0xD081F6C1, 0x6C85037C)
190
+ resolved = typedb.get_class_for_fields(
191
+ "ace.btable.user_data.BTableOrderList",
192
+ field_names={"_OperatorFactories", "_CommandFactories"},
193
+ )
194
+ crc_alias = typedb.get_class_for_fields(
195
+ "ace.btable.user_data.BTableOrderList",
196
+ field_names={
197
+ "_OperatorFactories",
198
+ "_CommandFactories",
199
+ "_OperatorHashList",
200
+ "_CommandHashList",
201
+ },
202
+ crc=0x12345678,
203
+ )
204
+
205
+ self.assertIsNotNone(current)
206
+ self.assertEqual(len(current.fields), 4)
207
+ self.assertIsNotNone(legacy)
208
+ self.assertEqual(legacy.crc, 0x6C85037C)
209
+ self.assertEqual([field.name for field in legacy.fields], [
210
+ "_OperatorFactories",
211
+ "_CommandFactories",
212
+ ])
213
+ self.assertIsNotNone(resolved)
214
+ self.assertEqual(resolved[1].crc, 0x6C85037C)
215
+ self.assertIsNotNone(crc_alias)
216
+ self.assertEqual(crc_alias[1].crc, 0x12345678)
217
+ self.assertEqual(len(crc_alias[1].fields), 4)
218
+
219
+
220
+ if __name__ == "__main__":
221
+ unittest.main()
@@ -1,92 +0,0 @@
1
- import unittest
2
-
3
- from pyreuser3.core import BinaryReader, RSZ_MAGIC, USR_MAGIC, enum_storage_type_from_size
4
- from pyreuser3.export.fields import ExporterFieldParserMixin
5
- from pyreuser3.pack.models import BinaryWriter
6
- from pyreuser3.pack.writer import PackerWriterMixin
7
- from pyreuser3.schema import FieldDef
8
-
9
-
10
- class EnumFieldParser(ExporterFieldParserMixin):
11
- def __init__(self, storage_type: str | None = None):
12
- self.storage_type = storage_type
13
-
14
- def _resolve_enum_storage_type(self, field: FieldDef) -> str:
15
- return self.storage_type or enum_storage_type_from_size(field.size)
16
-
17
-
18
- class EnumFieldWriter(PackerWriterMixin):
19
- def __init__(self):
20
- self.enum_underlying_types = {"via.motion.AxisDirection": "U8"}
21
- self.member_lookup = {"via.motion.AxisDirection": {"Right": 2}}
22
-
23
- @staticmethod
24
- def _to_s32(value: int) -> int:
25
- u32 = value & 0xFFFFFFFF
26
- return u32 if u32 < 0x80000000 else u32 - 0x100000000
27
-
28
-
29
- class EmptyBinaryWriter(PackerWriterMixin):
30
- def __init__(self):
31
- self.instances = [None]
32
- self.user_magic = USR_MAGIC
33
- self.rsz_magic = RSZ_MAGIC
34
-
35
-
36
- class EnumBinaryTests(unittest.TestCase):
37
- def test_build_binary_uses_shared_alignment_helper(self):
38
- writer_mixin = EmptyBinaryWriter()
39
-
40
- data = writer_mixin._build_binary([])
41
-
42
- self.assertEqual(data[:4], USR_MAGIC.to_bytes(4, "little"))
43
- self.assertEqual(data[0x30:0x34], RSZ_MAGIC.to_bytes(4, "little"))
44
-
45
- def test_enum_reader_uses_schema_size_fallback(self):
46
- parser = EnumFieldParser()
47
- reader = BinaryReader(b"\x02abcdef")
48
- field = FieldDef(
49
- name="v2_LegDirection",
50
- field_type="Enum",
51
- original_type="via.motion.AxisDirection",
52
- size=1,
53
- align=1,
54
- is_array=False,
55
- )
56
-
57
- self.assertEqual(parser._parse_field_value(reader, field), 2)
58
- self.assertEqual(reader.tell(), 1)
59
-
60
- def test_enum_reader_uses_il2cpp_underlying_type(self):
61
- parser = EnumFieldParser("U8")
62
- reader = BinaryReader(b"\x02\x00\x00\x00")
63
- field = FieldDef(
64
- name="v2_LegDirection",
65
- field_type="Enum",
66
- original_type="via.motion.AxisDirection",
67
- size=4,
68
- align=4,
69
- is_array=False,
70
- )
71
-
72
- self.assertEqual(parser._parse_field_value(reader, field), 2)
73
- self.assertEqual(reader.tell(), 1)
74
-
75
- def test_enum_writer_uses_il2cpp_underlying_type_for_labels(self):
76
- writer_mixin = EnumFieldWriter()
77
- writer = BinaryWriter()
78
- field = FieldDef(
79
- name="v2_LegDirection",
80
- field_type="Enum",
81
- original_type="via.motion.AxisDirection",
82
- size=1,
83
- align=1,
84
- is_array=False,
85
- )
86
-
87
- writer_mixin._write_scalar(writer, field, "Right")
88
- self.assertEqual(bytes(writer.data), b"\x02")
89
-
90
-
91
- if __name__ == "__main__":
92
- unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes