916 lines
40 KiB
GDScript
916 lines
40 KiB
GDScript
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
|