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.
Files changed (110) hide show
  1. rust_map_parser-0.1.0.dist-info/METADATA +538 -0
  2. rust_map_parser-0.1.0.dist-info/RECORD +110 -0
  3. rust_map_parser-0.1.0.dist-info/WHEEL +5 -0
  4. rust_map_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
  5. rust_map_parser-0.1.0.dist-info/top_level.txt +1 -0
  6. rustmap/__init__.py +44 -0
  7. rustmap/config.py +236 -0
  8. rustmap/data/__init__.py +1 -0
  9. rustmap/data/monument_metadata.json +954 -0
  10. rustmap/data/no_build_zones.json +9543 -0
  11. rustmap/data/prefab_manifest.json +242637 -0
  12. rustmap/data/spawn_rules.json +3308 -0
  13. rustmap/data/tunnel_tiles/__init__.py +1 -0
  14. rustmap/data/tunnel_tiles/template_000__curve-nw-0.png +0 -0
  15. rustmap/data/tunnel_tiles/template_001__curve-nw-1.png +0 -0
  16. rustmap/data/tunnel_tiles/template_002__intersection-bb1-n.png +0 -0
  17. rustmap/data/tunnel_tiles/template_003__intersection-bb4-n.png +0 -0
  18. rustmap/data/tunnel_tiles/template_004__intersection-b5-n.png +0 -0
  19. rustmap/data/tunnel_tiles/template_005__intersection-b3-n.png +0 -0
  20. rustmap/data/tunnel_tiles/template_006__intersection-b2-n.png +0 -0
  21. rustmap/data/tunnel_tiles/template_007__intersection-bb2-n.png +0 -0
  22. rustmap/data/tunnel_tiles/template_008__intersection-b6-n.png +0 -0
  23. rustmap/data/tunnel_tiles/template_009__intersection-bb3-n.png +0 -0
  24. rustmap/data/tunnel_tiles/template_010__intersection-b4-n.png +0 -0
  25. rustmap/data/tunnel_tiles/template_011__intersection-bb6-n.png +0 -0
  26. rustmap/data/tunnel_tiles/template_012__intersection-n.png +0 -0
  27. rustmap/data/tunnel_tiles/template_013__intersection-b1-n.png +0 -0
  28. rustmap/data/tunnel_tiles/template_014__intersection-bb5-n.png +0 -0
  29. rustmap/data/tunnel_tiles/template_015__station-sn-0.png +0 -0
  30. rustmap/data/tunnel_tiles/template_016__straight-sn-3.png +0 -0
  31. rustmap/data/tunnel_tiles/template_017__straight-sn-2.png +0 -0
  32. rustmap/data/tunnel_tiles/template_018__station-sn-1.png +0 -0
  33. rustmap/data/tunnel_tiles/template_019__straight-sn-0.png +0 -0
  34. rustmap/data/tunnel_tiles/template_020__straight-sn-1.png +0 -0
  35. rustmap/data/tunnel_tiles/template_021__station-sn-3.png +0 -0
  36. rustmap/data/tunnel_tiles/template_022__straight-sn-4.png +0 -0
  37. rustmap/data/tunnel_tiles/template_023__station-sn-2.png +0 -0
  38. rustmap/data/tunnel_tiles/template_024__straight-sn-5.png +0 -0
  39. rustmap/data/tunnel_tiles/template_025__curve-ne-1.png +0 -0
  40. rustmap/data/tunnel_tiles/template_026__curve-ne-0.png +0 -0
  41. rustmap/data/tunnel_tiles/template_027__intersection.png +0 -0
  42. rustmap/data/tunnel_tiles/template_028__intersection-bb2-s.png +0 -0
  43. rustmap/data/tunnel_tiles/template_029__intersection-b2-s.png +0 -0
  44. rustmap/data/tunnel_tiles/template_030__intersection-b6-s.png +0 -0
  45. rustmap/data/tunnel_tiles/template_031__intersection-bb5-s.png +0 -0
  46. rustmap/data/tunnel_tiles/template_032__intersection-bb1-s.png +0 -0
  47. rustmap/data/tunnel_tiles/template_033__intersection-s.png +0 -0
  48. rustmap/data/tunnel_tiles/template_034__intersection-b1-s.png +0 -0
  49. rustmap/data/tunnel_tiles/template_035__intersection-bb6-s.png +0 -0
  50. rustmap/data/tunnel_tiles/template_036__intersection-b5-s.png +0 -0
  51. rustmap/data/tunnel_tiles/template_037__intersection-bb4-s.png +0 -0
  52. rustmap/data/tunnel_tiles/template_038__intersection-b3-s.png +0 -0
  53. rustmap/data/tunnel_tiles/template_039__intersection-bb3-s.png +0 -0
  54. rustmap/data/tunnel_tiles/template_040__intersection-b4-s.png +0 -0
  55. rustmap/data/tunnel_tiles/template_041__straight-we-3.png +0 -0
  56. rustmap/data/tunnel_tiles/template_042__station-we-1.png +0 -0
  57. rustmap/data/tunnel_tiles/template_043__straight-we-5.png +0 -0
  58. rustmap/data/tunnel_tiles/template_044__straight-we-4.png +0 -0
  59. rustmap/data/tunnel_tiles/template_045__station-we-2.png +0 -0
  60. rustmap/data/tunnel_tiles/template_046__station-we-3.png +0 -0
  61. rustmap/data/tunnel_tiles/template_047__station-we-0.png +0 -0
  62. rustmap/data/tunnel_tiles/template_048__straight-we-2.png +0 -0
  63. rustmap/data/tunnel_tiles/template_049__straight-we-1.png +0 -0
  64. rustmap/data/tunnel_tiles/template_050__straight-we-0.png +0 -0
  65. rustmap/data/tunnel_tiles/template_051__curve-se-0.png +0 -0
  66. rustmap/data/tunnel_tiles/template_052__curve-se-1.png +0 -0
  67. rustmap/data/tunnel_tiles/template_053__intersection-bb3-w.png +0 -0
  68. rustmap/data/tunnel_tiles/template_054__intersection-w.png +0 -0
  69. rustmap/data/tunnel_tiles/template_055__intersection-bb5-w.png +0 -0
  70. rustmap/data/tunnel_tiles/template_056__intersection-b5-w.png +0 -0
  71. rustmap/data/tunnel_tiles/template_057__intersection-b1-w.png +0 -0
  72. rustmap/data/tunnel_tiles/template_058__intersection-bb1-w.png +0 -0
  73. rustmap/data/tunnel_tiles/template_059__intersection-bb4-w.png +0 -0
  74. rustmap/data/tunnel_tiles/template_060__intersection-b4-w.png +0 -0
  75. rustmap/data/tunnel_tiles/template_061__intersection-b6-w.png +0 -0
  76. rustmap/data/tunnel_tiles/template_062__intersection-bb2-w.png +0 -0
  77. rustmap/data/tunnel_tiles/template_063__intersection-b3-w.png +0 -0
  78. rustmap/data/tunnel_tiles/template_064__intersection-b2-w.png +0 -0
  79. rustmap/data/tunnel_tiles/template_065__intersection-bb6-w.png +0 -0
  80. rustmap/data/tunnel_tiles/template_066__curve-sw-0.png +0 -0
  81. rustmap/data/tunnel_tiles/template_067__curve-sw-1.png +0 -0
  82. rustmap/data/tunnel_tiles/template_068__intersection-bb3-e.png +0 -0
  83. rustmap/data/tunnel_tiles/template_069__intersection-bb6-e.png +0 -0
  84. rustmap/data/tunnel_tiles/template_070__intersection-b4-e.png +0 -0
  85. rustmap/data/tunnel_tiles/template_071__intersection-b6-e.png +0 -0
  86. rustmap/data/tunnel_tiles/template_072__intersection-b5-e.png +0 -0
  87. rustmap/data/tunnel_tiles/template_073__intersection-b1-e.png +0 -0
  88. rustmap/data/tunnel_tiles/template_074__intersection-b3-e.png +0 -0
  89. rustmap/data/tunnel_tiles/template_075__intersection-bb2-e.png +0 -0
  90. rustmap/data/tunnel_tiles/template_076__intersection-e.png +0 -0
  91. rustmap/data/tunnel_tiles/template_077__intersection-bb1-e.png +0 -0
  92. rustmap/data/tunnel_tiles/template_078__intersection-bb4-e.png +0 -0
  93. rustmap/data/tunnel_tiles/template_079__intersection-b2-e.png +0 -0
  94. rustmap/data/tunnel_tiles/template_080__intersection-bb5-e.png +0 -0
  95. rustmap/data/tunnel_tiles/tiles.json +997 -0
  96. rustmap/exporter.py +409 -0
  97. rustmap/layers.py +323 -0
  98. rustmap/monument_assets.py +145 -0
  99. rustmap/monuments.py +226 -0
  100. rustmap/no_build.py +288 -0
  101. rustmap/no_build_assets.py +303 -0
  102. rustmap/parser.py +297 -0
  103. rustmap/png.py +18 -0
  104. rustmap/populations.py +361 -0
  105. rustmap/prefabs.py +281 -0
  106. rustmap/renderer.py +460 -0
  107. rustmap/resources.py +26 -0
  108. rustmap/tunnel_assets.py +398 -0
  109. rustmap/tunnels.py +417 -0
  110. rustmap/validation.py +106 -0
