PyREUser3 0.7.2__tar.gz → 0.8.1__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 (56) hide show
  1. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/PKG-INFO +6 -4
  2. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/PyREUser3.egg-info/PKG-INFO +6 -4
  3. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/PyREUser3.egg-info/SOURCES.txt +3 -0
  4. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/README.md +23 -7
  5. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/docs/PYPI.md +5 -3
  6. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/docs/README.zh-CN.md +15 -4
  7. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyproject.toml +1 -1
  8. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/__init__.py +1 -1
  9. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/api.py +7 -9
  10. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/cli.py +10 -4
  11. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/core.py +43 -0
  12. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/export/base.py +12 -11
  13. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/export/enums.py +93 -0
  14. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/export/fields.py +17 -0
  15. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/export/metadata.py +6 -0
  16. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/export/postprocess.py +52 -4
  17. pyreuser3-0.8.1/pyreuser3/native_structs.py +368 -0
  18. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/pack/base.py +27 -5
  19. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/pack/models.py +8 -0
  20. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/pack/values.py +65 -1
  21. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/pack/writer.py +59 -6
  22. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/usr_container.py +5 -9
  23. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/web/page.py +13 -13
  24. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/web/runners.py +7 -1
  25. pyreuser3-0.8.1/tests/test_discovery_and_web_depth.py +189 -0
  26. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/tests/test_enum_binary.py +78 -0
  27. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/tests/test_export_enums.py +229 -0
  28. pyreuser3-0.8.1/tests/test_native_structs.py +339 -0
  29. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/LICENSE +0 -0
  30. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/MANIFEST.in +0 -0
  31. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/PyREUser3.egg-info/dependency_links.txt +0 -0
  32. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/PyREUser3.egg-info/entry_points.txt +0 -0
  33. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/PyREUser3.egg-info/requires.txt +0 -0
  34. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/PyREUser3.egg-info/top_level.txt +0 -0
  35. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/__main__.py +0 -0
  36. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/enum_codec.py +0 -0
  37. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/export/__init__.py +0 -0
  38. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/export/tree.py +0 -0
  39. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/export/user3.py +0 -0
  40. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/pack/__init__.py +0 -0
  41. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/pack/container.py +0 -0
  42. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/pack/plan.py +0 -0
  43. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/rich_ui.py +0 -0
  44. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/schema.py +0 -0
  45. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/usr_layouts.py +0 -0
  46. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/web/__init__.py +0 -0
  47. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/web/__main__.py +0 -0
  48. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/web/handler.py +0 -0
  49. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/web/jobs.py +0 -0
  50. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/web/picker.py +0 -0
  51. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/web/server.py +0 -0
  52. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/pyreuser3/web/settings.py +0 -0
  53. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/setup.cfg +0 -0
  54. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/tests/test_api_cache.py +0 -0
  55. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/tests/test_enum_codec.py +0 -0
  56. {pyreuser3-0.7.2 → pyreuser3-0.8.1}/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.1
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
@@ -43,7 +43,7 @@ pip install pyreuser3
43
43
 
44
44
  ## What Is Included
45
45
 
46
- - `.user.3 -> JSON` export.
46
+ - `.user.3` and decorated `.user.3.*` export.
47
47
  - `JSON -> .user.3` packing.
48
48
  - A reusable Python API through `REUser3Converter`.
49
49
  - CLI commands through `pyreuser3`.
@@ -51,6 +51,8 @@ pip install pyreuser3
51
51
  - A local `.user.3` export Web UI through `pyreuser3-web`.
52
52
  - Automatic separation of the USR outer layout from the embedded RSZ header family;
53
53
  modern RSZ v4+ files preserve their original numeric version during repack.
54
+ - il2cpp-validated readable codecs for `via.Int2`, `via.Uint2`, `via.Range`,
55
+ `via.RangeI`, and `via.Sphere`, with scalar/array round trips and raw fallback.
54
56
 
55
57
  The published package intentionally does not include game resources, dumped game data, RE_RSZ templates,
56
58
  `il2cpp_dump.json`, or repository-specific helper scripts. You need to provide data files that match the target game and
@@ -71,8 +73,8 @@ of Python scalar objects.
71
73
 
72
74
  - Python 3.9 or newer.
73
75
  - A RE_RSZ schema JSON file for the target game/version.
74
- - An `il2cpp_dump.json` file when exporting readable enum labels.
75
- - One or more unpacked `.user.3` files.
76
+ - An `il2cpp_dump.json` file when exporting readable enum labels and validated native structures.
77
+ - One or more unpacked `.user.3` or `.user.3.*` files.
76
78
 
