extends Node ## Multiplayer harness for the kitchen flow, living in test/multiPlayerTest.tscn ## (and, inert unless asked for, in Scenes/multiPlayer.tscn). ## ## This file is the orchestration only — the scripted sequence, the RPC plumbing ## between the two peers, and the manual keyboard controls. The work itself lives ## in four modules, each with one job: ## ## MpWorldView finding things in the world and describing what they are doing ## MpSteps the simulated player actions (reach, grab, carry, drop) ## MpAsserts the per-step checks ## MpSnapshot the cross-peer sync audit ## MpReport the ledger, the log, the overlay, the screenshots ## ## Two ways to use it: ## ## AUTOMATIC — pass `--mptest` and the server runs the whole sequence and exits ## non-zero if any check fails. test/run_mp_test.ps1 does this headlessly; ## test/run_mp_test_windowed.ps1 does it in two visible windows with a pause ## between steps so you can watch it happen. ## ## MANUAL — just run the scene (no `--mptest`). Every window gets an overlay, a ## fixed camera over the kitchen, and keyboard controls. Keys act on the peer ## whose window has focus, so you can do a step on the client and watch the ## server window follow (or fail to). ## ## The scripted sequence: ## 1. client grabs the plate, then drops it ## 2. server grabs the plate, then drops it ## 3. client carries it to the dirt station -> snaps + turns dirty on both ## 4. both peers confirm they can still pick it up ## 5. client carries the dirty plate to the sink -> washed clean on both ## 6. a cook-and-plate round, run once by the CLIENT and once by the SERVER: ## raw burger -> hob (cooks, raw one removed), cooked burger -> counter, ## buns -> cooked burger (combines into a hamburger), plate -> counter 2, ## hamburger -> plate. ## How long the server waits for a client step reply before calling it failed. const STEP_TIMEOUT_SEC := 30.0 ## How long to wait for the client to connect and for the world to replicate. const SETUP_TIMEOUT_SEC := 30.0 ## Where the debug camera sits and what it aims at: a fixed vantage point framing ## the whole kitchen (Counter2 at x=-2 through DirtStation at x=2). const DEBUG_CAM_POS := Vector3(0.0, 2.1, 3.4) const DEBUG_CAM_LOOK_AT := Vector3(0.0, 0.9, 0.0) const HELP := "[H]ost [J]oin 1 grab 2 drop 3 dirt 4 sink 5 hob 6 counter 7 combine 8 plate>counter2 9 >plate R run-all A sync-audit 0 dump C cam" var _view: MpWorldView var _steps: MpSteps var _asserts: MpAsserts var _snapshot: MpSnapshot var _report: MpReport var _world: Node3D var _controller: Node3D var _hand: XRToolsFunctionPickup var _debug_cam: Camera3D var _role := "?" var _client_id := 0 var _auto_mode := false var _running := false var _step_pause := 0.0 var _end_hold := 0.0 var _step_no := 0 ## Latest reply from the client, consumed by _remote(). var _reply: Dictionary = {} var _client_snapshot: Dictionary = {} var _snapshot_pending := false func _ready() -> void: var args := OS.get_cmdline_user_args() _auto_mode = "--mptest" in args # Opt-in only. This node also sits in the real multiplayer scene (so a client # joining a test session has a driver), and must be completely inert during an # ordinary game — no overlay, no debug camera, no keyboard hooks. if not _auto_mode and not ("--mptest-manual" in args): queue_free() return _step_pause = _arg_value(args, "--mptest-pause", 0.0) _end_hold = _arg_value(args, "--mptest-hold", 0.0) _world = get_parent() await get_tree().process_frame _refresh_role() _build_modules(_role == "server") _report.setup_frames("--mptest-frames" in args, _role == "server") _report.log_line("=== mp test driver ready (role=%s, peer=%d, mode=%s) ===" % [_role, multiplayer.get_unique_id(), "automatic" if _auto_mode else "manual"]) if not await _resolve_nodes(): if _auto_mode: _finish(false) return _build_debug_camera() if not _auto_mode: _report.log_line("MANUAL MODE - keys act on this window's peer:") _report.log_line(" " + HELP) return if _role == "server": await _run_server() func _build_modules(is_server: bool) -> void: _report = MpReport.new() add_child(_report) _report.setup(is_server, _world) _view = MpWorldView.new() add_child(_view) _view.setup(_world, _report) _snapshot = MpSnapshot.new() add_child(_snapshot) _snapshot.setup(_view, _report) func _arg_value(args: PackedStringArray, key: String, fallback: float) -> float: var i := args.find(key) if i >= 0 and i + 1 < args.size(): return args[i + 1].to_float() return fallback func _refresh_role() -> void: _role = "server" if NetworkManager.is_server() else ("client" if NetworkManager.is_online() else "offline") ## Only the hand is resolved up front; everything else is looked up by name at ## use time (see MpWorldView). func _resolve_nodes() -> bool: _controller = _world.get_node_or_null("XROrigin3D/XRControllerRightHand") as Node3D _hand = _controller.get_node_or_null("FunctionPickup") as XRToolsFunctionPickup if _controller else null if not _hand: _report.log_line("FATAL: could not resolve XROrigin3D/XRControllerRightHand/FunctionPickup") return false _steps = MpSteps.new() add_child(_steps) _steps.setup(_view, _report, _hand, _controller) _asserts = MpAsserts.new() add_child(_asserts) _asserts.setup(_view, _snapshot, _hand) # The kitchen is not baked into the live scene: the server harvests the # authored nodes and respawns them replicated, so on a client nothing exists # until those spawns arrive. Wait for them rather than failing immediately. for required in ["Hob", "Sink", "DirtStation", "Counter", "Counter2", "Plate"]: if not await _steps.wait_until(func(): return _view.find(required) != null, "'%s' to arrive from the server" % required, SETUP_TIMEOUT_SEC): _report.log_line("FATAL: '%s' never appeared in the world" % required) return false _report.log_line("resolved hand=%s; kitchen has Hob, Sink, DirtStation, Counter, Counter2" % _hand.get_path()) _view.disable_despawn_timers() return true # --- server orchestration -------------------------------------------------- func _run_server() -> void: _running = true _report.results.clear() _step_no = 0 if not await _wait_for_client(): _running = false if _auto_mode: _finish(false) return await _steps.wait_frames(60) _report.banner("starting sequence") # A frame of the untouched kitchen, so the GIF opens on the starting state. await _capture_step_frame("start") # 0. The client opened a different, empty scene, so everything it has must # have arrived over the network. Check that before touching anything — this is # the same path a player joining mid-session takes. await _step("server", "world_replicated_to_client", "verify_world_replicated", []) # 1-2. Both peers can pick the plate up and put it down. await _step("client", "client_grab_plate", "grab", ["Plate"]) await _step("client", "client_drop_plate", "drop", ["Plate"]) await _step("server", "server_grab_plate", "grab", ["Plate"]) await _step("server", "server_drop_plate", "drop", ["Plate"]) # 3. Client carries it to the dirt station; it should snap in and go dirty. await _step("client", "client_plate_to_dirt", "place_in_zone", ["Plate", "DirtStation"]) await _both("plate_snapped_and_dirty", "verify_snapped", ["Plate", "DirtStation"]) await _both("plate_is_dirty", "verify_dirty", ["Plate", "true"]) # 4. Both peers can still pick it back up out of the station. await _step("client", "client_regrab_plate", "grab", ["Plate"]) await _step("client", "client_redrop_plate", "drop", ["Plate"]) await _step("server", "server_regrab_plate", "grab", ["Plate"]) await _step("server", "server_redrop_plate", "drop", ["Plate"]) # 5. Client takes the dirty plate to the sink, which should wash it clean. await _step("client", "client_plate_to_sink", "place_in_zone", ["Plate", "Sink"]) await _both("plate_snapped_in_sink", "verify_snapped", ["Plate", "Sink"]) await _both("sink_bar_shown_while_washing", "verify_station_bar", ["Sink", "true"]) await _step("server", "wait_for_wash", "await_clean", ["Plate"]) await _both("plate_washed_clean", "verify_dirty", ["Plate", "false"]) await _both("sink_bar_hidden_when_done", "verify_station_bar", ["Sink", "false"]) # 6. The cook-and-plate round, once per peer. The first round uses the objects # baked into the scene; the second uses freshly spawned ones, so both paths # through the spawner get covered. Finished plates are parked well clear of # every snap zone (all the stations sit on z=0), so they cannot get re-snapped # on the way down. await _cook_and_plate_round("client", "raw_burger", "BurgerBuns", "Plate", Vector3(-2.0, 1.1, 1.5)) await _spawn_round_items() await _cook_and_plate_round("server", "RawBurger2", "BurgerBuns2", "Plate2", Vector3(-1.0, 1.1, 1.5)) _running = false await _finish_run() ## One full food round performed by `actor`: cook a burger, combine it with buns ## on the counter, and put the result on a plate sitting on another counter. ## Every result is checked on BOTH peers, since the whole point is that the ## server's authoritative outcome reaches the client. func _cook_and_plate_round(actor: String, burger: String, buns: String, plate: String, park_at: Vector3) -> void: _report.banner("%s's cook-and-plate round (%s + %s -> %s)" % [actor, burger, buns, plate]) # Burger onto the hob; it should cook and the raw one should disappear. await _step(actor, "%s_burger_to_hob" % actor, "place_in_zone", [burger, "Hob"]) await _both("%s_burger_snapped_in_hob" % actor, "verify_snapped", [burger, "Hob"]) await _both("%s_hob_bar_shown_while_cooking" % actor, "verify_station_bar", ["Hob", "true"]) await _step("server", "%s_wait_for_cook" % actor, "await_food", ["cooked_burger"]) await _both("%s_raw_burger_removed" % actor, "verify_gone", [burger]) await _both("%s_cooked_burger_exists" % actor, "verify_food_exists", ["cooked_burger"]) # Cooked burger onto the counter, then buns into it: they combine. await _step(actor, "%s_cooked_to_counter" % actor, "place_food_in_zone", ["cooked_burger", "Counter"]) await _both("%s_hob_bar_hidden_when_empty" % actor, "verify_station_bar", ["Hob", "false"]) await _step(actor, "%s_buns_to_cooked" % actor, "carry_food_to_food", [buns, "cooked_burger"]) await _step("server", "%s_wait_for_combine" % actor, "await_food", ["hamburger"]) await _both("%s_ingredients_consumed" % actor, "verify_gone", [buns]) await _both("%s_hamburger_exists" % actor, "verify_food_exists", ["hamburger"]) # Plate onto the second counter, then the hamburger onto the plate. await _step(actor, "%s_plate_to_counter2" % actor, "place_in_zone", [plate, "Counter2"]) await _both("%s_plate_snapped_on_counter2" % actor, "verify_snapped", [plate, "Counter2"]) await _step(actor, "%s_hamburger_to_plate" % actor, "carry_food_to_item", ["hamburger", plate]) await _both("%s_plate_holds_hamburger" % actor, "verify_plate_contains", [plate, "hamburger"]) await _both("%s_food_on_plate_before_lift" % actor, "verify_plate_visuals", [plate]) # Lift the plate off the counter and set it down clear of every station. await _step(actor, "%s_plate_off_counter2" % actor, "park", [plate, park_at]) await _both("%s_counter2_freed" % actor, "verify_zone_empty", ["Counter2"]) # The food must still be on the plate after being carried off and set down. await _both("%s_food_stayed_on_plate" % actor, "verify_plate_visuals", [plate]) ## A second set of ingredients for the server's round (the scene bakes only one ## of each), spawned through the normal path so the runtime-spawn case is covered ## as well as the authored one. func _spawn_round_items() -> void: _report.banner("state between rounds (server's view)") _view.dump_state(MpSnapshot.WATCHED_STATIONS, _hand) _report.banner("spawning a second set of ingredients for the server's round") var spawns := [ ["res://Items/burger.tscn", "RawBurger2", Vector3(0.3, 1.05, 0.7)], ["res://Items/BurgerBuns.tscn", "BurgerBuns2", Vector3(-0.4, 1.05, 0.7)], ["res://Containers/plate.tscn", "Plate2", Vector3(1.4, 1.05, 0.7)], ] for s in spawns: NetworkManager.spawn_item(s[0], Transform3D(Basis(), s[2]), s[1]) _report.log_line(" spawned %s" % s[1]) await _steps.wait_frames(30) func _wait_for_client() -> bool: var ok := await _steps.wait_until(func(): return _find_client_id() != 0, "client to connect", SETUP_TIMEOUT_SEC) _client_id = _find_client_id() if ok: _report.log_line("client connected: peer %d" % _client_id) return ok func _find_client_id() -> int: for id in multiplayer.get_peers(): if id != 1: return id return 0 ## Run one step as `actor` (locally if that is us, over RPC if it is the client) ## and record the verdict. func _step(actor: String, label: String, step: String, args: Array) -> void: _snapshot.current_step = label _view.disable_despawn_timers() _report.banner("STEP %d: %s (on the %s)" % [_next_step_no(), label, actor.to_upper()]) var res: Dictionary if actor == "server": res = await _run_local_step(step, args) else: res = await _remote(step, args) _report.record(label, actor, res) await _audit_sync(label) await _capture_step_frame(label) await _pause() ## Run the same check on both peers — the server's state and the client's must ## agree, which is the whole point of the exercise. func _both(label: String, step: String, args: Array) -> void: _snapshot.current_step = label _view.disable_despawn_timers() _report.banner("STEP %d: %s (checked on BOTH peers)" % [_next_step_no(), label]) _report.record(label, "server", await _run_local_step(step, args)) _report.record(label, "client", await _remote(step, args)) await _audit_sync(label) await _capture_step_frame(label) await _pause() func _remote(step: String, args: Array) -> Dictionary: _reply = {} _cmd.rpc_id(_client_id, step, args) var deadline := Time.get_ticks_msec() + int(STEP_TIMEOUT_SEC * 1000.0) while _reply.is_empty() and Time.get_ticks_msec() < deadline: await get_tree().process_frame if _reply.is_empty(): return {"ok": false, "detail": "no reply from client within %.0fs" % STEP_TIMEOUT_SEC} return _reply.duplicate() func _next_step_no() -> int: _step_no += 1 return _step_no func _pause() -> void: if _step_pause > 0.0: await get_tree().create_timer(_step_pause).timeout func _finish_run() -> void: _report.print_summary() await _capture_step_frame("final") _report.write_frame_index() _report.write_report() if not _auto_mode: return _quit_client.rpc_id(_client_id) if _end_hold > 0.0: _report.log_line("holding for %.0fs so you can look at the final state..." % _end_hold) await get_tree().create_timer(_end_hold).timeout else: await _steps.wait_frames(30) _finish(_report.failed_count() == 0) # --- client command handling ---------------------------------------------- @rpc("authority", "reliable") func _cmd(step: String, args: Array) -> void: _snapshot.current_step = step _view.disable_despawn_timers() _report.log_line("<- server: %s%s" % [step, args]) _running = true var res := await _run_local_step(step, args) _running = false _report.log_line("%s %s" % ["PASS" if res.get("ok") else "FAIL", step]) _report.log_line(" %s" % res.get("detail")) _result.rpc_id(1, step, res.get("ok", false), str(res.get("detail", ""))) @rpc("any_peer", "reliable") func _result(step: String, ok: bool, detail: String) -> void: _reply = {"ok": ok, "detail": detail, "step": step} @rpc("authority", "reliable") func _quit_client() -> void: _report.write_frame_index() if _end_hold > 0.0: await get_tree().create_timer(_end_hold).timeout _finish(true) func _run_local_step(step: String, args: Array) -> Dictionary: match step: "grab": return await _steps.grab(_view.find(args[0]), args[0]) "drop": return await _steps.drop(_view.find(args[0]), args[0]) "place_in_zone": return await _steps.place_in_zone(_view.find(args[0]), args[0], args[1]) "place_food_in_zone": return await _steps.place_in_zone(_view.find_by_food_id(args[0]), args[0], args[1]) "carry_food_to_food": return await _steps.carry_item_to_item( _view.find(args[0]), args[0], _view.find_by_food_id(args[1]), args[1]) "carry_food_to_item": return await _steps.carry_item_to_item( _view.find_by_food_id(args[0]), args[0], _view.find(args[1]), args[1]) "await_food": return await _steps.await_food(args[0]) "await_clean": return await _steps.await_clean(args[0]) "park": return await _steps.park(_view.find(args[0]), args[0], args[1]) "verify_snapped": return _asserts.snapped(args[0], args[1]) "verify_dirty": return _asserts.dirty(args[0], args[1] == "true") "verify_gone": return _asserts.gone(args[0]) "verify_food_exists": return _asserts.food_exists(args[0]) "verify_plate_contains": return _asserts.plate_contains(args[0], args[1]) "verify_plate_visuals": return _asserts.plate_visuals(args[0]) "verify_station_bar": return _asserts.station_bar(args[0], args[1] == "true") "verify_zone_empty": return _asserts.zone_empty(args[0]) "verify_world_replicated": return await _check_world_replicated() return {"ok": false, "detail": "unknown step %s" % step} # --- cross-peer sync audit ------------------------------------------------- @rpc("authority", "reliable") func _request_snapshot() -> void: _snapshot_reply.rpc_id(1, _snapshot.take()) @rpc("any_peer", "reliable") func _snapshot_reply(snap: Dictionary) -> void: _client_snapshot = snap _snapshot_pending = false func _fetch_client_snapshot() -> Dictionary: _client_snapshot = {} _snapshot_pending = true _request_snapshot.rpc_id(_client_id) var deadline := Time.get_ticks_msec() + 10000 while _snapshot_pending and Time.get_ticks_msec() < deadline: await get_tree().process_frame return _client_snapshot ## Poll until the two peers agree, so ordinary physics settling is not reported ## as a desync — a real one never converges and gets reported with its details. func _audit_sync(label: String) -> void: if not NetworkManager.is_server() or _client_id == 0: return var problems: Array[String] = [] var deadline := Time.get_ticks_msec() + int(MpSnapshot.SETTLE_SEC * 1000.0) while true: var mine := _snapshot.take() var theirs := await _fetch_client_snapshot() problems = _snapshot.compare(mine, theirs) if problems.is_empty() or Time.get_ticks_msec() > deadline: break await _steps.wait_frames(10) var res := {"ok": problems.is_empty(), "detail": ""} if problems.is_empty(): res["detail"] = "server and client agree on all %d objects" % _snapshot.take().size() else: res["detail"] = "after %.0fs the peers still disagree: %s" % [MpSnapshot.SETTLE_SEC, "; ".join(problems)] _report.record("sync_after_" + label, "both", res) ## The client loaded a bare multiplayer scene with no kitchen in it, so every ## object it can see arrived from the server. This confirms it got the whole ## layout — the same thing that has to work for a player joining mid-session. func _check_world_replicated() -> Dictionary: var mine := _snapshot.take() var theirs := await _fetch_client_snapshot() if theirs.is_empty(): return {"ok": false, "detail": "the client reported nothing at all"} var problems := _snapshot.compare(mine, theirs) var detail := "server has %d objects, client has %d" % [mine.size(), theirs.size()] if not problems.is_empty(): return {"ok": false, "detail": "%s; %s" % [detail, "; ".join(problems)]} return {"ok": true, "detail": "%s, all matching (client received the world over the network)" % detail} # --- per-step screenshots -------------------------------------------------- ## The server numbers the frames and tells the client to grab the matching one, ## so frame N is the same step on both sides and they can be stitched in pairs. func _capture_step_frame(label: String) -> void: if not _report.frames_enabled(): return var index := _report.next_frame_index() if NetworkManager.is_server() and _client_id != 0: _capture_frame_rpc.rpc_id(_client_id, index, label) await _report.save_frame(index, label) @rpc("authority", "reliable") func _capture_frame_rpc(index: int, label: String) -> void: await _report.save_frame(index, label) # --- manual controls ------------------------------------------------------- func _unhandled_input(event: InputEvent) -> void: if not (event is InputEventKey) or not event.pressed or event.echo: return if _running and event.keycode != KEY_0: _report.log_line("busy running a step, ignoring that key") return match event.keycode: KEY_H: _report.log_line("KEY: host"); NetworkManager.host(); _refresh_role() KEY_J: _report.log_line("KEY: join 127.0.0.1"); NetworkManager.join("127.0.0.1") await get_tree().create_timer(1.0).timeout _refresh_role() KEY_1: await _manual("grab plate", "grab", ["Plate"]) KEY_2: await _manual("drop plate", "drop", ["Plate"]) KEY_3: await _manual("plate -> dirt station", "place_in_zone", ["Plate", "DirtStation"]) KEY_4: await _manual("plate -> sink", "place_in_zone", ["Plate", "Sink"]) KEY_5: await _manual("raw burger -> hob", "place_in_zone", ["raw_burger", "Hob"]) KEY_6: await _manual("cooked burger -> counter", "place_food_in_zone", ["cooked_burger", "Counter"]) KEY_7: await _manual("buns -> cooked burger", "carry_food_to_food", ["BurgerBuns", "cooked_burger"]) KEY_8: await _manual("plate -> counter 2", "place_in_zone", ["Plate", "Counter2"]) KEY_9: await _manual("hamburger -> plate", "carry_food_to_item", ["hamburger", "Plate"]) KEY_R: _refresh_role() if _role != "server": _report.log_line("KEY: run-all is server-only (this peer is %s)" % _role) return _report.log_line("KEY: running the full automatic sequence") await _run_server() KEY_0: _report.log_line("KEY: state dump") _view.dump_state(MpSnapshot.WATCHED_STATIONS, _hand) KEY_A: _refresh_role() if _role != "server": _report.log_line("KEY: the sync audit runs from the server window") return if _client_id == 0: _client_id = _find_client_id() _report.log_line("KEY: comparing every object against the client") _running = true await _audit_sync("manual_check") _running = false KEY_C: _toggle_debug_camera() func _manual(what: String, step: String, args: Array) -> void: _refresh_role() _report.banner("MANUAL: %s" % what) _running = true var res := await _run_local_step(step, args) _running = false _report.log_line("%s %s :: %s" % ["PASS" if res.get("ok") else "FAIL", what, res.get("detail", "")]) # Capture manual steps too, so a hand-driven repro can be turned into a GIF. await _capture_step_frame(step) _report.write_frame_index() func _finish(ok: bool) -> void: _report.log_line("=== %s exiting (%s) ===" % [_role, "ok" if ok else "FAILED"]) get_tree().quit(0 if ok else 1) # --- camera ---------------------------------------------------------------- ## A fixed camera overlooking the kitchen. ## ## Without this a windowed run shows nothing useful: the only camera is the ## XROrigin's XRCamera3D, which nothing drives when XR is off, and the rig's ## PlayerBody keeps falling under gravity — the view ends up metres below the ## floor pointing at the void. Ours is a plain Camera3D parented to the world, ## not the rig, so it stays put. func _build_debug_camera() -> void: if DisplayServer.get_name() == "headless": return _debug_cam = Camera3D.new() _debug_cam.name = "MPTestDebugCamera" _world.add_child(_debug_cam) _debug_cam.global_position = DEBUG_CAM_POS _debug_cam.look_at(DEBUG_CAM_LOOK_AT) # Stand the rig's camera down explicitly first: setting `current` alone lost # the race against XRCamera3D, leaving the view stuck under the floor. var xr_cam := _world.get_node_or_null("XROrigin3D/XRCamera3D") as Camera3D if xr_cam: xr_cam.current = false _debug_cam.make_current() _report.log_line("debug camera at %s looking at %s ([C] toggles back to the XR rig camera)" % [DEBUG_CAM_POS, DEBUG_CAM_LOOK_AT]) _assert_debug_camera.call_deferred() func _assert_debug_camera() -> void: if _debug_cam and _debug_cam.current and get_viewport().get_camera_3d() != _debug_cam: _debug_cam.make_current() _report.log_line("rendering through camera: %s" % get_viewport().get_camera_3d()) _check_framing() ## Confirm the things the test acts on are actually on screen. Checked by ## projection rather than by eye, because that is the only way to be sure across ## window sizes and display scaling. func _check_framing() -> void: var size := get_viewport().get_visible_rect().size var offscreen: Array[String] = [] for name in ["Counter2", "Counter", "Hob", "Sink", "DirtStation", "Plate"]: var n := _view.find(name) if not n: continue var p := _debug_cam.unproject_position(n.global_position) var frac := Vector2(p.x / size.x, p.y / size.y) var on := not _debug_cam.is_position_behind(n.global_position) \ and frac.x > 0.02 and frac.x < 0.98 and frac.y > 0.02 and frac.y < 0.98 _report.log_line(" framing: %-12s at %.2f,%.2f of frame%s" % [name, frac.x, frac.y, "" if on else " <-- OFF SCREEN"]) if not on: offscreen.append(name) if offscreen.is_empty(): _report.log_line(" framing: all test objects are in view") else: _report.log_line(" framing: WARNING - not visible: %s (adjust DEBUG_CAM_POS)" % ", ".join(offscreen)) func _toggle_debug_camera() -> void: if not _debug_cam: return var xr_cam := _world.get_node_or_null("XROrigin3D/XRCamera3D") as Camera3D if _debug_cam.current and xr_cam: xr_cam.make_current() _report.log_line("switched to the XR rig camera") else: _debug_cam.make_current() _report.log_line("switched to the debug camera")