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
rustmap/populations.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Extract and evaluate Rust server DensitySpawnPopulation filters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .layers import biome_grid, splat_grid, topology_grid
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class SpawnRule:
|
|
17
|
+
asset_path: str
|
|
18
|
+
name: str
|
|
19
|
+
class_name: str
|
|
20
|
+
active: bool
|
|
21
|
+
resource_folder: str
|
|
22
|
+
resources: list[str]
|
|
23
|
+
target_density: float
|
|
24
|
+
scale_with_large_maps: bool
|
|
25
|
+
scale_with_spawn_filter: bool
|
|
26
|
+
splat_mask: int
|
|
27
|
+
biome_mask: int
|
|
28
|
+
topology_any: int
|
|
29
|
+
topology_all: int
|
|
30
|
+
topology_not: int
|
|
31
|
+
filter_cutoff: float
|
|
32
|
+
filter_radius: float
|
|
33
|
+
filter_out_tutorial_islands: bool
|
|
34
|
+
filter_out_monuments: list[int]
|
|
35
|
+
align_to_normal: bool
|
|
36
|
+
npc_radius_check_distance: float
|
|
37
|
+
population_convar: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def heatmap_categories(rules: list[SpawnRule]) -> dict[str, list[str]]:
|
|
41
|
+
"""Map Rust+ Desktop's category keys to active population asset paths."""
|
|
42
|
+
exact = {
|
|
43
|
+
"ores": {"ores", "ores_sand", "ores_snow", "v3_ores_jungle"},
|
|
44
|
+
"wood": {"collectable-resource-wood"},
|
|
45
|
+
"mushroom": {"collectable-food-mushroom"},
|
|
46
|
+
"corn": {"plant-corn"}, "pumpkin": {"plant-pumpkin"},
|
|
47
|
+
"potato": {"plant-potato"}, "wheat": {"plant-wheat"},
|
|
48
|
+
"junkpiles": {"junkpiles", "junkpiles_water"},
|
|
49
|
+
"rowboat": {"rowboat.population"}, "modularcar": {"modularcar.population"},
|
|
50
|
+
"horse": {"ridablehorse.population"},
|
|
51
|
+
# Monument bikes use fixed spawn points, not a terrain distribution.
|
|
52
|
+
# Including their unrestricted default filter makes the raster solid.
|
|
53
|
+
"pedalbike": {"pedalbikes_world.population"},
|
|
54
|
+
"hab": {"hab.population"},
|
|
55
|
+
"hemp": {"collectable-resource-hemp", "jungle-collectable-resource-hemp"},
|
|
56
|
+
"bear": {"bear.population", "polarbear.population"},
|
|
57
|
+
"boar": {"boar.population"}, "chicken": {"chicken.population"},
|
|
58
|
+
"wolf": {"wolf.population"}, "stag": {"stag.population"},
|
|
59
|
+
"crocodile": {"crocodile.population"}, "tiger": {"tiger.population"},
|
|
60
|
+
"snake": {"snake.population"},
|
|
61
|
+
}
|
|
62
|
+
result = {key: [] for key in exact}
|
|
63
|
+
result.update({"logs": [], "berries": [], "flowers": []})
|
|
64
|
+
for rule in rules:
|
|
65
|
+
if not rule.active:
|
|
66
|
+
continue
|
|
67
|
+
name = rule.name.casefold()
|
|
68
|
+
for category, names in exact.items():
|
|
69
|
+
if name in names:
|
|
70
|
+
result[category].append(rule.asset_path)
|
|
71
|
+
if name.startswith("logs_") or name in {"driftwood", "wood_log_pile"}:
|
|
72
|
+
result["logs"].append(rule.asset_path)
|
|
73
|
+
if "plant-berry-" in name:
|
|
74
|
+
result["berries"].append(rule.asset_path)
|
|
75
|
+
if name in {"plant-rose", "plant-orchid", "plant-sunflower"}:
|
|
76
|
+
result["flowers"].append(rule.asset_path)
|
|
77
|
+
return {key: sorted(value) for key, value in sorted(result.items())}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _u32(value: int) -> int:
|
|
81
|
+
return int(value) & 0xFFFFFFFF
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _ptr_id(value: Any) -> int:
|
|
85
|
+
if isinstance(value, dict):
|
|
86
|
+
return int(value.get("m_PathID", value.get("path_id", 0)))
|
|
87
|
+
return int(getattr(value, "path_id", 0))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _script_classes(environment) -> dict[int, str]:
|
|
91
|
+
result = {}
|
|
92
|
+
for obj in environment.objects:
|
|
93
|
+
if obj.type.name == "MonoScript":
|
|
94
|
+
data = obj.read()
|
|
95
|
+
result[obj.path_id] = getattr(data, "m_ClassName", "")
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _class_name(data, fallback: dict[int, str], obj=None) -> str:
|
|
100
|
+
"""Resolve a script within its serialized file (path IDs repeat per file)."""
|
|
101
|
+
try:
|
|
102
|
+
return str(data.m_Script.get_obj().read().m_ClassName)
|
|
103
|
+
except Exception:
|
|
104
|
+
try:
|
|
105
|
+
script = obj.assets_file.objects[data.m_Script.path_id]
|
|
106
|
+
return str(script.read().m_ClassName)
|
|
107
|
+
except Exception:
|
|
108
|
+
return fallback.get(data.m_Script.path_id, "")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _guid_paths(environment, classes: dict[int, str]) -> dict[str, str]:
|
|
112
|
+
for obj in environment.objects:
|
|
113
|
+
if obj.type.name != "MonoBehaviour":
|
|
114
|
+
continue
|
|
115
|
+
data = obj.read()
|
|
116
|
+
if _class_name(data, classes, obj) != "GameManifest":
|
|
117
|
+
continue
|
|
118
|
+
tree = obj.read_typetree()
|
|
119
|
+
# Current Rust stores parallel GUID/path arrays. Retain fallbacks for
|
|
120
|
+
# older manifest layouts.
|
|
121
|
+
guids = tree.get("guidList") or tree.get("guids") or []
|
|
122
|
+
paths = tree.get("guidPaths") or tree.get("paths") or []
|
|
123
|
+
if isinstance(paths, list) and paths and isinstance(paths[0], dict):
|
|
124
|
+
return {
|
|
125
|
+
str(x.get("guid", "")): str(x.get("path", x.get("name", "")))
|
|
126
|
+
for x in paths if x.get("guid")
|
|
127
|
+
}
|
|
128
|
+
if len(guids) == len(paths):
|
|
129
|
+
return {str(g): str(p) for g, p in zip(guids, paths)}
|
|
130
|
+
for item in tree.get("prefabProperties", []):
|
|
131
|
+
if item.get("guid") and item.get("name"):
|
|
132
|
+
guids.append(item["guid"]); paths.append(item["name"])
|
|
133
|
+
return {str(g): str(p) for g, p in zip(guids, paths)}
|
|
134
|
+
return {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _active_population_ids_local(maps_bundle: Path) -> tuple[set[int], dict]:
|
|
138
|
+
import UnityPy
|
|
139
|
+
|
|
140
|
+
env = UnityPy.load(str(maps_bundle))
|
|
141
|
+
classes = _script_classes(env)
|
|
142
|
+
candidates = []
|
|
143
|
+
# Locate SpawnHandler scripts first, then inspect only their serialized
|
|
144
|
+
# files. A full typetree scan of the 50k behaviours is unnecessarily large.
|
|
145
|
+
for script in env.objects:
|
|
146
|
+
if script.type.name != "MonoScript" or getattr(script.read(), "m_ClassName", "") != "SpawnHandler":
|
|
147
|
+
continue
|
|
148
|
+
for obj in script.assets_file.objects.values():
|
|
149
|
+
if obj.type.name != "MonoBehaviour":
|
|
150
|
+
continue
|
|
151
|
+
data = obj.read()
|
|
152
|
+
if data.m_Script.path_id != script.path_id:
|
|
153
|
+
continue
|
|
154
|
+
tree = obj.read_typetree()
|
|
155
|
+
regular = tree.get("SpawnPopulations", [])
|
|
156
|
+
convar = tree.get("ConvarSpawnPopulations", [])
|
|
157
|
+
refs = regular + convar
|
|
158
|
+
ids = {_ptr_id(x) for x in refs if _ptr_id(x)}
|
|
159
|
+
if ids:
|
|
160
|
+
candidates.append((len(ids), ids, obj.path_id, len(regular), len(convar)))
|
|
161
|
+
if not candidates:
|
|
162
|
+
raise RuntimeError("No SpawnHandler was found in maps.bundle")
|
|
163
|
+
count, ids, path_id, regular_count, convar_count = max(candidates, key=lambda x: x[0])
|
|
164
|
+
result = ids, {"handler_count": len(candidates), "selected_path_id": path_id,
|
|
165
|
+
"selected_population_count": count,
|
|
166
|
+
"regular_population_count": regular_count,
|
|
167
|
+
"convar_population_count": convar_count}
|
|
168
|
+
del env
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _active_worker(path: str, connection) -> None:
|
|
173
|
+
try:
|
|
174
|
+
ids, metadata = _active_population_ids_local(Path(path))
|
|
175
|
+
connection.send((sorted(ids), metadata, None))
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
connection.send(([], {}, repr(exc)))
|
|
178
|
+
finally:
|
|
179
|
+
connection.close()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _active_population_ids(maps_bundle: Path) -> tuple[set[int], dict]:
|
|
183
|
+
"""Read maps.bundle in a disposable process to release UnityPy's graph."""
|
|
184
|
+
import multiprocessing as mp
|
|
185
|
+
parent, child = mp.get_context("spawn").Pipe(False)
|
|
186
|
+
process = mp.get_context("spawn").Process(
|
|
187
|
+
target=_active_worker, args=(str(maps_bundle), child)
|
|
188
|
+
)
|
|
189
|
+
process.start()
|
|
190
|
+
ids, metadata, error = parent.recv()
|
|
191
|
+
process.join()
|
|
192
|
+
if error:
|
|
193
|
+
raise RuntimeError(f"SpawnHandler extraction failed: {error}")
|
|
194
|
+
return set(ids), metadata
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def extract_spawn_rules(content_bundle: str | Path, maps_bundle: str | Path) -> dict:
|
|
198
|
+
"""Extract all population assets and mark those in the main SpawnHandler."""
|
|
199
|
+
import UnityPy
|
|
200
|
+
|
|
201
|
+
content = Path(content_bundle).resolve()
|
|
202
|
+
maps = Path(maps_bundle).resolve()
|
|
203
|
+
active_ids, handler = _active_population_ids(maps)
|
|
204
|
+
env = UnityPy.load(str(content))
|
|
205
|
+
classes = _script_classes(env)
|
|
206
|
+
guid_paths = _guid_paths(env, classes)
|
|
207
|
+
|
|
208
|
+
populations = {
|
|
209
|
+
"DensitySpawnPopulation", "ConvarControlledSpawnPopulation",
|
|
210
|
+
"ConvarControlledSpawnPopulationRail", "SpawnPointSpawnPopulation",
|
|
211
|
+
"ConvarControlledSpawnPointPopulation",
|
|
212
|
+
}
|
|
213
|
+
rules = []
|
|
214
|
+
for asset_path, obj in env.container.items():
|
|
215
|
+
normalized_path = str(asset_path).casefold().replace("\\", "/")
|
|
216
|
+
if "/spawnpopulation/" not in normalized_path and not normalized_path.endswith(".population.asset"):
|
|
217
|
+
continue
|
|
218
|
+
if obj.type.name != "MonoBehaviour":
|
|
219
|
+
continue
|
|
220
|
+
data = obj.read()
|
|
221
|
+
class_name = _class_name(data, classes, obj)
|
|
222
|
+
if class_name not in populations:
|
|
223
|
+
continue
|
|
224
|
+
tree = obj.read_typetree()
|
|
225
|
+
filt = tree.get("Filter", {})
|
|
226
|
+
resources = []
|
|
227
|
+
for ref in tree.get("ResourceList", []):
|
|
228
|
+
guid = ref.get("guid", ref) if isinstance(ref, dict) else ref
|
|
229
|
+
resources.append(guid_paths.get(str(guid), str(guid)))
|
|
230
|
+
convar = tree.get("PopulationConvar")
|
|
231
|
+
if isinstance(convar, dict):
|
|
232
|
+
convar = convar.get("name") or convar.get("Value") or str(convar)
|
|
233
|
+
rules.append(SpawnRule(
|
|
234
|
+
asset_path=str(asset_path), name=str(tree.get("m_Name", Path(asset_path).stem)),
|
|
235
|
+
class_name=class_name, active=obj.path_id in active_ids,
|
|
236
|
+
resource_folder=str(tree.get("ResourceFolder", "")), resources=resources,
|
|
237
|
+
target_density=float(tree.get("_targetDensity", 0)),
|
|
238
|
+
scale_with_large_maps=bool(tree.get("ScaleWithLargeMaps", False)),
|
|
239
|
+
scale_with_spawn_filter=bool(tree.get("ScaleWithSpawnFilter", False)),
|
|
240
|
+
splat_mask=int(filt.get("SplatType", -1)), biome_mask=int(filt.get("BiomeType", -1)),
|
|
241
|
+
topology_any=_u32(filt.get("TopologyAny", -1)),
|
|
242
|
+
topology_all=_u32(filt.get("TopologyAll", -1)),
|
|
243
|
+
topology_not=_u32(filt.get("TopologyNot", 0)),
|
|
244
|
+
filter_cutoff=float(tree.get("FilterCutoff", 0)),
|
|
245
|
+
filter_radius=float(tree.get("FilterRadius", 0)),
|
|
246
|
+
filter_out_tutorial_islands=bool(tree.get("FilterOutTutorialIslands", False)),
|
|
247
|
+
filter_out_monuments=[int(x) for x in tree.get("FilterOutMonuments", [])],
|
|
248
|
+
align_to_normal=bool(tree.get("AlignToNormal", False)),
|
|
249
|
+
npc_radius_check_distance=float(tree.get("NpcRadiusCheckDistance", 0)),
|
|
250
|
+
population_convar=str(convar) if convar else None,
|
|
251
|
+
))
|
|
252
|
+
rules.sort(key=lambda x: x.asset_path.casefold())
|
|
253
|
+
return {
|
|
254
|
+
"schema_version": 1,
|
|
255
|
+
"sources": {
|
|
256
|
+
"content_bundle": {"path": str(content), "size": content.stat().st_size,
|
|
257
|
+
"mtime_ns": content.stat().st_mtime_ns},
|
|
258
|
+
"maps_bundle": {"path": str(maps), "size": maps.stat().st_size,
|
|
259
|
+
"mtime_ns": maps.stat().st_mtime_ns},
|
|
260
|
+
},
|
|
261
|
+
"spawn_handler": handler,
|
|
262
|
+
"rule_count": len(rules), "active_rule_count": sum(r.active for r in rules),
|
|
263
|
+
"heatmap_categories": heatmap_categories(rules),
|
|
264
|
+
"rules": [asdict(r) for r in rules],
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def save_rule_database(database: dict, path: str | Path) -> None:
|
|
269
|
+
target = Path(path); target.parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
target.write_text(json.dumps(database, indent=2) + "\n", encoding="utf-8")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class SpawnFilterEvaluator:
|
|
274
|
+
"""Reusable population evaluator with resolution-specific sampling caches."""
|
|
275
|
+
|
|
276
|
+
def __init__(self, world, resolution: int = 512):
|
|
277
|
+
self.resolution = resolution
|
|
278
|
+
self.source_splat = splat_grid(world)
|
|
279
|
+
source_biome = biome_grid(world)
|
|
280
|
+
source_topology = topology_grid(world)
|
|
281
|
+
self.norm = (np.arange(resolution, dtype=np.float32) + 0.5) / resolution
|
|
282
|
+
ix = np.minimum((self.norm * source_topology.shape[1]).astype(int), source_topology.shape[1] - 1)
|
|
283
|
+
iz = np.minimum((self.norm * source_topology.shape[0]).astype(int), source_topology.shape[0] - 1)
|
|
284
|
+
self.topology = source_topology[np.ix_(iz, ix)]
|
|
285
|
+
sampled_biome = source_biome[:, iz][:, :, ix]
|
|
286
|
+
# Server uses >=, so ties select the last/highest channel.
|
|
287
|
+
self.dominant_biome = sampled_biome.shape[0] - 1 - np.argmax(sampled_biome[::-1], axis=0)
|
|
288
|
+
self.biome_count = sampled_biome.shape[0]
|
|
289
|
+
self._splat_cache: dict[int, np.ndarray] = {}
|
|
290
|
+
# Bilinear sampling a 2048² splat channel is relatively expensive, and
|
|
291
|
+
# the rule database combines the same eight channels into many masks.
|
|
292
|
+
# Keep float64 terms because the original NumPy expression evaluates in
|
|
293
|
+
# float64 before each in-place float32 addition. Storing float32 here
|
|
294
|
+
# changes a small number of final uint8 values.
|
|
295
|
+
self._splat_channel_cache: dict[int, np.ndarray] = {}
|
|
296
|
+
self._evaluation_cache: dict[tuple[int, int, int, int, int, float], np.ndarray] = {}
|
|
297
|
+
|
|
298
|
+
source = self.source_splat
|
|
299
|
+
self._splat_pos = self.norm * (source.shape[2] - 1)
|
|
300
|
+
self._splat_x0 = np.floor(self._splat_pos).astype(int)
|
|
301
|
+
self._splat_x1 = np.minimum(self._splat_x0 + 1, source.shape[2] - 1)
|
|
302
|
+
self._splat_t = self._splat_pos - self._splat_x0
|
|
303
|
+
|
|
304
|
+
def _sample_splat_channel(self, channel: int) -> np.ndarray:
|
|
305
|
+
cached = self._splat_channel_cache.get(channel)
|
|
306
|
+
if cached is not None:
|
|
307
|
+
return cached
|
|
308
|
+
grid = self.source_splat[channel]
|
|
309
|
+
x0, x1, t = self._splat_x0, self._splat_x1, self._splat_t
|
|
310
|
+
a = grid[np.ix_(x0, x0)]
|
|
311
|
+
b = grid[np.ix_(x0, x1)]
|
|
312
|
+
c = grid[np.ix_(x1, x0)]
|
|
313
|
+
d = grid[np.ix_(x1, x1)]
|
|
314
|
+
sampled = ((a * (1-t)[None,:] + b * t[None,:]) * (1-t)[:,None]
|
|
315
|
+
+ (c * (1-t)[None,:] + d * t[None,:]) * t[:,None]) / 255.0
|
|
316
|
+
self._splat_channel_cache[channel] = sampled
|
|
317
|
+
return sampled
|
|
318
|
+
|
|
319
|
+
def _splat_factor(self, mask: int) -> np.ndarray:
|
|
320
|
+
cached = self._splat_cache.get(mask)
|
|
321
|
+
if cached is not None:
|
|
322
|
+
return cached
|
|
323
|
+
value = np.zeros((self.resolution, self.resolution), dtype=np.float32)
|
|
324
|
+
for channel in range(self.source_splat.shape[0]):
|
|
325
|
+
if not (mask & (1 << channel)):
|
|
326
|
+
continue
|
|
327
|
+
value += self._sample_splat_channel(channel)
|
|
328
|
+
value = np.clip(value, 0, 1)
|
|
329
|
+
self._splat_cache[mask] = value
|
|
330
|
+
return value
|
|
331
|
+
|
|
332
|
+
def evaluate(self, rule: SpawnRule | dict) -> np.ndarray:
|
|
333
|
+
if isinstance(rule, dict):
|
|
334
|
+
rule = SpawnRule(**rule)
|
|
335
|
+
cache_key = (
|
|
336
|
+
_u32(rule.topology_any), _u32(rule.topology_all), _u32(rule.topology_not),
|
|
337
|
+
int(rule.biome_mask), int(rule.splat_mask), float(rule.filter_cutoff),
|
|
338
|
+
)
|
|
339
|
+
cached = self._evaluation_cache.get(cache_key)
|
|
340
|
+
if cached is not None:
|
|
341
|
+
return cached
|
|
342
|
+
topo = self.topology
|
|
343
|
+
factor = np.ones((self.resolution, self.resolution), dtype=np.float32)
|
|
344
|
+
any_mask, all_mask, not_mask = map(_u32, (rule.topology_any, rule.topology_all, rule.topology_not))
|
|
345
|
+
if any_mask != 0xFFFFFFFF: factor[(topo & any_mask) == 0] = 0
|
|
346
|
+
if all_mask != 0xFFFFFFFF: factor[(topo & all_mask) != all_mask] = 0
|
|
347
|
+
if not_mask: factor[(topo & not_mask) != 0] = 0
|
|
348
|
+
|
|
349
|
+
if rule.biome_mask != -1:
|
|
350
|
+
allowed = np.array([(rule.biome_mask & (1 << i)) != 0 for i in range(self.biome_count)])
|
|
351
|
+
factor[~allowed[self.dominant_biome]] = 0
|
|
352
|
+
if rule.splat_mask != -1:
|
|
353
|
+
factor *= self._splat_factor(rule.splat_mask)
|
|
354
|
+
result = np.where(factor > rule.filter_cutoff, factor * 255, 0).astype(np.uint8)
|
|
355
|
+
self._evaluation_cache[cache_key] = result
|
|
356
|
+
return result
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def evaluate_filter(world, rule: SpawnRule | dict, resolution: int = 512) -> np.ndarray:
|
|
360
|
+
"""Reproduce DensitySpawnPopulation.GetBaseMapValues (placement map excluded)."""
|
|
361
|
+
return SpawnFilterEvaluator(world, resolution).evaluate(rule)
|
rustmap/prefabs.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Extract Rust's GameManifest and resolve numeric map prefab IDs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from collections import Counter, defaultdict
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .parser import RustMap, RustMapError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class PrefabManifestEntry:
|
|
16
|
+
prefab_id: int
|
|
17
|
+
path: str
|
|
18
|
+
guid: str | None = None
|
|
19
|
+
pooled: bool | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class PrefabResolution:
|
|
24
|
+
prefab_id: int
|
|
25
|
+
path: str | None
|
|
26
|
+
instance_count: int
|
|
27
|
+
map_categories: list[str]
|
|
28
|
+
classification: str
|
|
29
|
+
heatmap_relevance: str
|
|
30
|
+
guid: str | None = None
|
|
31
|
+
pooled: bool | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class PrefabManifest:
|
|
36
|
+
entries: dict[int, PrefabManifestEntry]
|
|
37
|
+
collisions: dict[int, list[str]]
|
|
38
|
+
source_bundle: str
|
|
39
|
+
source_size: int
|
|
40
|
+
source_mtime_ns: int
|
|
41
|
+
|
|
42
|
+
def get(self, prefab_id: int) -> PrefabManifestEntry | None:
|
|
43
|
+
return self.entries.get(prefab_id)
|
|
44
|
+
|
|
45
|
+
def save(self, path: str | Path) -> None:
|
|
46
|
+
target = Path(path)
|
|
47
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
payload = {
|
|
49
|
+
"source_bundle": self.source_bundle,
|
|
50
|
+
"source_size": self.source_size,
|
|
51
|
+
"source_mtime_ns": self.source_mtime_ns,
|
|
52
|
+
"entry_count": len(self.entries),
|
|
53
|
+
"entries": {
|
|
54
|
+
str(key): asdict(value) for key, value in sorted(self.entries.items())
|
|
55
|
+
},
|
|
56
|
+
"collisions": {str(k): v for k, v in sorted(self.collisions.items())},
|
|
57
|
+
}
|
|
58
|
+
target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def load(cls, path: str | Path) -> "PrefabManifest":
|
|
62
|
+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
63
|
+
entries = {
|
|
64
|
+
int(key): PrefabManifestEntry(**value)
|
|
65
|
+
for key, value in payload["entries"].items()
|
|
66
|
+
}
|
|
67
|
+
return cls(
|
|
68
|
+
entries=entries,
|
|
69
|
+
collisions={int(k): v for k, v in payload.get("collisions", {}).items()},
|
|
70
|
+
source_bundle=payload["source_bundle"],
|
|
71
|
+
source_size=int(payload["source_size"]),
|
|
72
|
+
source_mtime_ns=int(payload["source_mtime_ns"]),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def find_content_bundle(rust_install: str | Path) -> Path:
|
|
77
|
+
root = Path(rust_install)
|
|
78
|
+
candidates = (
|
|
79
|
+
root / "Bundles" / "shared" / "content.bundle",
|
|
80
|
+
root / "shared" / "content.bundle",
|
|
81
|
+
root if root.name.casefold() == "content.bundle" else None,
|
|
82
|
+
)
|
|
83
|
+
for candidate in candidates:
|
|
84
|
+
if candidate is not None and candidate.is_file():
|
|
85
|
+
return candidate
|
|
86
|
+
raise FileNotFoundError(f"Could not find Bundles/shared/content.bundle below {root}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def extract_game_manifest(content_bundle: str | Path) -> PrefabManifest:
|
|
90
|
+
"""Extract the authoritative ID/path table from Rust's GameManifest asset."""
|
|
91
|
+
try:
|
|
92
|
+
import UnityPy
|
|
93
|
+
except ImportError as exc:
|
|
94
|
+
raise RuntimeError("UnityPy is required to extract the installed GameManifest") from exc
|
|
95
|
+
|
|
96
|
+
bundle = Path(content_bundle).resolve()
|
|
97
|
+
stat = bundle.stat()
|
|
98
|
+
environment = UnityPy.load(str(bundle))
|
|
99
|
+
|
|
100
|
+
game_manifest_script_id: int | None = None
|
|
101
|
+
for obj in environment.objects:
|
|
102
|
+
if obj.type.name != "MonoScript":
|
|
103
|
+
continue
|
|
104
|
+
script = obj.read()
|
|
105
|
+
if getattr(script, "m_ClassName", "") == "GameManifest":
|
|
106
|
+
game_manifest_script_id = obj.path_id
|
|
107
|
+
break
|
|
108
|
+
if game_manifest_script_id is None:
|
|
109
|
+
raise RustMapError("GameManifest MonoScript was not found in content.bundle")
|
|
110
|
+
|
|
111
|
+
tree = None
|
|
112
|
+
for obj in environment.objects:
|
|
113
|
+
if obj.type.name != "MonoBehaviour":
|
|
114
|
+
continue
|
|
115
|
+
behaviour = obj.read()
|
|
116
|
+
if behaviour.m_Script.path_id == game_manifest_script_id:
|
|
117
|
+
tree = obj.read_typetree()
|
|
118
|
+
break
|
|
119
|
+
if tree is None:
|
|
120
|
+
raise RustMapError("GameManifest data object was not found in content.bundle")
|
|
121
|
+
|
|
122
|
+
properties_by_path: dict[str, dict] = {
|
|
123
|
+
item["name"].casefold(): item for item in tree.get("prefabProperties", [])
|
|
124
|
+
}
|
|
125
|
+
entries: dict[int, PrefabManifestEntry] = {}
|
|
126
|
+
all_paths: dict[int, list[str]] = defaultdict(list)
|
|
127
|
+
for item in tree.get("pooledStrings", []):
|
|
128
|
+
prefab_id = int(item["hash"])
|
|
129
|
+
asset_path = item["str"]
|
|
130
|
+
all_paths[prefab_id].append(asset_path)
|
|
131
|
+
prop = properties_by_path.get(asset_path.casefold())
|
|
132
|
+
entries[prefab_id] = PrefabManifestEntry(
|
|
133
|
+
prefab_id=prefab_id,
|
|
134
|
+
path=asset_path,
|
|
135
|
+
guid=prop.get("guid") if prop else None,
|
|
136
|
+
pooled=bool(prop.get("pool")) if prop else None,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
collisions = {
|
|
140
|
+
prefab_id: sorted(set(paths))
|
|
141
|
+
for prefab_id, paths in all_paths.items()
|
|
142
|
+
if len(set(paths)) > 1
|
|
143
|
+
}
|
|
144
|
+
return PrefabManifest(
|
|
145
|
+
entries=entries,
|
|
146
|
+
collisions=collisions,
|
|
147
|
+
source_bundle=str(bundle),
|
|
148
|
+
source_size=stat.st_size,
|
|
149
|
+
source_mtime_ns=stat.st_mtime_ns,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def load_or_extract_manifest(
|
|
154
|
+
rust_install: str | Path, cache_path: str | Path | None = None
|
|
155
|
+
) -> PrefabManifest:
|
|
156
|
+
bundle = find_content_bundle(rust_install)
|
|
157
|
+
stat = bundle.stat()
|
|
158
|
+
if cache_path is not None and Path(cache_path).is_file():
|
|
159
|
+
cached = PrefabManifest.load(cache_path)
|
|
160
|
+
if cached.source_size == stat.st_size and cached.source_mtime_ns == stat.st_mtime_ns:
|
|
161
|
+
return cached
|
|
162
|
+
manifest = extract_game_manifest(bundle)
|
|
163
|
+
if cache_path is not None:
|
|
164
|
+
manifest.save(cache_path)
|
|
165
|
+
return manifest
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def classify_prefab(path: str | None, map_categories: list[str]) -> tuple[str, str]:
|
|
169
|
+
if path is None:
|
|
170
|
+
return "unresolved", "unknown"
|
|
171
|
+
value = path.casefold().replace("\\", "/")
|
|
172
|
+
category_text = " ".join(map_categories).casefold()
|
|
173
|
+
|
|
174
|
+
if "/monument/" in value or "monument" in category_text:
|
|
175
|
+
return "monument", "spawn exclusion and orientation reference"
|
|
176
|
+
if any(token in value for token in (
|
|
177
|
+
"rock_formation", "/cliff", "cliff_", "coastal_rock", "shore_ice",
|
|
178
|
+
"iceberg", "ice_sheet", "rockformation",
|
|
179
|
+
)):
|
|
180
|
+
return "terrain_geometry", "slope/ore environment and spawn exclusion"
|
|
181
|
+
if any(token in category_text for token in ("road", "powerline")) or any(
|
|
182
|
+
token in value for token in ("/road", "roadside", "powerline")
|
|
183
|
+
):
|
|
184
|
+
return "transport_corridor", "vehicle and junkpile spawn environment"
|
|
185
|
+
if "river" in category_text or any(
|
|
186
|
+
token in value for token in ("/river", "riversound", "waterfall", "water_")
|
|
187
|
+
):
|
|
188
|
+
return "water_feature", "water/river spawn environment"
|
|
189
|
+
if "dungeon" in category_text or "/dungeon" in value:
|
|
190
|
+
return "dungeon", "spawn exclusion and underground geometry"
|
|
191
|
+
if any(token in value for token in (
|
|
192
|
+
"/resource/", "/collectable/", "/animals/", "/plant/", "tree",
|
|
193
|
+
"bush", "foliage", "vine",
|
|
194
|
+
)):
|
|
195
|
+
return "natural_resource", "possible direct resource or vegetation feature"
|
|
196
|
+
if "decor" in category_text or "/decor/" in value:
|
|
197
|
+
return "decor", "possible spawn environment or exclusion geometry"
|
|
198
|
+
return "other", "not yet assigned"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def resolve_world_prefabs(
|
|
202
|
+
world: RustMap, manifest: PrefabManifest
|
|
203
|
+
) -> list[PrefabResolution]:
|
|
204
|
+
counts = Counter(item.prefab_id for item in world.prefabs)
|
|
205
|
+
categories: dict[int, set[str]] = defaultdict(set)
|
|
206
|
+
for item in world.prefabs:
|
|
207
|
+
categories[item.prefab_id].add(item.category)
|
|
208
|
+
|
|
209
|
+
result: list[PrefabResolution] = []
|
|
210
|
+
for prefab_id, count in counts.most_common():
|
|
211
|
+
entry = manifest.get(prefab_id)
|
|
212
|
+
map_categories = sorted(categories[prefab_id])
|
|
213
|
+
classification, relevance = classify_prefab(
|
|
214
|
+
entry.path if entry else None, map_categories
|
|
215
|
+
)
|
|
216
|
+
result.append(PrefabResolution(
|
|
217
|
+
prefab_id=prefab_id,
|
|
218
|
+
path=entry.path if entry else None,
|
|
219
|
+
instance_count=count,
|
|
220
|
+
map_categories=map_categories,
|
|
221
|
+
classification=classification,
|
|
222
|
+
heatmap_relevance=relevance,
|
|
223
|
+
guid=entry.guid if entry else None,
|
|
224
|
+
pooled=entry.pooled if entry else None,
|
|
225
|
+
))
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def write_resolution_reports(
|
|
230
|
+
world: RustMap,
|
|
231
|
+
manifest: PrefabManifest,
|
|
232
|
+
output_dir: str | Path,
|
|
233
|
+
) -> dict:
|
|
234
|
+
output = Path(output_dir)
|
|
235
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
resolutions = resolve_world_prefabs(world, manifest)
|
|
237
|
+
resolved = [item for item in resolutions if item.path is not None]
|
|
238
|
+
unresolved = [item for item in resolutions if item.path is None]
|
|
239
|
+
classification_instances = Counter()
|
|
240
|
+
for item in resolutions:
|
|
241
|
+
classification_instances[item.classification] += item.instance_count
|
|
242
|
+
|
|
243
|
+
report = {
|
|
244
|
+
"manifest": {
|
|
245
|
+
"source_bundle": manifest.source_bundle,
|
|
246
|
+
"source_size": manifest.source_size,
|
|
247
|
+
"entry_count": len(manifest.entries),
|
|
248
|
+
"hash_collision_count": len(manifest.collisions),
|
|
249
|
+
},
|
|
250
|
+
"map": {
|
|
251
|
+
"world_size": world.size,
|
|
252
|
+
"prefab_instances": len(world.prefabs),
|
|
253
|
+
"unique_prefab_ids": len(resolutions),
|
|
254
|
+
},
|
|
255
|
+
"coverage": {
|
|
256
|
+
"resolved_unique_ids": len(resolved),
|
|
257
|
+
"unresolved_unique_ids": len(unresolved),
|
|
258
|
+
"resolved_instances": sum(x.instance_count for x in resolved),
|
|
259
|
+
"unresolved_instances": sum(x.instance_count for x in unresolved),
|
|
260
|
+
},
|
|
261
|
+
"classification_instance_counts": dict(classification_instances.most_common()),
|
|
262
|
+
"unresolved": [asdict(item) for item in unresolved],
|
|
263
|
+
"resolved": [asdict(item) for item in resolutions],
|
|
264
|
+
}
|
|
265
|
+
(output / "phase2_resolution.json").write_text(
|
|
266
|
+
json.dumps(report, indent=2) + "\n", encoding="utf-8"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
with (output / "resolved_prefabs.csv").open("w", newline="", encoding="utf-8") as stream:
|
|
270
|
+
writer = csv.writer(stream)
|
|
271
|
+
writer.writerow((
|
|
272
|
+
"prefab_id", "instance_count", "map_categories", "classification",
|
|
273
|
+
"heatmap_relevance", "path", "guid", "pooled",
|
|
274
|
+
))
|
|
275
|
+
for item in resolutions:
|
|
276
|
+
writer.writerow((
|
|
277
|
+
item.prefab_id, item.instance_count, ";".join(item.map_categories),
|
|
278
|
+
item.classification, item.heatmap_relevance, item.path or "",
|
|
279
|
+
item.guid or "", "" if item.pooled is None else item.pooled,
|
|
280
|
+
))
|
|
281
|
+
return report
|