PyREUser3 0.7.2__tar.gz → 0.8.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 (53) hide show
  1. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/PKG-INFO +1 -1
  2. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/PyREUser3.egg-info/PKG-INFO +1 -1
  3. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/README.md +4 -2
  4. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyproject.toml +1 -1
  5. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/__init__.py +1 -1
  6. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/export/enums.py +85 -0
  7. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/export/postprocess.py +52 -4
  8. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/pack/writer.py +32 -5
  9. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/tests/test_enum_binary.py +78 -0
  10. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/tests/test_export_enums.py +229 -0
  11. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/LICENSE +0 -0
  12. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/MANIFEST.in +0 -0
  13. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/PyREUser3.egg-info/SOURCES.txt +0 -0
  14. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/PyREUser3.egg-info/dependency_links.txt +0 -0
  15. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/PyREUser3.egg-info/entry_points.txt +0 -0
  16. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/PyREUser3.egg-info/requires.txt +0 -0
  17. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/PyREUser3.egg-info/top_level.txt +0 -0
  18. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/docs/PYPI.md +0 -0
  19. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/docs/README.zh-CN.md +0 -0
  20. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/__main__.py +0 -0
  21. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/api.py +0 -0
  22. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/cli.py +0 -0
  23. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/core.py +0 -0
  24. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/enum_codec.py +0 -0
  25. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/export/__init__.py +0 -0
  26. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/export/base.py +0 -0
  27. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/export/fields.py +0 -0
  28. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/export/metadata.py +0 -0
  29. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/export/tree.py +0 -0
  30. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/export/user3.py +0 -0
  31. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/pack/__init__.py +0 -0
  32. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/pack/base.py +0 -0
  33. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/pack/container.py +0 -0
  34. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/pack/models.py +0 -0
  35. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/pack/plan.py +0 -0
  36. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/pack/values.py +0 -0
  37. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/rich_ui.py +0 -0
  38. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/schema.py +0 -0
  39. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/usr_container.py +0 -0
  40. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/usr_layouts.py +0 -0
  41. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/web/__init__.py +0 -0
  42. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/web/__main__.py +0 -0
  43. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/web/handler.py +0 -0
  44. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/web/jobs.py +0 -0
  45. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/web/page.py +0 -0
  46. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/web/picker.py +0 -0
  47. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/web/runners.py +0 -0
  48. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/web/server.py +0 -0
  49. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/pyreuser3/web/settings.py +0 -0
  50. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/setup.cfg +0 -0
  51. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/tests/test_api_cache.py +0 -0
  52. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/tests/test_enum_codec.py +0 -0
  53. {pyreuser3-0.7.2 → pyreuser3-0.8.0}/tests/test_usr_container.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyREUser3
3
- Version: 0.7.2
3
+ Version: 0.8.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.7.2
3
+ Version: 0.8.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
@@ -169,8 +169,10 @@ Use `json_format="readable"` for the same shape produced by `export_file()`. Thi
169
169
  shape is read-only. Use `json_format="repack"` for the full document accepted by
170
170
  `pack()`; the packer rejects readable JSON.
171
171
 
172
- Enum fields are rendered as `[numeric] Name` labels using the enum's actual storage
173
- width. Scalar flag enums are rendered as arrays of labels. ``ace.Bitset`1<T>`` values are
172
+ Readable enum fields are rendered as `[numeric] Name` labels using the enum's actual
173
+ storage width and signedness, so signed Fixed IDs match their in-game runtime values.
174
+ Repack documents retain canonical unsigned Fixed prefixes for stable machine editing.
175
+ Scalar flag enums are rendered as arrays of labels. ``ace.Bitset`1<T>`` values are
174
176
  rendered as enum-index labels together with `_MaxElement` and `_WordCount`, so unknown
175
177
  bits and padded word arrays remain reversible. Repack exports use
176
178
  `re_user3_pack_v3`, which records the independently detected USR outer layout, RSZ
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "PyREUser3"
7
- version = "0.7.2"
7
+ version = "0.8.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"
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  from importlib import import_module
10
10
  from typing import Any
