PyREUser3 0.5.0__tar.gz → 0.7.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 (50) hide show
  1. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/PKG-INFO +7 -1
  2. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/PyREUser3.egg-info/PKG-INFO +7 -1
  3. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/PyREUser3.egg-info/SOURCES.txt +6 -1
  4. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/README.md +36 -4
  5. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/docs/PYPI.md +6 -0
  6. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/docs/README.zh-CN.md +31 -4
  7. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyproject.toml +1 -1
  8. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/__init__.py +3 -0
  9. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/api.py +18 -5
  10. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/cli.py +10 -6
  11. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/core.py +6 -1
  12. pyreuser3-0.7.0/pyreuser3/enum_codec.py +231 -0
  13. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/export/base.py +19 -5
  14. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/export/enums.py +36 -20
  15. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/export/metadata.py +36 -11
  16. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/export/postprocess.py +97 -7
  17. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/export/user3.py +114 -97
  18. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/pack/base.py +98 -21
  19. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/pack/models.py +46 -0
  20. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/pack/plan.py +379 -9
  21. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/pack/writer.py +263 -46
  22. pyreuser3-0.7.0/pyreuser3/usr_container.py +339 -0
  23. pyreuser3-0.7.0/pyreuser3/usr_layouts.py +321 -0
  24. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/tests/test_enum_binary.py +17 -0
  25. pyreuser3-0.7.0/tests/test_enum_codec.py +208 -0
  26. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/tests/test_export_enums.py +46 -0
  27. pyreuser3-0.7.0/tests/test_usr_container.py +377 -0
  28. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/LICENSE +0 -0
  29. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/MANIFEST.in +0 -0
  30. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/PyREUser3.egg-info/dependency_links.txt +0 -0
  31. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/PyREUser3.egg-info/entry_points.txt +0 -0
  32. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/PyREUser3.egg-info/requires.txt +0 -0
  33. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/PyREUser3.egg-info/top_level.txt +0 -0
  34. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/__main__.py +0 -0
  35. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/export/__init__.py +0 -0
  36. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/export/fields.py +0 -0
  37. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/export/tree.py +0 -0
  38. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/pack/__init__.py +0 -0
  39. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/rich_ui.py +0 -0
  40. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/schema.py +0 -0
  41. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/web/__init__.py +0 -0
  42. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/web/__main__.py +0 -0
  43. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/web/handler.py +0 -0
  44. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/web/jobs.py +0 -0
  45. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/web/page.py +0 -0
  46. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/web/picker.py +0 -0
  47. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/web/runners.py +0 -0
  48. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/web/server.py +0 -0
  49. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/pyreuser3/web/settings.py +0 -0
  50. {pyreuser3-0.5.0 → pyreuser3-0.7.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyREUser3
3
- Version: 0.5.0
3
+ Version: 0.7.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
@@ -48,11 +48,17 @@ pip install pyreuser3
48
48
  - A reusable Python API through `REUser3Converter`.
49
49
  - CLI commands through `pyreuser3`.
50
50
  - A local `.user.3` export Web UI through `pyreuser3-web`.
51
+ - Automatic separation of the USR outer layout from the embedded RSZ header family;
52
+ modern RSZ v4+ files preserve their original numeric version during repack.
51
53
 
52
54
  The published package intentionally does not include game resources, dumped game data, RE_RSZ templates,
53
55
  `il2cpp_dump.json`, or repository-specific helper scripts. You need to provide data files that match the target game and
54
56
  version.
55
57
 
58
+ Verified H30/modern layouts support repacking. Experimental physical H28 and legacy
59
+ RSZ v3 layouts are readable for analysis but intentionally blocked from repacking
60
+ until real fixtures provide byte-for-byte validation.
61
+
56
62
  ## Requirements
57
63
 
58
64
  - Python 3.9 or newer.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyREUser3
3
- Version: 0.5.0
3
+ Version: 0.7.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
@@ -48,11 +48,17 @@ pip install pyreuser3
48
48
  - A reusable Python API through `REUser3Converter`.
49
49
  - CLI commands through `pyreuser3`.
50
50
  - A local `.user.3` export Web UI through `pyreuser3-web`.
51
+ - Automatic separation of the USR outer layout from the embedded RSZ header family;
52
+ modern RSZ v4+ files preserve their original numeric version during repack.
51
53
 
52
54
  The published package intentionally does not include game resources, dumped game data, RE_RSZ templates,
53
55
  `il2cpp_dump.json`, or repository-specific helper scripts. You need to provide data files that match the target game and
54
56
  version.
55
57
 
58
+ Verified H30/modern layouts support repacking. Experimental physical H28 and legacy
59
+ RSZ v3 layouts are readable for analysis but intentionally blocked from repacking
60
+ until real fixtures provide byte-for-byte validation.
61
+
56
62
  ## Requirements
57
63
 
58
64
  - Python 3.9 or newer.
@@ -15,8 +15,11 @@ 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
21
+ pyreuser3/usr_container.py
22
+ pyreuser3/usr_layouts.py
20
23
  pyreuser3/export/__init__.py
21
24
  pyreuser3/export/base.py
22
25
  pyreuser3/export/enums.py
@@ -40,4 +43,6 @@ pyreuser3/web/runners.py
40
43
  pyreuser3/web/server.py
41
44
  pyreuser3/web/settings.py
42
45
  tests/test_enum_binary.py
43
- tests/test_export_enums.py
46
+ tests/test_enum_codec.py
47
+ tests/test_export_enums.py
48
+ tests/test_usr_container.py
@@ -55,11 +55,20 @@ pyreuser3 export \
55
55
  -p <il2cpp_dump.json>
56
56
  ```
57
57
 
58
- Pack JSON back to `.user.3`:
58
+ Export full repack JSON, then pack it back to `.user.3`:
59
+
60
+ ```bash
61
+ pyreuser3 export \
62
+ -i <input-user3-file-or-directory> \
63
+ -s <RE_RSZ-schema.json> \
64
+ -o <repack-json-output-directory> \
65
+ -p <il2cpp_dump.json> \
66
+ --json-format repack
67
+ ```
59
68
 
60
69
  ```bash
61
70
  pyreuser3 pack \
62
- -j <input-json-file-or-directory> \
71
+ -j <input-repack-json-file-or-directory> \
63
72
  -s <RE_RSZ-schema.json> \
64
73
  -o <user3-output-directory> \
65
74
  -p <il2cpp_dump.json>
@@ -90,8 +99,14 @@ converter.export_file(
90
99
  "json/OtomonData.user.3.json",
91
100
  )
92
101
 
102
+ # Packing only accepts the full repack document. Readable exports are read-only.
103
+ converter.export_file(
104
+ "input/OtomonData.user.3",
105
+ "json/OtomonData.user.3.pack.json",
106
+ json_format="repack",
107
+ )
93
108
  converter.pack_file(
94
- "json/OtomonData.user.3.json",
109
+ "json/OtomonData.user.3.pack.json",
95
110
  "mod/OtomonData.user.3",
96
111
  )
97
112
  ```
@@ -110,7 +125,24 @@ repack_data = converter.user3_to_json(
110
125
  )
111
126
  ```
112
127
 
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()`.
128
+ Use `json_format="readable"` for the same shape produced by `export_file()`. This
129
+ shape is read-only. Use `json_format="repack"` for the full document accepted by
130
+ `pack()`; the packer rejects readable JSON.
131
+
132
+ Enum fields are rendered as `[numeric] Name` labels using the enum's actual storage
133
+ width. Scalar flag enums are rendered as arrays of labels. ``ace.Bitset`1<T>`` values are
134
+ rendered as enum-index labels together with `_MaxElement` and `_WordCount`, so unknown
135
+ bits and padded word arrays remain reversible. Repack exports use
136
+ `re_user3_pack_v3`, which records the independently detected USR outer layout, RSZ
137
+ header family, and numeric RSZ version while preserving resource and userdata
138
+ dependency tables. Layout candidates and their read/repack capability status are
139
+ declared in `pyreuser3/usr_layouts.py`; RSZ field definitions still come from the
140
+ supplied REFramework-compatible schema. The verified modern header family accepts
141
+ structurally valid RSZ v4+ files and preserves their original version instead of
142
+ forcing MHWS version 16. Experimental physical H28 and legacy RSZ v3 candidates are
143
+ read-only until real fixtures validate byte-for-byte repacking. V1 and v2 documents
144
+ are recognized for diagnostics but must be re-exported as v3 before packing because
145
+ they do not record the required layout metadata.
114
146
 
115
147
  For stable patch-and-repack workflows, use `patch_file()` or `parse_pack_file()`:
116
148
 
@@ -15,11 +15,17 @@ pip install pyreuser3
15
15
  - A reusable Python API through `REUser3Converter`.
16
16
  - CLI commands through `pyreuser3`.
17
17
  - A local `.user.3` export Web UI through `pyreuser3-web`.
18
+ - Automatic separation of the USR outer layout from the embedded RSZ header family;
19
+ modern RSZ v4+ files preserve their original numeric version during repack.
18
20
 
19
21
  The published package intentionally does not include game resources, dumped game data, RE_RSZ templates,
20
22
  `il2cpp_dump.json`, or repository-specific helper scripts. You need to provide data files that match the target game and
21
23
  version.
22
24
 
25
+ Verified H30/modern layouts support repacking. Experimental physical H28 and legacy
26
+ RSZ v3 layouts are readable for analysis but intentionally blocked from repacking
27
+ until real fixtures provide byte-for-byte validation.
28
+
23
29
  ## Requirements
24
30
 
25
31
  - Python 3.9 or newer.
@@ -56,11 +56,20 @@ pyreuser3 export \
56
56
  -p <il2cpp_dump.json>
57
57
  ```
58
58
 
59
- JSON 封回 `.user.3`:
59
+ 导出完整 repack JSON,然后封回 `.user.3`:
60
+
61
+ ```bash
62
+ pyreuser3 export \
63
+ -i <输入的-user3-文件或目录> \
64
+ -s <RE_RSZ-schema.json> \
65
+ -o <repack-JSON-输出目录> \
66
+ -p <il2cpp_dump.json> \
67
+ --json-format repack
68
+ ```
60
69
 
61
70
  ```bash
62
71
  pyreuser3 pack \
63
- -j <输入的-JSON-文件或目录> \
72
+ -j <输入的-repack-JSON-文件或目录> \
64
73
  -s <RE_RSZ-schema.json> \
65
74
  -o <user3-输出目录> \
66
75
  -p <il2cpp_dump.json>
@@ -118,8 +127,14 @@ converter.export_file(
118
127
  "json/OtomonData.user.3.json",
119
128
  )
120
129
 
130
+ # 封包只接受完整 repack 文档;readable 导出仅供读取。
131
+ converter.export_file(
132
+ "input/OtomonData.user.3",
133
+ "json/OtomonData.user.3.pack.json",
134
+ json_format="repack",
135
+ )
121
136
  converter.pack_file(
122
- "json/OtomonData.user.3.json",
137
+ "json/OtomonData.user.3.pack.json",
123
138
  "mod/OtomonData.user.3",
124
139
  )
125
140
  ```
@@ -138,7 +153,18 @@ repack_data = converter.user3_to_json(
138
153
  )
139
154
  ```
140
155
 
141
- 使用 `json_format="readable"` 时返回与 `export_file()` 一致的可读导出结构;使用 `json_format="repack"` 时返回可传给 `pack()` 的完整实例表结构。
156
+ 使用 `json_format="readable"` 时返回与 `export_file()` 一致的只读导出结构;使用
157
+ `json_format="repack"` 时返回可传给 `pack()` 的完整实例表结构。封回器会拒绝 readable JSON。
158
+
159
+ 枚举字段会按照真实底层存储宽度输出为 `[数值] 名称`。标量位标志枚举输出为标签数组;
160
+ ``ace.Bitset`1<T>`` 输出为枚举索引标签,并保留 `_MaxElement` 与 `_WordCount`,因此未知位和
161
+ 填充词也可以无损封回。新的 repack 文档格式为 `re_user3_pack_v3`,它会分别记录自动探测到的
162
+ USR 外层布局、RSZ 头族和文件中的真实 RSZ 版本,并保留 resource 与 userdata 依赖表。布局
163
+ 候选及其读取/回封能力状态集中声明在 `pyreuser3/usr_layouts.py`,RSZ 字段定义仍来自传入的
164
+ REFramework 兼容模板。已验证的现代头族接受通过完整结构校验的 RSZ v4+ 文件,并原样保留
165
+ 版本号,不再固定为 MHWS 的版本 16。实验性的物理 H28 与 legacy RSZ v3 候选暂时只读,获得
166
+ 真实样本并完成逐字节回封验证后才能启用 repack。v1 和 v2 文档仍可识别以便诊断,但由于缺少
167
+ 必要的布局元数据,封回前必须从源文件重新导出为 v3。
142
168
 
143
169
  批量处理目录:
144
170
 
@@ -153,6 +179,7 @@ converter = REUser3Converter(
153
179
  export_result = converter.export_directory(
154
180
  "D:/game/unpacked",
155
181
  "D:/game/json",
182
+ json_format="repack",
156
183
  )
157
184
 
158
185
  pack_result = converter.pack_directory(
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "PyREUser3"
7
- version = "0.5.0"
7
+ version = "0.7.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,10 @@ from __future__ import annotations
9
9
  from importlib import import_module
10
10
  from typing import Any
11
11
 
12
+ __version__ = "0.7.0"
13
+
12
14
  __all__ = [
15
+ "__version__",
13
16
  "BinaryReader",
14
17
  "ClassDef",
15
18
  "FieldDef",
@@ -73,6 +73,7 @@ class REUser3Converter:
73
73
  user3_root: str | Path,
74
74
  output_root: str | Path,
75
75
  exclude_regexes: list[str] | None = None,
76
+ json_format: JsonFormat = "readable",
76
77
  ) -> dict[str, int]:
77
78
  """Export every selected .user.3 file under a directory or single-file root.
78
79
 
@@ -81,33 +82,42 @@ class REUser3Converter:
81
82
  output_root (str | Path): Directory where generated output is written.
82
83
  exclude_regexes (list[str] | None): Regular expressions used to skip matching
83
84
  relative paths.
85
+ json_format (JsonFormat): "readable" for read-only exports or "repack"
86
+ for documents accepted by pack().
84
87
 
85
88
  Returns:
86
89
  dict[str, int]: Counters describing total, successful, and failed items.
87
90
  """
88
- exporter = self._new_exporter(user3_root, output_root, exclude_regexes)
91
+ exporter = self._new_exporter(
92
+ user3_root,
93
+ output_root,
94
+ exclude_regexes,
95
+ json_format=self._normalize_json_format(json_format),
96
+ )
89
97
  return exporter.run()
90
98
 
91
99
  def export_file(
92
100
  self,
93
101
  user3_path: str | Path,
94
102
  json_path: str | Path,
103
+ json_format: JsonFormat = "readable",
95
104
  ) -> Path:
96
105
  """Export one .user.3 file to the requested JSON path.
97
106
 
98
107
  Args:
99
108
  user3_path (str | Path): Path to the .user.3 file being parsed, exported, patched, or packed.
100
109
  json_path (str | Path): Path to the JSON document read from or written by this workflow.
110
+ json_format (JsonFormat): "readable" for a read-only tree or "repack"
111
+ for a document accepted by pack().
101
112
 
102
113
  Returns:
103
114
  Path: Concrete filesystem path returned after the read, write, or resolution step finishes.
104
115
  """
105
- # Reuse parse_file so single-file and batch exports keep the same parsed JSON shape and metadata handling.
106
- # Preserve the exported JSON structure so external scripts and hand-edited files
107
- # remain compatible across workflows.
116
+ # Use the same in-memory format selector as parse_file/parse_pack_file so
117
+ # single-file and batch exports keep identical document shapes.
108
118
  tree = self.user3_to_json(
109
119
  user3_path,
110
- json_format="readable",
120
+ json_format=json_format,
111
121
  round_floats=True,
112
122
  )
113
123
  target = Path(json_path)
@@ -343,6 +353,7 @@ class REUser3Converter:
343
353
  user3_root: str | Path,
344
354
  output_root: str | Path,
345
355
  exclude_regexes: list[str] | None,
356
+ json_format: str = "readable",
346
357
  ) -> User3Exporter:
347
358
  """Create an exporter with this facade's schema, enum metadata, and magic values.
348
359
 
@@ -351,6 +362,7 @@ class REUser3Converter:
351
362
  output_root (str | Path): Directory where generated output is written.
352
363
  exclude_regexes (list[str] | None): Regular expressions used to skip matching
353
364
  relative paths.
365
+ json_format (str): Export document shape requested by the caller.
354
366
 
355
367
  Returns:
356
368
  User3Exporter: Configured object or normalized value returned for the caller to use directly.
@@ -369,6 +381,7 @@ class REUser3Converter:
369
381
  il2cpp_dump_path=self.il2cpp_dump_path,
370
382
  user_magic=self.user_magic,
371
383
  rsz_magic=self.rsz_magic,
384
+ json_format=json_format,
372
385
  )
373
386
 
374
387
  def _new_packer(self, output_root: str | Path | None) -> User3Packer:
@@ -8,9 +8,9 @@ from __future__ import annotations
8
8
 
9
9
  import argparse
10
10
  import json
11
- from importlib.metadata import PackageNotFoundError, version
12
11
  from typing import Sequence
13
12
 
13
+ from . import __version__
14
14
  from .core import RSZ_MAGIC, USR_MAGIC
15
15
 
16
16
 
@@ -33,10 +33,7 @@ def package_version() -> str:
33
33
  Returns:
34
34
  str: Normalized or formatted text.
35
35
  """
36
- try:
37
- return version("PyREUser3")
38
- except PackageNotFoundError:
39
- return "0.1.0"
36
+ return __version__
40
37
 
41
38
 
42
39
  def normalize_tree_depth(value: str) -> int | str:
@@ -143,6 +140,12 @@ def build_parser() -> argparse.ArgumentParser:
143
140
  required=True,
144
141
  help="Path to il2cpp_dump.json, used to generate enum labels.",
145
142
  )
143
+ export_parser.add_argument(
144
+ "--json-format",
145
+ choices=("readable", "repack"),
146
+ default="readable",
147
+ help="Export read-only readable JSON or packable repack JSON (default: readable).",
148
+ )
146
149
  add_magic_args(export_parser)
147
150
  export_parser.set_defaults(func=run_export)
148
151
 
@@ -154,7 +157,7 @@ def build_parser() -> argparse.ArgumentParser:
154
157
  "--input-json",
155
158
  "-j",
156
159
  required=True,
157
- help="JSON file or root directory that contains .user.3.json files.",
160
+ help="Repack JSON file or root directory that contains repack documents.",
158
161
  )
159
162
  pack_parser.add_argument(
160
163
  "--schema-path",
@@ -212,6 +215,7 @@ def run_export(args: argparse.Namespace) -> int:
212
215
  il2cpp_dump_path=args.il2cpp_dump_path,
213
216
  user_magic=args.user_magic,
214
217
  rsz_magic=args.rsz_magic,
218
+ json_format=args.json_format,
215
219
  )
216
220
  result = exporter.run()
217
221
  console.log("Export complete:", json.dumps(result, ensure_ascii=False))
@@ -20,7 +20,12 @@ 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_V2 = "re_user3_pack_v2"
25
+ PACK_JSON_FORMAT = "re_user3_pack_v3"
26
+ PACK_JSON_FORMATS = frozenset(
27
+ {PACK_JSON_FORMAT_V1, PACK_JSON_FORMAT_V2, PACK_JSON_FORMAT}
28
+ )
24
29
  # Decode strings and GUID-like values conservatively so invalid data does not corrupt
25
30
  # subsequent parsing.
26
31
  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