svmap: Prüfsummen-Schema v2 (t_9814f18f)

- sign_map schreibt schema=v2, Hash über die quantisierte Payload
  (12 signifikante Stellen, Snap über die Godot-Textform → idempotent)
- save_to_file quantisiert VOR dem Schreiben → Dateien ohne Rausch-Floats
- Laden: schema=v2 → v2-Hash; fehlt → Legacy-Fallback (Bestandskarten)
- FirstFight-Fall: Python-repr-Floats (15+ sig) hashen formatunabhängig
- Beweise: tests/test_svmap_checksum_v2.gd (18 Checks, ALL OK);
  echte FirstFight.svmap lädt nach v2-Resave, cross-validiert im Server
This commit is contained in:
Hermes (scopedvr-dev) 2026-10-20 21:33:17 +00:00
parent 7a1b5037e7
commit 65b33a207a

View file

@ -96,11 +96,18 @@ static func load_from_file(path: String) -> Dictionary:
## 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 errors := _validate_core(data)
if not errors.is_empty():
return {"ok": false, "errors": errors}
# Quantisieren auf einer TIEFEN Kopie (das Eingabe-doc bleibt unangetastet),
# dann signieren und schreiben.
var canonical: Dictionary = _canonicalize(data)
_quantisieren_deep(canonical)
sign_map(canonical)
var fa := FileAccess.open(path, FileAccess.WRITE)
if fa == null:
@ -121,8 +128,9 @@ static func validate_map(data: Dictionary, manifest: Dictionary = {}) -> PackedS
## 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", "value": _payload_sha256(data)}
data["checksum"] = {"algorithm": "sha256", "schema": "v2", "value": _payload_sha256_v2(data)}
# ------------------------------------------------------------- Validierung --
@ -359,7 +367,14 @@ static func _validate_checksum(data: Dictionary) -> PackedStringArray:
if not (value is String) or not (value as String).is_valid_hex_number() \
or (value as String).length() != 64:
return PackedStringArray(["V13: checksum.value muss ein 64-Zeichen-Hex-String sein."])
var expected := _payload_sha256(data)
# 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()
@ -378,6 +393,76 @@ static func _payload_sha256(data: Dictionary) -> String:
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 := []