11
11
 
12
- __version__ = "0.7.2"
12
+ __version__ = "0.8.0"
13
13
 
14
14
  __all__ = [
15
15
  "__version__",
@@ -18,6 +18,9 @@ from ..core import ENUM_UNUSED_KEY, normalize_enum_storage_type
18
18
  _FIXED_ENUM_RE = re.compile(r"[A-Za-z0-9_.+]+_Fixed")
19
19
  _PROPERTY_METHOD_RE = re.compile(r"^(?:get|set)_(?P<name>.+?)(?:\d+)?$")
20
20
  _BACKING_FIELD_RE = re.compile(r"^<(?P<name>[^>]+)>")
21
+ _INTEGER_STORAGE_TYPES = frozenset(
22
+ {"S8", "U8", "S16", "U16", "S32", "U32", "S64", "U64"}
23
+ )
21
24
 
22
25
 
23
26
  class ExporterEnumSourceMixin:
@@ -104,6 +107,39 @@ class ExporterEnumSourceMixin:
104
107
  for candidate in cls._field_name_variants(field_name):
105
108
  field_map.setdefault(candidate, fixed_type)
106
109
 
110
+ @classmethod
111
+ def _add_fixed_backing_candidate(
112
+ cls,
113
+ candidates: dict[str, set[str]],
114
+ field_name: Any,
115
+ type_name: Any,
116
+ ) -> None:
117
+ """Record an integer ``*_Fixed`` backing field by its runtime property name."""
118
+ if not isinstance(field_name, str):
119
+ return
120
+ storage_type = normalize_enum_storage_type(type_name)
121
+ if storage_type not in _INTEGER_STORAGE_TYPES:
122
+ return
123
+ for field_variant in cls._field_name_variants(field_name):
124
+ normalized_name = field_variant.lstrip("_")
125
+ if not normalized_name.endswith("_Fixed"):
126
+ continue
127
+ property_name = normalized_name[: -len("_Fixed")]
128
+ if property_name:
129
+ candidates.setdefault(property_name, set()).add(field_variant)
130
+ candidates.setdefault(normalized_name, set()).add(field_variant)
131
+
132
+ @staticmethod
133
+ def _method_return_type(method: Any) -> str | None:
134
+ """Return one concrete method return type when metadata exposes it."""
135
+ if not isinstance(method, dict):
136
+ return None
137
+ returns = method.get("returns")
138
+ if not isinstance(returns, dict):
139
+ return None
140
+ return_type = returns.get("type")
141
+ return return_type if isinstance(return_type, str) and return_type else None
142
+
107
143
  @staticmethod
108
144
  def _method_matches_property(method_name: str, getter_or_setter: str) -> bool:
109
145
  """Return whether an il2cpp method key represents the named property accessor."""
@@ -264,6 +300,7 @@ class ExporterEnumSourceMixin:
264
300
  "enum_underlying_types": {},
265
301
  "enum_types": set(),
266
302
  "serializable_fallback_candidates": [],
303
+ "fixed_backing_candidates": [],
267
304
  }
268
305
 
269
306
  @classmethod
@@ -282,6 +319,7 @@ class ExporterEnumSourceMixin:
282
319
  state["enum_underlying_types"][class_name] = storage_type
283
320
 
284
321
  field_map: dict[str, str] = {}
322
+ fixed_backing_fields: dict[str, set[str]] = {}
285
323
  fields_obj = obj.get("fields")
286
324
  if isinstance(fields_obj, dict):
287
325
  for field_name, field_info in fields_obj.items():
@@ -290,6 +328,9 @@ class ExporterEnumSourceMixin:
290
328
  cls._add_field_fixed_type(
291
329
  field_map, field_name, field_info.get("type")
292
330
  )
331
+ cls._add_fixed_backing_candidate(
332
+ fixed_backing_fields, field_name, field_info.get("type")
333
+ )
293
334
 
294
335
  # Older dumps expose RE_RSZ field hints through an explicit RSZ array.
295
336
  rsz_fields = obj.get("RSZ")