77
79
  `pyreuser3 probe` does not require a schema or `il2cpp_dump.json`.
78
80
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyREUser3
3
- Version: 0.7.2
3
+ Version: 0.8.1
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
@@ -43,7 +43,7 @@ pip install pyreuser3
43
43
 
44
44
  ## What Is Included
45
45
 
46
- - `.user.3 -> JSON` export.
46
+ - `.user.3` and decorated `.user.3.*` export.
47
47
  - `JSON -> .user.3` packing.
48
48
  - A reusable Python API through `REUser3Converter`.
49
49
  - CLI commands through `pyreuser3`.
@@ -51,6 +51,8 @@ pip install pyreuser3
51
51
  - A local `.user.3` export Web UI through `pyreuser3-web`.
52
52
  - Automatic separation of the USR outer layout from the embedded RSZ header family;
53
53
  modern RSZ v4+ files preserve their original numeric version during repack.
54
+ - il2cpp-validated readable codecs for `via.Int2`, `via.Uint2`, `via.Range`,
55
+ `via.RangeI`, and `via.Sphere`, with scalar/array round trips and raw fallback.
54
56
 
55
57
  The published package intentionally does not include game resources, dumped game data, RE_RSZ templates,
56
58
  `il2cpp_dump.json`, or repository-specific helper scripts. You need to provide data files that match the target game and
@@ -71,8 +73,8 @@ of Python scalar objects.
71
73
 
72
74
  - Python 3.9 or newer.
73
75
  - A RE_RSZ schema JSON file for the target game/version.
74
- - An `il2cpp_dump.json` file when exporting readable enum labels.
75
- - One or more unpacked `.user.3` files.
76
+ - An `il2cpp_dump.json` file when exporting readable enum labels and validated native structures.
77
+ - One or more unpacked `.user.3` or `.user.3.*` files.
76
78
 
77
79
  `pyreuser3 probe` does not require a schema or `il2cpp_dump.json`.
78
80
 
@@ -16,6 +16,7 @@ pyreuser3/api.py
16
16
  pyreuser3/cli.py
17
17
  pyreuser3/core.py
18
18
  pyreuser3/enum_codec.py
19
+ pyreuser3/native_structs.py
19
20
  pyreuser3/rich_ui.py
20
21
  pyreuser3/schema.py
21
22
  pyreuser3/usr_container.py
@@ -45,7 +46,9 @@ pyreuser3/web/runners.py
45
46
  pyreuser3/web/server.py
46
47
  pyreuser3/web/settings.py
47
48
  tests/test_api_cache.py
49
+ tests/test_discovery_and_web_depth.py
48
50
  tests/test_enum_binary.py
49
51
  tests/test_enum_codec.py
50
52
  tests/test_export_enums.py
53
+ tests/test_native_structs.py
51
54
  tests/test_usr_container.py
@@ -27,7 +27,7 @@ from pyreuser3 import REUser3Converter
27
27
 
28
28
  ## What Is Included
29
29
 
30
- - `.user.3 -> JSON` export.
30
+ - `.user.3` and decorated `.user.3.*` (for example `.user.3.X64`) export.
31
31
  - `JSON -> .user.3` packing.
32
32
  - A reusable Python API through `REUser3Converter`.
33
33
  - CLI commands through `pyreuser3`.
@@ -41,8 +41,8 @@ or repository-specific helper scripts.
41
41
 
42
42
  - Python 3.9 or newer.
43
43
  - A RE_RSZ schema JSON file for the target game/version.
44
- - An `il2cpp_dump.json` file when exporting readable enum labels.
45
- - One or more unpacked `.user.3` files.
44
+ - An `il2cpp_dump.json` file when exporting readable enum labels and validated native structures.
45
+ - One or more unpacked `.user.3` or `.user.3.*` files.
46
46
 
47
47
  Layout probing is the exception: `pyreuser3 probe` only inspects the USR/RSZ
48
48
  container and does not require a schema or `il2cpp_dump.json`.
@@ -78,7 +78,9 @@ pyreuser3 pack \
78
78
  -p <il2cpp_dump.json>
79
79
  ```
80
80
 
81
- The `-p/--il2cpp-dump-path` option is required for export and optional for pack. Passing it during pack is recommended when enum names need to be resolved back to numeric values.
81
+ The `-p/--il2cpp-dump-path` option is required for export and optional for pack. Pass it
82
+ during pack when enum names or readable native structures need to be resolved back to
83
+ binary values. Legacy `{"raw": "..."}` structure values remain packable without it.
82
84
 
83
85
  Probe a file or directory without loading game metadata:
84
86
 
@@ -99,7 +101,9 @@ Start the local `.user.3` export Web UI:
99
101
  pyreuser3-web --port 8765
100
102
  ```
