rust-map-parser 0.1.0__py3-none-any.whl
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.
- rust_map_parser-0.1.0.dist-info/METADATA +538 -0
- rust_map_parser-0.1.0.dist-info/RECORD +110 -0
- rust_map_parser-0.1.0.dist-info/WHEEL +5 -0
- rust_map_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
- rust_map_parser-0.1.0.dist-info/top_level.txt +1 -0
- rustmap/__init__.py +44 -0
- rustmap/config.py +236 -0
- rustmap/data/__init__.py +1 -0
- rustmap/data/monument_metadata.json +954 -0
- rustmap/data/no_build_zones.json +9543 -0
- rustmap/data/prefab_manifest.json +242637 -0
- rustmap/data/spawn_rules.json +3308 -0
- rustmap/data/tunnel_tiles/__init__.py +1 -0
- rustmap/data/tunnel_tiles/template_000__curve-nw-0.png +0 -0
- rustmap/data/tunnel_tiles/template_001__curve-nw-1.png +0 -0
- rustmap/data/tunnel_tiles/template_002__intersection-bb1-n.png +0 -0
- rustmap/data/tunnel_tiles/template_003__intersection-bb4-n.png +0 -0
- rustmap/data/tunnel_tiles/template_004__intersection-b5-n.png +0 -0
- rustmap/data/tunnel_tiles/template_005__intersection-b3-n.png +0 -0
- rustmap/data/tunnel_tiles/template_006__intersection-b2-n.png +0 -0
- rustmap/data/tunnel_tiles/template_007__intersection-bb2-n.png +0 -0
- rustmap/data/tunnel_tiles/template_008__intersection-b6-n.png +0 -0
- rustmap/data/tunnel_tiles/template_009__intersection-bb3-n.png +0 -0
- rustmap/data/tunnel_tiles/template_010__intersection-b4-n.png +0 -0
- rustmap/data/tunnel_tiles/template_011__intersection-bb6-n.png +0 -0
- rustmap/data/tunnel_tiles/template_012__intersection-n.png +0 -0
- rustmap/data/tunnel_tiles/template_013__intersection-b1-n.png +0 -0
- rustmap/data/tunnel_tiles/template_014__intersection-bb5-n.png +0 -0
- rustmap/data/tunnel_tiles/template_015__station-sn-0.png +0 -0
- rustmap/data/tunnel_tiles/template_016__straight-sn-3.png +0 -0
- rustmap/data/tunnel_tiles/template_017__straight-sn-2.png +0 -0
- rustmap/data/tunnel_tiles/template_018__station-sn-1.png +0 -0
- rustmap/data/tunnel_tiles/template_019__straight-sn-0.png +0 -0
- rustmap/data/tunnel_tiles/template_020__straight-sn-1.png +0 -0
- rustmap/data/tunnel_tiles/template_021__station-sn-3.png +0 -0
- rustmap/data/tunnel_tiles/template_022__straight-sn-4.png +0 -0
- rustmap/data/tunnel_tiles/template_023__station-sn-2.png +0 -0
- rustmap/data/tunnel_tiles/template_024__straight-sn-5.png +0 -0
- rustmap/data/tunnel_tiles/template_025__curve-ne-1.png +0 -0
- rustmap/data/tunnel_tiles/template_026__curve-ne-0.png +0 -0
- rustmap/data/tunnel_tiles/template_027__intersection.png +0 -0
- rustmap/data/tunnel_tiles/template_028__intersection-bb2-s.png +0 -0
- rustmap/data/tunnel_tiles/template_029__intersection-b2-s.png +0 -0
- rustmap/data/tunnel_tiles/template_030__intersection-b6-s.png +0 -0
- rustmap/data/tunnel_tiles/template_031__intersection-bb5-s.png +0 -0
- rustmap/data/tunnel_tiles/template_032__intersection-bb1-s.png +0 -0
- rustmap/data/tunnel_tiles/template_033__intersection-s.png +0 -0
- rustmap/data/tunnel_tiles/template_034__intersection-b1-s.png +0 -0
- rustmap/data/tunnel_tiles/template_035__intersection-bb6-s.png +0 -0
- rustmap/data/tunnel_tiles/template_036__intersection-b5-s.png +0 -0
- rustmap/data/tunnel_tiles/template_037__intersection-bb4-s.png +0 -0
- rustmap/data/tunnel_tiles/template_038__intersection-b3-s.png +0 -0
- rustmap/data/tunnel_tiles/template_039__intersection-bb3-s.png +0 -0
- rustmap/data/tunnel_tiles/template_040__intersection-b4-s.png +0 -0
- rustmap/data/tunnel_tiles/template_041__straight-we-3.png +0 -0
- rustmap/data/tunnel_tiles/template_042__station-we-1.png +0 -0
- rustmap/data/tunnel_tiles/template_043__straight-we-5.png +0 -0
- rustmap/data/tunnel_tiles/template_044__straight-we-4.png +0 -0
- rustmap/data/tunnel_tiles/template_045__station-we-2.png +0 -0
- rustmap/data/tunnel_tiles/template_046__station-we-3.png +0 -0
- rustmap/data/tunnel_tiles/template_047__station-we-0.png +0 -0
- rustmap/data/tunnel_tiles/template_048__straight-we-2.png +0 -0
- rustmap/data/tunnel_tiles/template_049__straight-we-1.png +0 -0
- rustmap/data/tunnel_tiles/template_050__straight-we-0.png +0 -0
- rustmap/data/tunnel_tiles/template_051__curve-se-0.png +0 -0
- rustmap/data/tunnel_tiles/template_052__curve-se-1.png +0 -0
- rustmap/data/tunnel_tiles/template_053__intersection-bb3-w.png +0 -0
- rustmap/data/tunnel_tiles/template_054__intersection-w.png +0 -0
- rustmap/data/tunnel_tiles/template_055__intersection-bb5-w.png +0 -0
- rustmap/data/tunnel_tiles/template_056__intersection-b5-w.png +0 -0
- rustmap/data/tunnel_tiles/template_057__intersection-b1-w.png +0 -0
- rustmap/data/tunnel_tiles/template_058__intersection-bb1-w.png +0 -0
- rustmap/data/tunnel_tiles/template_059__intersection-bb4-w.png +0 -0
- rustmap/data/tunnel_tiles/template_060__intersection-b4-w.png +0 -0
- rustmap/data/tunnel_tiles/template_061__intersection-b6-w.png +0 -0
- rustmap/data/tunnel_tiles/template_062__intersection-bb2-w.png +0 -0
- rustmap/data/tunnel_tiles/template_063__intersection-b3-w.png +0 -0
- rustmap/data/tunnel_tiles/template_064__intersection-b2-w.png +0 -0
- rustmap/data/tunnel_tiles/template_065__intersection-bb6-w.png +0 -0
- rustmap/data/tunnel_tiles/template_066__curve-sw-0.png +0 -0
- rustmap/data/tunnel_tiles/template_067__curve-sw-1.png +0 -0
- rustmap/data/tunnel_tiles/template_068__intersection-bb3-e.png +0 -0
- rustmap/data/tunnel_tiles/template_069__intersection-bb6-e.png +0 -0
- rustmap/data/tunnel_tiles/template_070__intersection-b4-e.png +0 -0
- rustmap/data/tunnel_tiles/template_071__intersection-b6-e.png +0 -0
- rustmap/data/tunnel_tiles/template_072__intersection-b5-e.png +0 -0
- rustmap/data/tunnel_tiles/template_073__intersection-b1-e.png +0 -0
- rustmap/data/tunnel_tiles/template_074__intersection-b3-e.png +0 -0
- rustmap/data/tunnel_tiles/template_075__intersection-bb2-e.png +0 -0
- rustmap/data/tunnel_tiles/template_076__intersection-e.png +0 -0
- rustmap/data/tunnel_tiles/template_077__intersection-bb1-e.png +0 -0
- rustmap/data/tunnel_tiles/template_078__intersection-bb4-e.png +0 -0
- rustmap/data/tunnel_tiles/template_079__intersection-b2-e.png +0 -0
- rustmap/data/tunnel_tiles/template_080__intersection-bb5-e.png +0 -0
- rustmap/data/tunnel_tiles/tiles.json +997 -0
- rustmap/exporter.py +409 -0
- rustmap/layers.py +323 -0
- rustmap/monument_assets.py +145 -0
- rustmap/monuments.py +226 -0
- rustmap/no_build.py +288 -0
- rustmap/no_build_assets.py +303 -0
- rustmap/parser.py +297 -0
- rustmap/png.py +18 -0
- rustmap/populations.py +361 -0
- rustmap/prefabs.py +281 -0
- rustmap/renderer.py +460 -0
- rustmap/resources.py +26 -0
- rustmap/tunnel_assets.py +398 -0
- rustmap/tunnels.py +417 -0
- rustmap/validation.py +106 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Maintainer extraction of sanitized monument no-build collider geometry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import multiprocessing as mp
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from .no_build import AUTOSPAWN_PREFIX, SURFACE_FAMILIES, _autospawn_family, _decompose
|
|
15
|
+
from .tunnel_assets import _component, _local_matrix, _world_matrix, bundle_identity
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DATA_SCHEMA_VERSION = 4
|
|
19
|
+
ONLY_BLOCK_DEPLOYABLES = 512
|
|
20
|
+
BLOCKER_NAME_PARTS = ("prevent_build", "preventbuilding", "no_build", "nobuild")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _include_surface_blocker(family: str | None, tagged: bool) -> bool:
|
|
24
|
+
return family in SURFACE_FAMILIES and (family != "tunnel-upwards" or tagged)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_no_build_data() -> Path:
|
|
28
|
+
if sys.platform == "win32":
|
|
29
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
30
|
+
elif sys.platform == "darwin":
|
|
31
|
+
base = Path.home() / "Library" / "Caches"
|
|
32
|
+
else:
|
|
33
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
34
|
+
return base / "rustmap" / "no_build_zones.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _class_name(component) -> str:
|
|
38
|
+
if component.component.type.name != "MonoBehaviour":
|
|
39
|
+
return component.component.type.name
|
|
40
|
+
try:
|
|
41
|
+
return component.component.read().m_Script.read().m_ClassName
|
|
42
|
+
except Exception:
|
|
43
|
+
return "MonoBehaviour"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _root_game_object(game_object):
|
|
47
|
+
current = game_object
|
|
48
|
+
while True:
|
|
49
|
+
transform = _component(current, "Transform")
|
|
50
|
+
if transform is None or not transform.m_Father.path_id:
|
|
51
|
+
return current
|
|
52
|
+
current = transform.m_Father.read().m_GameObject.read()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _has_prevent_tag(game_object) -> bool:
|
|
56
|
+
return any(_class_name(item) == "PreventBuildingMonumentTag"
|
|
57
|
+
for item in game_object.m_Component)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _collider_flags(game_object) -> int:
|
|
61
|
+
for item in game_object.m_Component:
|
|
62
|
+
if _class_name(item) == "ColliderInfo":
|
|
63
|
+
try:
|
|
64
|
+
return int(item.component.read_typetree().get("flags", 0))
|
|
65
|
+
except Exception:
|
|
66
|
+
return 0
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _collider_definition(component, local_matrix: np.ndarray, source: str):
|
|
71
|
+
kind = component.type.name
|
|
72
|
+
value = component.read()
|
|
73
|
+
center = getattr(value, "m_Center", None)
|
|
74
|
+
cx, cy, cz = (float(center.x), float(center.y), float(center.z)) if center else (0.0, 0.0, 0.0)
|
|
75
|
+
if kind == "BoxCollider":
|
|
76
|
+
size = value.m_Size
|
|
77
|
+
sx, sy, sz = float(size.x), float(size.y), float(size.z)
|
|
78
|
+
local_points = np.asarray([
|
|
79
|
+
[cx+dx*sx/2, cy+dy*sy/2, cz+dz*sz/2]
|
|
80
|
+
for dx in (-1,1) for dy in (-1,1) for dz in (-1,1)
|
|
81
|
+
], dtype=np.float64)
|
|
82
|
+
homogeneous = np.column_stack((local_points, np.ones(len(local_points))))
|
|
83
|
+
root_points = (local_matrix @ homogeneous.T).T[:, :3]
|
|
84
|
+
planar = root_points[[0,4,5,1]][:, [0,2]]
|
|
85
|
+
edge_x, edge_z = planar[1]-planar[0], planar[3]-planar[0]
|
|
86
|
+
length_x, length_z = np.linalg.norm(edge_x), np.linalg.norm(edge_z)
|
|
87
|
+
if length_x == 0.0 or length_z == 0.0 or abs(float(edge_x @ edge_z)) > length_x*length_z*1e-5:
|
|
88
|
+
return None, "nonrepresentable"
|
|
89
|
+
projected_area = float(length_x * length_z)
|
|
90
|
+
definition = {"shape": "rectangle", "size": {"x": sx, "y": sy, "z": sz}}
|
|
91
|
+
root_center = (local_matrix @ np.asarray([cx,cy,cz,1.0]))[:3]
|
|
92
|
+
analysis = {
|
|
93
|
+
"shape": "rectangle", "center": root_center[[0,2]].tolist(),
|
|
94
|
+
"axis_x": (edge_x/length_x).tolist(), "axis_z": (edge_z/length_z).tolist(),
|
|
95
|
+
"half_width": float(length_x/2.0), "half_height": float(length_z/2.0),
|
|
96
|
+
}
|
|
97
|
+
elif kind == "SphereCollider":
|
|
98
|
+
radius = float(value.m_Radius)
|
|
99
|
+
projected = local_matrix[[0,2], :3]
|
|
100
|
+
gram = projected @ projected.T
|
|
101
|
+
scale2 = float((gram[0,0] + gram[1,1]) * 0.5)
|
|
102
|
+
tolerance = max(1.0, scale2) * 1e-5
|
|
103
|
+
if abs(float(gram[0,0]-gram[1,1])) > tolerance or abs(float(gram[0,1])) > tolerance:
|
|
104
|
+
return None, "nonrepresentable"
|
|
105
|
+
projected_area = math.pi * radius * radius * scale2
|
|
106
|
+
root_center = (local_matrix @ np.asarray([cx,cy,cz,1.0]))[:3]
|
|
107
|
+
y_radius = radius * float(np.linalg.norm(local_matrix[1,:3]))
|
|
108
|
+
root_points = np.asarray([
|
|
109
|
+
[root_center[0], root_center[1]-y_radius, root_center[2]],
|
|
110
|
+
[root_center[0], root_center[1]+y_radius, root_center[2]],
|
|
111
|
+
])
|
|
112
|
+
definition = {"shape": "circle", "radius": radius}
|
|
113
|
+
analysis = {
|
|
114
|
+
"shape": "circle", "center": root_center[[0,2]].tolist(),
|
|
115
|
+
"radius": float(radius * math.sqrt(scale2)),
|
|
116
|
+
}
|
|
117
|
+
else:
|
|
118
|
+
return None, "unsupported_shape"
|
|
119
|
+
definition.update({
|
|
120
|
+
"source": source,
|
|
121
|
+
"local_matrix": np.round(local_matrix, 12).tolist(),
|
|
122
|
+
"local_transform": _decompose(local_matrix),
|
|
123
|
+
"center": {"x": cx, "y": cy, "z": cz},
|
|
124
|
+
"local_y_bounds": [float(root_points[:,1].min()), float(root_points[:,1].max())],
|
|
125
|
+
"projected_area_m2": projected_area,
|
|
126
|
+
"_analysis": analysis,
|
|
127
|
+
})
|
|
128
|
+
return definition, None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _rectangle_corners(primitive: dict) -> list[np.ndarray]:
|
|
132
|
+
center=np.asarray(primitive["center"],dtype=np.float64)
|
|
133
|
+
axis_x=np.asarray(primitive["axis_x"],dtype=np.float64)*primitive["half_width"]
|
|
134
|
+
axis_z=np.asarray(primitive["axis_z"],dtype=np.float64)*primitive["half_height"]
|
|
135
|
+
return [center+sx*axis_x+sz*axis_z for sx,sz in ((-1,-1),(1,-1),(1,1),(-1,1))]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _primitive_contains(outer: dict, inner: dict, tolerance: float = 1e-6) -> bool:
|
|
139
|
+
"""Return whether one prefab-local circle/rectangle fully contains another."""
|
|
140
|
+
outer_center=np.asarray(outer["center"],dtype=np.float64)
|
|
141
|
+
inner_center=np.asarray(inner["center"],dtype=np.float64)
|
|
142
|
+
if outer["shape"] == "circle":
|
|
143
|
+
radius=float(outer["radius"])+tolerance
|
|
144
|
+
if inner["shape"] == "circle":
|
|
145
|
+
return float(np.linalg.norm(inner_center-outer_center))+float(inner["radius"]) <= radius
|
|
146
|
+
return all(float(np.linalg.norm(point-outer_center)) <= radius
|
|
147
|
+
for point in _rectangle_corners(inner))
|
|
148
|
+
|
|
149
|
+
axis_x=np.asarray(outer["axis_x"],dtype=np.float64)
|
|
150
|
+
axis_z=np.asarray(outer["axis_z"],dtype=np.float64)
|
|
151
|
+
half_width=float(outer["half_width"])+tolerance
|
|
152
|
+
half_height=float(outer["half_height"])+tolerance
|
|
153
|
+
if inner["shape"] == "circle":
|
|
154
|
+
offset=inner_center-outer_center
|
|
155
|
+
radius=float(inner["radius"])
|
|
156
|
+
return (abs(float(offset@axis_x))+radius <= half_width and
|
|
157
|
+
abs(float(offset@axis_z))+radius <= half_height)
|
|
158
|
+
for point in _rectangle_corners(inner):
|
|
159
|
+
offset=point-outer_center
|
|
160
|
+
if (abs(float(offset@axis_x)) > half_width or
|
|
161
|
+
abs(float(offset@axis_z)) > half_height):
|
|
162
|
+
return False
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _remove_contained(zones: list[dict]) -> tuple[list[dict], int]:
|
|
167
|
+
ordered=sorted(zones,key=lambda item:(
|
|
168
|
+
-float(item["projected_area_m2"]), item["object_name"].casefold(), item["shape"],
|
|
169
|
+
json.dumps(item["local_matrix"],separators=(",",":")),
|
|
170
|
+
))
|
|
171
|
+
retained=[]
|
|
172
|
+
removed=0
|
|
173
|
+
for zone in ordered:
|
|
174
|
+
if any(_primitive_contains(outer["_analysis"],zone["_analysis"])
|
|
175
|
+
for outer in retained):
|
|
176
|
+
removed+=1
|
|
177
|
+
continue
|
|
178
|
+
retained.append(zone)
|
|
179
|
+
for zone in retained:
|
|
180
|
+
zone.pop("_analysis",None)
|
|
181
|
+
zone.pop("object_name",None)
|
|
182
|
+
zone.pop("collider_flags",None)
|
|
183
|
+
zone.pop("local_transform",None)
|
|
184
|
+
return retained,removed
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _extract_worker(install_text: str, target_text: str, conn) -> None:
|
|
188
|
+
try:
|
|
189
|
+
import UnityPy
|
|
190
|
+
install = Path(install_text)
|
|
191
|
+
identity = bundle_identity(install)
|
|
192
|
+
environment = UnityPy.load(
|
|
193
|
+
str(install / "Bundles" / "shared" / "assetscenes.bundle"),
|
|
194
|
+
str(install / "Bundles" / "shared" / "content.bundle"),
|
|
195
|
+
)
|
|
196
|
+
scenes = []
|
|
197
|
+
for root in environment.files.values():
|
|
198
|
+
for name, asset_file in (getattr(root, "files", None) or {}).items():
|
|
199
|
+
if name.startswith("BuildPlayer-AssetScene-monument.") and not name.endswith("sharedAssets"):
|
|
200
|
+
scenes.append((name, asset_file))
|
|
201
|
+
prefab_zones: dict[str, list[dict]] = {}
|
|
202
|
+
excluded = {"contained_by_larger_same_owner_zone": 0,
|
|
203
|
+
"unsupported_shape": 0, "nonrepresentable": 0,
|
|
204
|
+
"deployable_only": 0,
|
|
205
|
+
"untagged_tunnel_upwards_internal": 0}
|
|
206
|
+
for scene_name, scene in sorted(scenes):
|
|
207
|
+
transform_memo: dict[int, np.ndarray] = {}
|
|
208
|
+
for obj in scene.objects.values():
|
|
209
|
+
if obj.type.name != "GameObject":
|
|
210
|
+
continue
|
|
211
|
+
try:
|
|
212
|
+
game_object = obj.read()
|
|
213
|
+
normalized_name = game_object.m_Name.casefold().replace(" ", "_")
|
|
214
|
+
named = any(part in normalized_name for part in BLOCKER_NAME_PARTS)
|
|
215
|
+
tagged = _has_prevent_tag(game_object)
|
|
216
|
+
if not named and not tagged:
|
|
217
|
+
continue
|
|
218
|
+
flags = _collider_flags(game_object)
|
|
219
|
+
if flags & ONLY_BLOCK_DEPLOYABLES:
|
|
220
|
+
excluded["deployable_only"] += 1
|
|
221
|
+
continue
|
|
222
|
+
root_go = _root_game_object(game_object)
|
|
223
|
+
path = root_go.m_Name.casefold().replace("\\", "/")
|
|
224
|
+
family=_autospawn_family(path)
|
|
225
|
+
if not path.startswith(AUTOSPAWN_PREFIX) or family not in SURFACE_FAMILIES:
|
|
226
|
+
continue
|
|
227
|
+
if not _include_surface_blocker(family,tagged):
|
|
228
|
+
excluded["untagged_tunnel_upwards_internal"] += sum(
|
|
229
|
+
item.component.type.name in {"BoxCollider","SphereCollider",
|
|
230
|
+
"CapsuleCollider","MeshCollider"}
|
|
231
|
+
for item in game_object.m_Component
|
|
232
|
+
)
|
|
233
|
+
continue
|
|
234
|
+
root_transform = _component(root_go, "Transform")
|
|
235
|
+
child_transform = _component(game_object, "Transform")
|
|
236
|
+
relative = np.linalg.inv(_world_matrix(root_transform, transform_memo)) @ \
|
|
237
|
+
_world_matrix(child_transform, transform_memo)
|
|
238
|
+
source = "prevent_building_monument_tag" if tagged else "block_placement"
|
|
239
|
+
for item in game_object.m_Component:
|
|
240
|
+
if item.component.type.name not in {
|
|
241
|
+
"BoxCollider", "SphereCollider", "CapsuleCollider", "MeshCollider"
|
|
242
|
+
}:
|
|
243
|
+
continue
|
|
244
|
+
definition, reason = _collider_definition(item.component, relative, source)
|
|
245
|
+
if definition is not None:
|
|
246
|
+
definition["object_name"] = game_object.m_Name
|
|
247
|
+
definition["collider_flags"] = flags
|
|
248
|
+
prefab_zones.setdefault(path, []).append(definition)
|
|
249
|
+
elif reason:
|
|
250
|
+
excluded[reason] += 1
|
|
251
|
+
except Exception:
|
|
252
|
+
continue
|
|
253
|
+
prefabs = []
|
|
254
|
+
for path, zones in sorted(prefab_zones.items()):
|
|
255
|
+
zones,removed=_remove_contained(zones)
|
|
256
|
+
excluded["contained_by_larger_same_owner_zone"]+=removed
|
|
257
|
+
if zones:
|
|
258
|
+
prefabs.append({"prefab_path": path, "zones": zones})
|
|
259
|
+
content = identity["bundles"]["content"]
|
|
260
|
+
payload = {
|
|
261
|
+
"schema_version": DATA_SCHEMA_VERSION,
|
|
262
|
+
"source": {
|
|
263
|
+
"rust_build_id": identity.get("rust_build_id"),
|
|
264
|
+
"content_bundle_size": content["size"],
|
|
265
|
+
"content_bundle_mtime_ns": content["mtime_ns"],
|
|
266
|
+
"asset_scene_count": len(scenes),
|
|
267
|
+
"selection": "prevent-building named/tagged colliders affecting building blocks",
|
|
268
|
+
"selection_strategy": "maximal_same_owner_containment",
|
|
269
|
+
"minimum_area_m2": None,
|
|
270
|
+
"allowed_shapes": ["circle", "rectangle"],
|
|
271
|
+
"included_surface_families": sorted(SURFACE_FAMILIES),
|
|
272
|
+
},
|
|
273
|
+
"prefab_count": len(prefabs),
|
|
274
|
+
"zone_definition_count": sum(len(item["zones"]) for item in prefabs),
|
|
275
|
+
"excluded_definition_counts": excluded,
|
|
276
|
+
"prefabs": prefabs,
|
|
277
|
+
}
|
|
278
|
+
target = Path(target_text)
|
|
279
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8", newline="\n")
|
|
281
|
+
conn.send({"ok": True, "path": str(target), "prefab_count": len(prefabs),
|
|
282
|
+
"zone_count": payload["zone_definition_count"]})
|
|
283
|
+
except BaseException as exc:
|
|
284
|
+
conn.send({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
|
|
285
|
+
finally:
|
|
286
|
+
conn.close()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def refresh_no_build_zone_data(rust_install_path: str | Path,
|
|
290
|
+
output_path: str | Path | None = None) -> Path:
|
|
291
|
+
"""Extract sanitized building-block exclusion geometry from local Rust assets."""
|
|
292
|
+
install = Path(rust_install_path).resolve()
|
|
293
|
+
target = Path(output_path) if output_path is not None else default_no_build_data()
|
|
294
|
+
context = mp.get_context("spawn")
|
|
295
|
+
parent, child = context.Pipe(duplex=False)
|
|
296
|
+
process = context.Process(target=_extract_worker, args=(str(install), str(target), child))
|
|
297
|
+
process.start()
|
|
298
|
+
child.close()
|
|
299
|
+
result = parent.recv()
|
|
300
|
+
process.join()
|
|
301
|
+
if not result.get("ok"):
|
|
302
|
+
raise RuntimeError(result.get("error", "No-build extraction failed"))
|
|
303
|
+
return Path(result["path"])
|
rustmap/parser.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Dependency-light parser for Facepunch Rust ``.map`` world files.
|
|
2
|
+
|
|
3
|
+
The only external dependency is ``lz4``. Protobuf wire decoding is implemented
|
|
4
|
+
locally so the parser does not need generated ``.proto`` classes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import struct
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Iterator
|
|
15
|
+
|
|
16
|
+
import lz4.block
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RustMapError(ValueError):
|
|
20
|
+
"""Raised when a map is truncated, corrupt, or unsupported."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class Vector3:
|
|
25
|
+
x: float = 0.0
|
|
26
|
+
y: float = 0.0
|
|
27
|
+
z: float = 0.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class MapLayer:
|
|
32
|
+
name: str
|
|
33
|
+
data: memoryview
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class Prefab:
|
|
38
|
+
category: str = ""
|
|
39
|
+
prefab_id: int = 0
|
|
40
|
+
position: Vector3 | None = None
|
|
41
|
+
rotation: Vector3 | None = None
|
|
42
|
+
scale: Vector3 | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class MapPath:
|
|
47
|
+
name: str = ""
|
|
48
|
+
spline: bool = False
|
|
49
|
+
start: bool = False
|
|
50
|
+
end: bool = False
|
|
51
|
+
width: float = 0.0
|
|
52
|
+
inner_padding: float = 0.0
|
|
53
|
+
outer_padding: float = 0.0
|
|
54
|
+
inner_fade: float = 0.0
|
|
55
|
+
outer_fade: float = 0.0
|
|
56
|
+
random_scale: float = 0.0
|
|
57
|
+
mesh_offset: float = 0.0
|
|
58
|
+
terrain_offset: float = 0.0
|
|
59
|
+
splat: int = 0
|
|
60
|
+
topology: int = 0
|
|
61
|
+
nodes: list[Vector3] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class RustMap:
|
|
66
|
+
serialization_version: int
|
|
67
|
+
timestamp: int
|
|
68
|
+
size: int
|
|
69
|
+
layers: list[MapLayer]
|
|
70
|
+
prefabs: list[Prefab]
|
|
71
|
+
paths: list[MapPath]
|
|
72
|
+
_protobuf_buffer: bytearray = field(repr=False)
|
|
73
|
+
|
|
74
|
+
def layer(self, name: str) -> MapLayer:
|
|
75
|
+
for layer in self.layers:
|
|
76
|
+
if layer.name.casefold() == name.casefold():
|
|
77
|
+
return layer
|
|
78
|
+
raise KeyError(name)
|
|
79
|
+
|
|
80
|
+
def summary(self) -> dict:
|
|
81
|
+
categories = Counter(p.category for p in self.prefabs)
|
|
82
|
+
prefab_ids = Counter(p.prefab_id for p in self.prefabs)
|
|
83
|
+
return {
|
|
84
|
+
"serialization_version": self.serialization_version,
|
|
85
|
+
"timestamp": self.timestamp,
|
|
86
|
+
"world_size": self.size,
|
|
87
|
+
"layers": [{"name": x.name, "bytes": len(x.data)} for x in self.layers],
|
|
88
|
+
"prefab_count": len(self.prefabs),
|
|
89
|
+
"path_count": len(self.paths),
|
|
90
|
+
"prefab_categories": dict(categories.most_common()),
|
|
91
|
+
"most_common_prefab_ids": [
|
|
92
|
+
{"id": prefab_id, "count": count}
|
|
93
|
+
for prefab_id, count in prefab_ids.most_common(100)
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_varint(data: memoryview, offset: int, limit: int) -> tuple[int, int]:
|
|
99
|
+
result = 0
|
|
100
|
+
shift = 0
|
|
101
|
+
while offset < limit and shift < 70:
|
|
102
|
+
byte = data[offset]
|
|
103
|
+
offset += 1
|
|
104
|
+
result |= (byte & 0x7F) << shift
|
|
105
|
+
if not byte & 0x80:
|
|
106
|
+
return result, offset
|
|
107
|
+
shift += 7
|
|
108
|
+
raise RustMapError("Invalid or truncated varint")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _fields(data: memoryview) -> Iterator[tuple[int, int, int | memoryview]]:
|
|
112
|
+
offset = 0
|
|
113
|
+
limit = len(data)
|
|
114
|
+
while offset < limit:
|
|
115
|
+
tag, offset = _read_varint(data, offset, limit)
|
|
116
|
+
field_number, wire_type = tag >> 3, tag & 7
|
|
117
|
+
if field_number == 0:
|
|
118
|
+
raise RustMapError("Invalid protobuf field number 0")
|
|
119
|
+
if wire_type == 0:
|
|
120
|
+
value, offset = _read_varint(data, offset, limit)
|
|
121
|
+
yield field_number, wire_type, value
|
|
122
|
+
elif wire_type == 1:
|
|
123
|
+
if offset + 8 > limit:
|
|
124
|
+
raise RustMapError("Truncated protobuf fixed64")
|
|
125
|
+
value = struct.unpack_from("<Q", data, offset)[0]
|
|
126
|
+
offset += 8
|
|
127
|
+
yield field_number, wire_type, value
|
|
128
|
+
elif wire_type == 2:
|
|
129
|
+
length, offset = _read_varint(data, offset, limit)
|
|
130
|
+
end = offset + length
|
|
131
|
+
if end > limit:
|
|
132
|
+
raise RustMapError("Truncated protobuf length-delimited field")
|
|
133
|
+
yield field_number, wire_type, data[offset:end]
|
|
134
|
+
offset = end
|
|
135
|
+
elif wire_type == 5:
|
|
136
|
+
if offset + 4 > limit:
|
|
137
|
+
raise RustMapError("Truncated protobuf fixed32")
|
|
138
|
+
value = struct.unpack_from("<I", data, offset)[0]
|
|
139
|
+
offset += 4
|
|
140
|
+
yield field_number, wire_type, value
|
|
141
|
+
else:
|
|
142
|
+
raise RustMapError(f"Unsupported protobuf wire type {wire_type}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _text(value: int | memoryview) -> str:
|
|
146
|
+
if not isinstance(value, memoryview):
|
|
147
|
+
raise RustMapError("Expected a protobuf string")
|
|
148
|
+
return bytes(value).decode("utf-8", errors="replace")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _float32(value: int | memoryview) -> float:
|
|
152
|
+
if not isinstance(value, int):
|
|
153
|
+
raise RustMapError("Expected a protobuf fixed32")
|
|
154
|
+
return struct.unpack("<f", struct.pack("<I", value))[0]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _vector(data: memoryview) -> Vector3:
|
|
158
|
+
result = Vector3()
|
|
159
|
+
for number, wire, value in _fields(data):
|
|
160
|
+
if wire != 5:
|
|
161
|
+
continue
|
|
162
|
+
if number == 1:
|
|
163
|
+
result.x = _float32(value)
|
|
164
|
+
elif number == 2:
|
|
165
|
+
result.y = _float32(value)
|
|
166
|
+
elif number == 3:
|
|
167
|
+
result.z = _float32(value)
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _map_layer(data: memoryview) -> MapLayer:
|
|
172
|
+
name = ""
|
|
173
|
+
payload = memoryview(b"")
|
|
174
|
+
for number, wire, value in _fields(data):
|
|
175
|
+
if number == 1 and wire == 2:
|
|
176
|
+
name = _text(value)
|
|
177
|
+
elif number == 2 and wire == 2 and isinstance(value, memoryview):
|
|
178
|
+
payload = value
|
|
179
|
+
return MapLayer(name, payload)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _prefab(data: memoryview) -> Prefab:
|
|
183
|
+
result = Prefab()
|
|
184
|
+
for number, wire, value in _fields(data):
|
|
185
|
+
if number == 1 and wire == 2:
|
|
186
|
+
result.category = _text(value)
|
|
187
|
+
elif number == 2 and wire == 0:
|
|
188
|
+
result.prefab_id = int(value)
|
|
189
|
+
elif wire == 2 and isinstance(value, memoryview):
|
|
190
|
+
if number == 3:
|
|
191
|
+
result.position = _vector(value)
|
|
192
|
+
elif number == 4:
|
|
193
|
+
result.rotation = _vector(value)
|
|
194
|
+
elif number == 5:
|
|
195
|
+
result.scale = _vector(value)
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _path(data: memoryview) -> MapPath:
|
|
200
|
+
result = MapPath()
|
|
201
|
+
float_fields = {
|
|
202
|
+
5: "width", 6: "inner_padding", 7: "outer_padding", 8: "inner_fade",
|
|
203
|
+
9: "outer_fade", 10: "random_scale", 11: "mesh_offset", 12: "terrain_offset",
|
|
204
|
+
}
|
|
205
|
+
for number, wire, value in _fields(data):
|
|
206
|
+
if number == 1 and wire == 2:
|
|
207
|
+
result.name = _text(value)
|
|
208
|
+
elif number in (2, 3, 4) and wire == 0:
|
|
209
|
+
setattr(result, {2: "spline", 3: "start", 4: "end"}[number], bool(value))
|
|
210
|
+
elif number in float_fields and wire == 5:
|
|
211
|
+
setattr(result, float_fields[number], _float32(value))
|
|
212
|
+
elif number == 13 and wire == 0:
|
|
213
|
+
result.splat = int(value)
|
|
214
|
+
elif number == 14 and wire == 0:
|
|
215
|
+
result.topology = int(value)
|
|
216
|
+
elif number == 15 and wire == 2 and isinstance(value, memoryview):
|
|
217
|
+
result.nodes.append(_vector(value))
|
|
218
|
+
return result
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _read_stream_varint(stream, *, allow_eof: bool = False) -> int | None:
|
|
222
|
+
result = 0
|
|
223
|
+
shift = 0
|
|
224
|
+
for index in range(10):
|
|
225
|
+
raw = stream.read(1)
|
|
226
|
+
if not raw:
|
|
227
|
+
if allow_eof and index == 0:
|
|
228
|
+
return None
|
|
229
|
+
raise RustMapError("Truncated legacy LZ4 chunk header")
|
|
230
|
+
byte = raw[0]
|
|
231
|
+
result |= (byte & 0x7F) << shift
|
|
232
|
+
if not byte & 0x80:
|
|
233
|
+
return result
|
|
234
|
+
shift += 7
|
|
235
|
+
raise RustMapError("Invalid legacy LZ4 chunk varint")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _decompress_legacy_lz4(stream) -> bytearray:
|
|
239
|
+
output = bytearray()
|
|
240
|
+
while True:
|
|
241
|
+
flags = _read_stream_varint(stream, allow_eof=True)
|
|
242
|
+
if flags is None:
|
|
243
|
+
break
|
|
244
|
+
original_length = _read_stream_varint(stream)
|
|
245
|
+
assert original_length is not None
|
|
246
|
+
is_compressed = bool(flags & 1)
|
|
247
|
+
if flags >> 2:
|
|
248
|
+
raise RustMapError("Multi-pass legacy LZ4 chunks are unsupported")
|
|
249
|
+
compressed_length = _read_stream_varint(stream) if is_compressed else original_length
|
|
250
|
+
assert compressed_length is not None
|
|
251
|
+
if compressed_length > original_length:
|
|
252
|
+
raise RustMapError("Legacy LZ4 chunk length is invalid")
|
|
253
|
+
block = stream.read(compressed_length)
|
|
254
|
+
if len(block) != compressed_length:
|
|
255
|
+
raise RustMapError("Truncated legacy LZ4 chunk")
|
|
256
|
+
if is_compressed:
|
|
257
|
+
decoded = lz4.block.decompress(block, uncompressed_size=original_length)
|
|
258
|
+
if len(decoded) != original_length:
|
|
259
|
+
raise RustMapError("Legacy LZ4 block decoded to the wrong size")
|
|
260
|
+
output.extend(decoded)
|
|
261
|
+
else:
|
|
262
|
+
output.extend(block)
|
|
263
|
+
return output
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def load_map(path: str | Path) -> RustMap:
|
|
267
|
+
"""Load and decode a Rust world map.
|
|
268
|
+
|
|
269
|
+
Layer byte arrays are zero-copy ``memoryview`` objects backed by the map's
|
|
270
|
+
decompressed protobuf buffer. Keep the returned ``RustMap`` alive while
|
|
271
|
+
using them.
|
|
272
|
+
"""
|
|
273
|
+
path = Path(path)
|
|
274
|
+
with path.open("rb") as stream:
|
|
275
|
+
header = stream.read(12)
|
|
276
|
+
if len(header) != 12:
|
|
277
|
+
raise RustMapError("File is too short to be a Rust map")
|
|
278
|
+
version, timestamp = struct.unpack("<IQ", header)
|
|
279
|
+
protobuf_buffer = _decompress_legacy_lz4(stream)
|
|
280
|
+
|
|
281
|
+
view = memoryview(protobuf_buffer)
|
|
282
|
+
size = 0
|
|
283
|
+
layers: list[MapLayer] = []
|
|
284
|
+
prefabs: list[Prefab] = []
|
|
285
|
+
paths: list[MapPath] = []
|
|
286
|
+
for number, wire, value in _fields(view):
|
|
287
|
+
if number == 1 and wire == 0:
|
|
288
|
+
size = int(value)
|
|
289
|
+
elif wire == 2 and isinstance(value, memoryview):
|
|
290
|
+
if number == 2:
|
|
291
|
+
layers.append(_map_layer(value))
|
|
292
|
+
elif number == 3:
|
|
293
|
+
prefabs.append(_prefab(value))
|
|
294
|
+
elif number == 4:
|
|
295
|
+
paths.append(_path(value))
|
|
296
|
+
return RustMap(version, timestamp, size, layers, prefabs, paths, protobuf_buffer)
|
|
297
|
+
|
rustmap/png.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Shared PNG encoding with project provenance metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from PIL import Image, PngImagePlugin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PROJECT_URL = "https://github.com/Cooperkit/Rustmap-Parser"
|
|
11
|
+
PNG_SOURCE_KEY = "Source"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def save_png(image: Image.Image, path: str | Path, **save_options: object) -> None:
|
|
15
|
+
"""Save a PNG with a non-visible link back to this project's source."""
|
|
16
|
+
metadata = PngImagePlugin.PngInfo()
|
|
17
|
+
metadata.add_text(PNG_SOURCE_KEY, PROJECT_URL)
|
|
18
|
+
image.save(path, "PNG", pnginfo=metadata, **save_options)
|