382 lines
16 KiB
GDScript
382 lines
16 KiB
GDScript
extends RefCounted
|
||
class_name SvmapIO
|
||
## SvmapIO — Editor-seitige Implementierung des `.svmap`-Map-Paket-Formats (v1).
|
||
##
|
||
## Grundlage: docs/map_format.md im scopedvr-match-server-Repo (Karte t_74976e9f).
|
||
## Bewusst EIGENE Implementierung (kein Match-Server-Import): das Format-Doku
|
||
## ist der Vertrag. Validierungs-IDs V1..V14 und Kanonisierung/Prüfsumme sind
|
||
## 1:1 zur Doku übernommen, damit eine vom Editor gespeicherte Map vom
|
||
## Match-Server akzeptiert wird (und umgekehrt).
|
||
##
|
||
## API (alles static, testbar ohne UI):
|
||
## parse_and_validate(text, manifest = {}) -> {ok, errors, data}
|
||
## load_from_file(path) -> {ok, errors, data}
|
||
## save_to_file(path, data) -> {ok, errors} (validiert VOR dem Schreiben)
|
||
## validate_map(data, manifest) -> PackedStringArray
|
||
## sign_map(data) -> void (setzt/erneuert Prüfsumme)
|
||
|
||
const FORMAT_VERSION := 1
|
||
const WEATHER_PRESETS: Array[String] = ["mittag", "abend", "regen", "nacht"]
|
||
|
||
# Perf-/Plausibilitäts-Limits — identisch zum Server-Loader (docs/map_format.md §4).
|
||
const MAX_FILE_BYTES := 50 * 1024 * 1024 # V1
|
||
const MAX_OBJECTS := 5000 # V8a
|
||
const MAX_TOTAL_POLY := 1_500_000 # V8b (× scale² gewichtet)
|
||
const MAX_TERRAIN_RESOLUTION := 513 # V6
|
||
const MIN_TERRAIN_RESOLUTION := 17
|
||
const MAX_TERRAIN_SIZE_M := 8192.0 # V6/V14
|
||
const MIN_TERRAIN_SIZE_M := 16.0
|
||
const MAX_HEIGHT_RANGE_M := 500.0 # V6
|
||
const MAX_BOUNDS_EXTENT_M := 8192.0 # V14
|
||
const BOUNDS_TOLERANCE_M := 1.0 # V9
|
||
const MIN_SPAWN_POINTS_PER_TEAM := 4 # V11 (Entscheidung 11)
|
||
|
||
const MANIFEST_PATH := "res://library/objects.json"
|
||
const JSON_INDENT := " "
|
||
|
||
|
||
static func _add(result: Dictionary, msg: String) -> void:
|
||
(result["errors"] as Array).append(msg)
|
||
|
||
|
||
# --------------------------------------------------------------- Öffentlich --
|
||
|
||
## Komplette Pipeline: JSON-Text → Validierung (inkl. Prüfsumme) → Map-Daten.
|
||
## errors bewusst als Array: Godot 4 kopiert PackedArrays beim Dict-Zugriff,
|
||
## Mutationen würden sonst verloren gehen.
|
||
static func parse_and_validate(text: String, manifest: Dictionary = {}) -> Dictionary:
|
||
var result := {"ok": false, "errors": [], "data": {}}
|
||
if text.length() > MAX_FILE_BYTES:
|
||
_add(result, "V1: Map-Datei zu groß (%d Bytes, Limit %d)." % [text.length(), MAX_FILE_BYTES])
|
||
return result
|
||
var parsed: Variant = JSON.parse_string(text)
|
||
if parsed == null or not (parsed is Dictionary):
|
||
_add(result, "V2: Kein gültiges JSON-Objekt (Datei beschädigt oder keine .svmap?).")
|
||
return result
|
||
var data: Dictionary = parsed
|
||
var errors := validate_map(data, manifest)
|
||
if not errors.is_empty():
|
||
result["errors"] = Array(errors)
|
||
return result
|
||
result["ok"] = true
|
||
result["data"] = data
|
||
return result
|
||
|
||
|
||
static func load_from_file(path: String) -> Dictionary:
|
||
var result := {"ok": false, "errors": [], "data": {}}
|
||
if not FileAccess.file_exists(path):
|
||
_add(result, "Datei nicht gefunden: %s" % path)
|
||
return result
|
||
var fa := FileAccess.open(path, FileAccess.READ)
|
||
if fa == null:
|
||
_add(result, "Datei nicht lesbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()])
|
||
return result
|
||
if fa.get_length() > MAX_FILE_BYTES:
|
||
_add(result, "V1: Map-Datei zu groß (%d Bytes, Limit %d)." % [fa.get_length(), MAX_FILE_BYTES])
|
||
return result
|
||
return parse_and_validate(fa.get_as_text())
|
||
|
||
|
||
## Kern-Validierung → Kanonisieren → Signieren → Schreiben. Nur gültige Maps
|
||
## landen auf Disk. Kanonisieren VOR dem Schreiben (alle Zahlen → float):
|
||
## macht Dateien deterministisch — speichern→laden→speichern bleibt
|
||
## byte-identisch, weil Godots JSON-Parser ints ohnehin als float zurückgibt.
|
||
## Kompatibel zum Server: dessen Prüfsumme läuft über die kanonisierte Payload,
|
||
## int- und float-Form kanonisieren zum selben Hash.
|
||
static func save_to_file(path: String, data: Dictionary) -> Dictionary:
|
||
var errors := _validate_core(data)
|
||
if not errors.is_empty():
|
||
return {"ok": false, "errors": errors}
|
||
var canonical: Dictionary = _canonicalize(data)
|
||
sign_map(canonical)
|
||
var fa := FileAccess.open(path, FileAccess.WRITE)
|
||
if fa == null:
|
||
var out := {"ok": false, "errors": []}
|
||
_add(out, "Datei nicht schreibbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()])
|
||
return out
|
||
fa.store_string(JSON.stringify(canonical, JSON_INDENT))
|
||
return {"ok": true, "errors": PackedStringArray()}
|
||
|
||
|
||
## Validierung OHNE Prüfsumme (V3–V12 + V14); validate_map = Kern + Prüfsumme.
|
||
## manifest = Bibliotheks-Registry (leer → Default aus res://library/objects.json).
|
||
static func validate_map(data: Dictionary, manifest: Dictionary = {}) -> PackedStringArray:
|
||
var errors := _validate_core(data, manifest)
|
||
if not errors.is_empty():
|
||
return errors
|
||
return _validate_checksum(data)
|
||
|
||
|
||
## Setzt/erneuert die Prüfsumme (save = neue Map-Version).
|
||
static func sign_map(data: Dictionary) -> void:
|
||
data["checksum"] = {"algorithm": "sha256", "value": _payload_sha256(data)}
|
||
|
||
|
||
# ------------------------------------------------------------- Validierung --
|
||
|
||
static func _validate_core(data: Dictionary, manifest: Dictionary = {}) -> PackedStringArray:
|
||
var errors := PackedStringArray()
|
||
|
||
# V3: Versions-Stempel
|
||
var fv = data.get("format_version", null)
|
||
if not (fv is int or fv is float) or int(fv) != FORMAT_VERSION:
|
||
errors.append("V3: format_version muss %d sein (ist %s). Diese Datei ist keine .svmap-Map v1." % [FORMAT_VERSION, str(fv)])
|
||
return errors # Ohne passende Version ist alles Weitere sinnlos.
|
||
|
||
# V4: Pflichtblöcke + Typen (checksum prüft validate_map separat).
|
||
for key in ["info", "terrain", "spawn_zones", "bounds"]:
|
||
if not data.has(key) or not (data[key] is Dictionary):
|
||
errors.append("V4: Pflichtblock '%s' fehlt oder ist kein Objekt." % key)
|
||
if not data.has("objects") or not (data["objects"] is Array):
|
||
errors.append("V4: Pflichtblock 'objects' fehlt oder ist kein Array.")
|
||
if not errors.is_empty():
|
||
return errors
|
||
|
||
errors.append_array(_validate_info(data["info"]))
|
||
errors.append_array(_validate_terrain(data["terrain"]))
|
||
errors.append_array(_validate_bounds(data["bounds"]))
|
||
|
||
var terrain: Dictionary = data["terrain"]
|
||
var bounds: Dictionary = data["bounds"]
|
||
errors.append_array(_validate_objects(data["objects"], bounds, manifest))
|
||
errors.append_array(_validate_spawns(data["spawn_zones"], bounds))
|
||
|
||
# V12: Wetter (optional, aber dann gültig)
|
||
var weather = data.get("weather", "")
|
||
if weather != null and str(weather) != "":
|
||
if not (weather is String) or not WEATHER_PRESETS.has(weather):
|
||
errors.append("V12: weather '%s' ist kein gültiges Preset (%s)." % [
|
||
str(weather), ", ".join(WEATHER_PRESETS)])
|
||
return errors
|
||
|
||
|
||
static func _validate_info(info: Dictionary) -> PackedStringArray:
|
||
var errors := PackedStringArray()
|
||
var name_v = info.get("name", "")
|
||
if not (name_v is String) or (name_v as String).strip_edges().is_empty() \
|
||
or (name_v as String).length() > 64:
|
||
errors.append("V4: info.name muss 1–64 Zeichen lang sein.")
|
||
var author = info.get("author", "")
|
||
if not (author is String) or (author as String).strip_edges().is_empty():
|
||
errors.append("V4: info.author darf nicht leer sein.")
|
||
var version = info.get("version", 0)
|
||
if not (version is int or version is float) or int(version) < 1:
|
||
errors.append("V4: info.version muss eine Zahl ≥ 1 sein.")
|
||
var tgv = info.get("target_game_version", "")
|
||
if tgv != null and str(tgv) != "" and not (tgv is String):
|
||
errors.append("V4: info.target_game_version muss ein String sein.")
|
||
return errors
|
||
|
||
|
||
static func _validate_terrain(terrain: Dictionary) -> PackedStringArray:
|
||
var errors := PackedStringArray()
|
||
if terrain.get("type", "") != "heightmap":
|
||
errors.append("V4: terrain.type muss 'heightmap' sein.")
|
||
return errors
|
||
var res = terrain.get("resolution", 0)
|
||
if not (res is int or res is float) or int(res) < MIN_TERRAIN_RESOLUTION \
|
||
or int(res) > MAX_TERRAIN_RESOLUTION:
|
||
errors.append("V6: terrain.resolution muss %d–%d sein (ist %s)." % [
|
||
MIN_TERRAIN_RESOLUTION, MAX_TERRAIN_RESOLUTION, str(res)])
|
||
return errors
|
||
var sx = terrain.get("size_x_m", 0.0)
|
||
var sz = terrain.get("size_z_m", 0.0)
|
||
for pair in [["size_x_m", sx], ["size_z_m", sz]]:
|
||
if not _is_finite_num(pair[1]) or float(pair[1]) < MIN_TERRAIN_SIZE_M \
|
||
or float(pair[1]) > MAX_TERRAIN_SIZE_M:
|
||
errors.append("V6: terrain.%s muss %.0f–%.0f m sein (ist %s)." % [
|
||
pair[0], MIN_TERRAIN_SIZE_M, MAX_TERRAIN_SIZE_M, str(pair[1])])
|
||
var hmin = terrain.get("min_height_m", 0.0)
|
||
var hmax = terrain.get("max_height_m", 0.0)
|
||
if not _is_finite_num(hmin) or not _is_finite_num(hmax) or float(hmin) >= float(hmax) \
|
||
or (float(hmax) - float(hmin)) > MAX_HEIGHT_RANGE_M:
|
||
errors.append("V6: terrain.min_height_m < max_height_m (Spannweite ≤ %.0f m) verletzt." % MAX_HEIGHT_RANGE_M)
|
||
# V5: Höhenfeld-Länge
|
||
var res_i := int(res)
|
||
var raw: PackedByteArray = Marshalls.base64_to_raw(str(terrain.get("heightmap_r16_b64", "")))
|
||
if raw.size() != res_i * res_i * 2:
|
||
errors.append("V5: heightmap_r16_b64 dekodiert zu %d Bytes, erwartet %d (= resolution² × 2)." % [
|
||
raw.size(), res_i * res_i * 2])
|
||
return errors
|
||
|
||
|
||
static func _validate_bounds(bounds: Dictionary) -> PackedStringArray:
|
||
var errors := PackedStringArray()
|
||
var mn = bounds.get("min", null)
|
||
var mx = bounds.get("max", null)
|
||
if not (mn is Array) or not (mx is Array) or (mn as Array).size() != 3 \
|
||
or (mx as Array).size() != 3:
|
||
errors.append("V4: bounds.min/max müssen je [x, y, z] sein.")
|
||
return errors
|
||
for i in 3:
|
||
if not _is_finite_num(mn[i]) or not _is_finite_num(mx[i]):
|
||
errors.append("V9: bounds enthalten nicht-endliche Werte.")
|
||
return errors
|
||
if float(mx[i]) <= float(mn[i]):
|
||
errors.append("V14: bounds.max[%d] muss > bounds.min[%d] sein." % [i, i])
|
||
var extent := Vector3(float(mx[0]) - float(mn[0]), float(mx[1]) - float(mn[1]), float(mx[2]) - float(mn[2]))
|
||
if extent.length() > MAX_BOUNDS_EXTENT_M:
|
||
errors.append("V14: bounds-Diagonale %.0f m > %.0f m." % [extent.length(), MAX_BOUNDS_EXTENT_M])
|
||
return errors
|
||
|
||
|
||
static func _validate_objects(objects: Array, bounds: Dictionary, manifest: Dictionary) -> PackedStringArray:
|
||
var errors := PackedStringArray()
|
||
if objects.size() > MAX_OBJECTS:
|
||
errors.append("V8: %d Objekte > Limit %d." % [objects.size(), MAX_OBJECTS])
|
||
return errors
|
||
var lib: Dictionary = _manifest_or(manifest).get("objects", {})
|
||
var total_poly := 0.0
|
||
for i in objects.size():
|
||
var obj = objects[i]
|
||
if not (obj is Dictionary):
|
||
errors.append("V4: objects[%d] ist kein Objekt." % i)
|
||
continue
|
||
var oid = obj.get("id", "")
|
||
if not (oid is String) or not lib.has(oid):
|
||
errors.append("V7: objects[%d]: unbekannte Objekt-ID '%s' (nicht in der Bibliothek)." % [i, str(oid)])
|
||
continue
|
||
var pos = obj.get("pos", null)
|
||
if not (pos is Array) or (pos as Array).size() != 3 \
|
||
or not _is_finite_num(pos[0]) or not _is_finite_num(pos[1]) or not _is_finite_num(pos[2]):
|
||
errors.append("V9: objects[%d]: pos muss [x, y, z] mit endlichen Werten sein." % i)
|
||
elif not _inside_bounds(pos, bounds):
|
||
errors.append("V9: objects[%d] (%s) liegt außerhalb der Match-Grenzen." % [i, str(oid)])
|
||
var scale = obj.get("scale", 1.0)
|
||
if not _is_finite_num(scale) or float(scale) < 0.05 or float(scale) > 50.0:
|
||
errors.append("V10: objects[%d]: scale muss 0.05–50 sein (ist %s)." % [i, str(scale)])
|
||
continue
|
||
var rot = obj.get("rot_y_deg", 0.0)
|
||
if not _is_finite_num(rot):
|
||
errors.append("V9: objects[%d]: rot_y_deg muss endlich sein." % i)
|
||
# V8b: Poly-Budget (× scale² — Flächen-/Kantenwachstum der Meshes)
|
||
var poly = float(lib[oid].get("poly", 0))
|
||
total_poly += poly * float(scale) * float(scale)
|
||
if total_poly > MAX_TOTAL_POLY:
|
||
errors.append("V8: Poly-Budget überschritten (%.0f > %d)." % [total_poly, MAX_TOTAL_POLY])
|
||
return errors
|
||
|
||
|
||
static func _validate_spawns(zones: Dictionary, bounds: Dictionary) -> PackedStringArray:
|
||
var errors := PackedStringArray()
|
||
var seen_ids := {}
|
||
for team in [0, 1]:
|
||
var key := str(team)
|
||
if not zones.has(key) or not (zones[key] is Dictionary):
|
||
errors.append("V11: spawn_zones für Team %d fehlt." % team)
|
||
continue
|
||
var points = zones[key].get("points", [])
|
||
if not (points is Array) or points.size() < MIN_SPAWN_POINTS_PER_TEAM:
|
||
errors.append("V11: Team %d hat %s Spawn-Punkte, mindestens %d nötig." % [
|
||
team, str(points.size()), MIN_SPAWN_POINTS_PER_TEAM])
|
||
continue
|
||
for i in points.size():
|
||
var p = points[i]
|
||
if not (p is Dictionary):
|
||
errors.append("V11: Team %d Punkt %d ist kein Objekt." % [team, i])
|
||
continue
|
||
var pid = p.get("id", "")
|
||
if not (pid is String) or (pid as String).strip_edges().is_empty():
|
||
errors.append("V11: Team %d Punkt %d: id fehlt/leer." % [team, i])
|
||
elif seen_ids.has(pid):
|
||
errors.append("V11: Spawn-ID '%s' ist doppelt (Team %d, Punkt %d)." % [pid, team, i])
|
||
else:
|
||
seen_ids[pid] = true
|
||
var pos = p.get("pos", null)
|
||
if not (pos is Array) or (pos as Array).size() != 3 \
|
||
or not _is_finite_num(pos[0]) or not _is_finite_num(pos[1]) or not _is_finite_num(pos[2]):
|
||
errors.append("V9: Spawn '%s': pos muss [x, y, z] mit endlichen Werten sein." % str(pid))
|
||
elif not _inside_bounds(pos, bounds):
|
||
errors.append("V9: Spawn '%s' liegt außerhalb der Match-Grenzen." % str(pid))
|
||
return errors
|
||
|
||
|
||
static func _validate_checksum(data: Dictionary) -> PackedStringArray:
|
||
if not data.has("checksum") or not (data["checksum"] is Dictionary):
|
||
return PackedStringArray(["V4: checksum-Block fehlt oder ist kein Objekt."])
|
||
var cs: Dictionary = data["checksum"]
|
||
var algo = cs.get("algorithm", "")
|
||
var value = cs.get("value", "")
|
||
if algo != "sha256":
|
||
return PackedStringArray(["V13: checksum.algorithm muss 'sha256' sein (ist %s)." % str(algo)])
|
||
if not (value is String) or not (value as String).is_valid_hex_number() \
|
||
or (value as String).length() != 64:
|
||
return PackedStringArray(["V13: checksum.value muss ein 64-Zeichen-Hex-String sein."])
|
||
var expected := _payload_sha256(data)
|
||
if (value as String).to_lower() != expected:
|
||
return PackedStringArray(["V13: Prüfsumme stimmt nicht — die Map wurde nach dem Speichern verändert oder ist beschädigt."])
|
||
return PackedStringArray()
|
||
|
||
|
||
# ---------------------------------------------------------------- Prüfung --
|
||
|
||
## SHA-256 über die kanonisierte Payload (alles außer 'checksum').
|
||
## Kanonisierung: alle Zahlen rekursiv → float (sonst unterscheiden sich
|
||
## save- und load-seitiges JSON.stringify bei int/float — docs/map_format.md §5).
|
||
## EXAKT dieselbe Prozedur wie der Server-Loader → Prüsummen sind kompatibel.
|
||
static func _payload_sha256(data: Dictionary) -> String:
|
||
var payload: Dictionary = _canonicalize(data)
|
||
payload.erase("checksum")
|
||
var text := JSON.stringify(payload, JSON_INDENT)
|
||
return text.sha256_text()
|
||
|
||
|
||
static func _canonicalize(value: Variant) -> Variant:
|
||
if value is Array:
|
||
var arr := []
|
||
for v in value:
|
||
arr.append(_canonicalize(v))
|
||
return arr
|
||
if value is Dictionary:
|
||
var dict := {}
|
||
for k in value.keys():
|
||
dict[str(k)] = _canonicalize(value[k])
|
||
return dict
|
||
if value is int:
|
||
return float(value)
|
||
if value is bool or value is String or value is float or value == null:
|
||
return value
|
||
# PackedByteArray & Co. dürfen im svmap-Dict nicht vorkommen — als String.
|
||
return str(value)
|
||
|
||
|
||
static func _is_finite_num(v: Variant) -> bool:
|
||
return (v is int or v is float) and is_finite(float(v))
|
||
|
||
|
||
static func _inside_bounds(pos: Array, bounds: Dictionary) -> bool:
|
||
var mn: Array = bounds["min"]
|
||
var mx: Array = bounds["max"]
|
||
for i in 3:
|
||
if float(pos[i]) < float(mn[i]) - BOUNDS_TOLERANCE_M \
|
||
or float(pos[i]) > float(mx[i]) + BOUNDS_TOLERANCE_M:
|
||
return false
|
||
return true
|
||
|
||
|
||
# ------------------------------------------------------------------ Utils --
|
||
|
||
static func _manifest_or(manifest: Dictionary) -> Dictionary:
|
||
if not manifest.is_empty():
|
||
return manifest
|
||
if FileAccess.file_exists(MANIFEST_PATH):
|
||
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(MANIFEST_PATH))
|
||
if parsed is Dictionary:
|
||
return parsed
|
||
return {}
|
||
|
||
|
||
## Höhenfeld dekodieren: uint16 little-endian, row-major, absolut in Metern.
|
||
## h = min_height + (v / 65535) * (max_height - min_height)
|
||
static func decode_heights(terrain: Dictionary) -> PackedFloat32Array:
|
||
var res := int(terrain["resolution"])
|
||
var raw := Marshalls.base64_to_raw(str(terrain["heightmap_r16_b64"]))
|
||
var hmin := float(terrain["min_height_m"])
|
||
var hmax := float(terrain["max_height_m"])
|
||
var out := PackedFloat32Array()
|
||
out.resize(res * res)
|
||
for i in res * res:
|
||
var v: int = raw.decode_u16(i * 2)
|
||
out[i] = hmin + (float(v) / 65535.0) * (hmax - hmin)
|
||
return out
|