diff --git a/Net/net_replication.gd b/Net/net_replication.gd new file mode 100644 index 0000000..5094041 --- /dev/null +++ b/Net/net_replication.gd @@ -0,0 +1,134 @@ +extends Node +class_name NetReplication + +## Builds MultiplayerSynchronizers for any node by convention, so no scene needs +## a hand-authored replication config. +## +## Every replicated node gets TWO synchronizers, both generated here: +## +## NetSync script state (is_dirty, contained_ids, time_cooked, ...). +## Always owned by the server. Gameplay outcomes are the server's to +## decide, so this never changes hands. +## NetXform position + quaternion. Owned by the server while the object is +## loose, and handed to a peer for as long as that peer holds it. +## +## Splitting them is what makes client-side grab prediction work. A +## MultiplayerSynchronizer never applies inbound state on the peer that owns it, +## so giving the holder NetXform means their own hand drives the object with no +## round trip and no fight with the server's copy — while is_dirty and friends +## keep flowing one way, server to client, exactly as before. +## +## The obvious alternative, per-peer visibility (set_visibility_for), does NOT +## work: MultiplayerSpawner also uses synchronizer visibility to decide whether +## a node should exist on a peer, so hiding an object from its holder despawns +## it in their hand. Verified in test/spike/. +## +## The convention for what gets replicated: +## * a Node3D replicates `position` and `quaternion` (never `scale` — nothing +## in this game animates scale as gameplay state) +## * every script variable, on the node and on any scripted descendant, whose +## name does not start with "_" and whose declared type is serialisable +## +## Underscore-prefixed vars are the opt-out, and they already mark exactly the +## state that must not be replicated: @onready node references, cached lookups, +## per-frame bookkeeping. + +## SceneReplicationConfig's replication modes. The enum is not exposed under a +## friendly name in GDScript, so they are spelled out here. +const MODE_ALWAYS := 1 +const MODE_ON_CHANGE := 2 + +const SYNC_NAME := "NetSync" +const XFORM_NAME := "NetXform" + +## Types that survive a network round trip. Object/Callable/Signal/RID cannot be +## serialised, and are exactly what @onready and cached references hold. +const SYNCABLE_TYPES := [ + TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_STRING_NAME, + TYPE_VECTOR2, TYPE_VECTOR3, TYPE_QUATERNION, TYPE_TRANSFORM3D, + TYPE_COLOR, TYPE_PACKED_STRING_ARRAY, TYPE_ARRAY, +] + + +## Adds both synchronizers to `node`, unless they are already there. Idempotent. +## +## Must run on EVERY peer, not just the authority: the client's copy is built by +## MultiplayerSpawner straight from the .tscn, so it only has synchronizers if +## something puts them there. NetWorld calls this from the content root's +## child_entered_tree on both sides — early enough that they enter the tree +## inside the spawner's own add_child(), which is what lets them pick up the +## spawn payload (verified in test/spike/: a late joiner receives position and +## script state from a synchronizer that exists only at runtime). +static func attach(node: Node) -> void: + if node is Node3D and not node.has_node(XFORM_NAME): + _add_sync(node, XFORM_NAME, _transform_config()) + if not node.has_node(SYNC_NAME): + _add_sync(node, SYNC_NAME, state_config(node)) + + +static func _add_sync(node: Node, sync_name: String, config: SceneReplicationConfig) -> void: + var sync := MultiplayerSynchronizer.new() + sync.name = sync_name + sync.replication_config = config + # Sync every network tick. ON_CHANGE properties are only sent when they + # actually change regardless of this interval. + sync.replication_interval = 0.0 + node.add_child(sync) + + +## Transform-only config. ALWAYS rather than ON_CHANGE: a carried or simulated +## object changes every tick anyway, so ON_CHANGE would only add a comparison +## per property per tick. spawn = true so a replicated object arrives already in +## the right place instead of sitting at the origin for a frame. +static func _transform_config() -> SceneReplicationConfig: + var config := SceneReplicationConfig.new() + _add(config, ".:position", true, MODE_ALWAYS) + _add(config, ".:quaternion", true, MODE_ALWAYS) + return config + + +## Script-state config for `node` and its scripted descendants. Public so the +## tests can inspect what the convention picked up without spinning up a session. +## +## ON_CHANGE, not ALWAYS: the value is then only sent — and, crucially, only +## ASSIGNED on the receiving peer — when it actually changes. Under ALWAYS every +## replicated setter becomes a per-tick hot path, which is how this project +## previously ended up rebuilding every plate's visuals 60 times a second. +static func state_config(node: Node) -> SceneReplicationConfig: + var config := SceneReplicationConfig.new() + for path in _script_var_paths(node, node): + _add(config, path, true, MODE_ON_CHANGE) + return config + + +static func _add(config: SceneReplicationConfig, path: String, spawn: bool, mode: int) -> void: + var np := NodePath(path) + config.add_property(np) + config.property_set_spawn(np, spawn) + config.property_set_replication_mode(np, mode) + + +## Every ":" on `node` and its scripted descendants. +## Descends through children but stops at anything carrying its own synchronizer +## — that subtree replicates itself and must not be replicated twice. +static func _script_var_paths(root: Node, node: Node) -> Array[String]: + var paths: Array[String] = [] + var prefix: String = "." if node == root else str(root.get_path_to(node)) + var script: Script = node.get_script() as Script + if script: + for prop in script.get_script_property_list(): + var prop_name := str(prop["name"]) + if prop_name.begins_with("_"): + continue + if not (prop["usage"] & PROPERTY_USAGE_SCRIPT_VARIABLE): + continue + if not SYNCABLE_TYPES.has(prop["type"]): + continue + paths.append("%s:%s" % [prefix, prop_name]) + for child in node.get_children(): + if child is MultiplayerSynchronizer or child is MultiplayerSpawner: + continue + if child.has_node(SYNC_NAME) or child.has_node(XFORM_NAME): + continue + paths.append_array(_script_var_paths(root, child)) + return paths diff --git a/Net/net_replication.gd.uid b/Net/net_replication.gd.uid new file mode 100644 index 0000000..361f386 --- /dev/null +++ b/Net/net_replication.gd.uid @@ -0,0 +1 @@ +uid://bmuqb0yywjfyw diff --git a/docs/superpowers/specs/2026-07-28-high-level-replication-design.md b/docs/superpowers/specs/2026-07-28-high-level-replication-design.md new file mode 100644 index 0000000..0e4fdac --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-high-level-replication-design.md @@ -0,0 +1,129 @@ +# High-level replication rebuild + +Rebuild VRyHungry's multiplayer on Godot's stock `MultiplayerSpawner` and +`MultiplayerSynchronizer`, so that adding a new object to the game requires no +networking code at all. Replaces the bespoke spawn payloads, per-scene +replication configs, and grab-authority handshake in place today. + +Baseline being replaced: `logs/mptest_report.txt`, 146/146 checks passing as of +2026-07-28 19:47. That is the bar the rebuild has to clear. + +## The constraint that shapes everything + +`MultiplayerSpawner` replicates **node creation, name, and deletion** — nothing +more. All state comes from `MultiplayerSynchronizer` plus a +`SceneReplicationConfig`. There is no engine switch for "sync everything +regardless of what it is". + +The way to get there anyway: a `SceneReplicationConfig` is a plain `Resource`, +so it can be **built in code from a convention** and attached at runtime. One +helper then covers every object in the game, and nothing has to be authored +per scene. + +## Verified engine behaviour + +Established by `test/spike/`, run against Godot 4.7.stable. These are load +bearing — the design below is only correct because these hold. + +| # | Question | Result | +|---|----------|--------| +| Q1 | Does default auto-spawn carry the spawn properties of a synchronizer attached at **runtime**, not baked into the `.tscn`? | **Yes.** A peer joining 4s after the spawn received `pos=(5,6,7) health=42 label=hello`. | +| Q2 | Do `ON_CHANGE` properties reach a late joiner, and do later changes propagate? | **Yes**, both. | +| Q3 | Can `set_visibility_for(peer, false)` suppress one peer's updates without despawning the node there? | **No.** The node is despawned and respawned on every visibility flip. Unusable — it would make a held object vanish from the holder's hand. | +| Q4 | Can handing one of two synchronizers to a client let it drive the transform locally while server-owned state keeps arriving? | **Yes.** Client showed its own `(1,1,1)` and ignored the server's competing `(-9,-9,-9)`; the server saw `(1,1,1)`; server-owned `health=123` still arrived. | + +Two traps worth recording. `set_visibility_for` is only an *override* on top of +`public_visibility`, which defaults to `true` — calling it alone does nothing. +And the spike's first version chained relative timers, which drifted enough that +the client's observations straddled the server's actions; it now runs both peers +on one absolute clock. + +## Design + +### Layer 1 — `Net/net_replication.gd` + +The convention-based config builder. `NetReplication.attach(node)` gives a node +two generated synchronizers: + +- **`NetSync`** — script state. Always server-owned; gameplay outcomes are the + server's to decide. `ON_CHANGE`. +- **`NetXform`** — `position` + `quaternion`. Server-owned while the object is + loose, handed to a peer for the duration of a hold. `ALWAYS`. + +What gets replicated, by convention: + +- a `Node3D` replicates `position` and `quaternion` (never `scale` — nothing in + this game animates scale as gameplay state) +- every script variable, on the node and any scripted descendant, whose name + does not start with `_` and whose declared type is serialisable + +Underscore-prefixed vars are the opt-out, and they already mark exactly the +state that must not cross the wire: `@onready` references, cached lookups, +per-frame bookkeeping. + +`ON_CHANGE` for state is a deliberate correctness choice, not just bandwidth. +Under `ALWAYS` the synchronizer *assigns* every property every tick on receiving +peers, so every replicated setter becomes a per-frame hot path — which is how +this project previously ended up rebuilding every plate's visuals 60 times a +second and rewriting four physics properties per item per tick. + +### Layer 2 — `Net/net_world.gd` + +- Spawnable scenes registered by **sorted** directory scan of `Items/`, + `Containers/`, `Stations/`, `Prefabs/`. Auto-spawn transmits an *index* into + that array, so the order must be identical on every peer or clients build the + wrong scene. +- `spawn()` = instantiate → set transform/name → `add_child` under the content + root. No `spawn_function`, no payload dictionary. +- `despawn()` = `queue_free()` on the server. The `_despawn_static_item` RPC + disappears: with nothing baked into the world scene, the spawner covers every + case. +- `NetReplication.attach` runs from the content root's `child_entered_tree` on + **both** peers. That is early enough for the synchronizers to enter the tree + inside the spawner's own `add_child`, which is what lets them pick up the + spawn payload (Q1). +- **Client gating becomes one generic rule** applied at the same hook: disable + `_process`, disable every `XRToolsSnapZone`, freeze `RigidBody3D`. This + replaces `_gate_station` + `gate_existing_stations` and applies to anything, + not just things that happen to be stations. + +### Layer 3 — `Net/net_grab.gd` and `Net/net_stations.gd` + +Two RPCs for the entire game: `request_grab(item)` and +`request_release(item, xform, lin, ang)`. + +A client's hand picks the item up locally the instant the player grabs — no +round trip — and asks the server to confirm. The server grants by moving +`NetXform` authority to that peer (Q4), which is precisely what makes the local +copy stop applying inbound transform updates. On release, authority returns to +the server, which decides where the item actually ends up: server-authoritative +snapping into stations lives in `net_stations.gd`, generic over the `station` +group rather than per-station. + +Deleted outright: `Net/net_pickable.gd` (209 lines), `net_held_by` and its +held-state juggling, `_set_item_authority`, the grant/reject/force-release +negotiation, and `_gate_station`. + +### Layer 4 — tests + +`test/mp_test_driver.gd` (1458 lines) splits into orchestration, steps, +asserts, snapshot, and report modules. The scenario list and the sync audit +carry over unchanged — that audit compares what is actually *rendered* on both +peers, which is what caught the plate-visuals desync that targeted assertions +missed. Assertions written against old internals (`net_held_by`, "the client is +the item's authority after grabbing") are rewritten against the new model. + +## Risks + +- **Replacing a green suite.** Mitigated by running the headless suite after + each layer rather than only at the end. +- **The harness changes alongside the code it checks.** Mitigated by keeping + scenario list and audit logic byte-identical wherever possible, so it remains + an independent check rather than one shaped to fit the new code. +- **Blanket state replication picks up more vars than the old hand-authored + configs.** `ON_CHANGE` makes this cheap, but any setter reached this way still + has to be idempotent and null-guarded — a `spawn = true` property fires its + setter *before* the node is in the tree, so `@onready` refs are null there. +- **Client→client transform** relies on `SceneMultiplayer.server_relay` (on by + default) to forward a holder's updates to the other clients. Fine for a + listen-server, worth remembering if the topology ever changes. diff --git a/test/spike/spike_item.gd b/test/spike/spike_item.gd new file mode 100644 index 0000000..5693521 --- /dev/null +++ b/test/spike/spike_item.gd @@ -0,0 +1,8 @@ +extends Node3D + +## Spike fixture only. A plain scene with NO authored MultiplayerSynchronizer, +## so the run proves the runtime-generated one is what carries the state. + +var health: int = 0 +var label: String = "" +var _private_should_not_replicate: int = 99 diff --git a/test/spike/spike_item.gd.uid b/test/spike/spike_item.gd.uid new file mode 100644 index 0000000..d8e5bca --- /dev/null +++ b/test/spike/spike_item.gd.uid @@ -0,0 +1 @@ +uid://cbgmii1peyxu8 diff --git a/test/spike/spike_item.tscn b/test/spike/spike_item.tscn new file mode 100644 index 0000000..1c5f76b --- /dev/null +++ b/test/spike/spike_item.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://bspike0item00"] + +[ext_resource type="Script" path="res://test/spike/spike_item.gd" id="1_spike"] + +[node name="SpikeItem" type="Node3D"] +script = ExtResource("1_spike") diff --git a/test/spike/spike_world.gd b/test/spike/spike_world.gd new file mode 100644 index 0000000..aab1390 --- /dev/null +++ b/test/spike/spike_world.gd @@ -0,0 +1,222 @@ +extends Node3D + +## Throwaway spike. Answers the questions about Godot's high-level replication +## that the rebuild depends on, before any of the real code is rewritten. +## +## Q1 Does default (auto) MultiplayerSpawner replication carry the spawn +## properties of synchronizers attached AT RUNTIME rather than baked into +## the .tscn? [CONFIRMED] +## Q2 Do ON_CHANGE properties reach a peer that joins AFTER the value was +## set, and do later changes propagate? [CONFIRMED] +## Q3 Can set_visibility_for(peer, false) suppress a peer's updates without +## despawning the node on it? [REFUTED — the +## node is despawned and respawned on every visibility flip, so this +## cannot be used to stop the server fighting a client's held object.] +## Q4 Does handing ONE of a node's two synchronizers (NetXform) to a client +## let that client drive the transform locally — ignoring the server's +## inbound updates — while the other (NetSync) keeps pushing server-owned +## state to it? This is the grab-prediction mechanism. +## +## Both peers run on one ABSOLUTE clock (see _at) rather than chains of relative +## timers: the first version of this spike drifted enough that the client's +## reports straddled the server's actions and read as inconclusive. + +const PORT := 24599 +const ITEM := "res://test/spike/spike_item.tscn" + +var _log: FileAccess +var _is_server := false +var _start_ms: int = 0 + + +func _ready() -> void: + var args := OS.get_cmdline_user_args() + _is_server = args.has("--server") + _start_ms = Time.get_ticks_msec() + _open_log() + + # Registered identically on both peers BEFORE connecting: auto-spawn sends an + # index into this array, so a mismatch builds the wrong scene on the client. + $Spawner.add_spawnable_scene(ITEM) + + # The hook under test: every peer attaches the generated synchronizers as the + # node enters the tree. On the server that is our own add_child; on the + # client it is MultiplayerSpawner's. + $Content.child_entered_tree.connect(_on_content_child) + + if _is_server: + _run_server() + else: + _run_client() + + +func _on_content_child(node: Node) -> void: + if node is MultiplayerSynchronizer: + return + NetReplication.attach(node) + _say("attached synchronizers to %s" % node.name) + + +## Waits until `t` seconds after this scene started. Both peers start within a +## few hundred ms of each other, which is close enough to interleave their +## actions and observations deterministically. +func _at(t: float) -> void: + var target := _start_ms + int(t * 1000.0) + while Time.get_ticks_msec() < target: + await get_tree().process_frame + + +## set_multiplayer_authority is a local call, so it has to run on every peer for +## them to agree on who is driving the transform. +@rpc("authority", "call_local", "reliable") +func _set_xform_authority(path: NodePath, peer: int) -> void: + var node := get_node_or_null(path) + if not node: + return + node.get_node(NetReplication.XFORM_NAME).set_multiplayer_authority(peer) + _say("NetXform authority of %s -> peer %d" % [node.name, peer]) + + +func _item() -> Node3D: + return $Content.get_node_or_null("SpikeItem") as Node3D + + +# --- server ---------------------------------------------------------------- + +func _run_server() -> void: + var peer := ENetMultiplayerPeer.new() + var err := peer.create_server(PORT, 4) + if err != OK: + _say("FATAL: create_server failed: %s" % error_string(err)) + _finish() + return + multiplayer.multiplayer_peer = peer + _say("server up on %d" % PORT) + + # t=1: spawn BEFORE anyone joins, so the client receives it purely as a + # late-joiner replay — the same path as joining a running game. + await _at(1.0) + var item: Node3D = load(ITEM).instantiate() + item.name = "SpikeItem" + item.position = Vector3(5, 6, 7) + item.health = 42 + item.label = "hello" + $Content.add_child(item) + _say("spawned SpikeItem at %s health=%d label=%s" % [item.position, item.health, item.label]) + + await _at(7.0) + if multiplayer.get_peers().is_empty(): + _say("FATAL: no client connected by t=7") + _finish() + return + var client: int = multiplayer.get_peers()[0] + _say("client connected: %d" % client) + + # t=8: change an ON_CHANGE value well after the client joined (Q2b). + await _at(8.0) + item.health = 7 + item.label = "changed" + _say("changed health=7 label=changed") + + # t=12: hand the transform to the client, as if it had just grabbed the item. + await _at(12.0) + _set_xform_authority.rpc(item.get_path(), client) + + # t=13: the server now tries to move it and also changes server-owned state. + # The move must NOT reach the client (it owns the transform); the state must. + await _at(13.0) + item.position = Vector3(-9, -9, -9) + item.health = 123 + _say("server set pos=%s health=123 while the CLIENT owns NetXform" % item.position) + + await _at(15.5) + _say("Q4 server's view of item pos (expect the CLIENT's 1,1,1) -> %s" % item.position) + + # t=17: take it back, as if the client had released it, and move it. + await _at(17.0) + _set_xform_authority.rpc(item.get_path(), 1) + await _at(17.5) + item.position = Vector3(-3, -3, -3) + _say("server reclaimed NetXform and set pos=%s" % item.position) + + # Stay alive past the client's last report, so a shutdown-induced despawn + # can't be mistaken for anything else. + await _at(24.0) + _say("server done") + _finish() + + +# --- client ---------------------------------------------------------------- + +func _run_client() -> void: + # Deliberately late, so the spawn is replayed rather than observed live. + await _at(5.0) + var peer := ENetMultiplayerPeer.new() + var err := peer.create_client("127.0.0.1", PORT) + if err != OK: + _say("FATAL: create_client failed: %s" % error_string(err)) + _finish() + return + multiplayer.multiplayer_peer = peer + _say("client connecting...") + + await _at(7.5) + _report("Q1/Q2a late-join spawn (expect pos=5,6,7 health=42 label=hello)") + + await _at(10.0) + _report("Q2b after change (expect health=7 label=changed)") + + # t=13.5: we now own NetXform. Drive the item ourselves, the way a held + # object's grab driver would, and see whether the server's competing write + # from t=13 gets ignored here and whether ours reaches the server. + await _at(13.5) + var item := _item() + if item: + item.position = Vector3(1, 1, 1) + _say("client set pos=(1,1,1) while owning NetXform") + + await _at(15.0) + _report("Q4 client owns NetXform (expect pos=1,1,1 — NOT -9; health=123 — server state still arrives)") + + await _at(20.0) + _report("Q4b server reclaimed (expect pos=-3,-3,-3)") + + _say("client done") + _finish() + + +func _report(what: String) -> void: + var item := _item() + if not item: + _say("%s -> NO NODE (item absent on client)" % what) + return + _say("%s -> pos=%s health=%d label=%s private=%d" % [ + what, item.position, item.health, item.label, + item._private_should_not_replicate, + ]) + + +# --- logging --------------------------------------------------------------- + +func _open_log() -> void: + var who := "server" if _is_server else "client" + var dir := ProjectSettings.globalize_path("res://logs") + DirAccess.make_dir_recursive_absolute(dir) + _log = FileAccess.open(dir.path_join("spike_%s.log" % who), FileAccess.WRITE) + + +func _say(s: String) -> void: + var line := "[%s t=%5.1f] %s" % [ + "SERVER" if _is_server else "CLIENT", + (Time.get_ticks_msec() - _start_ms) / 1000.0, s, + ] + print(line) + if _log: + _log.store_line(line) + _log.flush() + + +func _finish() -> void: + if _log: + _log.flush() + get_tree().quit() diff --git a/test/spike/spike_world.gd.uid b/test/spike/spike_world.gd.uid new file mode 100644 index 0000000..a66fcac --- /dev/null +++ b/test/spike/spike_world.gd.uid @@ -0,0 +1 @@ +uid://bh6fdnthnelfa diff --git a/test/spike/spike_world.tscn b/test/spike/spike_world.tscn new file mode 100644 index 0000000..1ee1c32 --- /dev/null +++ b/test/spike/spike_world.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=2 format=3 uid="uid://bspike0world0"] + +[ext_resource type="Script" path="res://test/spike/spike_world.gd" id="1_spikew"] + +[node name="SpikeWorld" type="Node3D"] +script = ExtResource("1_spikew") + +[node name="Content" type="Node3D" parent="."] + +[node name="Spawner" type="MultiplayerSpawner" parent="."] +spawn_path = NodePath("../Content")