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