@@ -303,6 +344,9 @@ class ExporterEnumSourceMixin:
303
344
  cls._add_field_fixed_type(
304
345
  field_map, potential_name, rsz_field.get("type")
305
346
  )
347
+ cls._add_fixed_backing_candidate(
348
+ fixed_backing_fields, potential_name, rsz_field.get("type")
349
+ )
306
350
 
307
351
  # Newer dumps can omit RSZ entirely but keep property/type hints here.
308
352
  reflection_props = obj.get("reflection_properties")
@@ -311,8 +355,12 @@ class ExporterEnumSourceMixin:
311
355
  if not isinstance(prop_name, str) or not isinstance(prop_info, dict):
312
356
  continue
313
357
  cls._add_field_fixed_type(field_map, prop_name, prop_info.get("type"))
358
+ cls._add_fixed_backing_candidate(
359
+ fixed_backing_fields, prop_name, prop_info.get("type")
360
+ )
314
361
 
315
362
  methods_obj = obj.get("methods")
363
+ runtime_property_types: dict[str, set[str]] = {}
316
364
  if isinstance(methods_obj, dict):
317
365
  properties_obj = obj.get("properties")
318
366
  if isinstance(properties_obj, dict):
@@ -326,6 +374,11 @@ class ExporterEnumSourceMixin:
326
374
  continue
327
375
  if not cls._method_matches_property(method_name, getter):
328
376
  continue
377
+ return_type = cls._method_return_type(method)
378
+ if return_type is not None:
379
+ runtime_property_types.setdefault(
380
+ prop_name, set()
381
+ ).add(return_type)
329
382
  fixed_type = cls._fixed_type_from_method_return(method)
330
383
  if fixed_type is not None:
