extends RefCounted class_name SvmapIO ## SvmapIO — Editor-seitige Implementierung des `.svmap`-Map-Paket-Formats (v2). ## ## Grundlage: docs/map_format.md im scopedvr-match-server-Repo (Karte t_74976e9f ## + v2-Erweiterung t_603ec1a8: Material-Schicht am Höhenfeld). ## Bewusst EIGENE Implementierung (kein Match-Server-Import): das Format-Doku ## ist der Vertrag. Validierungs-IDs V1..V15 und Kanonisierung/Prüfsumme sind ## 1:1 zur Doku übernommen, damit eine vom Editor gespeicherte Map vom ## Match-Server akzeptiert wird (und umgekehrt). ## ## Format v2: terrain.materials_r8_b64 (OPTIONAL, Base64 uint8 je Sample, ## 0=Gras/1=Sand/2=Fels). Fehlt es (Legacy-v1), gilt alles Gras. Geladen ## werden v1 UND v2; gespeichert wird immer v2 (mit Schicht). ## ## 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) ## decode_materials(terrain) / encode_materials(mats) ## const FORMAT_VERSION := 2 ## Akzeptierte Format-Versionen (V3): 1 = Legacy ohne Material-Schicht, ## 2 = aktuell. Neu gespeicherte Maps tragen immer FORMAT_VERSION. const SUPPORTED_FORMAT_VERSIONS: Array[int] = [1, 2] ## Material-IDs (Index = ID). 0 = Gras = Default für Maps ohne Schicht. ## Karte t_ab408127 (Sly-Vielfalt): Palette von 3 auf 20 erweitert — NEUE IDs ## NUR ANGEHÄNGT, Legacy-Zuordnung (0=Gras, 1=Sand, 2=Fels) bleibt unverändert, ## damit alte Maps byte-identisch laden. Format v2 bleibt (Feldgröße = res², ## Werte = uint8 je Sample — der Bereich wächst nur implizit mit der Länge). const MATERIAL_IDS: Array[String] = [ "gras_steppe", # 0 = Legacy-Gras (Default/Standard-Boden) "sand_rippen", # 1 = Legacy-Sand (Dünen/offene Flächen) "fels_granit", # 2 = Legacy-Fels (Berge/Klippen) "gras_satt", # 3 = gesundes, sattes Grün "gras_vertrocknet", # 4 = ausgedörrt, gelblich "gras_moosig", # 5 = weiches Moosgrün "erde_braun", # 6 = feuchte braune Erde "erde_getreten", # 7 = festgetretener Pfad "erde_umgeackert", # 8 = umgegrabene/zerwühlte Erde "schotter", # 9 = Kies/Schotter "lehm_rot", # 10 = rotbrauner Lehm "sand_hell", # 11 = heller Flugsand "sand_rost", # 12 = rostroter Sand "fels_basalt", # 13 = dunkler Basalt "fels_geroell", # 14 = Geröllhalde "schlamm", # 15 = Matsch/Schlamm "schnee_frisch", # 16 = Neuschnee "schnee_matschig", # 17 = angeschmolzen/angesaut "asche", # 18 = Asche/verkohlt "stoppeln", # 19 = abgeerntetes Feld ] const WEATHER_PRESETS: Array[String] = ["mittag", "abend", "regen", "nacht"] ## Skybox-Option (Karte t_f72913d4): Werte des optionalen Feldes `weather_sky` ## — identische Semantik wie der Client (Weather.apply_preset sky_override): ## "sky_panorama" = Pack-Panorama erzwingen, "prozedural" = ProceduralSky ## erzwingen, leer/fehlend = Preset entscheidet (in allen Presets aus). const WEATHER_SKY_OPTIONS: Array[String] = ["sky_panorama", "prozedural"] # ALT; gültig = SkyboxKatalog.ist_gueltig # 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 SYM_KEY_MAX_LEN := 64 # V16 (Symmetrie-Partner-Kennung) ## Feines Material-Raster (Karte 24.10., Sly: „feiner malen“): optionales ## terrain.material_resolution — eigenes Raster für materials_r8_b64, feiner als ## das Höhenfeld. Fehlt es, gilt resolution (alte Maps unverändert). Muss ## ≥ resolution sein und auf dem Höhenraster liegen ((m−1) Vielfaches von ## (res−1)), damit jeder Höhenpunkt genau einen Materialpunkt hat. const MAX_MATERIAL_RESOLUTION := 1025 # V15 (1025² = 1 MB) const MATERIAL_TARGET_M := 1.0 # Ziel: 1 m pro Materialpunkt 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 ModusKatalog.migrate(data) # Alt-Namen (checkpoints → conquest) NACH der Prüfsumme umstellen 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. ## Schema v2 (Karte t_9814f18f): die Payload wird VOR dem Signieren quantisiert ## (12 signifikante Stellen) — die Datei enthält keine Rausch-Floats mehr, und ## der v2-Hash bleibt unter allen Godot-Versionen stabil (FirstFight-Fall: ## ein 15-stelliger Python-Float-Text war unter Legacy-Hash nicht verifizierbar). static func save_to_file(path: String, data: Dictionary) -> Dictionary: var signed := to_signed_text(data) if not bool(signed["ok"]): return {"ok": false, "errors": signed["errors"]} 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(str(signed["text"])) return {"ok": true, "errors": PackedStringArray()} ## Wie save_to_file, aber ohne Datei: liefert den fertigen, kanonisierten und ## signierten .svmap-Text (für den Server-Upload, Etappe 3). Das Eingabe-doc ## bleibt unangetastet. {ok, errors, text} static func to_signed_text(data: Dictionary) -> Dictionary: var errors := _validate_core(data) if not errors.is_empty(): return {"ok": false, "errors": errors, "text": ""} var canonical: Dictionary = _canonicalize(data) _quantisieren_deep(canonical) sign_map(canonical) return {"ok": true, "errors": PackedStringArray(), "text": JSON.stringify(canonical, JSON_INDENT)} ## 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). ## Schema v2: Hash über die quantisierte Payload (12 signifikante Stellen). static func sign_map(data: Dictionary) -> void: data["checksum"] = {"algorithm": "sha256", "schema": "v2", "value": _payload_sha256_v2(data)} # ------------------------------------------------------------- Validierung -- static func _validate_core(data: Dictionary, manifest: Dictionary = {}) -> PackedStringArray: var errors := PackedStringArray() # V3: Versions-Stempel (v1 = Legacy ohne Material-Schicht, v2 = aktuell) var fv = data.get("format_version", null) if not (fv is int or fv is float) or not SUPPORTED_FORMAT_VERSIONS.has(int(fv)): var wanted := "" for v in SUPPORTED_FORMAT_VERSIONS: wanted += (" oder " if wanted != "" else "") + str(v) errors.append("V3: format_version muss %s sein (ist %s). Diese Datei ist keine .svmap-Map." % [wanted, 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)]) # V17: weather_sky (optional, Karte t_f72913d4) — leer/fehlend = Standard. # Checksum-neutral wie tint/gebaeude: der Server kennt das Feld nicht, die # Prüfsumme läuft über die ganze Payload — unbekannte Zusatzfelder stören # die Server-Validierung nicht, nur WERT-Fehler fangen wir hier ab. var weather_sky = data.get("weather_sky", "") if weather_sky != null and str(weather_sky) != "": if not SkyboxKatalog.ist_gueltig(weather_sky): var ids: PackedStringArray = ["prozedural"] for e in SkyboxKatalog.EINTRAEGE: ids.append(e["id"]) errors.append("V17: weather_sky '%s' ist keine gültige Skybox (%s)." % [ str(weather_sky), ", ".join(ids)]) # V18: loadout (optional) — festes Map-Loadout, siehe LoadoutKatalog. errors.append_array(LoadoutKatalog.validate(data.get("loadout", null))) # V21–V24: Spielmodus, Zonen, Hills, Modus-Spawns (optional), siehe ModusKatalog. errors.append_array(ModusKatalog.validate(data, bounds)) 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]) # V15: Material-Schicht (v2, optional — fehlt sie, gilt alles Gras). var mres_v = terrain.get("material_resolution", res_i) var mres_i := res_i if not (mres_v is int or mres_v is float) or float(mres_v) != floorf(float(mres_v)) \ or int(mres_v) < res_i or int(mres_v) > MAX_MATERIAL_RESOLUTION \ or (res_i > 1 and (int(mres_v) - 1) % (res_i - 1) != 0): errors.append("V15: terrain.material_resolution muss eine ganze Zahl %d–%d sein, deren (Wert−1) ein Vielfaches von (resolution−1) ist (ist %s)." % [ res_i, MAX_MATERIAL_RESOLUTION, str(mres_v)]) else: mres_i = int(mres_v) var mats = terrain.get("materials_r8_b64", "") if mats is String and (mats as String) != "": var mraw := Marshalls.base64_to_raw(mats as String) if mraw.size() != mres_i * mres_i: errors.append("V15: materials_r8_b64 dekodiert zu %d Bytes, erwartet %d (= material_resolution²)." % [ mraw.size(), mres_i * mres_i]) else: for v in mraw: if int(v) >= MATERIAL_IDS.size(): errors.append("V15: materials_r8_b64 enthält unbekannte Material-ID %d (erlaubt: 0–%d)." % [ int(v), MATERIAL_IDS.size() - 1]) break elif not (mats is String): errors.append("V15: materials_r8_b64 muss ein Base64-String sein (ist %s)." % str(mats)) errors.append_array(_validate_mix(terrain, mres_i)) # V20 (Karte t_28fdef03): Wasser-Level (optional). errors.append_array(_validate_wasser(terrain)) 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) # V17 (Karte t_e166e656, Platzierungsfreiheit): optionales `rot` — # Euler x/y/z in Grad (Godot-Konvention YXZ). Vorhanden → er ersetzt # rot_y_deg KOMPLETT (3-Achsen-Kippen); fehlt er → Fallback rot_y_deg. # Identischer Check wie im Match-Server (svmap_loader._validate_objects). var rot_v: Variant = obj.get("rot", null) if rot_v != null: if not (rot_v is Array) or (rot_v as Array).size() != 3 \ or not _is_finite_num(rot_v[0]) \ or not _is_finite_num(rot_v[1]) \ or not _is_finite_num(rot_v[2]): errors.append("V17: objects[%d]: rot muss [x, y, z] mit endlichen Zahlen sein (ist %s)." % [i, str(rot_v)]) # V16 (Karte t_f5935023): optionales `sym` (Symmetrie-Partner-Kennung). # Editor-Format-Erweiterung: der Match-Server ignoriert unbekannte # Felder, die Prüfsumme läuft über die ganze Payload → kompatibel. # Fehlt das Feld (alte Karten) → keine Prüfung, alles unverändert. var sym = obj.get("sym", null) if sym != null: if not (sym is String): errors.append("V16: objects[%d]: sym muss ein String sein (ist %s)." % [i, str(sym)]) elif (sym as String).strip_edges().is_empty(): errors.append("V16: objects[%d]: sym darf nicht leer sein." % i) elif (sym as String).length() > SYM_KEY_MAX_LEN: errors.append("V16: objects[%d]: sym länger als %d Zeichen." % [i, SYM_KEY_MAX_LEN]) # V8b: Poly-Budget (× scale² — Flächen-/Kantenwachstum der Meshes) var poly = float(lib[oid].get("poly", 0)) total_poly += poly * float(scale) * float(scale) # V16: eine sym-Kennung verbindet GENAU EIN Paar — mehr als 2 Objekte # mit derselben Kennung sind ein Verkettungsfehler. var sym_counts: Dictionary = {} for obj2 in objects: if not (obj2 is Dictionary): continue var s2 = (obj2 as Dictionary).get("sym", null) if s2 is String and (s2 as String) != "": var k := str(s2) sym_counts[k] = int(sym_counts.get(k, 0)) + 1 for k2 in sym_counts.keys(): if int(sym_counts[k2]) > 2: errors.append("V16: sym-Kennung '%s' verbindet %d Objekte — ein Paar hat genau 2." % [ k2, int(sym_counts[k2])]) 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)) if p.has("yaw_deg") and not _is_finite_num(p["yaw_deg"]): errors.append("V9: Spawn '%s': yaw_deg muss endlich sein." % 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."]) # Prüfsummen-Schema v2 (Karte t_9814f18f, FirstFight-Vorfall 20.10.): der # Hash läuft über die quantisierte Payload (12 signifikante Stellen) — # formatunabhängig. Ein fremder Writer (z. B. Python-repr-Floats mit 15+ # Stellen) erzeugt denselben quantisierten Text wie Godot selbst. # Legacy (schema-Feld fehlt): Hash über die VOLLE Textform — beim Laden # als Fallback akzeptiert, sign_map schreibt beim nächsten Save v2. var schema := str(cs.get("schema", "legacy")) var expected := _payload_sha256_v2(data) if schema == "v2" else _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() ## SHA-256 über die quantisierte Payload (Schema v2, Karte t_9814f18f): ## Kanonisieren → alle Floats auf 12 signifikante Stellen quantisieren → ## stringify → sha256. Quantisierung macht den Hash formatunabhängig: ## 14-stelliger Godot-Text und Python-repr desselben quantisierten Werts ## erzeugen denselben Hash (beide laufen durch denselben Godot-Parser + ## denselben stringify-Snap). Details: docs/map_format.md §5. static func _payload_sha256_v2(data: Dictionary) -> String: var payload: Dictionary = _canonicalize(data) payload.erase("checksum") _quantisieren_deep(payload) var text := JSON.stringify(payload, JSON_INDENT) return text.sha256_text() ## Float-Quantisierung auf 12 signifikante Stellen (Schema v2). ## ## Zwei Stufen: ## 1. Mathematisch: round(a / 10^(lg-11)) · 10^(lg-11) mit lg = Exponent der ## führenden Zehnerpotenz (log10). Werte unter 1e-12 → 0.0 (unter der ## 12-sig-Auflösung; Positionen sind Meter — Sub-Pikometer ist gameplay-egal). ## 2. SNAP über die Godot-Textform (JSON.stringify): das Runden auf 12 sig ## kann mathematisch einen "Rausch-Double" hinterlassen (z. B. ## 983194046021 · 1e-10 → 98.31940460210001). Der stringify-Text (max ## 15 signifikante Stellen, Godot-kanonisch) zurückgeparst liefert den ## DOUBLE, den jeder Godot-Leser aus diesem Text bekommt — der Snap ## macht quant(v) idempotent und damit den Hash reproduzierbar. ## Geparst wird IMMER über JSON.parse_string/stringify — so erzeugen ## Produzenten mit anderem Float-Textformat (Python-repr & Co.) denselben ## quantisierten double, sofern die Texte denselben Wert darstellen. static func _quantisieren(v: float) -> float: if not is_finite(v): return v # NaN/INF — V9 fängt sie vor dem Hash ab; unverändert lassen. if v == 0.0: return 0.0 var a := absf(v) var lg: float = floor(log(a) / LOG10) if lg >= 12.0: var m: float = pow(10.0, lg - 11.0) return JSON.parse_string(JSON.stringify(signf(v) * roundf(a / m) * m)) if lg <= -12.0: return 0.0 var mant: float = a / pow(10.0, lg) var r: float = roundf(mant * 100000000000.0) / 100000000000.0 if r >= 1000000000000.0: # Aufrunden in die nächste Dekade (z. B. 999.9999999995 → 1000) lg += 1.0 r /= 10.0 return JSON.parse_string(JSON.stringify(r * pow(10.0, lg) * signf(v))) const LOG10 := 2.302585092994046 static func _quantisieren_deep(value: Variant) -> void: if value is Array: for i in value.size(): var item: Variant = value[i] if item is float: value[i] = _quantisieren(item) elif item is Array or item is Dictionary: _quantisieren_deep(item) elif value is Dictionary: for k in value.keys(): var child: Variant = value[k] if child is float: value[k] = _quantisieren(child) elif child is Array or child is Dictionary: _quantisieren_deep(child) 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 ## Material-Schicht dekodieren (v2): uint8 je Sample, 0=Gras/1=Sand/2=Fels. ## Fehlt das Feld (Legacy-v1) → alles Gras (Default). Identische Indexierung ## wie das Höhenfeld — TerrainField.apply_material_brush arbeitet darauf. static func decode_materials(terrain: Dictionary) -> PackedByteArray: var res := material_resolution(terrain) var out := PackedByteArray() out.resize(maxi(res * res, 0)) # PackedByteArray füllt mit 0 = Gras var mats = terrain.get("materials_r8_b64", "") if mats is String and (mats as String) != "": var mraw := Marshalls.base64_to_raw(mats as String) var n := res * res for i in n: if i < mraw.size() and int(mraw[i]) < MATERIAL_IDS.size(): out[i] = mraw[i] return out ## V15b (Karte 25.10., weiche Übergänge): optionale Misch-Schicht — ## materials_b_r8_b64 (zweites Material je Punkt) + materials_w_r8_b64 (Anteil ## des zweiten Materials, 0–255). Nur BEIDE oder KEINE; Länge material_resolution². static func _validate_mix(terrain: Dictionary, mres: int) -> PackedStringArray: var errors := PackedStringArray() var hb := terrain.has("materials_b_r8_b64") var hw := terrain.has("materials_w_r8_b64") if not hb and not hw: return errors if hb != hw: errors.append("V15: materials_b_r8_b64 und materials_w_r8_b64 gibt es nur zusammen.") return errors var b = terrain["materials_b_r8_b64"] var w = terrain["materials_w_r8_b64"] if not (b is String) or not (w is String): errors.append("V15: Misch-Schicht muss aus Base64-Strings bestehen.") return errors var braw := Marshalls.base64_to_raw(b as String) var wraw := Marshalls.base64_to_raw(w as String) if braw.size() != mres * mres or wraw.size() != mres * mres: errors.append("V15: Misch-Schicht hat %d/%d Bytes, erwartet je %d (= material_resolution²)." % [ braw.size(), wraw.size(), mres * mres]) return errors for v in braw: if int(v) >= MATERIAL_IDS.size(): errors.append("V15: materials_b_r8_b64 enthält unbekannte Material-ID %d." % int(v)) break return errors ## Misch-Schicht dekodieren: [b, w] (je material_resolution², fehlt sie → Nullen). static func decode_mix(terrain: Dictionary) -> Array: var n := material_resolution(terrain) var b := PackedByteArray() var w := PackedByteArray() b.resize(maxi(n * n, 0)) w.resize(maxi(n * n, 0)) var bs = terrain.get("materials_b_r8_b64", "") var ws = terrain.get("materials_w_r8_b64", "") if bs is String and ws is String and (bs as String) != "" and (ws as String) != "": var braw := Marshalls.base64_to_raw(bs as String) var wraw := Marshalls.base64_to_raw(ws as String) if braw.size() == b.size() and wraw.size() == w.size(): for i in braw.size(): if int(braw[i]) < MATERIAL_IDS.size(): b[i] = braw[i] w = wraw return [b, w] ## Misch-Schicht schreiben — ganz ohne Mischung (alle w = 0) fallen die Felder ## weg (Datei bleibt so klein wie vorher, alte Leser unberührt). static func encode_mix(terrain: Dictionary, b: PackedByteArray, w: PackedByteArray) -> void: var any := false for v in w: if v != 0: any = true break if not any: terrain.erase("materials_b_r8_b64") terrain.erase("materials_w_r8_b64") return terrain["materials_b_r8_b64"] = encode_materials(b) terrain["materials_w_r8_b64"] = Marshalls.raw_to_base64(w) ## Raster der Material-Schicht (feines Raster, Karte 24.10.) — ohne Feld ## gilt das Höhenraster (alte Maps). static func material_resolution(terrain: Dictionary) -> int: var v = terrain.get("material_resolution", null) if v is int or v is float: return int(v) return int(terrain.get("resolution", 0)) ## Ziel-Raster für eine Karte: ~MATERIAL_TARGET_M pro Punkt, auf dem ## Höhenraster ausgerichtet, gedeckelt auf MAX_MATERIAL_RESOLUTION. ## 1024 m / res 129 → 1025 (1 m); 256 m / 129 → 257 (1 m); 2048 m → 1025 (2 m). static func material_resolution_for(res: int, size_x: float, size_z: float) -> int: if res < 2: return res var cell := maxf(size_x, size_z) / float(res - 1) var k := maxi(1, int(ceilf(cell / MATERIAL_TARGET_M - 0.001))) while k > 1 and (res - 1) * k + 1 > MAX_MATERIAL_RESOLUTION: k -= 1 return (res - 1) * k + 1 ## Material-Schicht auf ein anderes Raster umrechnen (nächster Punkt über die ## normierte Position — beide Raster spannen dieselbe Kartenfläche auf). static func resample_materials(mats: PackedByteArray, from_res: int, to_res: int) -> PackedByteArray: var out := PackedByteArray() out.resize(maxi(to_res * to_res, 0)) if from_res < 2 or to_res < 2 or mats.size() < from_res * from_res: return out var f := float(from_res - 1) / float(to_res - 1) for j in to_res: var sj := mini(int(roundf(float(j) * f)), from_res - 1) * from_res var row := j * to_res for i in to_res: out[row + i] = mats[sj + mini(int(roundf(float(i) * f)), from_res - 1)] return out ## Alte Karte (Material-Raster = Höhenraster) auf das feine Raster heben. ## true = geändert. Optisch identisch (nächster Punkt), nur ab jetzt feiner malbar. static func upgrade_material_layer(terrain: Dictionary) -> bool: var res := int(terrain.get("resolution", 0)) if res < 2: return false var target := material_resolution_for(res, float(terrain.get("size_x_m", 0.0)), float(terrain.get("size_z_m", 0.0))) var cur := material_resolution(terrain) if cur >= target: return false var mats := resample_materials(decode_materials(terrain), cur, target) var mix := decode_mix(terrain) var mb := resample_materials(mix[0], cur, target) var mw := resample_materials(mix[1], cur, target) terrain["material_resolution"] = target terrain["materials_r8_b64"] = encode_materials(mats) encode_mix(terrain, mb, mw) return true ## Material-ID am Höhenpunkt (x, z) des Höhenrasters — für Vertex-Farben. static func material_at_height_point(mats: PackedByteArray, mres: int, res: int, x: int, z: int) -> int: if mres < 2 or res < 2 or mats.size() < mres * mres: return 0 var f := float(mres - 1) / float(res - 1) return int(mats[mini(int(roundf(float(z) * f)), mres - 1) * mres + mini(int(roundf(float(x) * f)), mres - 1)]) ## Material-Schicht kodieren: uint8 je Sample (Werte 0..MATERIAL_IDS.size()-1). static func encode_materials(mats: PackedByteArray) -> String: var bytes := PackedByteArray() bytes.resize(mats.size()) for i in mats.size(): bytes[i] = clampi(mats[i], 0, MATERIAL_IDS.size() - 1) return Marshalls.raw_to_base64(bytes) # ------------------------------------- Fremdformat-Dialog (t_9814f18f) ------ ## true, wenn ein Load NUR an der Pruefsumme haengt (V13) — die Kern-Validierung ## (V3–V16) ist gruen. Kriterium fuer den „Trotzdem laden (Pruefsumme erneuern)“- ## Dialog im Editor: die Datei kommt aus einem fremden Writer (FirstFight-Fall ## 20.10.: Python-repr-Floats), der Inhalt selbst ist intakt. static func checksum_only_failure(errors: Array) -> bool: if errors.is_empty(): return false for e in errors: if not str(e).begins_with("V13:"): return false return true ## true, wenn ein Load NUR an unbekannten Objekt-IDs (V7) und/oder der Prüfsumme ## (V13) hängt — Kriterium für die Migration beim Öffnen (SvmapMigration). static func migrierbar(errors: Array) -> bool: if errors.is_empty(): return false var v7 := false for e in errors: if str(e).begins_with("V7:"): v7 = true elif not str(e).begins_with("V13:"): return false return v7 ## Laden mit Migration veralteter Objekt-IDs: JSON parsen, unbekannte IDs per ## SvmapMigration ersetzen/überspringen, Kern-Validierung, v2-Prüfsumme erneuern. ## Rückgabe wie load_from_file plus "migration" (Bericht von migriere_objekte). static func load_from_file_migriert(path: String) -> Dictionary: var result := {"ok": false, "errors": [], "data": {}, "migration": {}} 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 gross (%d Bytes, Limit %d)." % [fa.get_length(), MAX_FILE_BYTES]) return result var parsed: Variant = JSON.parse_string(fa.get_as_text()) if parsed == null or not (parsed is Dictionary): _add(result, "V2: Kein gueltiges JSON-Objekt (Datei beschaedigt oder keine .svmap?).") return result var data: Dictionary = parsed result["migration"] = SvmapMigration.migriere_objekte(data, _manifest_or({})) var errors := _validate_core(data) if not errors.is_empty(): result["errors"] = Array(errors) return result sign_map(data) result["ok"] = true result["data"] = data return result ## Laden mit Pruefsummen-Erneuerung (t_9814f18f, „Trotzdem laden“-Weg): ## Datei lesen + JSON parsen, dann NUR die Kern-Validierung (V3–V16 ohne ## Pruefsumme). Ist die gruen, traegt sign_map() die v2-Pruefsumme ins Dokument ## ein — beim naechsten Speichern landet sie in der Datei. Schlaegt die ## Kern-Validierung durch, bleibt der harte Abbruch (echter Schaden). ## Rueckgabe wie load_from_file: {ok, errors, data}. static func load_from_file_resign(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 gross (%d Bytes, Limit %d)." % [fa.get_length(), MAX_FILE_BYTES]) return result var parsed: Variant = JSON.parse_string(fa.get_as_text()) if parsed == null or not (parsed is Dictionary): _add(result, "V2: Kein gueltiges JSON-Objekt (Datei beschaedigt oder keine .svmap?).") return result var data: Dictionary = parsed var errors := _validate_core(data) if not errors.is_empty(): result["errors"] = Array(errors) return result sign_map(data) result["ok"] = true result["data"] = data return result # ------------------------------------- Platzierungsfreiheit (Karte t_e166e656) -- ## Objekt-Rotation als Euler-Grad (Karte t_e166e656): `rot` (Euler x/y/z in ## Grad, Godot-Konvention YXZ) vorhanden → verwenden; fehlt er → Fallback ## `rot_y_deg` (nur Y — alte Karten unverändert). Identische Regel im ## Quest-Client (svmap_world._objekt_rotation) und Match-Server ## (svmap_loader._objekt_rotation) — Identitäts-Vertrag über alle drei Repos. static func objekt_rotation(obj: Dictionary) -> Vector3: var rot_v: Variant = obj.get("rot", null) if rot_v is Array and (rot_v as Array).size() == 3: var a: Array = rot_v return Vector3(float(a[0]), float(a[1]), float(a[2])) return Vector3(0.0, float(obj.get("rot_y_deg", 0.0)), 0.0) ## rot-Feld KORREKT ins Objekt schreiben (Editor-Seite der Regel): nur wenn ## tatsächlich um X oder Z gekippt ist, wird `rot` geschrieben (zusätzlich ## bleibt rot_y_deg immer aktuell = die Y-Komponente, damit alte Builds die ## Drehung wenigstens um Y richtig lesen). x = z = 0 → Feld wieder entfernen ## (Datei bleibt legacy-sauber). static func rot_setzen(obj: Dictionary, euler: Vector3) -> void: obj["rot_y_deg"] = euler.y if absf(euler.x) < 0.0001 and absf(euler.z) < 0.0001: obj.erase("rot") else: obj["rot"] = [euler.x, euler.y, euler.z] # ------------------------------------------------- Wasser (Karte t_28fdef03) -- ## V20 (Karte t_28fdef03): optionales svmap-Feld terrain.water_level (float y). ## Abwesenheit = kein Wasser (Legacy-Maps unverändert). Gleiche Regel wie im ## Match-Server (svmap_loader._validate_wasser): endliche Zahl im/am Terrain- ## Höhenbereich ±10 m — sonst wäre das Level sinnlos (unendlich tief unter ## Terrain oder meilen über dem Gelände schwebend). static func _validate_wasser(terrain: Dictionary) -> PackedStringArray: var errors := PackedStringArray() var wl = terrain.get("water_level", null) if wl == null: return errors if not _is_finite_num(wl): errors.append("V20: terrain.water_level muss eine endliche Zahl sein (ist %s)." % str(wl)) return errors 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): return errors # V6 meldet das bereits; hier nichts doppelt. if float(wl) < float(hmin) - 10.0 or float(wl) > float(hmax) + 10.0: errors.append("V20: terrain.water_level (%.2f m) liegt außerhalb des Terrain-Höhenbereichs [%.2f, %.2f] ±10 m." % [ float(wl), float(hmin), float(hmax)]) return errors