PyREUser3 0.4.0__tar.gz → 0.6.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 (48) hide show
  1. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/PKG-INFO +1 -1
  2. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/PyREUser3.egg-info/PKG-INFO +1 -1
  3. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/PyREUser3.egg-info/SOURCES.txt +2 -0
  4. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/README.md +7 -0
  5. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/docs/README.zh-CN.md +5 -0
  6. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyproject.toml +1 -1
  7. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/core.py +3 -1
  8. pyreuser3-0.6.0/pyreuser3/enum_codec.py +231 -0
  9. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/export/base.py +3 -0
  10. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/export/enums.py +36 -20
  11. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/export/fields.py +7 -4
  12. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/export/metadata.py +39 -12
  13. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/export/postprocess.py +97 -7
  14. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/export/tree.py +11 -0
  15. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/export/user3.py +16 -7
  16. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/pack/base.py +35 -2
  17. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/pack/plan.py +118 -15
  18. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/pack/writer.py +3 -8
  19. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/schema.py +94 -2
  20. pyreuser3-0.6.0/tests/test_enum_binary.py +237 -0
  21. pyreuser3-0.6.0/tests/test_enum_codec.py +200 -0
  22. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/tests/test_export_enums.py +46 -0
  23. pyreuser3-0.4.0/tests/test_enum_binary.py +0 -92
  24. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/LICENSE +0 -0
  25. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/MANIFEST.in +0 -0
  26. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/PyREUser3.egg-info/dependency_links.txt +0 -0
  27. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/PyREUser3.egg-info/entry_points.txt +0 -0
  28. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/PyREUser3.egg-info/requires.txt +0 -0
  29. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/PyREUser3.egg-info/top_level.txt +0 -0
  30. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/docs/PYPI.md +0 -0
  31. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/__init__.py +0 -0
  32. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/__main__.py +0 -0
  33. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/api.py +0 -0
  34. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/cli.py +0 -0
  35. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/export/__init__.py +0 -0
  36. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/pack/__init__.py +0 -0
  37. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/pack/models.py +0 -0
  38. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/rich_ui.py +0 -0
  39. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/web/__init__.py +0 -0
  40. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/web/__main__.py +0 -0
  41. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/web/handler.py +0 -0
  42. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/web/jobs.py +0 -0
  43. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/web/page.py +0 -0
  44. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/web/picker.py +0 -0
  45. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/web/runners.py +0 -0
  46. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/web/server.py +0 -0
  47. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/pyreuser3/web/settings.py +0 -0
  48. {pyreuser3-0.4.0 → pyreuser3-0.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyREUser3
3
- Version: 0.4.0
3
+ Version: 0.6.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.6.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
@@ -15,6 +15,7 @@ pyreuser3/__main__.py
15
15
  pyreuser3/api.py
16
16
  pyreuser3/cli.py
17
17
  pyreuser3/core.py
18
+ pyreuser3/enum_codec.py
18
19
  pyreuser3/rich_ui.py
19
20
  pyreuser3/schema.py
20
21
  pyreuser3/export/__init__.py
@@ -40,4 +41,5 @@ pyreuser3/web/runners.py
40
41
  pyreuser3/web/server.py
41
42
  pyreuser3/web/settings.py
42
43
  tests/test_enum_binary.py
44
+ tests/test_enum_codec.py
43
45
  tests/test_export_enums.py
@@ -112,6 +112,13 @@ repack_data = converter.user3_to_json(
112
112
 
113
113
  Use `json_format="readable"` for the same shape produced by `export_file()`, or `json_format="repack"` for the full instance-table document accepted by `pack()`.
114
114
 
115
+ Enum fields are rendered as `[numeric] Name` labels using the enum's actual storage
116
+ width. Scalar flag enums are rendered as arrays of labels. ``ace.Bitset`1<T>`` values are
117
+ rendered as enum-index labels together with `_MaxElement` and `_WordCount`, so unknown
118
+ bits and padded word arrays remain reversible. Repack exports use
119
+ `re_user3_pack_v2`; the packer continues to accept v1 documents with raw numeric Bitset
120
+ word arrays.
121
+
115
122
  For stable patch-and-repack workflows, use `patch_file()` or `parse_pack_file()`:
116
123
 
117
124
  ```python
@@ -140,6 +140,11 @@ repack_data = converter.user3_to_json(
140
140
 
141
141
  使用 `json_format="readable"` 时返回与 `export_file()` 一致的可读导出结构;使用 `json_format="repack"` 时返回可传给 `pack()` 的完整实例表结构。
142
142
 
143
+ 枚举字段会按照真实底层存储宽度输出为 `[数值] 名称`。标量位标志枚举输出为标签数组;
144
+ ``ace.Bitset`1<T>`` 输出为枚举索引标签,并保留 `_MaxElement` 与 `_WordCount`,因此未知位和
145
+ 填充词也可以无损封回。新的 repack 文档格式为 `re_user3_pack_v2`,封回器仍兼容使用原始
146
+ 数值词数组的 v1 文档。
147
+
143
148
  批量处理目录:
144
149
 
145
150
  ```python
@@ -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.6.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"
@@ -20,7 +20,9 @@ USR_MAGIC = 5395285
20
20
  RSZ_MAGIC = 5919570
21
21
  # Preserve instance numbering and reference identity; RSZ object links depend on these
22
22
  # indexes remaining stable.
23
- PACK_JSON_FORMAT = "re_user3_pack_v1"
23
+ PACK_JSON_FORMAT_V1 = "re_user3_pack_v1"
24
+ PACK_JSON_FORMAT = "re_user3_pack_v2"
25
+ PACK_JSON_FORMATS = frozenset({PACK_JSON_FORMAT_V1, PACK_JSON_FORMAT})
24
26
  # Decode strings and GUID-like values conservatively so invalid data does not corrupt
25
27
  # subsequent parsing.
26
28
  HEX32_RE = re.compile(r"^[0-9a-fA-F]{32}$")
@@ -0,0 +1,231 @@
1
+ """Shared enum, flag, and ``ace.Bitset`` conversion helpers.
2
+
3
+ The exporter and packer deliberately share these operations so readable labels remain
4
+ lossless across signed/unsigned storage widths and generic container wrappers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Any
11
+
12
+
13
+ ENUM_LABEL_RE = re.compile(r"^\[(-?\d+)\]\s*(.*)$")
14
+ _GENERIC_RE = re.compile(r"^[^<]+<(.+)>$")
15
+
16
+
17
+ def normalize_integer_for_storage(value: int, storage_type: str | None) -> int:
18
+ """Normalize an integer according to an enum's declared storage width/sign."""
19
+ storage = storage_type or "S32"
20
+ widths = {
21
+ "S8": (8, True),
22
+ "U8": (8, False),
23
+ "S16": (16, True),
24
+ "U16": (16, False),
25
+ "S32": (32, True),
26
+ "U32": (32, False),
27
+ "S64": (64, True),
28
+ "U64": (64, False),
29
+ }
30
+ bits, signed = widths.get(storage, (32, True))
31
+ mask = (1 << bits) - 1
32
+ normalized = int(value) & mask
33
+ if signed and normalized >= 1 << (bits - 1):
34
+ normalized -= 1 << bits
35
+ return normalized
36
+
37
+
38
+ def parse_enum_label(value: Any) -> int | None:
39
+ """Return the authoritative numeric prefix from ``[N] Name`` labels."""
40
+ if isinstance(value, bool):
41
+ return int(value)
42
+ if isinstance(value, int):
43
+ return value
44
+ if not isinstance(value, str):
45
+ return None
46
+ match = ENUM_LABEL_RE.match(value.strip())
47
+ if match:
48
+ return int(match.group(1))
49
+ return None
50
+
51
+
52
+ def enum_member_for_value(
53
+ enum_lookup: dict[str, dict[int, tuple[str, int]]],
54
+ enum_type: str,
55
+ value: int,
56
+ enum_underlying_types: dict[str, str] | None = None,
57
+ ) -> tuple[str, int] | None:
58
+ """Resolve a value without inventing 32-bit aliases for wider enum types."""
59
+ value_map = enum_lookup.get(enum_type)
60
+ if not value_map:
61
+ return None
62
+ storage = (enum_underlying_types or {}).get(enum_type, "S32")
63
+ target = normalize_integer_for_storage(value, storage)
64
+ direct = value_map.get(value)
65
+ if direct is not None and normalize_integer_for_storage(direct[1], storage) == target:
66
+ return direct
67
+ for member_name, raw_value in value_map.values():
68
+ if normalize_integer_for_storage(raw_value, storage) == target:
69
+ return member_name, raw_value
70
+ return None
71
+
72
+
73
+ def generic_arguments(type_name: str) -> list[str]:
74
+ """Parse the simple, fully-qualified generic type names emitted by il2cpp dumps."""
75
+ if not isinstance(type_name, str):
76
+ return []
77
+ match = _GENERIC_RE.match(type_name)
78
+ if not match:
79
+ return []
80
+ # These metadata names are not assembly-qualified and current RE generic arguments
81
+ # are flat. Keeping this parser intentionally strict prevents ambiguous guesses.
82
+ return [part.strip() for part in match.group(1).split(",") if part.strip()]
83
+
84
+
85
+ def bitset_enum_type(class_name: str) -> str | None:
86
+ """Return the generic enum argument for one ``ace.Bitset`1`` class."""
87
+ if not isinstance(class_name, str) or not class_name.startswith("ace.Bitset`1<"):
88
+ return None
89
+ args = generic_arguments(class_name)
90
+ return args[0] if len(args) == 1 else None
91
+
92
+
93
+ def is_probable_flags_enum(
94
+ enum_type: str,
95
+ value_map: dict[int, tuple[str, int]],
96
+ storage_type: str | None = None,
97
+ ) -> bool:
98
+ """Conservatively identify bit-mask enums from their name and member values."""
99
+ upper = enum_type.upper()
100
+ markers = ("BIT", "BITS", "FLAG", "FLAGS", "MASK", "ATTR")
101
+ if not any(marker in upper for marker in markers):
102
+ return False
103
+ storage = storage_type or "S32"
104
+ width = int(storage[1:])
105
+ mask = (1 << width) - 1
106
+ values = {
107
+ normalize_integer_for_storage(raw, storage) & mask
108
+ for _name, raw in value_map.values()
109
+ if raw != 0
110
+ }
111
+ basis = {value for value in values if value and value & (value - 1) == 0}
112
+ if len(basis) < 2:
113
+ return False
114
+ known_mask = 0
115
+ for value in basis:
116
+ known_mask |= value
117
+ return all(value & ~known_mask == 0 for value in values)
118
+
119
+
120
+ def decode_flags(
121
+ enum_lookup: dict[str, dict[int, tuple[str, int]]],
122
+ enum_type: str,
123
+ value: int,
124
+ enum_underlying_types: dict[str, str] | None = None,
125
+ ) -> list[str]:
126
+ """Expand a scalar mask to stable labels, preserving unknown bits explicitly."""
127
+ storage = (enum_underlying_types or {}).get(enum_type, "S32")
128
+ normalized = normalize_integer_for_storage(value, storage)
129
+ bits = normalized if normalized >= 0 else normalized & ((1 << int(storage[1:])) - 1)
130
+ value_map = enum_lookup.get(enum_type, {})
131
+ if bits == 0:
132
+ zero = enum_member_for_value(enum_lookup, enum_type, 0, enum_underlying_types)
133
+ return [f"[0] {zero[0]}"] if zero else []
134
+ labels: list[str] = []
135
+ remaining = bits
136
+ width_mask = (1 << int(storage[1:])) - 1
137
+ members: list[tuple[int, str, int]] = []
138
+ for name, raw in value_map.values():
139
+ normalized_raw = normalize_integer_for_storage(raw, storage)
140
+ mask_value = normalized_raw & width_mask
141
+ if mask_value and mask_value & (mask_value - 1) == 0:
142
+ members.append((mask_value, name, raw))
143
+ for mask_value, name, raw in sorted(members):
144
+ if remaining & mask_value:
145
+ labels.append(f"[{raw}] {name}")
146
+ remaining &= ~mask_value
147
+ bit_index = 0
148
+ while remaining:
149
+ if remaining & 1:
150
+ unknown_value = 1 << bit_index
151
+ labels.append(f"[{unknown_value}] <unknown>")
152
+ remaining >>= 1
153
+ bit_index += 1
154
+ return labels
155
+
156
+
157
+ def decode_bitset(
158
+ words: list[int],
159
+ enum_type: str,
160
+ enum_lookup: dict[str, dict[int, tuple[str, int]]],
161
+ max_element: int | None = None,
162
+ enum_underlying_types: dict[str, str] | None = None,
163
+ ) -> list[str]:
164
+ """Decode 32-bit words into enum-index labels."""
165
+ labels: list[str] = []
166
+ for word_index, raw_word in enumerate(words):
167
+ word = int(raw_word) & 0xFFFFFFFF
168
+ for bit in range(32):
169
+ index = word_index * 32 + bit
170
+ if not (word & (1 << bit)):
171
+ continue
172
+ member = enum_member_for_value(
173
+ enum_lookup, enum_type, index, enum_underlying_types
174
+ )
175
+ labels.append(f"[{index}] {member[0] if member else '<unknown>'}")
176
+ return labels
177
+
178
+
179
+ def encode_bitset(
180
+ labels: list[Any],
181
+ enum_type: str,
182
+ member_lookup: dict[str, dict[str, int]],
183
+ max_element: int | None = None,
184
+ word_count: int | None = None,
185
+ ) -> list[int]:
186
+ """Encode enum-index labels into 32-bit words, preserving requested padding."""
187
+ indexes: list[int] = []
188
+ members = member_lookup.get(enum_type, {})
189
+ for item in labels:
190
+ index = parse_enum_label(item)
191
+ if index is None and isinstance(item, str):
192
+ index = members.get(item.strip())
193
+ if index is None or index < 0:
194
+ raise ValueError(f"invalid {enum_type} bitset member: {item!r}")
195
+ if (
196
+ max_element is not None
197
+ and index >= max_element
198
+ and (word_count is None or index >= word_count * 32)
199
+ ):
200
+ raise ValueError(
201
+ f"bitset index {index} exceeds _MaxElement {max_element} for {enum_type}"
202
+ )
203
+ indexes.append(index)
204
+ selected_minimum = (max(indexes) + 32) // 32 if indexes else 0
205
+ derived_count = max(
206
+ selected_minimum,
207
+ (max_element + 31) // 32 if max_element else 0,
208
+ )
209
+ count = derived_count if word_count is None else word_count
210
+ if count < selected_minimum:
211
+ raise ValueError(f"_WordCount {count} is too small for {enum_type} bitset")
212
+ words = [0] * count
213
+ for index in indexes:
214
+ words[index // 32] |= 1 << (index % 32)
215
+ return words
216
+
217
+
218
+ def encode_flags(
219
+ values: list[Any], enum_type: str, member_lookup: dict[str, dict[str, int]]
220
+ ) -> int:
221
+ """Combine readable scalar flag labels back into one integer mask."""
222
+ result = 0
223
+ members = member_lookup.get(enum_type, {})
224
+ for item in values:
225
+ numeric = parse_enum_label(item)
226
+ if numeric is None and isinstance(item, str):
227
+ numeric = members.get(item.strip())
228
+ if numeric is None:
229
+ raise ValueError(f"invalid {enum_type} flag member: {item!r}")
230
+ result |= numeric
231
+ return result
@@ -87,8 +87,11 @@ class User3Exporter(
87
87
  self.class_field_fixed_types: dict[str, dict[str, str]] = {}
88
88
  self.serializable_to_fixed: dict[str, str] = {}
89
89
  self.generic_container_rules: dict[str, tuple[str, str]] = {}
90
+ self.generic_scalar_rules: dict[str, str] = {}
91
+ self.bitset_rules: dict[str, str] = {}
90
92
  self.param_type_default_enum: dict[str, str] = {}
91
93
  self.enum_underlying_types: dict[str, str] = {}
94
+ self.enum_flags: set[str] = set()
92
95
  self.enum_member_to_types: dict[str, list[str]] = {}
93
96
  self._pending_enum_context: dict | None = None
94
97
 
@@ -258,6 +258,9 @@ class ExporterEnumSourceMixin:
258
258
  "class_field_fixed_types": {},
259
259
  "serializable_to_fixed": {},
260
260
  "generic_container_rules": {},
261
+ "generic_scalar_rules": {},
262
+ "bitset_rules": {},
263
+ "generic_candidates": {},
261
264
  "enum_underlying_types": {},
262
265
  "enum_types": set(),
263
266
  "serializable_fallback_candidates": [],
@@ -271,7 +274,7 @@ class ExporterEnumSourceMixin:
271
274
  if not isinstance(class_name, str) or not isinstance(obj, dict):
272
275
  return
273
276
 
274
- if class_name.endswith("_Fixed") and obj.get("parent") == "System.Enum":
277
+ if obj.get("parent") == "System.Enum":
275
278
  state["enum_types"].add(class_name)
276
279
  if obj.get("parent") == "System.Enum":
277
280
  storage_type = cls._extract_enum_underlying_type(obj)
@@ -383,25 +386,18 @@ class ExporterEnumSourceMixin:
383
386
  state["serializable_fallback_candidates"].append(class_name)
384
387
 
385
388
  generic_args = obj.get("generic_arg_types")
386
- if isinstance(generic_args, list) and len(generic_args) >= 2:
387
- enum_types_in_args: list[str] = []
388
- param_types: list[str] = []
389
- for arg in generic_args:
390
- if not isinstance(arg, dict):
391
- continue
392
- raw_type = arg.get("type")
393
- fixed_type = cls._extract_fixed_enum_type(raw_type)
394
- if fixed_type is not None:
395
- enum_types_in_args.append(fixed_type)
396
- elif isinstance(raw_type, str) and raw_type != "unknown":
397
- param_types.append(raw_type)
398
- enum_types_unique = list(dict.fromkeys(enum_types_in_args))
399
- param_types_unique = list(dict.fromkeys(param_types))
400
- if len(enum_types_unique) == 1 and len(param_types_unique) == 1:
401
- state["generic_container_rules"][class_name] = {
402
- "param_type": param_types_unique[0],
403
- "enum_type": enum_types_unique[0],
404
- }
389
+ if isinstance(generic_args, list):
390
+ raw_types = [
391
+ arg.get("type")
392
+ for arg in generic_args
393
+ if isinstance(arg, dict)
394
+ and isinstance(arg.get("type"), str)
395
+ and arg.get("type") != "unknown"
396
+ ]
397
+ if raw_types:
398
+ # Resolution is deferred until every top-level enum entry has been
399
+ # observed; il2cpp dump ordering is not a semantic contract.
400
+ state["generic_candidates"][class_name] = raw_types
405
401
 
406
402
  @classmethod
407
403
  def _finalize_enum_context_state(cls, state: dict[str, Any]) -> dict:
@@ -415,10 +411,30 @@ class ExporterEnumSourceMixin:
415
411
  if len(fallback_types) == 1:
416
412
  serializable_to_fixed[class_name] = fallback_types[0]
417
413
 
414
+ for class_name, raw_types in state["generic_candidates"].items():
415
+ enum_args = list(dict.fromkeys(t for t in raw_types if t in enum_types))
416
+ param_args = list(dict.fromkeys(t for t in raw_types if t not in enum_types))
417
+ if len(enum_args) != 1:
418
+ continue
419
+ enum_type = enum_args[0]
420
+ if class_name.startswith("ace.Bitset`1<"):
421
+ state["bitset_rules"][class_name] = enum_type
422
+ elif class_name.startswith("ace.btable.cEditFieldEnum`1<"):
423
+ state["generic_scalar_rules"][class_name] = enum_type
424
+ elif len(param_args) == 1 and (
425
+ enum_type.endswith("_Fixed") or "cEnumerableParam" in class_name
426
+ ):
427
+ state["generic_container_rules"][class_name] = {
428
+ "param_type": param_args[0],
429
+ "enum_type": enum_type,
430
+ }
431
+
418
432
  return {
419
433
  "class_field_fixed_types": state["class_field_fixed_types"],
420
434
  "serializable_to_fixed": serializable_to_fixed,
421
435
  "generic_container_rules": state["generic_container_rules"],
436
+ "generic_scalar_rules": state["generic_scalar_rules"],
437
+ "bitset_rules": state["bitset_rules"],
422
438
  "enum_underlying_types": state["enum_underlying_types"],
423
439
  }
424
440
 
@@ -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": {}}
@@ -11,6 +11,7 @@ from pathlib import Path
11
11
  from typing import Any
12
12
 
13
13
  from ..core import ParseError, enum_storage_type_from_size
14
+ from ..enum_codec import enum_member_for_value, is_probable_flags_enum
14
15
  from ..rich_ui import get_console
15
16
 
16
17
 
@@ -100,8 +101,6 @@ class ExporterMetadataMixin:
100
101
  # Register enum values through the shared lookup tables so readable
101
102
  # labels and numeric packing stay reversible.
102
103
  value_map[raw_value] = entry
103
- value_map[self._to_s32(raw_value)] = entry
104
- value_map[self._to_u32(raw_value)] = entry
105
104
  if value_map:
106
105
  lookup[enum_type] = value_map
107
106
  return lookup
@@ -205,11 +204,12 @@ class ExporterMetadataMixin:
205
204
  value_map = self.enum_lookup.get(enum_type)
206
205
  if value_map is None:
207
206
  continue
208
- if (
209
- value in value_map
210
- or self._to_s32(value) in value_map
211
- or self._to_u32(value) in value_map
212
- ):
207
+ if enum_member_for_value(
208
+ self.enum_lookup,
209
+ enum_type,
210
+ value,
211
+ self.enum_underlying_types,
212
+ ) is not None:
213
213
  matched.append(enum_type)
214
214
  if len(matched) == 1:
215
215
  return matched[0]
@@ -232,8 +232,11 @@ class ExporterMetadataMixin:
232
232
  self.class_field_fixed_types = {}
233
233
  self.serializable_to_fixed = {}
234
234
  self.generic_container_rules = {}
235
+ self.generic_scalar_rules = {}
236
+ self.bitset_rules = {}
235
237
  self.param_type_default_enum = {}
236
238
  self.enum_underlying_types = {}
239
+ self.enum_flags = set()
237
240
 
238
241
  class_field_fixed_types = raw.get("class_field_fixed_types")
239
242
  if isinstance(class_field_fixed_types, dict):
@@ -268,7 +271,6 @@ class ExporterMetadataMixin:
268
271
  if (
269
272
  isinstance(serializable_name, str)
270
273
  and isinstance(fixed_name, str)
271
- and fixed_name.endswith("_Fixed")
272
274
  ):
273
275
  self.serializable_to_fixed[serializable_name] = fixed_name
274
276
 
@@ -285,7 +287,6 @@ class ExporterMetadataMixin:
285
287
  if (
286
288
  isinstance(param_type, str)
287
289
  and isinstance(enum_type, str)
288
- and enum_type.endswith("_Fixed")
289
290
  ):
290
291
  self.generic_container_rules[container_name] = (
291
292
  param_type,
@@ -299,6 +300,32 @@ class ExporterMetadataMixin:
299
300
  # labels and numeric packing stay reversible.
300
301
  self.param_type_default_enum[param_type] = next(iter(enum_types))
301
302
 
303
+ generic_scalar_rules = raw.get("generic_scalar_rules")
304
+ if isinstance(generic_scalar_rules, dict):
305
+ self.generic_scalar_rules = {
306
+ class_name: enum_type
307
+ for class_name, enum_type in generic_scalar_rules.items()
308
+ if isinstance(class_name, str)
309
+ and isinstance(enum_type, str)
310
+ and enum_type in self.enum_lookup
311
+ }
312
+ bitset_rules = raw.get("bitset_rules")
313
+ if isinstance(bitset_rules, dict):
314
+ self.bitset_rules = {
315
+ class_name: enum_type
316
+ for class_name, enum_type in bitset_rules.items()
317
+ if isinstance(class_name, str)
318
+ and isinstance(enum_type, str)
319
+ and enum_type in self.enum_lookup
320
+ }
321
+ self.enum_flags = {
322
+ enum_type
323
+ for enum_type, value_map in self.enum_lookup.items()
324
+ if is_probable_flags_enum(
325
+ enum_type, value_map, self.enum_underlying_types.get(enum_type)
326
+ )
327
+ }
328
+
302
329
  def _enum_type_candidates_for_lookup(self, type_name: str) -> list[str]:
303
330
  """Yield enum lookup candidates for schema or il2cpp type names."""
304
331
  candidates = [type_name]
@@ -337,11 +364,11 @@ class ExporterMetadataMixin:
337
364
  if typedb is None or not enum_lookup:
338
365
  return
339
366
 
340
- for class_def in typedb.classes.values():
367
+ iter_classes = getattr(typedb, "iter_classes", None)
368
+ class_defs = iter_classes() if callable(iter_classes) else typedb.classes.values()
369
+ for class_def in class_defs:
341
370
  field_map = self.class_field_fixed_types.setdefault(class_def.name, {})
342
371
  for field in class_def.fields:
343
- if field.field_type != "Enum":
344
- continue
345
372
  enum_type = self._resolve_enum_type_for_lookup(field.original_type)
346
373
  if enum_type is not None:
347
374
  field_map.setdefault(field.name or "unnamed", enum_type)