331
384
  cls._add_field_fixed_type(
@@ -350,6 +403,12 @@ class ExporterEnumSourceMixin:
350
403
  method_prop_name = cls._property_name_from_method(method_name)
351
404
  if method_prop_name is None:
352
405
  continue
406
+ if method_name.startswith("get_"):
407
+ return_type = cls._method_return_type(method)
408
+ if return_type is not None:
409
+ runtime_property_types.setdefault(
410
+ method_prop_name, set()
411
+ ).add(return_type)
353
412
  fixed_type = None
354
413
  if method_name.startswith("get_"):
355
414
  fixed_type = cls._fixed_type_from_method_return(method)
@@ -358,6 +417,18 @@ class ExporterEnumSourceMixin:
358
417
  if fixed_type is not None:
359
418
  cls._add_field_fixed_type(field_map, method_prop_name, fixed_type)
360
419
 
420
+ for property_name, field_names in fixed_backing_fields.items():
421
+ runtime_types = runtime_property_types.get(property_name, set())
422
+ if len(runtime_types) != 1:
423
+ continue
424
+ state["fixed_backing_candidates"].append(
425
+ (
426
+ class_name,
427
+ tuple(sorted(field_names)),
428
+ next(iter(runtime_types)),
429
+ )
430
+ )
431
+
361
432
  if field_map:
362
433
  state["class_field_fixed_types"][class_name] = field_map
363
434
 
@@ -411,6 +482,20 @@ class ExporterEnumSourceMixin:
411
482
  if len(fallback_types) == 1:
412
483
  serializable_to_fixed[class_name] = fallback_types[0]
413
484
 
485
+ for class_name, field_names, runtime_type in state[
486
+ "fixed_backing_candidates"
487
+ ]:
488
+ fixed_type = (
489
+ runtime_type
490
+ if runtime_type.endswith("_Fixed")
491
+ else f"{runtime_type}_Fixed"
492
+ )
493
+ if fixed_type not in enum_types:
494
+ continue
495
+ field_map = state["class_field_fixed_types"].setdefault(class_name, {})
496
+ for field_name in field_names:
497
+ field_map.setdefault(field_name, fixed_type)
498
+
414
499
  for class_name, raw_types in state["generic_candidates"].items():
415
500
  enum_args = list(dict.fromkeys(t for t in raw_types if t in enum_types))
416
501
  param_args = list(dict.fromkeys(t for t in raw_types if t not in enum_types))
@@ -13,6 +13,7 @@ from ..enum_codec import (
13
13
  decode_bitset,
14
14
  decode_flags,
15
15
  enum_member_for_value,
16
+ normalize_integer_for_storage,
16
17
  )
17
18
 
18
19
 
@@ -68,7 +69,13 @@ class ExporterPostprocessMixin:
68
69
  return candidate
69
70
  return type_name
70
71
 
71
- def _format_enum_value(self, fixed_enum_type: str, value: int) -> Any:
72
+ def _format_enum_value(
73
+ self,
74
+ fixed_enum_type: str,
75
+ value: int,
76
+ *,
77
+ runtime_signedness: bool = True,
78
+ ) -> Any:
72
79
  """Format enum value.
73
80
 
74
81
  Exact declared members stay intact; flagged enums are decomposed only when the
@@ -97,7 +104,14 @@ class ExporterPostprocessMixin:
97
104
  )
98
105
  if matched is not None:
99
106
  member_name, fixed_value = matched
100
- return self._id_formatter(member_name, fixed_value)
107
+ return self._id_formatter(
108
+ member_name,
109
+ self._normalize_enum_display_value(
110
+ fixed_enum_type,
111
+ fixed_value,
112
+ runtime_signedness=runtime_signedness,
113
+ ),
114
+ )
101
115
  if fixed_enum_type in getattr(self, "enum_flags", set()):
102
116
  return decode_flags(
103
117
  self.enum_lookup,
@@ -105,7 +119,37 @@ class ExporterPostprocessMixin:
105
119
  value,
106
120
  getattr(self, "enum_underlying_types", {}),
107
121
  )
108
- return value
122
+ if not fixed_enum_type.endswith("_Fixed"):
123
+ return value
124
+ return self._id_formatter(
125
+ "<unknown>",
126
+ self._normalize_enum_display_value(
127
+ fixed_enum_type,
128
+ value,
129
+ runtime_signedness=runtime_signedness,
130
+ ),
131
+ )
132
+
133
+ def _normalize_enum_display_value(
134
+ self,
135
+ enum_type: str,
136
+ value: int,
137
+ *,
138
+ runtime_signedness: bool,
139
+ ) -> int:
140
+ """Normalize IDs to the integer domain exposed by the runtime enum."""
141
+ storage_type = getattr(self, "enum_underlying_types", {}).get(enum_type)
142
+ if (
143
+ not runtime_signedness
144
+ and enum_type.endswith("_Fixed")
145
+ and storage_type in {"S8", "S16", "S32", "S64"}
146
+ ):
147
+ storage_type = f"U{storage_type[1:]}"
148
+ return (
149
+ normalize_integer_for_storage(value, storage_type)
150
+ if storage_type is not None
151
+ else value
152
+ )
109
153
 
110
154
  @staticmethod
111
155
  def _looks_like_class_name(text: str) -> bool:
@@ -427,7 +471,11 @@ class ExporterPostprocessMixin:
427
471
  if isinstance(value, int) and scalar_enum_hint is not None:
428
472
  # Register enum values through the shared lookup tables so readable labels
429
473
  # and numeric packing stay reversible.
430
- return self._format_enum_value(scalar_enum_hint, value)
474
+ return self._format_enum_value(
475
+ scalar_enum_hint,
476
+ value,
477
+ runtime_signedness=output_mode != "repack",
478
+ )
431
479
  return value
432
480
 
433
481
  def _finalize_export_tree(self, value: Any) -> Any:
@@ -20,7 +20,7 @@ from .models import (
20
20
  StructValue,
21
21
  )
22
22
  from ..core import align, enum_storage_size, enum_storage_type_from_size
23
- from ..enum_codec import ENUM_LABEL_RE
23
+ from ..enum_codec import ENUM_LABEL_RE, normalize_integer_for_storage
24
24
  from ..schema import FieldDef
25
25
 
26
26
  class PackerWriterMixin:
@@ -437,19 +437,28 @@ class PackerWriterMixin:
437
437
  writer.write_struct("<B", 1 if bool(value) else 0)
438
438
  return
439
439
  if t == "S8":
440
- writer.write_struct("<b", self._coerce_int(value, field_def))
440
+ writer.write_struct(
441
+ "<b",
442
+ self._coerce_signed_storage(value, field_def, "S8"),
443
+ )
441
444
  return
442
445
  if t == "U8":
443
446
  writer.write_struct("<B", self._coerce_int(value, field_def) & 0xFF)
444
447
  return
445
448
  if t == "S16":
446
- writer.write_struct("<h", self._coerce_int(value, field_def))
449
+ writer.write_struct(
450
+ "<h",
451
+ self._coerce_signed_storage(value, field_def, "S16"),
452
+ )
447
453
  return
448
454
  if t == "U16":
449
455
  writer.write_struct("<H", self._coerce_int(value, field_def) & 0xFFFF)
450
456
  return
451
457
  if t in {"S32", "Sfix"}:
452
- writer.write_struct("<i", self._to_s32(self._coerce_int(value, field_def)))
458
+ writer.write_struct(
459
+ "<i",
460
+ self._coerce_signed_storage(value, field_def, "S32"),
461
+ )
453
462
  return
454
463
  if t == "Enum":
455
464
  self._write_enum_value(writer, field_def, value)
@@ -458,7 +467,10 @@ class PackerWriterMixin:
458
467
  writer.write_struct("<I", self._coerce_int(value, field_def) & 0xFFFFFFFF)
459
468
  return
460
469
  if t == "S64":
461
- writer.write_struct("<q", self._coerce_int(value, field_def))
470
+ writer.write_struct(
471
+ "<q",
472
+ self._coerce_signed_storage(value, field_def, "S64"),
473
+ )
462
474
  return
463
475
  if t == "U64":
464
476
  writer.write_struct(
@@ -617,6 +629,21 @@ class PackerWriterMixin:
617
629
  # remain binary-compatible.
618
630
  writer.write(b"\x00" * (value.declared_size - consumed))
619
631
 
632
+ def _coerce_signed_storage(
633
+ self, value: Any, field_def: FieldDef, storage_type: str
634
+ ) -> int:
635
+ """Accept a signed runtime value or its canonical unsigned bit pattern."""
636
+ number = self._coerce_int(value, field_def)
637
+ bits = int(storage_type[1:])
638
+ signed_minimum = -(1 << (bits - 1))
639
+ unsigned_maximum = (1 << bits) - 1
640
+ if number < signed_minimum or number > unsigned_maximum:
641
+ raise PackError(
642
+ f"value {number} is outside the signed or canonical unsigned "
643
+ f"{storage_type} range for field {field_def.name}"
644
+ )
645
+ return normalize_integer_for_storage(number, storage_type)
646
+
620
647
  def _coerce_int(self, value: Any, field_def: FieldDef) -> int:
621
648
  """Coerce int.
622
649
 
@@ -139,6 +139,84 @@ class EnumBinaryTests(unittest.TestCase):
139
139
 
140
140
  self.assertEqual(bytes(writer.data), b"\x02\x00\x00\x00")
141
141
 
142
+ def test_erased_fixed_field_accepts_unsigned_numeric_label(self):
143
+ writer_mixin = EnumFieldWriter()
144
+ writer = BinaryWriter()
145
+ field = FieldDef(
146
+ name="_StageID_Fixed",
147
+ field_type="S32",
148
+ original_type="System.Int32",
149
+ size=4,
150
+ align=4,
151
+ is_array=False,
152
+ )
153
+
154
+ writer_mixin._write_scalar(writer, field, "[3068809728] ST101")
155
+
156
+ self.assertEqual(bytes(writer.data), bytes.fromhex("00 52 ea b6"))
157
+
158
+ def test_erased_fixed_field_accepts_signed_runtime_label(self):
159
+ writer_mixin = EnumFieldWriter()
160
+ writer = BinaryWriter()
161
+ field = FieldDef(
162
+ name="_StageID_Fixed",
163
+ field_type="S32",
164
+ original_type="System.Int32",
165
+ size=4,
166
+ align=4,
167
+ is_array=False,
168
+ )
169
+
170
+ writer_mixin._write_scalar(writer, field, "[-1226157568] ST101")
171
+
172
+ self.assertEqual(bytes(writer.data), bytes.fromhex("00 52 ea b6"))
173
+
174
+ def test_erased_fixed_fields_accept_runtime_and_canonical_signed_widths(self):
175
+ writer_mixin = EnumFieldWriter()
176
+ cases = (
177
+ ("S8", "System.SByte", 1, "[255] MinusOne"),
178
+ ("S16", "System.Int16", 2, "[65535] MinusOne"),
179
+ ("S32", "System.Int32", 4, "[4294967295] MinusOne"),
180
+ (
181
+ "S64",
182
+ "System.Int64",
183
+ 8,
184
+ "[18446744073709551615] MinusOne",
185
+ ),
186
+ )
187
+
188
+ for field_type, original_type, size, unsigned_label in cases:
189
+ with self.subTest(field_type=field_type):
190
+ field = FieldDef(
191
+ name="_Value_Fixed",
192
+ field_type=field_type,
193
+ original_type=original_type,
194
+ size=size,
195
+ align=size,
196
+ is_array=False,
197
+ )
198
+
199
+ for label in ("[-1] MinusOne", unsigned_label):
200
+ writer = BinaryWriter()
201
+
202
+ writer_mixin._write_scalar(writer, field, label)
203
+
204
+ self.assertEqual(bytes(writer.data), b"\xff" * size)
205
+
206
+ def test_signed_field_rejects_values_outside_both_numeric_domains(self):
207
+ writer_mixin = EnumFieldWriter()
208
+ field = FieldDef(
209
+ name="_Value_Fixed",
210
+ field_type="S8",
211
+ original_type="System.SByte",
212
+ size=1,
213
+ align=1,
214
+ is_array=False,
215
+ )
216
+
217
+ with self.assertRaisesRegex(PackError, "outside .* S8 range"):
218
+ writer_mixin._write_scalar(BinaryWriter(), field, "[256] Invalid")
219
+
142
220
  def test_runtime_type_reader_uses_c8_string_layout(self):
143
221
  parser = EnumFieldParser()
144
222
  reader = BinaryReader(b"\x04\x00\x00\x00abc\x00tail")
@@ -54,6 +54,104 @@ class ExporterEnumSourceMixinTests(unittest.TestCase):
54
54
  {"v2_Interpolation": "[1] SmoothStep"},
55
55
  )