rustmap/renderer.py ADDED
@@ -0,0 +1,460 @@
1
+ """Python reconstruction of Rust dedicated server MapImageRenderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import time
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ from PIL import Image
14
+
15
+ from .layers import int16_grid, splat_grid, topology_grid, world_height_grid
16
+ from .png import save_png
17
+
18
+ try:
19
+ from numba import njit
20
+ except ImportError: # pragma: no cover
21
+ def njit(*args, **kwargs):
22
+ def decorate(fn): return fn
23
+ return decorate
24
+
25
+
26
+ COLORS = {
27
+ "start": (0.28627452, 0.27058825, 0.24705884, 1.0),
28
+ "water": (0.16941601, 0.31755757, 0.36200002, 1.0),
29
+ "gravel": (0.25, 0.24342105, 0.22039475, 1.0),
30
+ "dirt": (0.6, 0.47959462, 0.33, 1.0),
31
+ "sand": (0.7, 0.65968585, 0.5277487, 1.0),
32
+ "grass": (0.35486364, 0.37, 0.2035, 1.0),
33
+ "forest": (0.24843751, 0.3, 0.0703125, 1.0),
34
+ "rock": (0.4, 0.39379844, 0.37519377, 1.0),
35
+ "snow": (0.86274517, 0.9294118, 0.94117653, 1.0),
36
+ "pebble": (0.13725491, 0.2784314, 0.2761563, 1.0),
37
+ "offshore": (0.04090196, 0.22060032, 0.27450982, 1.0),
38
+ }
39
+ SPLAT_ORDER = ((7, "gravel"), (6, "pebble"), (3, "rock"), (0, "dirt"),
40
+ (4, "grass"), (5, "forest"), (2, "sand"), (1, "snow"))
41
+ SUN = np.asarray((0.95, 2.87, 2.37), dtype=np.float32)
42
+ SUN /= np.linalg.norm(SUN)
43
+
44
+
45
+ @njit(cache=True)
46
+ def _signed_distance(bitmap: np.ndarray) -> np.ndarray:
47
+ size = bitmap.shape[0]
48
+ padded = size + 2
49
+ near_x = np.full((padded, padded), -1, np.int32)
50
+ near_y = np.full((padded, padded), -1, np.int32)
51
+ dist = np.full((padded, padded), np.inf, np.float32)
52
+ # Literal port of DistanceField.Generate/GenerateJob boundary seeding.
53
+ for y in range(1, size - 2):
54
+ for x in range(1, size - 2):
55
+ land = bitmap[y, x] > 127
56
+ if land and ((bitmap[y, x-1] > 127) != land or
57
+ (bitmap[y, x+1] > 127) != land or
58
+ (bitmap[y-1, x] > 127) != land or
59
+ (bitmap[y+1, x] > 127) != land):
60
+ py, px = y + 1, x + 1
61
+ near_x[py, px] = x + 1
62
+ near_y[py, px] = y + 1
63
+ dist[py, px] = 0.0
64
+ for y in range(1, padded - 1):
65
+ for x in range(1, padded - 1):
66
+ best = dist[y, x]
67
+ for dy, dx, step in ((-1,-1,1.4142135),(-1,0,1.0),(-1,1,1.4142135),(0,-1,1.0)):
68
+ yy, xx = y + dy, x + dx
69
+ if dist[yy, xx] + step < best:
70
+ nx, ny = near_x[yy, xx], near_y[yy, xx]
71
+ near_x[y, x], near_y[y, x] = nx, ny
72
+ best = math.sqrt(float((x-nx)*(x-nx) + (y-ny)*(y-ny)))
73
+ dist[y, x] = best
74
+ for y in range(padded - 2, 0, -1):
75
+ for x in range(padded - 2, 0, -1):
76
+ best = dist[y, x]
77
+ for dy, dx, step in ((0,1,1.0),(1,-1,1.4142135),(1,0,1.0),(1,1,1.0)):
78
+ yy, xx = y + dy, x + dx
79
+ if dist[yy, xx] + step < best:
80
+ nx, ny = near_x[yy, xx], near_y[yy, xx]
81
+ near_x[y, x], near_y[y, x] = nx, ny
82
+ best = math.sqrt(float((x-nx)*(x-nx) + (y-ny)*(y-ny)))
83
+ dist[y, x] = best
84
+ out = np.empty((size, size), np.float32)
85
+ for y in range(size):
86
+ for x in range(size):
87
+ value = dist[y+1, x+1]
88
+ out[y, x] = -value if bitmap[y, x] > 127 else value
89
+ return out
90
+
91
+
92
+ @njit(cache=True)
93
+ def _gaussian_once(values: np.ndarray) -> np.ndarray:
94
+ size = values.shape[0]
95
+ offsets = (-6, -4, -2, 0, 2, 4, 6)
96
+ weights = (0.03125, 0.109375, 0.21875, 0.28125, 0.21875, 0.109375, 0.03125)
97
+ temp = np.empty_like(values)
98
+ out = np.empty_like(values)
99
+ for y in range(size):
100
+ for x in range(size):
101
+ total = 0.0
102
+ for k in range(7):
103
+ sx = min(max(x + offsets[k], 0), size - 1)
104
+ total += values[y, sx] * weights[k]
105
+ temp[y, x] = total
106
+ for y in range(size):
107
+ for x in range(size):
108
+ total = 0.0
109
+ for k in range(7):
110
+ sy = min(max(y + offsets[k], 0), size - 1)
111
+ total += temp[sy, x] * weights[k]
112
+ out[y, x] = total
113
+ return out
114
+
115
+
116
+ def _bilinear(grid: np.ndarray, xs: np.ndarray, ys: np.ndarray) -> np.ndarray:
117
+ last_y, last_x = grid.shape[-2] - 1, grid.shape[-1] - 1
118
+ px, py = xs * last_x, ys * last_y
119
+ x0raw, y0raw = np.trunc(px).astype(np.int64), np.trunc(py).astype(np.int64)
120
+ tx, ty = np.clip(px - x0raw, 0, 1).astype(np.float32), np.clip(py - y0raw, 0, 1).astype(np.float32)
121
+ x0, y0 = np.clip(x0raw, 0, last_x), np.clip(y0raw, 0, last_y)
122
+ x1, y1 = np.where(px < last_x, x0 + 1, x0), np.where(py < last_y, y0 + 1, y0)
123
+ x1, y1 = np.clip(x1, 0, last_x), np.clip(y1, 0, last_y)
124
+ a, b = grid[..., y0[:,None], x0[None,:]], grid[..., y0[:,None], x1[None,:]]
125
+ c, d = grid[..., y1[:,None], x0[None,:]], grid[..., y1[:,None], x1[None,:]]
126
+ top = a + (b-a) * tx[None,:]
127
+ bottom = c + (d-c) * tx[None,:]
128
+ return top + (bottom-top) * ty[:,None]
129
+
130
+
131
+ def _normal_vertices(normalized_height: np.ndarray, xs: np.ndarray, ys: np.ndarray, norm_y: float) -> np.ndarray:
132
+ last = normalized_height.shape[0] - 1
133
+ xm, xp = np.clip(xs-1,0,last), np.clip(xs+1,0,last)
134
+ ym, yp = np.clip(ys-1,0,last), np.clip(ys+1,0,last)
135
+ dx = (normalized_height[np.ix_(ys, xp)] - normalized_height[np.ix_(ys, xm)]) * np.float32(0.5)
136
+ dz = (normalized_height[np.ix_(yp, xs)] - normalized_height[np.ix_(ym, xs)]) * np.float32(0.5)
137
+ n = np.stack((-dx, np.full_like(dx, norm_y), -dz), axis=-1)
138
+ return n / np.maximum(np.linalg.norm(n,axis=-1,keepdims=True), 1e-20)
139
+
140
+
141
+ def _slerp(a: np.ndarray, b: np.ndarray, t: np.ndarray) -> np.ndarray:
142
+ t = np.clip(t, 0, 1)[...,None].astype(np.float32)
143
+ dot = np.clip(np.sum(a*b,axis=-1,keepdims=True), -1, 1)
144
+ theta = np.arccos(dot)
145
+ sin_theta = np.sin(theta)
146
+ linear = a + (b-a)*t
147
+ result = np.where(np.abs(sin_theta)>1e-6,
148
+ np.sin((1-t)*theta)/np.maximum(sin_theta,1e-20)*a + np.sin(t*theta)/np.maximum(sin_theta,1e-20)*b,
149
+ linear)
150
+ return result / np.maximum(np.linalg.norm(result,axis=-1,keepdims=True),1e-20)
151
+
152
+
153
+ def _sample_normals(normalized_height: np.ndarray, xs: np.ndarray, ys: np.ndarray,
154
+ world_size: int, vertex_normals: np.ndarray | None = None) -> np.ndarray:
155
+ last = normalized_height.shape[0]-1
156
+ px, py = xs*last, ys*last
157
+ x0 = np.clip(np.trunc(px).astype(int),0,last); y0=np.clip(np.trunc(py).astype(int),0,last)
158
+ x1=np.minimum(x0+1,last); y1=np.minimum(y0+1,last)
159
+ tx=np.clip(px-x0,0,1).astype(np.float32); ty=np.clip(py-y0,0,1).astype(np.float32)
160
+ if vertex_normals is None:
161
+ norm_y=np.float32(world_size/1000.0/normalized_height.shape[0])
162
+ n00=_normal_vertices(normalized_height,x0,y0,norm_y); n10=_normal_vertices(normalized_height,x1,y0,norm_y)
163
+ n01=_normal_vertices(normalized_height,x0,y1,norm_y); n11=_normal_vertices(normalized_height,x1,y1,norm_y)
164
+ top=_slerp(n00,n10,np.broadcast_to(tx,(len(ys),len(xs))))
165
+ bottom=_slerp(n01,n11,np.broadcast_to(tx,(len(ys),len(xs))))
166
+ else:
167
+ # Upscaled renders map multiple output rows onto the same height-grid
168
+ # row. Horizontally interpolate each unique source row once, then
169
+ # gather it for y0/y1. Each retained interpolation uses the exact same
170
+ # operands and operation order as the former per-output-row version.
171
+ unique_y, inverse = np.unique(np.concatenate((y0, y1)), return_inverse=True)
172
+ left=vertex_normals[unique_y[:,None],x0[None,:]]
173
+ right=vertex_normals[unique_y[:,None],x1[None,:]]
174
+ horizontal=_slerp(left,right,np.broadcast_to(tx,(len(unique_y),len(xs))))
175
+ top=horizontal[inverse[:len(y0)]]
176
+ bottom=horizontal[inverse[len(y0):]]
177
+ return _slerp(top,bottom,np.broadcast_to(ty[:,None],(len(ys),len(xs))))
178
+
179
+
180
+ def _apply_ocean_level(water: np.ndarray, topology: np.ndarray,
181
+ ocean_level: float = 0.0) -> np.ndarray:
182
+ ocean=(topology & np.uint32(384)) != 0
183
+ return np.where((water < ocean_level) & ocean,
184
+ np.maximum(water, ocean_level), water)
185
+
186
+
187
+ def _shore_distance(world, shore_size: int = 2048) -> np.ndarray:
188
+ coords=(np.arange(shore_size,dtype=np.float32)+0.5)/shore_size
189
+ terrain=_bilinear(world_height_grid(world),coords,coords)
190
+ water=_bilinear(world_height_grid(world,"water"),coords,coords)
191
+ # WaterLevel.GetWaterLevels only raises serialized negative water heights to
192
+ # the global ocean level where Ocean/Oceanside topology is present. Clamping
193
+ # every sample to zero creates fake lakes in dry below-sea-level terrain
194
+ # such as canyons and the Giant Excavator pit.
195
+ topology=topology_grid(world)
196
+ ix=np.clip(np.trunc(coords*topology.shape[1]).astype(np.int64),0,topology.shape[1]-1)
197
+ iy=np.clip(np.trunc(coords*topology.shape[0]).astype(np.int64),0,topology.shape[0]-1)
198
+ water=_apply_ocean_level(water,topology[np.ix_(iy,ix)])
199
+ bitmap=np.where(np.maximum(water-terrain,0)<=0,255,0).astype(np.uint8)
200
+ return _gaussian_once(_signed_distance(bitmap))
201
+
202
+
203
+ @dataclass
204
+ class MapRenderResult:
205
+ image: Image.Image
206
+ width: int
207
+ height: int
208
+ background: tuple[int,int,int]
209
+ timings: dict[str,float]
210
+
211
+
212
+ @dataclass
213
+ class MapRenderInputs:
214
+ splat: np.ndarray
215
+ topology: np.ndarray
216
+ normalized_height: np.ndarray
217
+ heights: np.ndarray
218
+ shore: np.ndarray
219
+ vertex_normals: np.ndarray
220
+ prepare_seconds: float
221
+
222
+
223
+ def save_full_map_tiles(image: Image.Image, output_dir: str | Path,
224
+ tile_size: int = 512) -> dict:
225
+ """Split a full map image into bottom-left-indexed, padded RGBA PNG tiles."""
226
+ if tile_size <= 0:
227
+ raise ValueError("tile_size must be positive")
228
+ started = time.perf_counter()
229
+ output = Path(output_dir)
230
+ output.mkdir(parents=True, exist_ok=True)
231
+ width, height = image.size
232
+ columns = math.ceil(width / tile_size)
233
+ rows = math.ceil(height / tile_size)
234
+ specifications = []
235
+ expected_files = set()
236
+ for tile_y in range(rows):
237
+ map_bottom = tile_y * tile_size
238
+ content_height = min(tile_size, height - map_bottom)
239
+ pil_top = height - map_bottom - content_height
240
+ pil_bottom = height - map_bottom
241
+ for tile_x in range(columns):
242
+ map_left = tile_x * tile_size
243
+ content_width = min(tile_size, width - map_left)
244
+ filename = f"x_{tile_x}_y_{tile_y}.png"
245
+ expected_files.add(filename)
246
+ specifications.append({
247
+ "x": tile_x, "y": tile_y, "filename": filename,
248
+ "content_width": content_width, "content_height": content_height,
249
+ "map_bounds": {
250
+ "left": map_left, "bottom": map_bottom,
251
+ "right": map_left + content_width,
252
+ "top": map_bottom + content_height,
253
+ },
254
+ "image_content_offset": {"x": 0, "y": tile_size - content_height},
255
+ "crop_box": (map_left, pil_top, map_left + content_width, pil_bottom),
256
+ })
257
+
258
+ def write_tile(specification: dict) -> int:
259
+ content = image.crop(specification["crop_box"]).convert("RGBA")
260
+ tile = Image.new("RGBA", (tile_size, tile_size), (0, 0, 0, 0))
261
+ offset = specification["image_content_offset"]
262
+ tile.paste(content, (offset["x"], offset["y"]))
263
+ path = output / specification["filename"]
264
+ save_png(tile, path)
265
+ return path.stat().st_size
266
+
267
+ encode_started = time.perf_counter()
268
+ with ThreadPoolExecutor(max_workers=4) as pool:
269
+ sizes = list(pool.map(write_tile, specifications))
270
+ encode_seconds = time.perf_counter() - encode_started
271
+ for old_tile in output.glob("x_*_y_*.png"):
272
+ if old_tile.name not in expected_files:
273
+ old_tile.unlink()
274
+
275
+ tiles = []
276
+ for specification, size in zip(specifications, sizes):
277
+ item = {key: value for key, value in specification.items() if key != "crop_box"}
278
+ item["size_bytes"] = size
279
+ tiles.append(item)
280
+ metadata = {
281
+ "schema_version": 1,
282
+ "map_width": width,
283
+ "map_height": height,
284
+ "tile_size": tile_size,
285
+ "columns": columns,
286
+ "rows": rows,
287
+ "tile_count": len(tiles),
288
+ "coordinate_system": {
289
+ "origin": "bottom-left",
290
+ "x_axis": "right",
291
+ "y_axis": "up",
292
+ "units": "map pixels (one pixel per world metre for the full-size render)",
293
+ },
294
+ "padding": {"mode": "transparent", "rgba": [0, 0, 0, 0]},
295
+ "filename_pattern": "x_{x}_y_{y}.png",
296
+ "tiles": tiles,
297
+ "timings": {"png_encode_seconds": encode_seconds,
298
+ "total_seconds": time.perf_counter() - started},
299
+ }
300
+ metadata_path = output / "tiles.json"
301
+ metadata["metadata_file"] = metadata_path.name
302
+ metadata["directory_size_bytes"] = sum(sizes)
303
+ for _ in range(4):
304
+ rendered = json.dumps(metadata, indent=2) + "\n"
305
+ total_size = sum(sizes) + len(rendered.encode("utf-8"))
306
+ if metadata["directory_size_bytes"] == total_size:
307
+ break
308
+ metadata["directory_size_bytes"] = total_size
309
+ metadata_path.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8", newline="\n")
310
+ return metadata
311
+
312
+
313
+ def _prepare_render_inputs(world, normal_chunk_rows: int = 256) -> MapRenderInputs:
314
+ started = time.perf_counter()
315
+ splat = splat_grid(world).astype(np.float32) / 255.0
316
+ topology = topology_grid(world)
317
+ raw_height = int16_grid(world, "height")
318
+ normalized_height = raw_height.astype(np.float32) / np.float32(32766.0)
319
+ heights = world_height_grid(world)
320
+ shore = _shore_distance(world)
321
+
322
+ size = normalized_height.shape[0]
323
+ coordinates = np.arange(size, dtype=np.int64)
324
+ norm_y = np.float32(world.size / 1000.0 / size)
325
+ vertex_normals = np.empty((size, size, 3), dtype=np.float32)
326
+ for row0 in range(0, size, normal_chunk_rows):
327
+ row1 = min(row0 + normal_chunk_rows, size)
328
+ ys = coordinates[row0:row1]
329
+ vertex_normals[row0:row1] = _normal_vertices(
330
+ normalized_height, coordinates, ys, norm_y
331
+ )
332
+ return MapRenderInputs(
333
+ splat, topology, normalized_height, heights, shore, vertex_normals,
334
+ time.perf_counter() - started,
335
+ )
336
+
337
+
338
+ def render_map_image(world, scale: float = 0.5, ocean_margin: int = 0,
339
+ transparent: bool = False, chunk_rows: int = 128,
340
+ prepared: MapRenderInputs | None = None) -> MapRenderResult:
341
+ started=time.perf_counter(); scale=float(np.clip(scale,0.1,4.0))
342
+ map_res=int(world.size*scale)
343
+ if map_res<=0 or ocean_margin<0: raise ValueError("Invalid map render dimensions")
344
+ width=height=map_res+ocean_margin*2
345
+ if prepared is None:
346
+ prepared = _prepare_render_inputs(world)
347
+ prepare_seconds = prepared.prepare_seconds
348
+ else:
349
+ prepare_seconds = 0.0
350
+ splat=prepared.splat; topo=prepared.topology
351
+ normalized_height=prepared.normalized_height; heights=prepared.heights
352
+ shore=prepared.shore
353
+ timings={"shore_seconds":prepare_seconds}
354
+ channels=4 if transparent else 3
355
+ pixels=np.empty((height,width,channels),np.uint8)
356
+ xs=(np.arange(width,dtype=np.float32)-ocean_margin)/map_res
357
+ for row0 in range(0,height,chunk_rows):
358
+ row1=min(row0+chunk_rows,height)
359
+ # PIL rows are top-down; Unity's SetPixels array is bottom-up.
360
+ ys=(height-1-np.arange(row0,row1,dtype=np.float32)-ocean_margin)/map_res
361
+ color=np.broadcast_to(np.asarray(COLORS["start"],np.float32),(len(ys),width,4)).copy()
362
+ sampled_splat=_bilinear(splat,xs,ys)
363
+ for channel,name in SPLAT_ORDER:
364
+ target=np.asarray(COLORS[name],np.float32)
365
+ t=sampled_splat[channel][...,None]*target[3]
366
+ color += (target-color)*t
367
+ height_values=_bilinear(heights,xs,ys)
368
+ normals=_sample_normals(normalized_height,xs,ys,world.size,prepared.vertex_normals)
369
+ sun=np.maximum(np.sum(normals*SUN,axis=-1),0)
370
+ ix=np.clip(np.trunc(xs*topo.shape[1]).astype(int),0,topo.shape[1]-1)
371
+ iy=np.clip(np.trunc(ys*topo.shape[0]).astype(int),0,topo.shape[0]-1)
372
+ ocean=(topo[np.ix_(iy,ix)] & np.uint32(384))!=0
373
+ shore_dist=_bilinear(shore,xs,ys)*(world.size/shore.shape[0])
374
+ depth=np.zeros_like(height_values,dtype=np.float32)
375
+ wet=shore_dist>0
376
+ depth[wet]=-height_values[wet]
377
+ replace=(depth<=0)|(~ocean)
378
+ depth[wet & replace]=np.maximum(depth[wet & replace],0.1*shore_dist[wet & replace])
379
+ water_mask=depth>0
380
+ if np.any(water_mask):
381
+ wc=np.asarray(COLORS["water"],np.float32); oc=np.asarray(COLORS["offshore"],np.float32)
382
+ if transparent: wc[3]=0.5; oc[:]=0
383
+ t1=np.clip(0.5+depth/5,0,1)[...,None]
384
+ t2=np.clip(depth/(max(abs(float(_bilinear(heights,np.asarray([0],np.float32),np.asarray([0],np.float32))[0,0])),5) if transparent else 50),0,1)[...,None]
385
+ water_color=color+(wc-color)*t1
386
+ water_color=water_color+(oc-water_color)*t2
387
+ color=np.where(water_mask[...,None],water_color,color)
388
+ land=~water_mask
389
+ color[land] += ((sun[land]-0.5)*0.65)[:,None]*color[land]
390
+ color[land]=(color[land]-0.5)*0.94+0.5
391
+ color*=1.05
392
+ color=np.clip(color,0,1)
393
+ converted=np.rint(color*255).astype(np.uint8)
394
+ pixels[row0:row1]=converted[...,:channels]
395
+ image=Image.fromarray(pixels,"RGBA" if transparent else "RGB")
396
+ timings["total_seconds"]=time.perf_counter()-started
397
+ return MapRenderResult(image,width,height,tuple(int(x) for x in pixels[0,0,:3]),timings)
398
+
399
+
400
+ def save_map_render(world, output_dir: str|Path, scale: float=0.5, ocean_margin: int=0,
401
+ formats=("png","jpg"), debug: bool=False,
402
+ full_size_png: bool=True, full_size_tiles: bool=False,
403
+ tile_size: int=512) -> dict:
404
+ if full_size_tiles and not full_size_png:
405
+ raise ValueError("full_size_tiles requires full_size_png=True")
406
+ if tile_size <= 0:
407
+ raise ValueError("tile_size must be positive")
408
+ output=Path(output_dir); output.mkdir(parents=True,exist_ok=True)
409
+ prepared = _prepare_render_inputs(world)
410
+ # The native render supersedes the convenience scaled render. This keeps
411
+ # full-size exports from doing a second expensive terrain pass merely
412
+ # because TerrainOptions.formats retains its friendly defaults.
413
+ scaled_requested = bool(formats) and not full_size_png
414
+ if scaled_requested:
415
+ result=render_map_image(world,scale,ocean_margin,prepared=prepared)
416
+ else:
417
+ result = None
418
+ full_only_result = (
419
+ render_map_image(world, scale=1.0, ocean_margin=0, prepared=prepared)
420
+ if full_size_png else None
421
+ )
422
+ timing_owner = result if result is not None else full_only_result
423
+ timing_owner.timings["prepare_seconds"] = prepared.prepare_seconds
424
+ artifacts={}
425
+ if scaled_requested and "png" in formats:
426
+ p=output/"map_render.png"; save_png(result.image,p); artifacts[p.name]=p.stat().st_size
427
+ elif (output/"map_render.png").is_file():
428
+ (output/"map_render.png").unlink()
429
+ if scaled_requested and ("jpg" in formats or "jpeg" in formats):
430
+ p=output/"map_render.jpg"; result.image.convert("RGB").save(p,"JPEG",quality=85); artifacts[p.name]=p.stat().st_size
431
+ elif (output/"map_render.jpg").is_file():
432
+ (output/"map_render.jpg").unlink()
433
+ full_size = None
434
+ tile_metadata = None
435
+ if full_size_png:
436
+ full_result = full_only_result
437
+ p=output/"map_render_full.png"
438
+ save_png(full_result.image,p)
439
+ artifacts[p.name]=p.stat().st_size
440
+ full_size={"width":full_result.width,"height":full_result.height,"scale":1.0,
441
+ "ocean_margin":0,"timings":full_result.timings,"artifact":p.name}
442
+ if full_size_tiles:
443
+ tile_metadata = save_full_map_tiles(
444
+ full_result.image, output / "map_render_tiles", tile_size
445
+ )
446
+ artifacts["map_render_tiles-total"] = tile_metadata["directory_size_bytes"]
447
+ background_owner = result if result is not None else full_result
448
+ metadata={"schema_version":1,"width":result.width if result else None,
449
+ "height":result.height if result else None,
450
+ "scale":float(np.clip(scale,0.1,4)) if result else None,
451
+ "ocean_margin":ocean_margin if result else None,
452
+ "transparent":False,"background_rgb":list(background_owner.background),
453
+ "constants":{"colors":COLORS,"splat_order":[name for _,name in SPLAT_ORDER],"sun_direction":SUN.tolist(),
454
+ "sun_power":0.65,"brightness":1.05,"contrast":0.94,"max_depth":50.0,
455
+ "ocean_water_level":0.0,"ocean_topology_mask":384},
456
+ "timings":result.timings if result else {},"full_size_render":full_size,
457
+ "full_size_tiles":tile_metadata,
458
+ "artifacts":artifacts,"validation":{"server_reference":"not_run"}}
459
+ p=output/"map_render_metadata.json"; p.write_text(json.dumps(metadata,indent=2)+"\n",encoding="utf-8",newline="\n")
460
+ return metadata
rustmap/resources.py ADDED
@@ -0,0 +1,26 @@
1
+ """APIs for refreshing version-specific Rust data resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .populations import extract_spawn_rules, save_rule_database
8
+ from .prefabs import extract_game_manifest, find_content_bundle
9
+
10
+
11
+ def refresh_prefab_manifest(rust_install: str | Path, output_path: str | Path):
12
+ """Extract and save the installed Rust GameManifest."""
13
+ manifest = extract_game_manifest(find_content_bundle(rust_install))
14
+ manifest.save(output_path)
15
+ return manifest
16
+
17
+
18
+ def refresh_spawn_rules(rust_install: str | Path, output_path: str | Path) -> dict:
19
+ """Extract and save population rules from an installed Rust build."""
20
+ root = Path(rust_install)
21
+ database = extract_spawn_rules(
22
+ root / "Bundles" / "shared" / "content.bundle",
23
+ root / "Bundles" / "maps" / "maps.bundle",
24
+ )
25
+ save_rule_database(database, output_path)
26
+ return database