101
103
 
102
- The Web UI only handles `.user.3` export. It does not pack files.
104
+ The Web UI handles `.user.3` and `.user.3.*` export. It does not pack files.
105
+ Its **Tree Depth** field accepts `auto` or a non-negative integer; an explicit
106
+ integer is passed through to readable reference-tree expansion.
103
107
 
104
108
  ## Python API
105
109
 
@@ -169,8 +173,10 @@ Use `json_format="readable"` for the same shape produced by `export_file()`. Thi
169
173
  shape is read-only. Use `json_format="repack"` for the full document accepted by
170
174
  `pack()`; the packer rejects readable JSON.
171
175
 
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
176
+ Readable enum fields are rendered as `[numeric] Name` labels using the enum's actual
177
+ storage width and signedness, so signed Fixed IDs match their in-game runtime values.
178
+ Repack documents retain canonical unsigned Fixed prefixes for stable machine editing.
179
+ Scalar flag enums are rendered as arrays of labels. ``ace.Bitset`1<T>`` values are
174
180
  rendered as enum-index labels together with `_MaxElement` and `_WordCount`, so unknown
175
181
  bits and padded word arrays remain reversible. Repack exports use
176
182
  `re_user3_pack_v3`, which records the independently detected USR outer layout, RSZ
@@ -189,6 +195,16 @@ fixtures validate byte-for-byte repacking. V1 and v2 documents are recognized fo
189
195
  diagnostics but must be re-exported as v3 before packing because they do not record
190
196
  the required layout metadata.
191
197
 
198
+ Fixed-width RE Engine value types use a game-independent, declarative codec registry.
199
+ The first supported codecs are `via.Int2`, `via.Uint2`, `via.Range`, `via.RangeI`,
200
+ and `via.Sphere`, including scalar and array fields. A codec is enabled only when the
201
+ active il2cpp dump exactly validates the value type's parent, boxed size, member types,
202
+ and member offsets, and the schema independently agrees on the original type and payload
203
+ size. Matching values are exported as named JSON objects, for example
204
+ `{"x": 10, "y": 20}` or `{"pos": [1.0, 2.0, 3.0], "r": 4.0}`.
205
+ Any absent or mismatched signature fails closed to the existing raw representation;
206
+ legacy raw scalar and array JSON remains accepted for lossless repacking.
207
+
192
208
  Modern files may use signed `-1` as an explicit null `Object` reference; repack
193
209
  preserves that sentinel while continuing to reject every other missing instance
194
210
  ID. Fixed-width arrays above one million elements are kept compactly as
@@ -10,7 +10,7 @@ pip install pyreuser3
10
10
 
11
11
  ## What Is Included
12
12
 
13
- - `.user.3 -> JSON` export.
13
+ - `.user.3` and decorated `.user.3.*` export.
14
14
  - `JSON -> .user.3` packing.
15
15
  - A reusable Python API through `REUser3Converter`.
16
16
  - CLI commands through `pyreuser3`.
@@ -18,6 +18,8 @@ pip install pyreuser3
18
18
  - A local `.user.3` export Web UI through `pyreuser3-web`.
19
19
  - Automatic separation of the USR outer layout from the embedded RSZ header family;
20
20
  modern RSZ v4+ files preserve their original numeric version during repack.
21
+ - il2cpp-validated readable codecs for `via.Int2`, `via.Uint2`, `via.Range`,
22
+ `via.RangeI`, and `via.Sphere`, with scalar/array round trips and raw fallback.
21
23
 
22
24
  The published package intentionally does not include game resources, dumped game data, RE_RSZ templates,
23
25
  `il2cpp_dump.json`, or repository-specific helper scripts. You need to provide data files that match the target game and
@@ -38,8 +40,8 @@ of Python scalar objects.
38
40
 
39
41
  - Python 3.9 or newer.
40
42
  - A RE_RSZ schema JSON file for the target game/version.
41
- - An `il2cpp_dump.json` file when exporting readable enum labels.
42
- - One or more unpacked `.user.3` files.
43
+ - An `il2cpp_dump.json` file when exporting readable enum labels and validated native structures.
44
+ - One or more unpacked `.user.3` or `.user.3.*` files.
43
45
 
44
46
  `pyreuser3 probe` does not require a schema or `il2cpp_dump.json`.
45
47
 
@@ -29,7 +29,7 @@ from pyreuser3 import REUser3Converter
29
29
 
30
30
  PyREUser3 当前提供:
31
31
 
32
- - `.user.3 -> JSON` 导出;
32
+ - `.user.3` 以及 `.user.3.X64` 这类 `.user.3.*` 变体导出;
33
33
  - `JSON -> .user.3` 封包;
34
34
  - 面向其他项目调用的 `REUser3Converter` Python API;
35
35
  - `pyreuser3` 命令行工具;
@@ -42,8 +42,8 @@ PyPI 包不会包含游戏资源、游戏 dump、RE_RSZ 模板、`il2cpp_dump.js
42
42
 
