PyREUser3 0.7.1__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 (54) hide show
  1. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/PKG-INFO +1 -8
  2. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/PyREUser3.egg-info/PKG-INFO +1 -8
  3. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/PyREUser3.egg-info/SOURCES.txt +2 -0
  4. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/README.md +4 -29
  5. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/docs/PYPI.md +0 -7
  6. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/docs/README.zh-CN.md +0 -23
  7. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyproject.toml +1 -1
  8. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/__init__.py +1 -1
  9. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/export/enums.py +85 -0
  10. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/export/postprocess.py +62 -12
  11. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/pack/base.py +4 -28
  12. pyreuser3-0.8.0/pyreuser3/pack/container.py +388 -0
  13. pyreuser3-0.8.0/pyreuser3/pack/plan.py +454 -0
  14. pyreuser3-0.8.0/pyreuser3/pack/values.py +278 -0
  15. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/pack/writer.py +44 -39
  16. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/tests/test_enum_binary.py +107 -2
  17. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/tests/test_enum_codec.py +27 -0
  18. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/tests/test_export_enums.py +229 -0
  19. pyreuser3-0.7.1/pyreuser3/pack/plan.py +0 -1040
  20. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/LICENSE +0 -0
  21. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/MANIFEST.in +0 -0
  22. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/PyREUser3.egg-info/dependency_links.txt +0 -0
  23. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/PyREUser3.egg-info/entry_points.txt +0 -0
  24. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/PyREUser3.egg-info/requires.txt +0 -0
  25. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/PyREUser3.egg-info/top_level.txt +0 -0
  26. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/__main__.py +0 -0
  27. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/api.py +0 -0
  28. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/cli.py +0 -0
  29. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/core.py +0 -0
  30. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/enum_codec.py +0 -0
  31. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/export/__init__.py +0 -0
  32. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/export/base.py +0 -0
  33. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/export/fields.py +0 -0
  34. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/export/metadata.py +0 -0
  35. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/export/tree.py +0 -0
  36. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/export/user3.py +0 -0
  37. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/pack/__init__.py +0 -0
  38. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/pack/models.py +0 -0
  39. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/rich_ui.py +0 -0
  40. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/schema.py +0 -0
  41. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/usr_container.py +0 -0
  42. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/usr_layouts.py +0 -0
  43. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/web/__init__.py +0 -0
  44. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/web/__main__.py +0 -0
  45. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/web/handler.py +0 -0
  46. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/web/jobs.py +0 -0
  47. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/web/page.py +0 -0
  48. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/web/picker.py +0 -0
  49. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/web/runners.py +0 -0
  50. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/web/server.py +0 -0
  51. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/pyreuser3/web/settings.py +0 -0
  52. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/setup.cfg +0 -0
  53. {pyreuser3-0.7.1 → pyreuser3-0.8.0}/tests/test_api_cache.py +0 -0
  54. {pyreuser3-0.7.1 → 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.1
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
@@ -60,13 +60,6 @@ Verified H30/modern layouts support repacking. Experimental physical H28 and leg
60
60
  RSZ v3 layouts are readable for analysis but intentionally blocked from repacking
61
61
  until real fixtures provide byte-for-byte validation.
62
62
 
63
- Version 0.7.1 fixes the 0.7.0 modern RSZ alignment regression. Modern userdata and
64
- data targets use absolute 16-byte file alignment even though their stored offsets
65
- remain RSZ-relative. Readable parsing accepts alignment-only deviations without
66
- hiding structural corruption, while the probe API reports them explicitly. Repack
67
- output blocks layouts carrying unverified diagnostics. The strict schema-free
68
- probe can validate large local corpora.
69
-
70
63
  Repeated operations on one `REUser3Converter` reuse schema and il2cpp metadata,
71
64
  with automatic file-signature invalidation and an explicit
72
65
  `clear_metadata_cache()` escape hatch. Batch patching reuses one exporter and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyREUser3
3
- Version: 0.7.1
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
@@ -60,13 +60,6 @@ Verified H30/modern layouts support repacking. Experimental physical H28 and leg
60
60
  RSZ v3 layouts are readable for analysis but intentionally blocked from repacking
61
61
  until real fixtures provide byte-for-byte validation.
62
62
 
63
- Version 0.7.1 fixes the 0.7.0 modern RSZ alignment regression. Modern userdata and
64
- data targets use absolute 16-byte file alignment even though their stored offsets
65
- remain RSZ-relative. Readable parsing accepts alignment-only deviations without
66
- hiding structural corruption, while the probe API reports them explicitly. Repack
67
- output blocks layouts carrying unverified diagnostics. The strict schema-free
68
- probe can validate large local corpora.
69
-
70
63
  Repeated operations on one `REUser3Converter` reuse schema and il2cpp metadata,
71
64
  with automatic file-signature invalidation and an explicit
72
65
  `clear_metadata_cache()` escape hatch. Batch patching reuses one exporter and
@@ -30,8 +30,10 @@ pyreuser3/export/tree.py
30
30
  pyreuser3/export/user3.py
31
31
  pyreuser3/pack/__init__.py
32
32
  pyreuser3/pack/base.py
33
+ pyreuser3/pack/container.py
33
34
  pyreuser3/pack/models.py
34
35
  pyreuser3/pack/plan.py
36
+ pyreuser3/pack/values.py
35
37
  pyreuser3/pack/writer.py
36
38
  pyreuser3/web/__init__.py
37
39
  pyreuser3/web/__main__.py
@@ -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
@@ -217,33 +219,6 @@ converter.patch_file(
217
219
  )
218
220
  ```
219
221
 
220
- ## Compatibility Validation
221
-
222
- The repository includes a schema-free corpus runner. Game files are not committed;
223
- point it at a locally unpacked `natives` tree:
224
-
225
- ```bash
226
- python tests/corpus_probe.py D:/game/natives \
227
- --expected-total 62768 \
228
- --expected-version 16 \
229
- --report layout-report.json
230
- ```
231
-
232
- Version 0.7.1 fixes modern RSZ alignment regression found in 0.7.0 and folds the
233
- planned compatibility work into the bug-fix release: staged layout detection,
234
- structured diagnostics, safe readable parsing, verified-only repacking, and the
235
- schema-free probe command. The modern rule was validated against 62,768 MHWS
236
- `.user.3` files; four SystemSetting fixtures additionally passed readable export,
237
- repack export, byte-identical packing, and reparse checks.
238
-
239
- A second corpus from Monster Hunter Stories 3 was also validated: all 42,945 files
240
- passed strict layout detection and schema-driven repack export. All 42,945 rebuilt
241
- without exceptions; 38,548 were byte-identical. The remaining 4,397 are successful
242
- rebuilds but not byte-identical; observed causes include nonzero padding and
243
- alternate empty-string encodings, so they are not claimed as byte-identical
244
- fixtures. Two voxel files use the compact large-array representation; both rebuilt
245
- byte-identically, including the 6.55 MB `dg100_Root` payload.
246
-
247
222
  ## Build From Source
248
223
 
249
224
  ```bash
@@ -27,13 +27,6 @@ Verified H30/modern layouts support repacking. Experimental physical H28 and leg
27
27
  RSZ v3 layouts are readable for analysis but intentionally blocked from repacking
28
28
  until real fixtures provide byte-for-byte validation.
29
29
 
30
- Version 0.7.1 fixes the 0.7.0 modern RSZ alignment regression. Modern userdata and
31
- data targets use absolute 16-byte file alignment even though their stored offsets
32
- remain RSZ-relative. Readable parsing accepts alignment-only deviations without
33
- hiding structural corruption, while the probe API reports them explicitly. Repack
34
- output blocks layouts carrying unverified diagnostics. The strict schema-free
35
- probe can validate large local corpora.
36
-
37
30
  Repeated operations on one `REUser3Converter` reuse schema and il2cpp metadata,
38
31
  with automatic file-signature invalidation and an explicit
39
32
  `clear_metadata_cache()` escape hatch. Batch patching reuses one exporter and
@@ -256,29 +256,6 @@ converter.patch_file(
256
256
  )
257
257
  ```
258
258
 
259
- ## 兼容性验证
260
-
261
- 仓库内提供无需 schema 的语料测试脚本。游戏文件不会提交到仓库,请让脚本指向本地已解包的
262
- `natives` 目录:
263
-
264
- ```bash
265
- python tests/corpus_probe.py D:/game/natives \
266
- --expected-total 62768 \
267
- --expected-version 16 \
268
- --report layout-report.json
269
- ```
270
-
271
- 0.7.1 修复了 0.7.0 引入的现代 RSZ 对齐回归,并把原计划中的兼容性工作合并进本次 bugfix:
272
- 分阶段布局探测、结构化诊断、安全 readable 解析、仅验证布局可 repack,以及无需 schema 的
273
- probe 命令。新的现代布局规则已用 62,768 个 MHWS `.user.3` 文件验证;其中四个
274
- SystemSetting 样本还完成了 readable 导出、repack 导出、逐字节一致回封和二次解析验证。
275
-
276
- 另外还验证了 Monster Hunter Stories 3 语料:42,945 个文件全部通过严格 layout 探测和
277
- schema 驱动的 repack 导出;42,945 个文件全部能够无异常地重新构建,其中 38,548 个逐字节
278
- 一致。其余 4,397 个可以成功重建但并非逐字节一致;已观察到的原因包括非零 padding、空字符串
279
- 的另一种物理编码,因此不把它们宣称为逐字节 fixture。两个使用紧凑超大数组表示的 voxel 文件
280
- 都能逐字节一致回封,其中包括 6.55 MB 的 `dg100_Root` payload。
281
-
282
259
  ## 常见注意事项
283
260
 
284
261
  - schema JSON、`il2cpp_dump.json` 和 `.user.3` 文件应来自同一个游戏版本;
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "PyREUser3"
7
- version = "0.7.1"
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.1"
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,9 +69,18 @@ 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
 
81
+ Exact declared members stay intact; flagged enums are decomposed only when the
82
+ numeric value has no exact member.
83
+
74
84
  The method keeps parsing, metadata lookup, and JSON shaping explicit so incomplete
75
85
  templates can still produce inspectable output.
76
86
 
@@ -86,7 +96,22 @@ class ExporterPostprocessMixin:
86
96
  value_map = self.enum_lookup.get(fixed_enum_type)
87
97
  if value_map is None:
88
98
  return value
89
- # Leave numeric enum values unchanged when no enum lookup is available for the current context.
99
+ matched = enum_member_for_value(
100
+ self.enum_lookup,
101
+ fixed_enum_type,
102
+ value,
103
+ getattr(self, "enum_underlying_types", {}),
104
+ )
105
+ if matched is not None:
106
+ member_name, fixed_value = matched
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
+ )
90
115
  if fixed_enum_type in getattr(self, "enum_flags", set()):
91
116
  return decode_flags(
92
117
  self.enum_lookup,
@@ -94,16 +119,37 @@ class ExporterPostprocessMixin:
94
119
  value,
95
120
  getattr(self, "enum_underlying_types", {}),
96
121
  )
97
- matched = enum_member_for_value(
98
- self.enum_lookup,
99
- fixed_enum_type,
100
- value,
101
- getattr(self, "enum_underlying_types", {}),
102
- )
103
- if matched is None:
122
+ if not fixed_enum_type.endswith("_Fixed"):
104
123
  return value
105
- member_name, fixed_value = matched
106
- return self._id_formatter(member_name, fixed_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
+ )
107
153
 
108
154
  @staticmethod
109
155
  def _looks_like_class_name(text: str) -> bool:
@@ -425,7 +471,11 @@ class ExporterPostprocessMixin:
425
471
  if isinstance(value, int) and scalar_enum_hint is not None:
426
472
  # Register enum values through the shared lookup tables so readable labels
427
473
  # and numeric packing stay reversible.
428
- 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
+ )
429
479
  return value
430
480
 
431
481
  def _finalize_export_tree(self, value: Any) -> Any:
@@ -11,12 +11,10 @@ import re
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
+ from .container import RepackContainer
14
15
  from .models import (
15
16
  ExternalUserdataSpec,
16
17
  InstanceSpec,
17
- RszUserdataSpec,
18
- UsrResourceSpec,
19
- UsrUserdataSpec,
20
18
  )
21
19
  from .plan import PackerPlanMixin
22
20
  from .writer import PackerWriterMixin
@@ -25,12 +23,6 @@ from ..enum_codec import is_probable_flags_enum
25
23
  from ..export import User3Exporter
26
24
  from ..rich_ui import BatchProgress
27
25
  from ..schema import TypeDB
28
- from ..usr_layouts import (
29
- DEFAULT_RSZ_HEADER_LAYOUT_ID,
30
- DEFAULT_USR_LAYOUT_ID,
31
- get_rsz_header_layout,
32
- get_usr_layout,
33
- )
34
26
 
35
27
 
36
28
  class User3Packer(PackerPlanMixin, PackerWriterMixin):
@@ -97,25 +89,9 @@ class User3Packer(PackerPlanMixin, PackerWriterMixin):
97
89
  )
98
90
  }
99
91
  self.instances: list[InstanceSpec | ExternalUserdataSpec | None] = []
100
- self.usr_layout = get_usr_layout(DEFAULT_USR_LAYOUT_ID)
101
- if self.usr_layout is None:
102
- raise RuntimeError(f"default USR layout is not registered: {DEFAULT_USR_LAYOUT_ID}")
103
- self.rsz_header_layout = get_rsz_header_layout(
104
- DEFAULT_RSZ_HEADER_LAYOUT_ID
105
- )
106
- if self.rsz_header_layout is None:
107
- raise RuntimeError(
108
- "default RSZ header layout is not registered: "
109
- f"{DEFAULT_RSZ_HEADER_LAYOUT_ID}"
110
- )
111
- self.usr_header_padding = b"\x00" * self.usr_layout.header_padding_size
112
- self.usr_resources: list[UsrResourceSpec] = []
113
- self.usr_userdata: list[UsrUserdataSpec] = []
114
- # A numeric RSZ version must be loaded from repack metadata. The modern
115
- # header family is shared across games and must not imply MHWS version 16.
116
- self.rsz_version: int | None = None
117
- self.rsz_reserved = 0
118
- self.rsz_userdata: list[RszUserdataSpec] = []
92
+ # The container is committed atomically only after one complete repack document
93
+ # has passed layout, table, instance, and resource validation.
94
+ self.container: RepackContainer | None = None
119
95
 
120
96
  def pack_json_file(self, json_path: str | Path, output_path: str | Path) -> Path:
121
97
  """Pack json file.