extends Node class_name MpSnapshot ## The cross-peer sync audit: both peers describe everything they can see, and ## the two descriptions must match. ## ## This is what actually catches desyncs. Per-step assertions only look at the ## one thing the step touched, which misses the whole class of bug where some ## OTHER object quietly drifts out of sync — or where the data replicates ## perfectly but the thing on screen does not. So the description deliberately ## includes what is RENDERED, not just the replicated values behind it: a plate ## whose contained_ids arrived but whose visuals were never rebuilt looks empty ## to the player, and that has to count as a desync. ## How far apart the same object may be on two peers before it counts as a desync. const POS_TOLERANCE := 0.08 ## Physics keeps moving after a step (dropped objects fall, snaps settle), so ## give the peers this long to converge before calling a difference a failure. const SETTLE_SEC := 3.0 ## How far a cosmetic item on a plate may sit from the plate's origin. The plate ## is ~0.4m across and the furthest slot ~0.12m out, so past this it has come off. const MAX_VISUAL_OFFSET := 0.3 ## Local offset at which a cosmetic item counts as having moved from its slot. const VISUAL_DRIFT_EPSILON := 0.05 ## Stations whose on-screen state has to match on every peer. const WATCHED_STATIONS := ["Hob", "Sink", "DirtStation", "Counter", "Counter2"] var view: MpWorldView var report: MpReport ## Named by the driver so a drift warning says which step was running. var current_step := "(before any step)" var _drift_reported := {} func setup(p_view: MpWorldView, p_report: MpReport) -> void: view = p_view report = p_report func take() -> Dictionary: var out := {} for child in view.pickables(): out[str(child.name)] = describe(child as Node3D) for name in WATCHED_STATIONS: var station := view.find(name) if station: out["station:" + name] = describe_station(station) return out ## Everything about an object that should look identical on every peer. func describe(item: Node3D) -> Dictionary: var d := {"pos": item.global_position, "visible": item.visible} var pc := item.get_node_or_null("PlateController") if pc: d["dirty"] = pc.is_dirty d["contents"] = ",".join(pc.contained_ids) var dirty_node := item.get_node_or_null("Dirty") d["dirty_shown"] = dirty_node.visible if dirty_node else false # The cosmetic copies the container builds from contained_ids — literally # what the player sees sitting on the plate. d["meals_shown"] = visual_count(item, "Container/MealContainer") d["sides_shown"] = visual_count(item, "Container/SidesContainer") # Food on a plate is a cosmetic child of the plate, so it must travel with # it. Keys starting with "_" are per-peer diagnostics that compare() skips # (floats will not match exactly across peers); the attached flag is # asserted absolutely instead, because this can — and did — go wrong on # both peers at once, which a diff would miss entirely. var off := max_visual_offset(item) d["_visual_offset"] = off d["visuals_attached"] = off <= MAX_VISUAL_OFFSET return d ## What a station shows the player. Only the world owner runs a station's logic ## and snap zone, so its display has to be driven from replicated state — a ## client that never updates it shows a hob that never lights up, or a sink bar ## still on screen after the plate came out clean. ## ## Bar VISIBILITY is compared across peers; the progress value is diagnostic only ## ("_" prefix), because it changes every tick and the peers are legitimately a ## frame apart. func describe_station(station: Node3D) -> Dictionary: var d := {} var bar := station.get_node_or_null("ProgressBar3D") as ProgressBar3D d["bar_visible"] = bar.is_bar_visible() if bar else false d["_bar_progress"] = snappedf(bar.get_progress(), 1.0) if bar else -1.0 if "cooking_result" in station: d["cooking"] = str(station.cooking_result) if "is_washing" in station: d["washing"] = bool(station.is_washing) return d ## Largest distance from a plate's origin to any cosmetic item it is displaying. ## -1 when the plate is showing nothing. func max_visual_offset(item: Node3D) -> float: var worst := -1.0 for path in ["Container/MealContainer", "Container/SidesContainer"]: var root := item.get_node_or_null(path) if not root: continue for c in root.get_children(): if c is Node3D and not c.is_queued_for_deletion(): worst = maxf(worst, item.global_position.distance_to((c as Node3D).global_position)) return snappedf(worst, 0.001) ## What the cosmetic children look like, for diagnosing why they moved. func visual_diag(item: Node3D) -> String: var parts: Array[String] = [] for path in ["Container/MealContainer", "Container/SidesContainer"]: var root := item.get_node_or_null(path) if not root: continue for c in root.get_children(): var s := "%s global=%s local=%s parent=%s (%.3fm from plate at %s)" % [ c.name, (c as Node3D).global_position, (c as Node3D).position, c.get_parent().name, item.global_position.distance_to((c as Node3D).global_position), item.global_position] if c is RigidBody3D: s += " [RigidBody3D freeze=%s freeze_mode=%d layer=%d top_level=%s queued=%s]" % [ c.freeze, c.freeze_mode, c.collision_layer, c.top_level, c.is_queued_for_deletion()] parts.append(s) return "; ".join(parts) if parts else "(nothing on the plate)" func visual_count(item: Node3D, path: String) -> int: var n := item.get_node_or_null(path) if not n: return -1 var count := 0 for c in n.get_children(): if not c.is_queued_for_deletion(): count += 1 return count ## Compares the server's view with the client's, returning the differences. func compare(server: Dictionary, client: Dictionary) -> Array[String]: var problems: Array[String] = [] for name in server: if not client.has(name): problems.append("'%s' exists on the server but NOT on the client" % name) for name in client: if not server.has(name): problems.append("'%s' exists on the client but NOT on the server (ghost copy)" % name) for name in server: if not client.has(name): continue var s: Dictionary = server[name] var c: Dictionary = client[name] # Stations are compared on their displayed state, not a position. if s.has("pos") and c.has("pos"): var dist: float = (s["pos"] as Vector3).distance_to(c["pos"]) if dist > POS_TOLERANCE: problems.append("%s is %.3fm apart (server %s vs client %s)" % [name, dist, s["pos"], c["pos"]]) for key in s: # "_" keys are per-peer diagnostics, not things that must match. if key == "pos" or key.begins_with("_"): continue if s[key] != c[key]: problems.append("%s.%s: server=%s client=%s" % [name, key, s[key], c[key]]) # Absolute invariants, checked per peer. A cross-peer diff cannot catch a # fault that happens identically on both sides. for peer_name in ["server", "client"]: var snap: Dictionary = server if peer_name == "server" else client for name in snap: var d: Dictionary = snap[name] if d.has("visuals_attached") and not d["visuals_attached"]: problems.append("on the %s, %s's food has come off the plate (%.3fm from it, limit %.2f)" % [peer_name, name, d.get("_visual_offset", -1.0), MAX_VISUAL_OFFSET]) return problems # --- live watch on plate visuals ------------------------------------------- # # The per-step checks tell us food ended up off a plate, but not when or why. # This watches every frame and reports the first frame a cosmetic item departs # from its slot, along with what was happening to the plate at the time. func _process(_delta: float) -> void: if not view or not view.world: return for item in view.roots_children(): if not (item is Node3D) or not item.get_node_or_null("PlateController"): continue _watch_plate(item as Node3D) func _watch_plate(plate: Node3D) -> void: for path in ["Container/MealContainer", "Container/SidesContainer"]: var holder := plate.get_node_or_null(path) if not holder: continue for c in holder.get_children(): if not (c is Node3D) or c.is_queued_for_deletion(): continue var drift: float = (c as Node3D).position.length() var key := c.get_instance_id() if drift <= VISUAL_DRIFT_EPSILON: _drift_reported.erase(key) continue if _drift_reported.has(key): continue _drift_reported[key] = true report.log_line("VISUAL DRIFT: %s on %s moved to local %s (%.3f from its slot) during '%s'" % [ c.name, plate.name, (c as Node3D).position, drift, current_step]) report.log_line(" plate: pos=%s freeze=%s held_by=%s xform_authority=%d" % [ plate.global_position, plate.freeze if plate is RigidBody3D else "-", plate.get_picked_up_by() if plate.has_method("get_picked_up_by") else "-", view.xform_authority(plate)]) if c is RigidBody3D: report.log_line(" visual: freeze=%s mode=%d layer=%d top_level=%s sleeping=%s lin_vel=%s" % [ c.freeze, c.freeze_mode, c.collision_layer, c.top_level, c.sleeping, c.linear_velocity])