43
43
  - Python 3.9 或更高版本;
44
44
  - 与目标游戏和版本匹配的 RE_RSZ schema JSON;
45
- - 导出可读枚举标签时需要 `il2cpp_dump.json`;
46
- - 一个或多个已解包的 `.user.3` 文件。
45
+ - 导出可读枚举标签和经验证的原生结构时需要 `il2cpp_dump.json`;
46
+ - 一个或多个已解包的 `.user.3` 或 `.user.3.*` 文件。
47
47
 
48
48
  布局探测是例外:`pyreuser3 probe` 只检查 USR/RSZ 容器,不需要 schema 或
49
49
  `il2cpp_dump.json`。
@@ -82,7 +82,8 @@ pyreuser3 pack \
82
82
  说明:
83
83
 
84
84
  - `export` 时 `-p/--il2cpp-dump-path` 是必填项,用于生成可读枚举标签;
85
- - `pack` 时 `-p/--il2cpp-dump-path` 是可选项,但如果 JSON 中包含枚举名称,建议传入;
85
+ - `pack` 时 `-p/--il2cpp-dump-path` 是可选项;如果 JSON 中包含枚举名称或可读原生结构,
86
+ 需要传入。旧版 `{"raw": "..."}` 结构值无需 il2cpp 仍可封回;
86
87
  - `-s/--schema-path` 必须指向具体 schema JSON 文件,不应传目录;
87
88
  - `-i`、`-j` 都可以传单个文件或目录,目录会递归处理。
88
89
 
@@ -117,6 +118,9 @@ pyreuser3 export \
117
118
  pyreuser3-web --port 8765
118
119
  ```
119
120
 
121
+ Web 界面的“树深度”可填写 `auto` 或非负整数;显式整数会直接用于 readable
122
+ 引用树展开。
123
+
120
124
  默认地址:
121
125
 
122
126
  ```text
@@ -205,6 +209,13 @@ API 则把它们报告为结构化警告;repack 导出会把同一诊断写入
205
209
  获得真实样本并完成逐字节回封验证后才能启用 repack。v1 和 v2 文档仍可识别以便诊断,但由于
206
210
  缺少必要的布局元数据,封回前必须从源文件重新导出为 v3。
207
211
 
212
+ 定长 RE Engine 值类型由与游戏无关的声明式 codec 注册表处理。首批支持
213
+ `via.Int2`、`via.Uint2`、`via.Range`、`via.RangeI` 和 `via.Sphere`,同时覆盖标量与数组。
214
+ 只有当前 il2cpp dump 精确验证其值类型父类、boxed size、成员类型和成员偏移,并且 schema
215
+ 中的原始类型与 payload size 也一致时,codec 才会启用。匹配的数据会导出为命名 JSON 对象,
216
+ 例如 `{"x": 10, "y": 20}` 或 `{"pos": [1.0, 2.0, 3.0], "r": 4.0}`。
217
+ 签名缺失或不匹配时会保守退回原有 raw 表示;旧版 raw 标量与数组 JSON 继续支持无损封回。
218
+
208
219
  部分现代文件会用有符号 `-1` 明确表示空 `Object` 引用;repack 会原样保留这个哨兵,同时继续
209
220
  拒绝其他不存在的实例 ID。超过一百万项的定长数组会紧凑保存为 `_raw_array_count` 和
210
221
  `_raw_array_hex`。这种表示不提供逐项可读性,但能够无损回封、避免展开数百万个 Python 整数,
@@ -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.1"
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.1"
13
13
 
