scopedvr-map-core/svmap/svmap_io.gd
slyphax 17a8927fec Map-Kern aus dem Editor übernommen (Stand e8ab954)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:30:18 +01:00

916 lines
40 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

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

extends RefCounted
class_name SvmapIO
## SvmapIO — Editor-seitige Implementierung des `.svmap`-Map-Paket-Formats (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