scopedvr-map-editor/src/svmap/svmap_io.gd

382 lines
16 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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