56
56
 
57
+ self.assertEqual(
58
+ postprocessor._postprocess_enum_nodes(
59
+ {"v2_Interpolation": 99},
60
+ current_class="via.ColorRampKey",
61
+ ),
62
+ {"v2_Interpolation": 99},
63
+ )
64
+
65
+ def test_postprocess_respects_unsigned_fixed_storage(self):
66
+ postprocessor = EnumPostprocessor()
67
+ fixed_type = "app.FieldDef.STAGE_Fixed"
68
+ postprocessor.enum_lookup[fixed_type] = {
69
+ 3068809728: ("ST101", 3068809728),
70
+ }
71
+ postprocessor.enum_underlying_types[fixed_type] = "U32"
72
+ postprocessor.class_field_fixed_types = {
73
+ "app.user_data.GrassCullingSetting.cStageData": {
74
+ "_StageID_Fixed": fixed_type,
75
+ }
76
+ }
77
+
78
+ self.assertEqual(
79
+ postprocessor._postprocess_enum_nodes(
80
+ {"_StageID_Fixed": -1226157568},
81
+ current_class="app.user_data.GrassCullingSetting.cStageData",
82
+ ),
83
+ {"_StageID_Fixed": "[3068809728] ST101"},
84
+ )
85
+
86
+ def test_postprocess_displays_signed_runtime_fixed_id(self):
87
+ postprocessor = EnumPostprocessor()
88
+ fixed_type = "app.FieldDef.STAGE_Fixed"
89
+ postprocessor.enum_lookup[fixed_type] = {
90
+ 3068809728: ("ST101", 3068809728),
91
+ }
92
+ postprocessor.enum_underlying_types[fixed_type] = "S32"
93
+ postprocessor.class_field_fixed_types = {
94
+ "app.user_data.GrassCullingSetting.cStageData": {
95
+ "_StageID_Fixed": fixed_type,
96
+ }
97
+ }
98
+
99
+ self.assertEqual(
100
+ postprocessor._postprocess_enum_nodes(
101
+ {"_StageID_Fixed": -1226157568},
102
+ current_class="app.user_data.GrassCullingSetting.cStageData",
103
+ ),
104
+ {"_StageID_Fixed": "[-1226157568] ST101"},
105
+ )
106
+
107
+ def test_repack_keeps_canonical_unsigned_fixed_id(self):
108
+ postprocessor = EnumPostprocessor()
109
+ fixed_type = "app.FieldDef.STAGE_Fixed"
110
+ postprocessor.enum_lookup[fixed_type] = {
111
+ 3068809728: ("ST101", 3068809728),
112
+ }
113
+ postprocessor.enum_underlying_types[fixed_type] = "S32"
114
+ postprocessor.class_field_fixed_types = {
115
+ "app.user_data.GrassCullingSetting.cStageData": {
116
+ "_StageID_Fixed": fixed_type,
117
+ }
118
+ }
119
+
120
+ self.assertEqual(
121
+ postprocessor._postprocess_enum_nodes(
122
+ {"_StageID_Fixed": -1226157568},
123
+ current_class="app.user_data.GrassCullingSetting.cStageData",
124
+ output_mode="repack",
125
+ ),
126
+ {"_StageID_Fixed": "[3068809728] ST101"},
127
+ )
128
+
129
+ def test_unsigned_fixed_type_keeps_unknown_value_as_structured_label(self):
130
+ postprocessor = EnumPostprocessor()
131
+ fixed_type = "app.FieldDef.STAGE_Fixed"
132
+ postprocessor.enum_lookup[fixed_type] = {
133
+ 3068809728: ("ST101", 3068809728),
134
+ }
135
+ postprocessor.enum_underlying_types[fixed_type] = "U32"
136
+
137
+ self.assertEqual(
138
+ postprocessor._format_enum_value(fixed_type, -1),
139
+ "[4294967295] <unknown>",
140
+ )
141
+
142
+ def test_signed_fixed_enum_unknown_value_uses_runtime_fixed_id(self):
143
+ postprocessor = EnumPostprocessor()
144
+ fixed_type = "app.StageDef.StageID_Fixed"
145
+ postprocessor.enum_lookup[fixed_type] = {
146
+ 884165440: ("st200", 884165440),
147
+ }
148
+ postprocessor.enum_underlying_types[fixed_type] = "S32"
149
+
150
+ self.assertEqual(
151
+ postprocessor._format_enum_value(fixed_type, -521343680),
152
+ "[-521343680] <unknown>",
153
+ )
154
+
57
155
  def test_export_enums_internal_skips_incomplete_enum_entries(self):
