From 65b33a207ae2a05f3ad067ee9ea6e79a0c36584d Mon Sep 17 00:00:00 2001 From: "Hermes (scopedvr-dev)" Date: Tue, 20 Oct 2026 21:33:17 +0000 Subject: [PATCH] =?UTF-8?q?svmap:=20Pr=C3=BCfsummen-Schema=20v2=20(t=5F981?= =?UTF-8?q?4f18f)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/svmap/svmap_io.gd | 89 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd index 415521d..4121242 100644 --- a/src/svmap/svmap_io.gd +++ b/src/svmap/svmap_io.gd @@ -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 := []