14
14
  __all__ = [
15
15
  "__version__",
@@ -14,7 +14,12 @@ import threading
14
14
  from pathlib import Path
15
15
  from typing import Any, Callable, Literal, Optional
16
16
 
17
- from .core import RSZ_MAGIC, USR_MAGIC, resolve_schema_path
17
+ from .core import (
18
+ RSZ_MAGIC,
19
+ USR_MAGIC,
20
+ discover_user3_files,
21
+ resolve_schema_path,
22
+ )
18
23
  from .export import User3Exporter
19
24
  from .pack import User3Packer
20
25
  from .schema import TypeDB
@@ -547,14 +552,7 @@ class REUser3Converter:
547
552
  Raises:
548
553
  FileNotFoundError: A required file or directory was missing.
549
554
  """
550
- if user3_root.is_file():
551
- return [user3_root]
552
- if not user3_root.is_dir():
553
- raise FileNotFoundError(f"user3 root not found: {user3_root}")
554
- files = sorted(user3_root.rglob("*.user.3"))
555
- if not files:
556
- raise FileNotFoundError(f"no *.user.3 found under: {user3_root}")
557
- return files
555
+ return discover_user3_files(user3_root)
558
556
 
559
557
  @staticmethod
560
558
  def _run_callback(
@@ -105,7 +105,7 @@ def build_parser() -> argparse.ArgumentParser:
105
105
  "--input-dir",
106
106
  "-i",
107
107
  required=True,
108
- help="Root directory or single .user.3 file to export.",
108
+ help="Root directory or single .user.3 / .user.3.* file to export.",
109
109
  )
110
110
  export_parser.add_argument(
111
111
  "--schema-path",
@@ -139,7 +139,10 @@ def build_parser() -> argparse.ArgumentParser:
139
139
  "--il2cpp-dump-path",
140
140
  "-p",
141
141
  required=True,
142
- help="Path to il2cpp_dump.json, used to generate enum labels.",
142
+ help=(
143
+ "Path to il2cpp_dump.json, used for enum labels and validated "
144
+ "native structures."
145
+ ),
143
146
  )
144
147
  export_parser.add_argument(
145
148
  "--json-format",
@@ -178,7 +181,10 @@ def build_parser() -> argparse.ArgumentParser:
178
181
  "--il2cpp-dump-path",
179
182
  "-p",
180
183
  default="",
181
- help="Optional il2cpp_dump.json path, used for enum name lookup.",
184
+ help=(
185
+ "Optional il2cpp_dump.json path, used for enum lookup and readable "
186
+ "native structures."
187
+ ),
182
188
  )
183
189
  pack_parser.add_argument(
184
190
  "--exclude-regex",
@@ -198,7 +204,7 @@ def build_parser() -> argparse.ArgumentParser:
198
204
  "--input-dir",
199
205
  "-i",
200
206
  required=True,
201
- help="Root directory or single .user.3 file to inspect.",
207
+ help="Root directory or single .user.3 / .user.3.* file to inspect.",
202
208
  )
203
209
  probe_parser.add_argument(
204
210
  "--json-report",
@@ -32,6 +32,7 @@ HEX32_RE = re.compile(r"^[0-9a-fA-F]{32}$")
32
32
  # Register enum values through the shared lookup tables so readable labels and numeric
33
33
  # packing stay reversible.
34
34
  ENUM_UNUSED_KEY = "value__"
35
+ USER3_NAME_MARKER = ".user.3"
35
36
 
36
37
  ENUM_UNDERLYING_TYPE_MAP = {
37
38
  "byte": "U8",
@@ -76,6 +77,48 @@ class ParseError(RuntimeError):
76
77
  pass
77
78
 
78
79
 
80
+ def is_user3_source_path(path: str | Path) -> bool:
81
+ """Return whether a filename is a binary ``.user.3`` source variant.
82
+
83
+ RE Engine resources may append platform or build qualifiers after the normal
84
+ suffix, for example ``foo.user.3.X64``. Generated JSON files deliberately do
85
+ not qualify, even though their names retain the source filename.
86
+ """
87
+
88
+ name = Path(path).name.casefold()
89
+ marker_index = name.rfind(USER3_NAME_MARKER)
90
+ if marker_index < 0 or name.endswith(".json"):
91
+ return False
92
+ tail = name[marker_index + len(USER3_NAME_MARKER) :]
93
+ return not tail or tail.startswith(".")
94
+
95
+
96
+ def discover_user3_files(root: str | Path) -> list[Path]:
97
+ """Discover standard and decorated ``.user.3`` files below a root path.
98
+
99
+ An explicitly supplied file is preserved for backward compatibility. Directory
100
+ discovery accepts both ``*.user.3`` and ``*.user.3.*`` (case-insensitively), while
101
+ excluding generated ``*.json`` export and pack documents.
102
+ """
103
+
104
+ source_root = Path(root)
105
+ if source_root.is_file():
106
+ return [source_root]
107
+ if not source_root.is_dir():
108
+ raise FileNotFoundError(f"user3 root not found: {source_root}")
109
+
110
+ files = sorted(
111
+ path
112
+ for path in source_root.rglob("*")
113
+ if path.is_file() and is_user3_source_path(path)
114
+ )
115
+ if not files:
116
+ raise FileNotFoundError(
117
+ f"no .user.3 or .user.3.* files found under: {source_root}"
118
+ )
119
+ return files
120
+
121
+
79
122
  def align(value: int, alignment: int) -> int:
80
123
  """Round an offset up to the requested byte alignment.
81
124
 
@@ -16,7 +16,12 @@ from .metadata import ExporterMetadataMixin
16
16
  from .postprocess import ExporterPostprocessMixin
17
17
  from .tree import ExporterTreeMixin
18
18
  from .user3 import ExporterUser3ParserMixin
19
- from ..core import RSZ_MAGIC, USR_MAGIC, resolve_schema_path
19
+ from ..core import (
20
+ RSZ_MAGIC,
21
+ USR_MAGIC,
22
+ discover_user3_files,
23
+ resolve_schema_path,
24
+ )
20
25
  from ..rich_ui import BatchProgress
21
26
  from ..schema import TypeDB
22
27
 
@@ -106,6 +111,7 @@ class User3Exporter(
106
111
  self.bitset_rules: dict[str, str] = {}
107
112
  self.param_type_default_enum: dict[str, str] = {}
108
113
  self.enum_underlying_types: dict[str, str] = {}
114
+ self.native_struct_layouts: dict[str, dict] = {}
109
115
  self.enum_flags: set[str] = set()
110
116
  self.enum_member_to_types: dict[str, list[str]] = {}
111
117
  self._pending_enum_context: dict | None = None
@@ -139,7 +145,7 @@ class User3Exporter(
139
145
  with BatchProgress(
140
146
  "Exporting user3", total=len(files), unit="file"
141
147
  ) as progress:
142
- progress.log(f"Found {len(files)} .user.3 file(s).")
148
+ progress.log(f"Found {len(files)} user3 source file(s).")
143
149
  progress.log(f"Schema: {self.schema_path}")
144
150
  progress.log(f"Output directory: {self.output_root}")
145
151
  for user3_file in files:
@@ -237,14 +243,7 @@ class User3Exporter(
237
243
  Raises:
238
244
  FileNotFoundError: A required file or directory was missing.
239
245
  """
240
- if self.user3_root.is_file():
241
- files = [self.user3_root]
242
- else:
243
- if not self.user3_root.is_dir():
244
- raise FileNotFoundError(f"user3 root not found: {self.user3_root}")
245
- files = sorted(self.user3_root.rglob("*.user.3"))
246
- if not files:
247
- raise FileNotFoundError(f"no *.user.3 found under: {self.user3_root}")
246
+ files = discover_user3_files(self.user3_root)
248
247
  if not self._exclude_patterns:
249
248
  return files
250
249
 
@@ -260,7 +259,9 @@ class User3Exporter(
260
259
  continue
261
260
  kept.append(file_path)
262
261
  if not kept:
263
- raise FileNotFoundError("all *.user.3 files were excluded by regex filters")
262
+ raise FileNotFoundError(
263
+ "all .user.3 and .user.3.* files were excluded by regex filters"
264
+ )
264
265
  return kept
265
266
 
266
267
  def _output_path_for(self, user3_file: Path) -> Path:
@@ -13,11 +13,15 @@ from pathlib import Path
13
13
  from typing import Any
14
14
 
15
15
  from ..core import ENUM_UNUSED_KEY, normalize_enum_storage_type
16
+ from ..native_structs import collect_validated_native_struct_layout
16
17
 
17
18
 
18
19
  _FIXED_ENUM_RE = re.compile(r"[A-Za-z0-9_.+]+_Fixed")
19
20
  _PROPERTY_METHOD_RE = re.compile(r"^(?:get|set)_(?P<name>.+?)(?:\d+)?$")
20
21
  _BACKING_FIELD_RE = re.compile(r"^<(?P<name>[^>]+)>")
22
+ _INTEGER_STORAGE_TYPES = frozenset(
23
+ {"S8", "U8", "S16", "U16", "S32", "U32", "S64", "U64"}
24
+ )
21
25
 
22
26
 
23
27
  class ExporterEnumSourceMixin:
@@ -104,6 +108,39 @@ class ExporterEnumSourceMixin:
104
108
  for candidate in cls._field_name_variants(field_name):
105
109
  field_map.setdefault(candidate, fixed_type)
106
110
 
111
+ @classmethod
112
+ def _add_fixed_backing_candidate(
113
+ cls,
114
+ candidates: dict[str, set[str]],
115
+ field_name: Any,
116
+ type_name: Any,
117
+ ) -> None:
118
+ """Record an integer ``*_Fixed`` backing field by its runtime property name."""
119
+ if not isinstance(field_name, str):
120
+ return
121
+ storage_type = normalize_enum_storage_type(type_name)
122
+ if storage_type not in _INTEGER_STORAGE_TYPES:
123
+ return
124
+ for field_variant in cls._field_name_variants(field_name):
125
+ normalized_name = field_variant.lstrip("_")
126
+ if not normalized_name.endswith("_Fixed"):
127
+ continue
128
+ property_name = normalized_name[: -len("_Fixed")]
129
+ if property_name:
130
+ candidates.setdefault(property_name, set()).add(field_variant)
131
+ candidates.setdefault(normalized_name, set()).add(field_variant)
132
+
133
+ @staticmethod
134
+ def _method_return_type(method: Any) -> str | None:
135
+ """Return one concrete method return type when metadata exposes it."""
136
+ if not isinstance(method, dict):
137
+ return None
138
+ returns = method.get("returns")
139
+ if not isinstance(returns, dict):
140
+ return None
141
+ return_type = returns.get("type")
142
+ return return_type if isinstance(return_type, str) and return_type else None
143
+
107
144
  @staticmethod
108
145
  def _method_matches_property(method_name: str, getter_or_setter: str) -> bool:
109
146
  """Return whether an il2cpp method key represents the named property accessor."""
@@ -262,8 +299,10 @@ class ExporterEnumSourceMixin:
262
299
  "bitset_rules": {},
263
300
  "generic_candidates": {},
264
301
  "enum_underlying_types": {},
302
+ "native_struct_layouts": {},
265
303
  "enum_types": set(),
266
304
  "serializable_fallback_candidates": [],
305
+ "fixed_backing_candidates": [],
267
306
  }
268
307
 
269
308
  @classmethod
@@ -274,6 +313,11 @@ class ExporterEnumSourceMixin:
274
313
  if not isinstance(class_name, str) or not isinstance(obj, dict):
275
314
  return
276
315
 
316
+ native_layout = collect_validated_native_struct_layout(class_name, obj)
317
+ if native_layout is not None:
318
+ type_name, descriptor = native_layout
319
+ state["native_struct_layouts"][type_name] = descriptor
320
+
277
321
  if obj.get("parent") == "System.Enum":
278
322
  state["enum_types"].add(class_name)
279
323
  if obj.get("parent") == "System.Enum":
@@ -282,6 +326,7 @@ class ExporterEnumSourceMixin:
282
326
  state["enum_underlying_types"][class_name] = storage_type
283
327
 
284
328
  field_map: dict[str, str] = {}
329
+ fixed_backing_fields: dict[str, set[str]] = {}
285
330
  fields_obj = obj.get("fields")
286
331
  if isinstance(fields_obj, dict):
287
332
  for field_name, field_info in fields_obj.items():
@@ -290,6 +335,9 @@ class ExporterEnumSourceMixin:
290
335
  cls._add_field_fixed_type(
291
336
  field_map, field_name, field_info.get("type")
292
337
  )
338
+ cls._add_fixed_backing_candidate(
339
+ fixed_backing_fields, field_name, field_info.get("type")
340
+ )
293
341
 
294
342
  # Older dumps expose RE_RSZ field hints through an explicit RSZ array.
295
343
  rsz_fields = obj.get("RSZ")
@@ -303,6 +351,9 @@ class ExporterEnumSourceMixin:
303
351
  cls._add_field_fixed_type(
304
352
  field_map, potential_name, rsz_field.get("type")
305
353
  )
354
+ cls._add_fixed_backing_candidate(
355
+ fixed_backing_fields, potential_name, rsz_field.get("type")
356
+ )
306
357
 
307
358
  # Newer dumps can omit RSZ entirely but keep property/type hints here.
308
359
  reflection_props = obj.get("reflection_properties")
@@ -311,8 +362,12 @@ class ExporterEnumSourceMixin:
311
362
  if not isinstance(prop_name, str) or not isinstance(prop_info, dict):
312
363
  continue
313
364
  cls._add_field_fixed_type(field_map, prop_name, prop_info.get("type"))
365
+ cls._add_fixed_backing_candidate(
366
+ fixed_backing_fields, prop_name, prop_info.get("type")
367
+ )
314
368
 
315
369
  methods_obj = obj.get("methods")
370
+ runtime_property_types: dict[str, set[str]] = {}
316
371
  if isinstance(methods_obj, dict):
317
372
  properties_obj = obj.get("properties")
318
373
  if isinstance(properties_obj, dict):
@@ -326,6 +381,11 @@ class ExporterEnumSourceMixin:
326
381
  continue
327
382
  if not cls._method_matches_property(method_name, getter):
328
383
  continue
384
+ return_type = cls._method_return_type(method)
385
+ if return_type is not None:
386
+ runtime_property_types.setdefault(
387
+ prop_name, set()
388
+ ).add(return_type)
329
389
  fixed_type = cls._fixed_type_from_method_return(method)
330
390
  if fixed_type is not None:
331
391
  cls._add_field_fixed_type(
@@ -350,6 +410,12 @@ class ExporterEnumSourceMixin:
350
410
  method_prop_name = cls._property_name_from_method(method_name)
351
411
  if method_prop_name is None:
352
412
  continue
413
+ if method_name.startswith("get_"):
414
+ return_type = cls._method_return_type(method)
415
+ if return_type is not None:
416
+ runtime_property_types.setdefault(
417
+ method_prop_name, set()
418
+ ).add(return_type)
353
419
  fixed_type = None
354
420
  if method_name.startswith("get_"):
355
421
  fixed_type = cls._fixed_type_from_method_return(method)
@@ -358,6 +424,18 @@ class ExporterEnumSourceMixin:
358
424
  if fixed_type is not None:
359
425
  cls._add_field_fixed_type(field_map, method_prop_name, fixed_type)
360
426
 
427
+ for property_name, field_names in fixed_backing_fields.items():
428
+ runtime_types = runtime_property_types.get(property_name, set())
429
+ if len(runtime_types) != 1:
430
+ continue
431
+ state["fixed_backing_candidates"].append(
432
+ (
433
+ class_name,
434
+ tuple(sorted(field_names)),
435
+ next(iter(runtime_types)),
436
+ )
437
+ )
438
+
361
439
  if field_map:
362
440
  state["class_field_fixed_types"][class_name] = field_map
363
441
 
@@ -411,6 +489,20 @@ class ExporterEnumSourceMixin:
411
489
  if len(fallback_types) == 1:
412
490
  serializable_to_fixed[class_name] = fallback_types[0]
413
491
 
492
+ for class_name, field_names, runtime_type in state[
493
+ "fixed_backing_candidates"
494
+ ]:
495
+ fixed_type = (
496
+ runtime_type
497
+ if runtime_type.endswith("_Fixed")
498
+ else f"{runtime_type}_Fixed"
499
+ )
500
+ if fixed_type not in enum_types:
501
+ continue
502
+ field_map = state["class_field_fixed_types"].setdefault(class_name, {})
503
+ for field_name in field_names:
504
+ field_map.setdefault(field_name, fixed_type)
505
+
414
506
  for class_name, raw_types in state["generic_candidates"].items():
415
507
  enum_args = list(dict.fromkeys(t for t in raw_types if t in enum_types))
416
508
  param_args = list(dict.fromkeys(t for t in raw_types if t not in enum_types))
@@ -436,6 +528,7 @@ class ExporterEnumSourceMixin:
436
528
  "generic_scalar_rules": state["generic_scalar_rules"],
437
529
  "bitset_rules": state["bitset_rules"],
438
530
  "enum_underlying_types": state["enum_underlying_types"],
531
+ "native_struct_layouts": state["native_struct_layouts"],
439
532
  }
440
533
 
441
534
  @classmethod
@@ -18,6 +18,11 @@ from ..core import (
18
18
  enum_storage_size,
19
19
  enum_storage_type_from_size,
20
20
  )
21
+ from ..native_structs import (
22
+ NativeStructValueError,
23
+ decode_native_struct,
24
+ resolve_native_struct_codec,
25
+ )
21
26
  from ..schema import ClassDef, FieldDef
22
27
 
23
28
 
@@ -141,6 +146,18 @@ class ExporterFieldParserMixin:
141
146
  count = max(field.size // 4, 1)
142
147
  return [reader.read_f32() for _ in range(count)]
143
148
 
149
+ codec = resolve_native_struct_codec(
150
+ field, getattr(self, "native_struct_layouts", {})
151
+ )
152
+ if codec is not None and reader.size - reader.tell() >= codec.payload_size:
153
+ payload = reader.read(codec.payload_size)
154
+ try:
155
+ return decode_native_struct(codec, payload)
156
+ except NativeStructValueError:
157
+ # Non-finite float payloads and any future non-lossless cases stay raw
158
+ # so a JSON round trip cannot silently alter their bit patterns.
159
+ return {"raw": payload.hex(), "type": t}
160
+
144
161
  if field.size <= 0:
145
162
  return None
146
163
  to_read = max(0, min(field.size, reader.size - reader.tell()))