58
156
  dump = {
59
157
  "app.Mode_Fixed": {
@@ -153,6 +251,137 @@ class ExporterEnumSourceMixinTests(unittest.TestCase):
153
251
  },
154
252
  )
155
253
 
254
+ def test_enum_context_infers_integer_fixed_backing_field_from_getter(self):
255
+ dump = {
256
+ "app.FieldDef.STAGE": {
257
+ "parent": "System.Enum",
258
+ "fields": {
259
+ "value__": {"type": "System.Int32"},
260
+ "ST101": {"default": 0},
261
+ },
262
+ },
263
+ "app.FieldDef.STAGE_Fixed": {
264
+ "parent": "System.Enum",
265
+ "fields": {
266
+ "value__": {"type": "System.UInt32"},
267
+ "ST101": {"default": 3068809728},
268
+ },
269
+ },
270
+ "app.user_data.GrassCullingSetting.cStageData": {
271
+ "fields": {
272
+ "_StageID_Fixed": {"type": "System.Int32"},
273
+ },
274
+ "RSZ": [
275
+ {
276
+ "potential_name": "_StageID_Fixed",
277
+ "type": "System.Int32",
278
+ }
279
+ ],
280
+ "methods": {
281
+ "get_StageID1185265": {
282
+ "returns": {"type": "app.FieldDef.STAGE"},
283
+ }
284
+ },
285
+ },
286
+ }
287
+
288
+ context = ExporterEnumSourceMixin.export_enum_context_internal(dump)
289
+
290
+ self.assertEqual(
291
+ context["class_field_fixed_types"][
292
+ "app.user_data.GrassCullingSetting.cStageData"
293
+ ],
294
+ {"_StageID_Fixed": "app.FieldDef.STAGE_Fixed"},
295
+ )
296
+
297
+ def test_enum_context_does_not_guess_fixed_type_without_enum_pair(self):
298
+ dump = {
299
+ "app.FieldDef.STAGE": {
300
+ "parent": "System.Enum",
301
+ "fields": {"ST101": {"default": 0}},
302
+ },
303
+ "app.Owner": {
304
+ "fields": {"_StageID_Fixed": {"type": "System.Int32"}},
305
+ "methods": {
306
+ "get_StageID1": {
307
+ "returns": {"type": "app.FieldDef.STAGE"},
308
+ }
309
+ },
310
+ },
311
+ }
312
+
313
+ context = ExporterEnumSourceMixin.export_enum_context_internal(dump)
314
+
315
+ self.assertNotIn("app.Owner", context["class_field_fixed_types"])
316
+
317
+ def test_enum_context_infers_integer_backing_field_from_fixed_getter(self):
318
+ dump = {
319
+ "app.StageDef.StageID_Fixed": {
320
+ "parent": "System.Enum",
321
+ "fields": {
322
+ "value__": {"type": "System.UInt32"},
323
+ "st100": {"default": 1769129856},
324
+ },
325
+ },
326
+ "app.user_data.WindGlobalAnimationData.cData": {
327
+ "fields": {
328
+ "_StageID_Fixed": {"type": "System.Int32"},
329
+ },
330
+ "methods": {
331
+ "get_StageID_Fixed332574": {
332
+ "returns": {"type": "app.StageDef.StageID_Fixed"},
333
+ }
334
+ },
335
+ },
336
+ }
337
+
338
+ context = ExporterEnumSourceMixin.export_enum_context_internal(dump)
339
+
340
+ self.assertEqual(
341
+ context["class_field_fixed_types"][
342
+ "app.user_data.WindGlobalAnimationData.cData"
343
+ ]["_StageID_Fixed"],
344
+ "app.StageDef.StageID_Fixed",
345
+ )
346
+
347
+ def test_enum_context_preserves_digits_from_explicit_property_name(self):
348
+ dump = {
349
+ "app.LOD": {
350
+ "parent": "System.Enum",
351
+ "fields": {
352
+ "value__": {"type": "System.Int32"},
353
+ "LOD0": {"default": 0},
354
+ },
355
+ },
356
+ "app.LOD_Fixed": {
357
+ "parent": "System.Enum",
358
+ "fields": {
359
+ "value__": {"type": "System.Int32"},
360
+ "LOD0": {"default": 123},
361
+ },
362
+ },
363
+ "app.Owner": {
364
+ "fields": {
365
+ "_LOD0_Fixed": {"type": "System.Int32"},
366
+ },
367
+ "properties": {
368
+ "LOD0": {"getter": "get_LOD0"},
369
+ },
370
+ "methods": {
371
+ "get_LOD0123456": {
372
+ "returns": {"type": "app.LOD"},
373
+ }
374
+ },
375
+ },
376
+ }
377
+
378
+ context = ExporterEnumSourceMixin.export_enum_context_internal(dump)
379
+
380
+ self.assertEqual(
381
+ context["class_field_fixed_types"]["app.Owner"]["_LOD0_Fixed"],
382
+ "app.LOD_Fixed",
383
+ )
384
+
156
385
  def test_generic_context_supports_ordinary_enum_wrappers(self):
157
386
  dump = {
158
387
  "app.Mode": {
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes