From b3325db18e4c13fb7e95405f6b3754e0c8ac4485 Mon Sep 17 00:00:00 2001
From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com>
Date: Fri, 7 Aug 2026 10:33:27 -0500
Subject: [PATCH 1/3] netcode performance: Add optional FP16 quantization;
reduce replication overhead;
---
.../doc_classes/SceneReplicationConfig.xml | 21 +
.../multiplayer/editor/replication_editor.cpp | 33 +-
.../multiplayer/editor/replication_editor.h | 2 +-
.../multiplayer/multiplayer_synchronizer.cpp | 139 ++++++-
.../multiplayer/multiplayer_synchronizer.h | 3 +
.../multiplayer/scene_replication_config.cpp | 105 +++++
.../multiplayer/scene_replication_config.h | 26 ++
.../scene_replication_interface.cpp | 98 ++++-
.../tests/test_scene_replication.h | 359 ++++++++++++++++++
.../tests/test_scene_replication_benchmark.h | 337 ++++++++++++++++
10 files changed, 1098 insertions(+), 25 deletions(-)
create mode 100644 modules/multiplayer/tests/test_scene_replication.h
create mode 100644 modules/multiplayer/tests/test_scene_replication_benchmark.h
diff --git a/modules/multiplayer/doc_classes/SceneReplicationConfig.xml b/modules/multiplayer/doc_classes/SceneReplicationConfig.xml
index f2a9ed3ef29..81207de63f5 100644
--- a/modules/multiplayer/doc_classes/SceneReplicationConfig.xml
+++ b/modules/multiplayer/doc_classes/SceneReplicationConfig.xml
@@ -37,6 +37,13 @@
Finds the index of the given [param path].
+
+
+
+
+ Returns the wire precision used for the property identified by the given [param path]. See [enum ReplicationPrecision].
+
+
@@ -65,6 +72,14 @@
Returns [code]true[/code] if the property identified by the given [param path] is configured to be reliably synchronized when changes are detected on process.
+
+
+
+
+
+ Sets the wire precision used for the property identified by the given [param path]. See [enum ReplicationPrecision]. Both peers must use the same configuration.
+
+
@@ -115,5 +130,11 @@
Replicate the given property on process by sending updates using reliable transfer mode when its value changes.
+
+ Replicate the given property at full precision using the standard variant encoding. This is the default.
+
+
+ Replicate the given property using half-precision (16-bit) floats to save bandwidth. Applies to [float], [Vector2], [Vector3], [Vector4], [Quaternion] and [Color]; other types fall back to full precision. Reduces range and precision, so it is best suited to values that tolerate small errors (positions on modest maps, velocities, rotations, colors). Both peers must use the same configuration.
+
diff --git a/modules/multiplayer/editor/replication_editor.cpp b/modules/multiplayer/editor/replication_editor.cpp
index 6af28d65f20..1e36b2a24a6 100644
--- a/modules/multiplayer/editor/replication_editor.cpp
+++ b/modules/multiplayer/editor/replication_editor.cpp
@@ -265,7 +265,7 @@ ReplicationEditor::ReplicationEditor() {
tree = memnew(Tree);
tree->set_hide_root(true);
- tree->set_columns(4);
+ tree->set_columns(5);
tree->set_column_titles_visible(true);
tree->set_column_title(0, TTR("Properties"));
tree->set_column_expand(0, true);
@@ -275,7 +275,10 @@ ReplicationEditor::ReplicationEditor() {
tree->set_column_title(2, TTR("Replicate"));
tree->set_column_custom_minimum_width(2, 100);
tree->set_column_expand(2, false);
+ tree->set_column_title(3, TTR("Precision"));
+ tree->set_column_custom_minimum_width(3, 100);
tree->set_column_expand(3, false);
+ tree->set_column_expand(4, false);
tree->create_item();
tree->connect("button_clicked", callable_mp(this, &ReplicationEditor::_tree_button_pressed));
tree->connect("item_edited", callable_mp(this, &ReplicationEditor::_tree_item_edited));
@@ -410,7 +413,7 @@ void ReplicationEditor::_tree_item_edited() {
return;
}
int column = tree->get_edited_column();
- ERR_FAIL_COND(column < 1 || column > 2);
+ ERR_FAIL_COND(column < 1 || column > 3);
const NodePath prop = ti->get_metadata(0);
EditorUndoRedoManager *undo_redo = EditorUndoRedoManager::get_singleton();
@@ -437,6 +440,15 @@ void ReplicationEditor::_tree_item_edited() {
undo_redo->add_do_method(this, "_update_value", prop, column, value);
undo_redo->add_undo_method(this, "_update_value", prop, column, old_value);
undo_redo->commit_action();
+ } else if (column == 3) {
+ undo_redo->create_action(TTR("Set property precision"));
+ int value = ti->get_range(column);
+ int old_value = config->property_get_precision(prop);
+ undo_redo->add_do_method(config.ptr(), "property_set_precision", prop, value);
+ undo_redo->add_undo_method(config.ptr(), "property_set_precision", prop, old_value);
+ undo_redo->add_do_method(this, "_update_value", prop, column, value);
+ undo_redo->add_undo_method(this, "_update_value", prop, column, old_value);
+ undo_redo->commit_action();
} else {
ERR_FAIL();
}
@@ -465,12 +477,14 @@ void ReplicationEditor::_dialog_closed(bool p_confirmed) {
int idx = config->property_get_index(prop);
bool spawn = config->property_get_spawn(prop);
SceneReplicationConfig::ReplicationMode mode = config->property_get_replication_mode(prop);
+ SceneReplicationConfig::ReplicationPrecision precision = config->property_get_precision(prop);
EditorUndoRedoManager *undo_redo = EditorUndoRedoManager::get_singleton();
undo_redo->create_action(TTR("Remove Property"));
undo_redo->add_do_method(config.ptr(), "remove_property", prop);
undo_redo->add_undo_method(config.ptr(), "add_property", prop, idx);
undo_redo->add_undo_method(config.ptr(), "property_set_spawn", prop, spawn);
undo_redo->add_undo_method(config.ptr(), "property_set_replication_mode", prop, mode);
+ undo_redo->add_undo_method(config.ptr(), "property_set_precision", prop, precision);
undo_redo->add_do_method(this, "_update_config");
undo_redo->add_undo_method(this, "_update_config");
undo_redo->commit_action();
@@ -487,7 +501,7 @@ void ReplicationEditor::_update_value(const NodePath &p_prop, int p_column, int
if (ti->get_metadata(0).operator NodePath() == p_prop) {
if (p_column == 1) {
ti->set_checked(p_column, p_value != 0);
- } else if (p_column == 2) {
+ } else if (p_column == 2 || p_column == 3) {
ti->set_range(p_column, p_value);
}
return;
@@ -510,7 +524,7 @@ void ReplicationEditor::_update_config() {
}
for (int i = 0; i < props.size(); i++) {
const NodePath path = props[i];
- _add_property(path, config->property_get_spawn(path), config->property_get_replication_mode(path));
+ _add_property(path, config->property_get_spawn(path), config->property_get_replication_mode(path), config->property_get_precision(path));
}
}
@@ -552,13 +566,14 @@ static bool can_sync(const Variant &p_var) {
}
}
-void ReplicationEditor::_add_property(const NodePath &p_property, bool p_spawn, SceneReplicationConfig::ReplicationMode p_mode) {
+void ReplicationEditor::_add_property(const NodePath &p_property, bool p_spawn, SceneReplicationConfig::ReplicationMode p_mode, SceneReplicationConfig::ReplicationPrecision p_precision) {
String prop = String(p_property);
TreeItem *item = tree->create_item();
item->set_selectable(0, false);
item->set_selectable(1, false);
item->set_selectable(2, false);
item->set_selectable(3, false);
+ item->set_selectable(4, false);
item->set_text(0, prop);
item->set_metadata(0, prop);
Node *root_node = current && !current->get_root_path().is_empty() ? current->get_node(current->get_root_path()) : nullptr;
@@ -583,7 +598,7 @@ void ReplicationEditor::_add_property(const NodePath &p_property, bool p_spawn,
} else {
item->set_icon(0, icon);
}
- item->add_button(3, get_theme_icon(SNAME("Remove"), EditorStringName(EditorIcons)));
+ item->add_button(4, get_theme_icon(SNAME("Remove"), EditorStringName(EditorIcons)));
item->set_text_alignment(1, HORIZONTAL_ALIGNMENT_CENTER);
item->set_cell_mode(1, TreeItem::CELL_MODE_CHECK);
item->set_checked(1, p_spawn);
@@ -594,4 +609,10 @@ void ReplicationEditor::_add_property(const NodePath &p_property, bool p_spawn,
item->set_text(2, TTR("Never", "Replication Mode") + "," + TTR("Always", "Replication Mode") + "," + TTR("On Change", "Replication Mode"));
item->set_range(2, (int)p_mode);
item->set_editable(2, true);
+ item->set_text_alignment(3, HORIZONTAL_ALIGNMENT_CENTER);
+ item->set_cell_mode(3, TreeItem::CELL_MODE_RANGE);
+ item->set_range_config(3, 0, 1, 1);
+ item->set_text(3, TTR("Full", "Replication Precision") + "," + TTR("Half", "Replication Precision"));
+ item->set_range(3, (int)p_precision);
+ item->set_editable(3, true);
}
diff --git a/modules/multiplayer/editor/replication_editor.h b/modules/multiplayer/editor/replication_editor.h
index 74df4b7453c..7eb9496ca79 100644
--- a/modules/multiplayer/editor/replication_editor.h
+++ b/modules/multiplayer/editor/replication_editor.h
@@ -84,7 +84,7 @@ class ReplicationEditor : public VBoxContainer {
void _update_value(const NodePath &p_prop, int p_column, int p_checked);
void _update_config();
void _dialog_closed(bool p_confirmed);
- void _add_property(const NodePath &p_property, bool p_spawn, SceneReplicationConfig::ReplicationMode p_mode);
+ void _add_property(const NodePath &p_property, bool p_spawn, SceneReplicationConfig::ReplicationMode p_mode, SceneReplicationConfig::ReplicationPrecision p_precision = SceneReplicationConfig::PRECISION_FULL);
void _pick_node_filter_text_changed(const String &p_newtext);
void _pick_node_select_recursive(TreeItem *p_item, const String &p_filter, Vector &p_select_candidates);
diff --git a/modules/multiplayer/multiplayer_synchronizer.cpp b/modules/multiplayer/multiplayer_synchronizer.cpp
index 9d2e7de6431..a1f612da19b 100644
--- a/modules/multiplayer/multiplayer_synchronizer.cpp
+++ b/modules/multiplayer/multiplayer_synchronizer.cpp
@@ -39,8 +39,88 @@
#include "multiplayer_synchronizer.h"
#include "core/config/engine.h"
+#include "core/io/marshalls.h"
+#include "core/math/math_funcs.h"
#include "scene/main/multiplayer_api.h"
+static int _half_float_component_count(Variant::Type p_type) {
+ switch (p_type) {
+ case Variant::FLOAT:
+ return 1;
+ case Variant::VECTOR2:
+ return 2;
+ case Variant::VECTOR3:
+ return 3;
+ case Variant::VECTOR4:
+ case Variant::QUATERNION:
+ case Variant::COLOR:
+ return 4;
+ default:
+ return 0;
+ }
+}
+
+static void _variant_to_floats(const Variant &p_v, Variant::Type p_type, float *r_components) {
+ switch (p_type) {
+ case Variant::FLOAT:
+ r_components[0] = (float)(double)p_v;
+ break;
+ case Variant::VECTOR2: {
+ Vector2 v = p_v;
+ r_components[0] = v.x;
+ r_components[1] = v.y;
+ } break;
+ case Variant::VECTOR3: {
+ Vector3 v = p_v;
+ r_components[0] = v.x;
+ r_components[1] = v.y;
+ r_components[2] = v.z;
+ } break;
+ case Variant::VECTOR4: {
+ Vector4 v = p_v;
+ r_components[0] = v.x;
+ r_components[1] = v.y;
+ r_components[2] = v.z;
+ r_components[3] = v.w;
+ } break;
+ case Variant::QUATERNION: {
+ Quaternion q = p_v;
+ r_components[0] = q.x;
+ r_components[1] = q.y;
+ r_components[2] = q.z;
+ r_components[3] = q.w;
+ } break;
+ case Variant::COLOR: {
+ Color c = p_v;
+ r_components[0] = c.r;
+ r_components[1] = c.g;
+ r_components[2] = c.b;
+ r_components[3] = c.a;
+ } break;
+ default:
+ break;
+ }
+}
+
+static Variant _floats_to_variant(Variant::Type p_type, const float *p_components) {
+ switch (p_type) {
+ case Variant::FLOAT:
+ return (double)p_components[0];
+ case Variant::VECTOR2:
+ return Vector2(p_components[0], p_components[1]);
+ case Variant::VECTOR3:
+ return Vector3(p_components[0], p_components[1], p_components[2]);
+ case Variant::VECTOR4:
+ return Vector4(p_components[0], p_components[1], p_components[2], p_components[3]);
+ case Variant::QUATERNION:
+ return Quaternion(p_components[0], p_components[1], p_components[2], p_components[3]);
+ case Variant::COLOR:
+ return Color(p_components[0], p_components[1], p_components[2], p_components[3]);
+ default:
+ return Variant();
+ }
+}
+
Object *MultiplayerSynchronizer::_get_prop_target(Object *p_obj, const NodePath &p_path) {
if (p_path.get_name_count() == 0) {
return p_obj;
@@ -190,6 +270,61 @@ Error MultiplayerSynchronizer::set_state(const List &p_properties, Obj
return OK;
}
+Error MultiplayerSynchronizer::encode_state_quantized(const Variant **p_variants, const int *p_precisions, int p_count, uint8_t *p_buffer, int &r_len, bool p_allow_object_decoding) {
+ r_len = 0;
+ for (int i = 0; i < p_count; i++) {
+ const Variant &v = *p_variants[i];
+ if (p_precisions[i] == SceneReplicationConfig::PRECISION_HALF) {
+ const Variant::Type type = v.get_type();
+ const int nc = _half_float_component_count(type);
+ if (nc > 0) {
+ if (p_buffer) {
+ p_buffer[r_len] = (uint8_t)type;
+ float components[4];
+ _variant_to_floats(v, type, components);
+ for (int c = 0; c < nc; c++) {
+ encode_uint16(Math::make_half_float(components[c]), &p_buffer[r_len + 1 + c * 2]);
+ }
+ }
+ r_len += 1 + nc * 2;
+ continue;
+ }
+ }
+ int size = 0;
+ Error err = MultiplayerAPI::encode_and_compress_variant(v, p_buffer ? p_buffer + r_len : nullptr, size, p_allow_object_decoding);
+ ERR_FAIL_COND_V(err != OK, err);
+ r_len += size;
+ }
+ return OK;
+}
+
+Error MultiplayerSynchronizer::decode_state_quantized(Vector &r_variants, const int *p_precisions, const uint8_t *p_buffer, int p_len, int &r_len, bool p_allow_object_decoding) {
+ r_len = 0;
+ const int count = r_variants.size();
+ for (int i = 0; i < count; i++) {
+ ERR_FAIL_COND_V(r_len >= p_len, ERR_INVALID_DATA);
+ if (p_precisions[i] == SceneReplicationConfig::PRECISION_HALF) {
+ const Variant::Type type = (Variant::Type)(p_buffer[r_len] & 0x3F);
+ const int nc = _half_float_component_count(type);
+ if (nc > 0) {
+ ERR_FAIL_COND_V(r_len + 1 + nc * 2 > p_len, ERR_INVALID_DATA);
+ float components[4] = { 0, 0, 0, 0 };
+ for (int c = 0; c < nc; c++) {
+ components[c] = Math::half_to_float(decode_uint16(&p_buffer[r_len + 1 + c * 2]));
+ }
+ r_variants.write[i] = _floats_to_variant(type, components);
+ r_len += 1 + nc * 2;
+ continue;
+ }
+ }
+ int vlen = 0;
+ Error err = MultiplayerAPI::decode_and_decompress_variant(r_variants.write[i], &p_buffer[r_len], p_len - r_len, &vlen, p_allow_object_decoding);
+ ERR_FAIL_COND_V(err != OK, err);
+ r_len += vlen;
+ }
+ return OK;
+}
+
bool MultiplayerSynchronizer::is_visibility_public() const {
return peer_visibility.has(0);
}
@@ -380,7 +515,7 @@ void MultiplayerSynchronizer::set_multiplayer_authority(int p_peer_id, bool p_re
Error MultiplayerSynchronizer::_watch_changes(uint64_t p_usec) {
ERR_FAIL_COND_V(replication_config.is_null(), FAILED);
- const List props = replication_config->get_watch_properties();
+ const List &props = replication_config->get_watch_properties();
if (props.size() != watchers.size()) {
watchers.resize(props.size());
}
@@ -444,7 +579,7 @@ List MultiplayerSynchronizer::get_delta_state(uint64_t p_cur_usec, uint
List MultiplayerSynchronizer::get_delta_properties(uint64_t p_indexes) {
List out;
ERR_FAIL_COND_V(replication_config.is_null(), out);
- const List watch_props = replication_config->get_watch_properties();
+ const List &watch_props = replication_config->get_watch_properties();
int idx = 0;
for (const NodePath &prop : watch_props) {
if ((p_indexes & (1ULL << idx++)) == 0) {
diff --git a/modules/multiplayer/multiplayer_synchronizer.h b/modules/multiplayer/multiplayer_synchronizer.h
index 6cfc5e375d1..a55f80cc634 100644
--- a/modules/multiplayer/multiplayer_synchronizer.h
+++ b/modules/multiplayer/multiplayer_synchronizer.h
@@ -89,6 +89,9 @@ class MultiplayerSynchronizer : public Node {
static Error get_state(const List &p_properties, Object *p_obj, Vector &r_variant, Vector &r_variant_ptrs);
static Error set_state(const List &p_properties, Object *p_obj, const Vector &p_state);
+ static Error encode_state_quantized(const Variant **p_variants, const int *p_precisions, int p_count, uint8_t *p_buffer, int &r_len, bool p_allow_object_decoding);
+ static Error decode_state_quantized(Vector &r_variants, const int *p_precisions, const uint8_t *p_buffer, int p_len, int &r_len, bool p_allow_object_decoding);
+
void reset();
Node *get_root_node();
diff --git a/modules/multiplayer/scene_replication_config.cpp b/modules/multiplayer/scene_replication_config.cpp
index 032ee4e42cb..05267136119 100644
--- a/modules/multiplayer/scene_replication_config.cpp
+++ b/modules/multiplayer/scene_replication_config.cpp
@@ -60,6 +60,12 @@ bool SceneReplicationConfig::_set(const StringName &p_name, const Variant &p_val
ERR_FAIL_COND_V(mode < REPLICATION_MODE_NEVER || mode > REPLICATION_MODE_ON_CHANGE, false);
property_set_replication_mode(prop.name, mode);
return true;
+ } else if (what == "precision") {
+ ERR_FAIL_COND_V(p_value.get_type() != Variant::INT, false);
+ ReplicationPrecision precision = (ReplicationPrecision)p_value.operator int();
+ ERR_FAIL_COND_V(precision < PRECISION_FULL || precision > PRECISION_HALF, false);
+ property_set_precision(prop.name, precision);
+ return true;
}
ERR_FAIL_COND_V(p_value.get_type() != Variant::BOOL, false);
if (what == "spawn") {
@@ -95,6 +101,9 @@ bool SceneReplicationConfig::_get(const StringName &p_name, Variant &r_ret) cons
} else if (what == "replication_mode") {
r_ret = prop.mode;
return true;
+ } else if (what == "precision") {
+ r_ret = prop.precision;
+ return true;
}
}
return false;
@@ -105,15 +114,23 @@ void SceneReplicationConfig::_get_property_list(List *p_list) cons
p_list->push_back(PropertyInfo(Variant::STRING, "properties/" + itos(i) + "/path", PROPERTY_HINT_NONE, "", PROPERTY_USAGE_NO_EDITOR | PROPERTY_USAGE_INTERNAL));
p_list->push_back(PropertyInfo(Variant::STRING, "properties/" + itos(i) + "/spawn", PROPERTY_HINT_NONE, "", PROPERTY_USAGE_NO_EDITOR | PROPERTY_USAGE_INTERNAL));
p_list->push_back(PropertyInfo(Variant::INT, "properties/" + itos(i) + "/replication_mode", PROPERTY_HINT_ENUM, "Never,Always,On Change", PROPERTY_USAGE_NO_EDITOR | PROPERTY_USAGE_INTERNAL));
+ p_list->push_back(PropertyInfo(Variant::INT, "properties/" + itos(i) + "/precision", PROPERTY_HINT_ENUM, "Full,Half", PROPERTY_USAGE_NO_EDITOR | PROPERTY_USAGE_INTERNAL));
}
}
void SceneReplicationConfig::reset_state() {
dirty = false;
+ reduced_precision = false;
+ spawn_reduced_precision = false;
+ sync_reduced_precision = false;
+ watch_reduced_precision = false;
properties.clear();
sync_props.clear();
spawn_props.clear();
watch_props.clear();
+ sync_precisions.clear();
+ spawn_precisions.clear();
+ watch_precisions.clear();
}
TypedArray SceneReplicationConfig::get_properties() const {
@@ -230,24 +247,86 @@ void SceneReplicationConfig::property_set_replication_mode(const NodePath &p_pat
dirty = true;
}
+SceneReplicationConfig::ReplicationPrecision SceneReplicationConfig::property_get_precision(const NodePath &p_path) {
+ List::Element *E = properties.find(p_path);
+ ERR_FAIL_COND_V(!E, PRECISION_FULL);
+ return E->get().precision;
+}
+
+void SceneReplicationConfig::property_set_precision(const NodePath &p_path, ReplicationPrecision p_precision) {
+ ERR_FAIL_COND(p_precision < PRECISION_FULL || p_precision > PRECISION_HALF);
+ List::Element *E = properties.find(p_path);
+ ERR_FAIL_COND(!E);
+ if (E->get().precision == p_precision) {
+ return;
+ }
+ E->get().precision = p_precision;
+ dirty = true;
+}
+
+bool SceneReplicationConfig::has_reduced_precision() {
+ if (dirty) {
+ _update();
+ }
+ return reduced_precision;
+}
+
+bool SceneReplicationConfig::is_spawn_reduced_precision() {
+ if (dirty) {
+ _update();
+ }
+ return spawn_reduced_precision;
+}
+
+bool SceneReplicationConfig::is_sync_reduced_precision() {
+ if (dirty) {
+ _update();
+ }
+ return sync_reduced_precision;
+}
+
+bool SceneReplicationConfig::is_watch_reduced_precision() {
+ if (dirty) {
+ _update();
+ }
+ return watch_reduced_precision;
+}
+
void SceneReplicationConfig::_update() {
if (!dirty) {
return;
}
dirty = false;
+ reduced_precision = false;
+ spawn_reduced_precision = false;
+ sync_reduced_precision = false;
+ watch_reduced_precision = false;
sync_props.clear();
spawn_props.clear();
watch_props.clear();
+ sync_precisions.clear();
+ spawn_precisions.clear();
+ watch_precisions.clear();
for (const ReplicationProperty &prop : properties) {
+ const bool prop_reduced = prop.precision != PRECISION_FULL;
+ if (prop_reduced) {
+ reduced_precision = true;
+ }
if (prop.spawn) {
spawn_props.push_back(prop.name);
+ spawn_precisions.push_back(prop.precision);
+ spawn_reduced_precision |= prop_reduced;
}
switch (prop.mode) {
case REPLICATION_MODE_ALWAYS:
sync_props.push_back(prop.name);
+ sync_precisions.push_back(prop.precision);
+ sync_reduced_precision |= prop_reduced;
break;
case REPLICATION_MODE_ON_CHANGE:
watch_props.push_back(prop.name);
+ watch_precisions.push_back(prop.precision);
+ watch_reduced_precision |= prop_reduced;
break;
default:
break;
@@ -276,6 +355,27 @@ const List &SceneReplicationConfig::get_watch_properties() {
return watch_props;
}
+const Vector &SceneReplicationConfig::get_spawn_precisions() {
+ if (dirty) {
+ _update();
+ }
+ return spawn_precisions;
+}
+
+const Vector &SceneReplicationConfig::get_sync_precisions() {
+ if (dirty) {
+ _update();
+ }
+ return sync_precisions;
+}
+
+const Vector &SceneReplicationConfig::get_watch_precisions() {
+ if (dirty) {
+ _update();
+ }
+ return watch_precisions;
+}
+
void SceneReplicationConfig::_bind_methods() {
ClassDB::bind_method(D_METHOD("get_properties"), &SceneReplicationConfig::get_properties);
ClassDB::bind_method(D_METHOD("add_property", "path", "index"), &SceneReplicationConfig::add_property, DEFVAL(-1));
@@ -286,11 +386,16 @@ void SceneReplicationConfig::_bind_methods() {
ClassDB::bind_method(D_METHOD("property_set_spawn", "path", "enabled"), &SceneReplicationConfig::property_set_spawn);
ClassDB::bind_method(D_METHOD("property_get_replication_mode", "path"), &SceneReplicationConfig::property_get_replication_mode);
ClassDB::bind_method(D_METHOD("property_set_replication_mode", "path", "mode"), &SceneReplicationConfig::property_set_replication_mode);
+ ClassDB::bind_method(D_METHOD("property_get_precision", "path"), &SceneReplicationConfig::property_get_precision);
+ ClassDB::bind_method(D_METHOD("property_set_precision", "path", "precision"), &SceneReplicationConfig::property_set_precision);
BIND_ENUM_CONSTANT(REPLICATION_MODE_NEVER);
BIND_ENUM_CONSTANT(REPLICATION_MODE_ALWAYS);
BIND_ENUM_CONSTANT(REPLICATION_MODE_ON_CHANGE);
+ BIND_ENUM_CONSTANT(PRECISION_FULL);
+ BIND_ENUM_CONSTANT(PRECISION_HALF);
+
// Deprecated.
ClassDB::bind_method(D_METHOD("property_get_sync", "path"), &SceneReplicationConfig::property_get_sync);
ClassDB::bind_method(D_METHOD("property_set_sync", "path", "enabled"), &SceneReplicationConfig::property_set_sync);
diff --git a/modules/multiplayer/scene_replication_config.h b/modules/multiplayer/scene_replication_config.h
index be9fb1a80fb..06f7b8c6e7b 100644
--- a/modules/multiplayer/scene_replication_config.h
+++ b/modules/multiplayer/scene_replication_config.h
@@ -53,11 +53,17 @@ class SceneReplicationConfig : public Resource {
REPLICATION_MODE_ON_CHANGE,
};
+ enum ReplicationPrecision {
+ PRECISION_FULL,
+ PRECISION_HALF,
+ };
+
private:
struct ReplicationProperty {
NodePath name;
bool spawn = true;
ReplicationMode mode = REPLICATION_MODE_ALWAYS;
+ ReplicationPrecision precision = PRECISION_FULL;
bool operator==(const ReplicationProperty &p_to) {
return name == p_to.name;
@@ -74,7 +80,14 @@ class SceneReplicationConfig : public Resource {
List spawn_props;
List sync_props;
List watch_props;
+ Vector spawn_precisions;
+ Vector sync_precisions;
+ Vector watch_precisions;
bool dirty = false;
+ bool reduced_precision = false;
+ bool spawn_reduced_precision = false;
+ bool sync_reduced_precision = false;
+ bool watch_reduced_precision = false;
void _update();
@@ -107,11 +120,24 @@ class SceneReplicationConfig : public Resource {
ReplicationMode property_get_replication_mode(const NodePath &p_path);
void property_set_replication_mode(const NodePath &p_path, ReplicationMode p_mode);
+ ReplicationPrecision property_get_precision(const NodePath &p_path);
+ void property_set_precision(const NodePath &p_path, ReplicationPrecision p_precision);
+
+ bool has_reduced_precision();
+ bool is_spawn_reduced_precision();
+ bool is_sync_reduced_precision();
+ bool is_watch_reduced_precision();
+
const List &get_spawn_properties();
const List &get_sync_properties();
const List &get_watch_properties();
+ const Vector &get_spawn_precisions();
+ const Vector &get_sync_precisions();
+ const Vector &get_watch_precisions();
+
SceneReplicationConfig() {}
};
VARIANT_ENUM_CAST(SceneReplicationConfig::ReplicationMode);
+VARIANT_ENUM_CAST(SceneReplicationConfig::ReplicationPrecision);
diff --git a/modules/multiplayer/scene_replication_interface.cpp b/modules/multiplayer/scene_replication_interface.cpp
index 773391ff264..f81a8bb938a 100644
--- a/modules/multiplayer/scene_replication_interface.cpp
+++ b/modules/multiplayer/scene_replication_interface.cpp
@@ -254,12 +254,15 @@ Error SceneReplicationInterface::on_replication_start(Object *p_obj, Variant p_c
// Try to apply spawn state (before ready).
if (pending_buffer_size > 0) {
- ERR_FAIL_COND_V(!node || !sync->get_replication_config_ptr(), ERR_UNCONFIGURED);
+ SceneReplicationConfig *scfg = sync->get_replication_config_ptr();
+ ERR_FAIL_COND_V(!node || !scfg, ERR_UNCONFIGURED);
int consumed = 0;
- const List props = sync->get_replication_config_ptr()->get_spawn_properties();
+ const List &props = scfg->get_spawn_properties();
Vector vars;
vars.resize(props.size());
- Error err = MultiplayerAPI::decode_and_decompress_variants(vars, pending_buffer, pending_buffer_size, consumed);
+ Error err = scfg->is_spawn_reduced_precision()
+ ? MultiplayerSynchronizer::decode_state_quantized(vars, scfg->get_spawn_precisions().ptr(), pending_buffer, pending_buffer_size, consumed, false)
+ : MultiplayerAPI::decode_and_decompress_variants(vars, pending_buffer, pending_buffer_size, consumed);
ERR_FAIL_COND_V(err, err);
if (consumed > 0) {
pending_buffer += consumed;
@@ -496,6 +499,8 @@ Error SceneReplicationInterface::_make_spawn_packet(Node *p_node, MultiplayerSpa
// Prepare spawn state.
List state_props;
+ Vector state_precisions;
+ bool spawn_reduced = false;
List sync_ids;
const HashSet synchronizers = tnode->synchronizers;
for (const ObjectID &sid : synchronizers) {
@@ -504,10 +509,16 @@ Error SceneReplicationInterface::_make_spawn_packet(Node *p_node, MultiplayerSpa
continue;
}
ERR_CONTINUE(!sync);
- ERR_FAIL_NULL_V(sync->get_replication_config_ptr(), ERR_BUG);
- for (const NodePath &prop : sync->get_replication_config_ptr()->get_spawn_properties()) {
+ SceneReplicationConfig *scfg = sync->get_replication_config_ptr();
+ ERR_FAIL_NULL_V(scfg, ERR_BUG);
+ for (const NodePath &prop : scfg->get_spawn_properties()) {
state_props.push_back(prop);
}
+ const Vector &sprec = scfg->get_spawn_precisions();
+ for (int i = 0; i < sprec.size(); i++) {
+ state_precisions.push_back(sprec[i]);
+ }
+ spawn_reduced |= scfg->is_spawn_reduced_precision();
// Ensure the synchronizer has an ID.
if (sync->get_net_id() == 0) {
sync->set_net_id(++last_net_id);
@@ -520,7 +531,9 @@ Error SceneReplicationInterface::_make_spawn_packet(Node *p_node, MultiplayerSpa
if (state_props.size()) {
Error err = MultiplayerSynchronizer::get_state(state_props, p_node, state_vars, state_varp);
ERR_FAIL_COND_V_MSG(err != OK, err, "Unable to retrieve spawn state.");
- err = MultiplayerAPI::encode_and_compress_variants(state_varp.ptrw(), state_varp.size(), nullptr, state_size);
+ err = spawn_reduced
+ ? MultiplayerSynchronizer::encode_state_quantized(state_varp.ptrw(), state_precisions.ptr(), state_varp.size(), nullptr, state_size, false)
+ : MultiplayerAPI::encode_and_compress_variants(state_varp.ptrw(), state_varp.size(), nullptr, state_size);
ERR_FAIL_COND_V_MSG(err != OK, err, "Unable to encode spawn state.");
}
@@ -550,7 +563,9 @@ Error SceneReplicationInterface::_make_spawn_packet(Node *p_node, MultiplayerSpa
}
// Write state.
if (state_size) {
- Error err = MultiplayerAPI::encode_and_compress_variants(state_varp.ptrw(), state_varp.size(), &ptr[ofs], state_size);
+ Error err = spawn_reduced
+ ? MultiplayerSynchronizer::encode_state_quantized(state_varp.ptrw(), state_precisions.ptr(), state_varp.size(), &ptr[ofs], state_size, false)
+ : MultiplayerAPI::encode_and_compress_variants(state_varp.ptrw(), state_varp.size(), &ptr[ofs], state_size);
ERR_FAIL_COND_V(err, err);
ofs += state_size;
}
@@ -713,6 +728,19 @@ MultiplayerSynchronizer *SceneReplicationInterface::_find_synchronizer(int p_pee
return sync;
}
+static bool _delta_precisions(SceneReplicationConfig *p_config, uint64_t p_indexes, Vector &r_precisions) {
+ const Vector &watch = p_config->get_watch_precisions();
+ r_precisions.clear();
+ bool reduced = false;
+ for (int i = 0; i < watch.size(); i++) {
+ if ((p_indexes & (1ULL << i)) != 0) {
+ r_precisions.push_back(watch[i]);
+ reduced |= watch[i] != SceneReplicationConfig::PRECISION_FULL;
+ }
+ }
+ return reduced;
+}
+
void SceneReplicationInterface::_send_delta(int p_peer, const HashSet &p_synchronizers, uint64_t p_usec, const HashMap &p_last_watch_usecs) {
MAKE_ROOM(/* header */ 1 + /* element */ 4 + 8 + 4 + delta_mtu);
uint8_t *ptr = packet_cache.ptrw();
@@ -725,7 +753,8 @@ void SceneReplicationInterface::_send_delta(int p_peer, const HashSet
if (!_verify_synchronizer(p_peer, sync, net_id)) {
continue;
}
- uint64_t last_usec = p_last_watch_usecs.has(oid) ? p_last_watch_usecs[oid] : 0;
+ const uint64_t *last_usec_ptr = p_last_watch_usecs.getptr(oid);
+ uint64_t last_usec = last_usec_ptr ? *last_usec_ptr : 0;
uint64_t indexes;
List delta = sync->get_delta_state(p_usec, last_usec, indexes);
@@ -741,8 +770,15 @@ void SceneReplicationInterface::_send_delta(int p_peer, const HashSet
vptr[i] = &v;
i++;
}
+ Vector precisions;
+ bool reduced = false;
+ if (sync->get_replication_config_ptr()->is_watch_reduced_precision()) {
+ reduced = _delta_precisions(sync->get_replication_config_ptr(), indexes, precisions);
+ }
int size;
- Error err = MultiplayerAPI::encode_and_compress_variants(vptr, varp.size(), nullptr, size);
+ Error err = reduced
+ ? MultiplayerSynchronizer::encode_state_quantized(vptr, precisions.ptr(), varp.size(), nullptr, size, false)
+ : MultiplayerAPI::encode_and_compress_variants(vptr, varp.size(), nullptr, size);
ERR_CONTINUE_MSG(err != OK, "Unable to encode delta state.");
ERR_CONTINUE_MSG(size > delta_mtu, vformat("Synchronizer delta bigger than MTU will not be sent (%d > %d): %s", size, delta_mtu, sync->get_path()));
@@ -756,7 +792,11 @@ void SceneReplicationInterface::_send_delta(int p_peer, const HashSet
ofs += encode_uint32(sync->get_net_id(), &ptr[ofs]);
ofs += encode_uint64(indexes, &ptr[ofs]);
ofs += encode_uint32(size, &ptr[ofs]);
- MultiplayerAPI::encode_and_compress_variants(vptr, varp.size(), &ptr[ofs], size);
+ if (reduced) {
+ MultiplayerSynchronizer::encode_state_quantized(vptr, precisions.ptr(), varp.size(), &ptr[ofs], size, false);
+ } else {
+ MultiplayerAPI::encode_and_compress_variants(vptr, varp.size(), &ptr[ofs], size);
+ }
ofs += size;
}
#ifdef DEBUG_ENABLED
@@ -791,7 +831,17 @@ Error SceneReplicationInterface::on_delta_receive(int p_from, const uint8_t *p_b
Vector vars;
vars.resize(props.size());
int consumed = 0;
- Error err = MultiplayerAPI::decode_and_decompress_variants(vars, p_buffer + ofs, size, consumed);
+ Error err;
+ Vector precisions;
+ bool reduced = false;
+ if (sync->get_replication_config_ptr()->is_watch_reduced_precision()) {
+ reduced = _delta_precisions(sync->get_replication_config_ptr(), indexes, precisions);
+ }
+ if (reduced) {
+ err = MultiplayerSynchronizer::decode_state_quantized(vars, precisions.ptr(), p_buffer + ofs, size, consumed, false);
+ } else {
+ err = MultiplayerAPI::decode_and_decompress_variants(vars, p_buffer + ofs, size, consumed);
+ }
ERR_FAIL_COND_V(err != OK, err);
ERR_FAIL_COND_V(uint32_t(consumed) != size, ERR_INVALID_DATA);
err = MultiplayerSynchronizer::set_state(props, node, vars);
@@ -830,10 +880,16 @@ void SceneReplicationInterface::_send_sync(int p_peer, const HashSet &
int size;
Vector vars;
Vector varp;
- const List props = sync->get_replication_config_ptr()->get_sync_properties();
+ SceneReplicationConfig *cfg = sync->get_replication_config_ptr();
+ const List &props = cfg->get_sync_properties();
Error err = MultiplayerSynchronizer::get_state(props, node, vars, varp);
ERR_CONTINUE_MSG(err != OK, "Unable to retrieve sync state.");
- err = MultiplayerAPI::encode_and_compress_variants(varp.ptrw(), varp.size(), nullptr, size);
+ const bool reduced = cfg->is_sync_reduced_precision();
+ if (reduced) {
+ err = MultiplayerSynchronizer::encode_state_quantized(varp.ptrw(), cfg->get_sync_precisions().ptr(), varp.size(), nullptr, size, false);
+ } else {
+ err = MultiplayerAPI::encode_and_compress_variants(varp.ptrw(), varp.size(), nullptr, size);
+ }
ERR_CONTINUE_MSG(err != OK, "Unable to encode sync state.");
/// @todo Handle single state above MTU.
ERR_CONTINUE_MSG(size > sync_mtu, vformat("Node states bigger than MTU will not be sent (%d > %d): %s", size, sync_mtu, node->get_path()));
@@ -845,7 +901,11 @@ void SceneReplicationInterface::_send_sync(int p_peer, const HashSet &
if (size) {
ofs += encode_uint32(sync->get_net_id(), &ptr[ofs]);
ofs += encode_uint32(size, &ptr[ofs]);
- MultiplayerAPI::encode_and_compress_variants(varp.ptrw(), varp.size(), &ptr[ofs], size);
+ if (reduced) {
+ MultiplayerSynchronizer::encode_state_quantized(varp.ptrw(), cfg->get_sync_precisions().ptr(), varp.size(), &ptr[ofs], size, false);
+ } else {
+ MultiplayerAPI::encode_and_compress_variants(varp.ptrw(), varp.size(), &ptr[ofs], size);
+ }
ofs += size;
}
#ifdef DEBUG_ENABLED
@@ -889,11 +949,17 @@ Error SceneReplicationInterface::on_sync_receive(int p_from, const uint8_t *p_bu
ofs += size;
continue;
}
- const List props = sync->get_replication_config_ptr()->get_sync_properties();
+ SceneReplicationConfig *cfg = sync->get_replication_config_ptr();
+ const List &props = cfg->get_sync_properties();
Vector vars;
vars.resize(props.size());
int consumed;
- Error err = MultiplayerAPI::decode_and_decompress_variants(vars, &p_buffer[ofs], size, consumed);
+ Error err;
+ if (cfg->is_sync_reduced_precision()) {
+ err = MultiplayerSynchronizer::decode_state_quantized(vars, cfg->get_sync_precisions().ptr(), &p_buffer[ofs], size, consumed, false);
+ } else {
+ err = MultiplayerAPI::decode_and_decompress_variants(vars, &p_buffer[ofs], size, consumed);
+ }
ERR_FAIL_COND_V(err, err);
err = MultiplayerSynchronizer::set_state(props, node, vars);
ERR_FAIL_COND_V(err, err);
diff --git a/modules/multiplayer/tests/test_scene_replication.h b/modules/multiplayer/tests/test_scene_replication.h
new file mode 100644
index 00000000000..28745c13267
--- /dev/null
+++ b/modules/multiplayer/tests/test_scene_replication.h
@@ -0,0 +1,359 @@
+/**************************************************************************/
+/* test_scene_replication.h */
+/**************************************************************************/
+/* This file is part of: */
+/* REDOT ENGINE */
+/* https://redotengine.org */
+/**************************************************************************/
+/* Copyright (c) 2024-present Redot Engine contributors */
+/* (see REDOT_AUTHORS.md) */
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */
+/* */
+/* Permission is hereby granted, free of charge, to any person obtaining */
+/* a copy of this software and associated documentation files (the */
+/* "Software"), to deal in the Software without restriction, including */
+/* without limitation the rights to use, copy, modify, merge, publish, */
+/* distribute, sublicense, and/or sell copies of the Software, and to */
+/* permit persons to whom the Software is furnished to do so, subject to */
+/* the following conditions: */
+/* */
+/* The above copyright notice and this permission notice shall be */
+/* included in all copies or substantial portions of the Software. */
+/* */
+/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */
+/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */
+/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
+/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */
+/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */
+/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */
+/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */
+/**************************************************************************/
+
+#pragma once
+
+#include "tests/test_macros.h"
+
+#include "scene/main/scene_tree.h"
+#include "scene/main/window.h"
+
+#include "../multiplayer_synchronizer.h"
+#include "../scene_replication_config.h"
+
+namespace TestSceneReplication {
+
+class PrecisionDeltaNode : public Node {
+ GDCLASS(PrecisionDeltaNode, Node);
+
+ Vector3 v0;
+ Vector3 v1;
+ float f2 = 0.0f;
+ Vector3 v3;
+
+protected:
+ static void _bind_methods() {
+ ClassDB::bind_method(D_METHOD("set_v0", "v"), &PrecisionDeltaNode::set_v0);
+ ClassDB::bind_method(D_METHOD("get_v0"), &PrecisionDeltaNode::get_v0);
+ ADD_PROPERTY(PropertyInfo(Variant::VECTOR3, "v0"), "set_v0", "get_v0");
+ ClassDB::bind_method(D_METHOD("set_v1", "v"), &PrecisionDeltaNode::set_v1);
+ ClassDB::bind_method(D_METHOD("get_v1"), &PrecisionDeltaNode::get_v1);
+ ADD_PROPERTY(PropertyInfo(Variant::VECTOR3, "v1"), "set_v1", "get_v1");
+ ClassDB::bind_method(D_METHOD("set_f2", "v"), &PrecisionDeltaNode::set_f2);
+ ClassDB::bind_method(D_METHOD("get_f2"), &PrecisionDeltaNode::get_f2);
+ ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "f2"), "set_f2", "get_f2");
+ ClassDB::bind_method(D_METHOD("set_v3", "v"), &PrecisionDeltaNode::set_v3);
+ ClassDB::bind_method(D_METHOD("get_v3"), &PrecisionDeltaNode::get_v3);
+ ADD_PROPERTY(PropertyInfo(Variant::VECTOR3, "v3"), "set_v3", "get_v3");
+ }
+
+public:
+ void set_v0(const Vector3 &p_v) { v0 = p_v; }
+ Vector3 get_v0() const { return v0; }
+ void set_v1(const Vector3 &p_v) { v1 = p_v; }
+ Vector3 get_v1() const { return v1; }
+ void set_f2(float p_v) { f2 = p_v; }
+ float get_f2() const { return f2; }
+ void set_v3(const Vector3 &p_v) { v3 = p_v; }
+ Vector3 get_v3() const { return v3; }
+};
+
+static Vector _round_trip(const Vector &p_values, const Vector &p_precisions) {
+ Vector ptrs;
+ ptrs.resize(p_values.size());
+ for (int i = 0; i < p_values.size(); i++) {
+ ptrs.write[i] = &p_values[i];
+ }
+
+ int size = 0;
+ Error err = MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), p_precisions.ptr(), ptrs.size(), nullptr, size, false);
+ REQUIRE(err == OK);
+
+ Vector buffer;
+ buffer.resize(size);
+ int written = 0;
+ err = MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), p_precisions.ptr(), ptrs.size(), buffer.ptrw(), written, false);
+ REQUIRE(err == OK);
+ REQUIRE(written == size);
+
+ Vector out;
+ out.resize(p_values.size());
+ int consumed = 0;
+ err = MultiplayerSynchronizer::decode_state_quantized(out, p_precisions.ptr(), buffer.ptr(), size, consumed, false);
+ REQUIRE(err == OK);
+ REQUIRE(consumed == size);
+ return out;
+}
+
+TEST_CASE("[Multiplayer][SceneReplication] Half-precision codec round-trips supported types") {
+ Vector values;
+ values.push_back(1.5f);
+ values.push_back(Vector2(3.25, -7.5));
+ values.push_back(Vector3(10.0, -20.5, 0.125));
+ values.push_back(Vector4(2.5, -4.25, 8.0, -0.5));
+ values.push_back(Quaternion(0.0, 0.70703125, 0.0, 0.70703125));
+ values.push_back(Color(0.5, 0.25, 0.75, 1.0));
+
+ Vector precisions;
+ for (int i = 0; i < values.size(); i++) {
+ precisions.push_back(SceneReplicationConfig::PRECISION_HALF);
+ }
+
+ Vector out = _round_trip(values, precisions);
+
+ CHECK(((float)out[0]) == doctest::Approx(1.5f).epsilon(0.001));
+ CHECK(((Vector2)out[1]).is_equal_approx(Vector2(3.25, -7.5)));
+ CHECK(((Vector3)out[2]).is_equal_approx(Vector3(10.0, -20.5, 0.125)));
+ CHECK(((Vector4)out[3]).is_equal_approx(Vector4(2.5, -4.25, 8.0, -0.5)));
+ CHECK(((Quaternion)out[4]).is_equal_approx(Quaternion(0.0, 0.70703125, 0.0, 0.70703125)));
+ CHECK(((Color)out[5]).is_equal_approx(Color(0.5, 0.25, 0.75, 1.0)));
+}
+
+TEST_CASE("[Multiplayer][SceneReplication] Half precision shrinks the encoded size") {
+ Vector values;
+ values.push_back(Vector3(1.0, 2.0, 3.0));
+ Vector ptrs;
+ ptrs.resize(1);
+ ptrs.write[0] = &values[0];
+
+ Vector half = { SceneReplicationConfig::PRECISION_HALF };
+ Vector full = { SceneReplicationConfig::PRECISION_FULL };
+
+ int half_size = 0;
+ int full_size = 0;
+ MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), half.ptr(), 1, nullptr, half_size, false);
+ MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), full.ptr(), 1, nullptr, full_size, false);
+
+ CHECK(half_size == 7);
+ CHECK(half_size < full_size);
+}
+
+TEST_CASE("[Multiplayer][SceneReplication] Half on unsupported type falls back to full") {
+ Vector values;
+ values.push_back(String("hello"));
+ values.push_back(Vector3(1.0, 2.0, 3.0));
+
+ Vector precisions = { SceneReplicationConfig::PRECISION_HALF, SceneReplicationConfig::PRECISION_HALF };
+ Vector out = _round_trip(values, precisions);
+
+ CHECK(((String)out[0]) == String("hello"));
+ CHECK(((Vector3)out[1]).is_equal_approx(Vector3(1.0, 2.0, 3.0)));
+}
+
+TEST_CASE("[Multiplayer][SceneReplication] Config precision setting persists and round-trips") {
+ Ref config;
+ config.instantiate();
+ NodePath path(".:position");
+ config->add_property(path);
+
+ CHECK_FALSE(config->has_reduced_precision());
+ CHECK(config->property_get_precision(path) == SceneReplicationConfig::PRECISION_FULL);
+
+ config->property_set_precision(path, SceneReplicationConfig::PRECISION_HALF);
+ CHECK(config->property_get_precision(path) == SceneReplicationConfig::PRECISION_HALF);
+ CHECK(config->has_reduced_precision());
+
+ CHECK(((int)config->get("properties/0/precision")) == (int)SceneReplicationConfig::PRECISION_HALF);
+ config->set("properties/0/precision", (int)SceneReplicationConfig::PRECISION_FULL);
+ CHECK(config->property_get_precision(path) == SceneReplicationConfig::PRECISION_FULL);
+ CHECK_FALSE(config->has_reduced_precision());
+}
+
+TEST_CASE("[Multiplayer][SceneReplication] Mixed full/half properties round-trip") {
+ Vector values;
+ values.push_back(Vector3(100.5, -50.25, 7.125));
+ values.push_back(1234567);
+ values.push_back(Vector3(-1.0, 2.0, -3.0));
+ Vector precisions = {
+ SceneReplicationConfig::PRECISION_HALF,
+ SceneReplicationConfig::PRECISION_HALF,
+ SceneReplicationConfig::PRECISION_FULL,
+ };
+ Vector out = _round_trip(values, precisions);
+ CHECK(((Vector3)out[0]).is_equal_approx(Vector3(100.5, -50.25, 7.125)));
+ CHECK(((int)out[1]) == 1234567);
+ CHECK(((Vector3)out[2]).is_equal_approx(Vector3(-1.0, 2.0, -3.0)));
+}
+
+TEST_CASE("[Multiplayer][SceneReplication] Truncated buffer decodes to an error, not a crash") {
+ Vector values;
+ values.push_back(Vector3(1.0, 2.0, 3.0));
+ Vector ptrs;
+ ptrs.resize(1);
+ ptrs.write[0] = &values[0];
+ Vector half = { SceneReplicationConfig::PRECISION_HALF };
+
+ int size = 0;
+ MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), half.ptr(), 1, nullptr, size, false);
+ Vector buffer;
+ buffer.resize(size);
+ MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), half.ptr(), 1, buffer.ptrw(), size, false);
+
+ Vector out;
+ out.resize(1);
+ int consumed = 0;
+ Error err = MultiplayerSynchronizer::decode_state_quantized(out, half.ptr(), buffer.ptr(), size - 1, consumed, false);
+ CHECK(err != OK);
+}
+
+TEST_CASE("[Multiplayer][SceneReplication] Half/full precision mismatch is rejected for Vector3") {
+ Vector values;
+ values.push_back(Vector3(12.5, -3.25, 6.75));
+ Vector ptrs;
+ ptrs.resize(1);
+ ptrs.write[0] = &values[0];
+ Vector half = { SceneReplicationConfig::PRECISION_HALF };
+ Vector full = { SceneReplicationConfig::PRECISION_FULL };
+
+ int size = 0;
+ MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), half.ptr(), 1, nullptr, size, false);
+ Vector buffer;
+ buffer.resize(size);
+ MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), half.ptr(), 1, buffer.ptrw(), size, false);
+
+ Vector out;
+ out.resize(1);
+ int consumed = 0;
+ Error err = MultiplayerSynchronizer::decode_state_quantized(out, full.ptr(), buffer.ptr(), size, consumed, false);
+ CHECK((err != OK || consumed != size));
+}
+
+TEST_CASE("[Multiplayer][SceneReplication] Cached precision arrays align with property lists") {
+ Ref config;
+ config.instantiate();
+ NodePath spawn_sync(".:position");
+ NodePath watch_only(".:health");
+ config->add_property(spawn_sync);
+ config->add_property(watch_only);
+ config->property_set_precision(spawn_sync, SceneReplicationConfig::PRECISION_HALF);
+ config->property_set_spawn(watch_only, false);
+ config->property_set_replication_mode(watch_only, SceneReplicationConfig::REPLICATION_MODE_ON_CHANGE);
+
+ const List &sync_props = config->get_sync_properties();
+ const Vector &sync_prec = config->get_sync_precisions();
+ REQUIRE(sync_props.size() == sync_prec.size());
+ REQUIRE(sync_props.size() == 1);
+ CHECK(sync_prec[0] == SceneReplicationConfig::PRECISION_HALF);
+
+ const List &watch_props = config->get_watch_properties();
+ const Vector &watch_prec = config->get_watch_precisions();
+ REQUIRE(watch_props.size() == watch_prec.size());
+ REQUIRE(watch_props.size() == 1);
+ CHECK(watch_prec[0] == SceneReplicationConfig::PRECISION_FULL);
+
+ const List &spawn_props = config->get_spawn_properties();
+ const Vector &spawn_prec = config->get_spawn_precisions();
+ REQUIRE(spawn_props.size() == spawn_prec.size());
+ REQUIRE(spawn_props.size() == 1);
+ CHECK(spawn_prec[0] == SceneReplicationConfig::PRECISION_HALF);
+}
+
+TEST_CASE("[Multiplayer][SceneReplication][SceneTree] Delta round-trip preserves order with a mixed-precision subset") {
+ GDREGISTER_CLASS(PrecisionDeltaNode);
+
+ Ref cfg;
+ cfg.instantiate();
+ NodePath pv0(".:v0");
+ NodePath pv1(".:v1");
+ NodePath pf2(".:f2");
+ NodePath pv3(".:v3");
+ Vector paths = { pv0, pv1, pf2, pv3 };
+ for (const NodePath &p : paths) {
+ cfg->add_property(p);
+ cfg->property_set_replication_mode(p, SceneReplicationConfig::REPLICATION_MODE_ON_CHANGE);
+ }
+ cfg->property_set_precision(pv0, SceneReplicationConfig::PRECISION_HALF);
+ cfg->property_set_precision(pf2, SceneReplicationConfig::PRECISION_HALF);
+
+ Node *root = SceneTree::get_singleton()->get_root();
+ PrecisionDeltaNode *source = memnew(PrecisionDeltaNode);
+ root->add_child(source);
+ MultiplayerSynchronizer *sync = memnew(MultiplayerSynchronizer);
+ sync->set_replication_config(cfg);
+ source->add_child(sync);
+
+ source->set_v0(Vector3(1, 2, 3));
+ source->set_v1(Vector3(4, 5, 6));
+ source->set_f2(7.0);
+ source->set_v3(Vector3(8, 9, 10));
+
+ uint64_t indexes = 0;
+ sync->get_delta_state(100, 0, indexes);
+
+ source->set_v0(Vector3(100.5, -20.25, 6.125));
+ source->set_f2(42.5);
+ source->set_v3(Vector3(-3.0, 11.0, 0.5));
+
+ List delta = sync->get_delta_state(200, 100, indexes);
+ REQUIRE(delta.size() == 3);
+ REQUIRE(indexes == (uint64_t)((1 << 0) | (1 << 2) | (1 << 3)));
+
+ Vector vptr;
+ vptr.resize(delta.size());
+ int i = 0;
+ for (const Variant &v : delta) {
+ vptr.write[i++] = &v;
+ }
+ Vector precisions;
+ const Vector &wprec = cfg->get_watch_precisions();
+ for (int b = 0; b < wprec.size(); b++) {
+ if (indexes & (1ULL << b)) {
+ precisions.push_back(wprec[b]);
+ }
+ }
+ REQUIRE(precisions.size() == 3);
+
+ int size = 0;
+ MultiplayerSynchronizer::encode_state_quantized(vptr.ptrw(), precisions.ptr(), vptr.size(), nullptr, size, false);
+ Vector buffer;
+ buffer.resize(size);
+ MultiplayerSynchronizer::encode_state_quantized(vptr.ptrw(), precisions.ptr(), vptr.size(), buffer.ptrw(), size, false);
+
+ List props = sync->get_delta_properties(indexes);
+ REQUIRE(props.size() == 3);
+ Vector out;
+ out.resize(props.size());
+ int consumed = 0;
+ Error err = MultiplayerSynchronizer::decode_state_quantized(out, precisions.ptr(), buffer.ptr(), size, consumed, false);
+ REQUIRE(err == OK);
+ REQUIRE(consumed == size);
+
+ PrecisionDeltaNode *target = memnew(PrecisionDeltaNode);
+ root->add_child(target);
+ target->set_v1(Vector3(4, 5, 6));
+ err = MultiplayerSynchronizer::set_state(props, target, out);
+ REQUIRE(err == OK);
+
+ CHECK(target->get_v0().is_equal_approx(Vector3(100.5, -20.25, 6.125)));
+ CHECK(target->get_f2() == doctest::Approx(42.5).epsilon(0.001));
+ CHECK(target->get_v3().is_equal_approx(Vector3(-3.0, 11.0, 0.5)));
+ CHECK(target->get_v1().is_equal_approx(Vector3(4, 5, 6)));
+
+ source->remove_child(sync);
+ memdelete(sync);
+ root->remove_child(source);
+ memdelete(source);
+ root->remove_child(target);
+ memdelete(target);
+}
+
+} //namespace TestSceneReplication
diff --git a/modules/multiplayer/tests/test_scene_replication_benchmark.h b/modules/multiplayer/tests/test_scene_replication_benchmark.h
new file mode 100644
index 00000000000..1a78722eadf
--- /dev/null
+++ b/modules/multiplayer/tests/test_scene_replication_benchmark.h
@@ -0,0 +1,337 @@
+/**************************************************************************/
+/* test_scene_replication_benchmark.h */
+/**************************************************************************/
+/* This file is part of: */
+/* REDOT ENGINE */
+/* https://redotengine.org */
+/**************************************************************************/
+/* Copyright (c) 2024-present Redot Engine contributors */
+/* (see REDOT_AUTHORS.md) */
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */
+/* */
+/* Permission is hereby granted, free of charge, to any person obtaining */
+/* a copy of this software and associated documentation files (the */
+/* "Software"), to deal in the Software without restriction, including */
+/* without limitation the rights to use, copy, modify, merge, publish, */
+/* distribute, sublicense, and/or sell copies of the Software, and to */
+/* permit persons to whom the Software is furnished to do so, subject to */
+/* the following conditions: */
+/* */
+/* The above copyright notice and this permission notice shall be */
+/* included in all copies or substantial portions of the Software. */
+/* */
+/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */
+/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */
+/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
+/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */
+/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */
+/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */
+/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */
+/**************************************************************************/
+
+#pragma once
+
+#include "tests/test_macros.h"
+
+#include "core/math/math_funcs.h"
+#include "core/os/os.h"
+
+#include "scene/3d/node_3d.h"
+#include "scene/main/multiplayer_api.h"
+
+#include "../multiplayer_synchronizer.h"
+#include "../scene_replication_config.h"
+
+namespace TestSceneReplicationBenchmark {
+
+static int _encode(const Vector &p_vals, const Vector &p_prec, Vector &r_buf) {
+ Vector ptrs;
+ ptrs.resize(p_vals.size());
+ for (int i = 0; i < p_vals.size(); i++) {
+ ptrs.write[i] = &p_vals[i];
+ }
+ int size = 0;
+ MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), p_prec.ptr(), ptrs.size(), nullptr, size, false);
+ r_buf.resize(size);
+ int written = 0;
+ MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), p_prec.ptr(), ptrs.size(), r_buf.ptrw(), written, false);
+ return size;
+}
+
+TEST_CASE("[SceneReplication][Benchmark] Size / accuracy / speed" * doctest::skip()) {
+ Vector state;
+ state.push_back(Vector3(123.5, 4.25, -67.75));
+ state.push_back(Vector3(3.5, 0.0, -2.25));
+ state.push_back(1.5707964f);
+ state.push_back(87.5f);
+ state.push_back(42.0f);
+
+ Vector full;
+ Vector half;
+ for (int i = 0; i < state.size(); i++) {
+ full.push_back(SceneReplicationConfig::PRECISION_FULL);
+ half.push_back(SceneReplicationConfig::PRECISION_HALF);
+ }
+
+ Vector fbuf;
+ Vector hbuf;
+ const int fsize = _encode(state, full, fbuf);
+ const int hsize = _encode(state, half, hbuf);
+
+ print_line("");
+ print_line("=== Replication precision benchmark ===");
+ print_line(vformat("Snapshot fields: position(Vector3), velocity(Vector3), facing(float), health(float), mana(float)"));
+ print_line(vformat("SIZE full = %d bytes half = %d bytes reduction = %.1f%%", fsize, hsize, 100.0 * (fsize - hsize) / fsize));
+ CHECK(hsize < fsize);
+
+ print_line("ACCURACY (single Vector3 at increasing magnitude, worst-component abs error):");
+ const float mags[] = { 1.0f, 10.0f, 100.0f, 1000.0f, 10000.0f };
+ for (float m : mags) {
+ Vector one;
+ one.push_back(Vector3(m * 0.3713 + 0.531, -(m * 0.6127 + 0.208), m * 0.1373 + 0.769));
+ Vector hp;
+ hp.push_back(SceneReplicationConfig::PRECISION_HALF);
+ Vector b;
+ _encode(one, hp, b);
+ Vector out;
+ out.resize(1);
+ int consumed = 0;
+ MultiplayerSynchronizer::decode_state_quantized(out, hp.ptr(), b.ptr(), b.size(), consumed, false);
+ const Vector3 o = one[0];
+ const Vector3 d = out[0];
+ const float err = MAX(MAX(Math::abs(o.x - d.x), Math::abs(o.y - d.y)), Math::abs(o.z - d.z));
+ print_line(vformat(" |coord| ~ %-8.0f max abs err = %.4f (%.4f%%)", (double)m, err, 100.0 * err / m));
+ }
+
+ const int iters = 300000;
+ Vector ptrs;
+ ptrs.resize(state.size());
+ for (int i = 0; i < state.size(); i++) {
+ ptrs.write[i] = &state[i];
+ }
+ Vector buf;
+ buf.resize(128);
+ Vector out;
+ out.resize(state.size());
+
+ for (int mode = 0; mode < 2; mode++) {
+ const int *prec = mode == 0 ? full.ptr() : half.ptr();
+ int warm = 0;
+ MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), prec, ptrs.size(), buf.ptrw(), warm, false);
+
+ double sink = 0.0;
+ const uint64_t t0 = OS::get_singleton()->get_ticks_usec();
+ for (int i = 0; i < iters; i++) {
+ int s = 0;
+ MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), prec, ptrs.size(), buf.ptrw(), s, false);
+ int consumed = 0;
+ MultiplayerSynchronizer::decode_state_quantized(out, prec, buf.ptr(), s, consumed, false);
+ sink += ((Vector3)out[0]).x;
+ }
+ const uint64_t t1 = OS::get_singleton()->get_ticks_usec();
+ const double us = (double)(t1 - t0);
+ print_line(vformat("SPEED %s %.1f ns / encode+decode %.2f M ops/s (checksum %.1f)",
+ mode == 0 ? "full" : "half", 1000.0 * us / iters, iters / us, sink));
+ }
+ print_line("");
+}
+
+TEST_CASE("[SceneReplication][Benchmark] Property-list gather: forced copy vs reference" * doctest::skip()) {
+ const int counts[] = { 3, 8 };
+ const int iters = 2000000;
+ const int rounds = 5;
+ print_line("");
+ print_line("=== Property-list gather: forced value copy vs reference (min of alternating rounds) ===");
+ for (int prop_count : counts) {
+ Ref cfg;
+ cfg.instantiate();
+ for (int i = 0; i < prop_count; i++) {
+ cfg->add_property(NodePath(vformat(".:prop_%d", i)));
+ }
+ cfg->get_sync_properties(); // Prime the internal list.
+
+ uint64_t sink = 0;
+ double best_copy = 1e30;
+ double best_ref = 1e30;
+
+ for (int round = 0; round < rounds; round++) {
+ const bool copy_first = (round % 2) == 0;
+ for (int step = 0; step < 2; step++) {
+ const bool do_copy = (step == 0) == copy_first;
+ const uint64_t t0 = OS::get_singleton()->get_ticks_usec();
+ if (do_copy) {
+ for (int i = 0; i < iters; i++) {
+ const List c = cfg->get_sync_properties(); // Simulate old value-returning API.
+ for (const NodePath &p : c) {
+ sink += p.get_subname_count();
+ }
+ }
+ } else {
+ for (int i = 0; i < iters; i++) {
+ const List &r = cfg->get_sync_properties(); // New: reference.
+ for (const NodePath &p : r) {
+ sink += p.get_subname_count();
+ }
+ }
+ }
+ const double ns = 1000.0 * (double)(OS::get_singleton()->get_ticks_usec() - t0) / iters;
+ if (do_copy) {
+ best_copy = MIN(best_copy, ns);
+ } else {
+ best_ref = MIN(best_ref, ns);
+ }
+ }
+ }
+
+ print_line(vformat("props=%-2d copy=%6.1f ns ref=%6.1f ns saved=%5.1f ns/gather (%.0f%%) [sink=%d]",
+ prop_count, best_copy, best_ref, best_copy - best_ref,
+ best_copy > 0.0 ? 100.0 * (best_copy - best_ref) / best_copy : 0.0, (int64_t)sink));
+ }
+ print_line("");
+}
+
+TEST_CASE("[SceneReplication][Benchmark] Full sync tick (gather+get_state+encode) vs node count" * doctest::skip()) {
+ Ref cfg;
+ cfg.instantiate();
+ cfg->add_property(NodePath(".:position"));
+ cfg->add_property(NodePath(".:rotation"));
+ cfg->add_property(NodePath(".:scale"));
+ cfg->get_sync_properties(); // Prime the internal list.
+
+ const int scales[] = { 100, 1000, 10000, 50000, 100000 };
+ const int rounds = 5;
+ print_line("");
+ print_line("=== Full sync tick: gather+get_state+encode for N synchronizers (min of alternating rounds) ===");
+ print_line(" copy = old value-returning gather (~master), ref = new reference gather; socket I/O excluded.");
+
+ for (int n : scales) {
+ Vector nodes;
+ nodes.resize(n);
+ Node3D **nodep = nodes.ptrw();
+ for (int i = 0; i < n; i++) {
+ Node3D *nd = memnew(Node3D);
+ nd->set_position(Vector3(i * 0.37, i * 0.11, i * 0.53));
+ nd->set_rotation(Vector3(i * 0.013, i * 0.021, i * 0.005));
+ nd->set_scale(Vector3(1.0 + i * 0.0001, 1.0, 1.0 + i * 0.0002));
+ nodep[i] = nd;
+ }
+
+ uint64_t sink = 0;
+ double best_copy = 1e30;
+ double best_ref = 1e30;
+
+ for (int round = 0; round < rounds; round++) {
+ const bool copy_first = (round % 2) == 0;
+ for (int step = 0; step < 2; step++) {
+ const bool do_copy = (step == 0) == copy_first;
+ Vector vars;
+ Vector varp;
+ Vector buf;
+ buf.resize(256);
+ const uint64_t t0 = OS::get_singleton()->get_ticks_usec();
+ for (int i = 0; i < n; i++) {
+ if (do_copy) {
+ const List props = cfg->get_sync_properties(); // Simulate old value-returning API.
+ MultiplayerSynchronizer::get_state(props, nodep[i], vars, varp);
+ } else {
+ const List &props = cfg->get_sync_properties(); // New: reference.
+ MultiplayerSynchronizer::get_state(props, nodep[i], vars, varp);
+ }
+ int size = 0;
+ MultiplayerAPI::encode_and_compress_variants(varp.ptrw(), varp.size(), nullptr, size);
+ if (size > buf.size()) {
+ buf.resize(size);
+ }
+ MultiplayerAPI::encode_and_compress_variants(varp.ptrw(), varp.size(), buf.ptrw(), size);
+ sink += size;
+ }
+ const double ms = (double)(OS::get_singleton()->get_ticks_usec() - t0) / 1000.0;
+ if (do_copy) {
+ best_copy = MIN(best_copy, ms);
+ } else {
+ best_ref = MIN(best_ref, ms);
+ }
+ }
+ }
+
+ for (int i = 0; i < n; i++) {
+ memdelete(nodep[i]);
+ }
+
+ const double saved = best_copy - best_ref;
+ print_line(vformat("N=%-6d copy=%9.3f ms ref=%9.3f ms saved=%8.3f ms (%.1f%%) per-node=%.1f ns [sink=%d]",
+ n, best_copy, best_ref, saved,
+ best_copy > 0.0 ? 100.0 * saved / best_copy : 0.0,
+ 1000000.0 * saved / n, (int64_t)sink));
+ }
+ print_line("");
+}
+
+TEST_CASE("[SceneReplication][Benchmark] Reduced-precision encode tick: per-property find vs cached, vs property count" * doctest::skip()) {
+ const int counts[] = { 8, 32, 64 };
+ const int iters = 200000;
+ const int rounds = 5;
+ print_line("");
+ print_line("=== Reduced-precision encode tick: per-property find (old) vs cached (new), min of alternating rounds ===");
+ for (int prop_count : counts) {
+ Ref cfg;
+ cfg.instantiate();
+ Vector values;
+ for (int i = 0; i < prop_count; i++) {
+ NodePath p = NodePath(vformat(".:prop_%d", i));
+ cfg->add_property(p);
+ cfg->property_set_precision(p, SceneReplicationConfig::PRECISION_HALF);
+ values.push_back(Vector3(i * 0.5, i * 0.25, i * 0.125));
+ }
+ const List &props = cfg->get_sync_properties();
+ Vector vptr;
+ vptr.resize(values.size());
+ for (int i = 0; i < values.size(); i++) {
+ vptr.write[i] = &values[i];
+ }
+ Vector buf;
+ buf.resize(prop_count * 8 + 16);
+
+ uint64_t sink = 0;
+ double best_old = 1e30;
+ double best_new = 1e30;
+ for (int round = 0; round < rounds; round++) {
+ const bool old_first = (round % 2) == 0;
+ for (int step = 0; step < 2; step++) {
+ const bool do_old = (step == 0) == old_first;
+ const uint64_t t0 = OS::get_singleton()->get_ticks_usec();
+ for (int it = 0; it < iters; it++) {
+ Vector precisions;
+ const int *prec;
+ if (do_old) {
+ precisions.resize(props.size());
+ int *w = precisions.ptrw();
+ int j = 0;
+ for (const NodePath &p : props) {
+ w[j++] = cfg->property_get_precision(p);
+ }
+ prec = precisions.ptr();
+ } else {
+ prec = cfg->get_sync_precisions().ptr();
+ }
+ int size = 0;
+ MultiplayerSynchronizer::encode_state_quantized(vptr.ptrw(), prec, vptr.size(), buf.ptrw(), size, false);
+ sink += size;
+ }
+ const double ns = 1000.0 * (double)(OS::get_singleton()->get_ticks_usec() - t0) / iters;
+ if (do_old) {
+ best_old = MIN(best_old, ns);
+ } else {
+ best_new = MIN(best_new, ns);
+ }
+ }
+ }
+ print_line(vformat("props=%-3d find+encode=%8.1f ns cached+encode=%8.1f ns saved=%7.1f ns/tick (%.0f%%) [sink=%d]",
+ prop_count, best_old, best_new, best_old - best_new,
+ best_old > 0.0 ? 100.0 * (best_old - best_new) / best_old : 0.0, (int64_t)sink));
+ }
+ print_line("");
+}
+
+} //namespace TestSceneReplicationBenchmark
From 94246677c594b5c636249df1968eae084ff50535 Mon Sep 17 00:00:00 2001
From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com>
Date: Fri, 7 Aug 2026 13:12:42 -0500
Subject: [PATCH 2/3] Dealing with the rabbit's (fair) complaints.
---
modules/multiplayer/scene_replication_interface.cpp | 6 ++++--
modules/multiplayer/tests/test_scene_replication.h | 12 ++++++++++++
.../tests/test_scene_replication_benchmark.h | 4 +++-
3 files changed, 19 insertions(+), 3 deletions(-)
diff --git a/modules/multiplayer/scene_replication_interface.cpp b/modules/multiplayer/scene_replication_interface.cpp
index f81a8bb938a..bed02a923c9 100644
--- a/modules/multiplayer/scene_replication_interface.cpp
+++ b/modules/multiplayer/scene_replication_interface.cpp
@@ -732,7 +732,8 @@ static bool _delta_precisions(SceneReplicationConfig *p_config, uint64_t p_index
const Vector &watch = p_config->get_watch_precisions();
r_precisions.clear();
bool reduced = false;
- for (int i = 0; i < watch.size(); i++) {
+ const int count = MIN(watch.size(), 64);
+ for (int i = 0; i < count; i++) {
if ((p_indexes & (1ULL << i)) != 0) {
r_precisions.push_back(watch[i]);
reduced |= watch[i] != SceneReplicationConfig::PRECISION_FULL;
@@ -953,7 +954,7 @@ Error SceneReplicationInterface::on_sync_receive(int p_from, const uint8_t *p_bu
const List &props = cfg->get_sync_properties();
Vector vars;
vars.resize(props.size());
- int consumed;
+ int consumed = 0;
Error err;
if (cfg->is_sync_reduced_precision()) {
err = MultiplayerSynchronizer::decode_state_quantized(vars, cfg->get_sync_precisions().ptr(), &p_buffer[ofs], size, consumed, false);
@@ -961,6 +962,7 @@ Error SceneReplicationInterface::on_sync_receive(int p_from, const uint8_t *p_bu
err = MultiplayerAPI::decode_and_decompress_variants(vars, &p_buffer[ofs], size, consumed);
}
ERR_FAIL_COND_V(err, err);
+ ERR_FAIL_COND_V(uint32_t(consumed) != size, ERR_INVALID_DATA);
err = MultiplayerSynchronizer::set_state(props, node, vars);
ERR_FAIL_COND_V(err, err);
ofs += size;
diff --git a/modules/multiplayer/tests/test_scene_replication.h b/modules/multiplayer/tests/test_scene_replication.h
index 28745c13267..628ed6d468d 100644
--- a/modules/multiplayer/tests/test_scene_replication.h
+++ b/modules/multiplayer/tests/test_scene_replication.h
@@ -34,6 +34,7 @@
#include "tests/test_macros.h"
+#include "core/math/math_funcs.h"
#include "scene/main/scene_tree.h"
#include "scene/main/window.h"
@@ -159,6 +160,17 @@ TEST_CASE("[Multiplayer][SceneReplication] Half on unsupported type falls back t
CHECK(((Vector3)out[1]).is_equal_approx(Vector3(1.0, 2.0, 3.0)));
}
+TEST_CASE("[Multiplayer][SceneReplication] Half precision loses out-of-range magnitudes") {
+ Vector values;
+ values.push_back(Vector3(100000.0, -100000.0, 1e-9));
+ Vector precisions = { SceneReplicationConfig::PRECISION_HALF };
+ Vector out = _round_trip(values, precisions);
+ Vector3 v = out[0];
+ CHECK((Math::is_inf(v.x) || Math::is_nan(v.x)));
+ CHECK((Math::is_inf(v.y) || Math::is_nan(v.y)));
+ CHECK(v.z == 0.0f);
+}
+
TEST_CASE("[Multiplayer][SceneReplication] Config precision setting persists and round-trips") {
Ref config;
config.instantiate();
diff --git a/modules/multiplayer/tests/test_scene_replication_benchmark.h b/modules/multiplayer/tests/test_scene_replication_benchmark.h
index 1a78722eadf..f74061fab94 100644
--- a/modules/multiplayer/tests/test_scene_replication_benchmark.h
+++ b/modules/multiplayer/tests/test_scene_replication_benchmark.h
@@ -110,8 +110,10 @@ TEST_CASE("[SceneReplication][Benchmark] Size / accuracy / speed" * doctest::ski
for (int i = 0; i < state.size(); i++) {
ptrs.write[i] = &state[i];
}
+ int buf_size = 0;
+ MultiplayerSynchronizer::encode_state_quantized(ptrs.ptrw(), full.ptr(), ptrs.size(), nullptr, buf_size, false);
Vector buf;
- buf.resize(128);
+ buf.resize(buf_size);
Vector out;
out.resize(state.size());
From afbdb083cc3b46b96b720e7b3b4560e0b658384b Mon Sep 17 00:00:00 2001
From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com>
Date: Fri, 7 Aug 2026 13:28:20 -0500
Subject: [PATCH 3/3] more rabbit complaints...
---
.../multiplayer/multiplayer_synchronizer.cpp | 6 +++++-
.../multiplayer/tests/test_scene_replication.h | 17 ++++++++++++++---
2 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/modules/multiplayer/multiplayer_synchronizer.cpp b/modules/multiplayer/multiplayer_synchronizer.cpp
index a1f612da19b..02a47f4fe1c 100644
--- a/modules/multiplayer/multiplayer_synchronizer.cpp
+++ b/modules/multiplayer/multiplayer_synchronizer.cpp
@@ -283,7 +283,11 @@ Error MultiplayerSynchronizer::encode_state_quantized(const Variant **p_variants
float components[4];
_variant_to_floats(v, type, components);
for (int c = 0; c < nc; c++) {
- encode_uint16(Math::make_half_float(components[c]), &p_buffer[r_len + 1 + c * 2]);
+ float f = components[c];
+ if (Math::is_finite(f)) {
+ f = CLAMP(f, -65504.0f, 65504.0f);
+ }
+ encode_uint16(Math::make_half_float(f), &p_buffer[r_len + 1 + c * 2]);
}
}
r_len += 1 + nc * 2;
diff --git a/modules/multiplayer/tests/test_scene_replication.h b/modules/multiplayer/tests/test_scene_replication.h
index 628ed6d468d..c8e40db371f 100644
--- a/modules/multiplayer/tests/test_scene_replication.h
+++ b/modules/multiplayer/tests/test_scene_replication.h
@@ -160,17 +160,28 @@ TEST_CASE("[Multiplayer][SceneReplication] Half on unsupported type falls back t
CHECK(((Vector3)out[1]).is_equal_approx(Vector3(1.0, 2.0, 3.0)));
}
-TEST_CASE("[Multiplayer][SceneReplication] Half precision loses out-of-range magnitudes") {
+TEST_CASE("[Multiplayer][SceneReplication] Half precision saturates finite out-of-range magnitudes") {
Vector values;
values.push_back(Vector3(100000.0, -100000.0, 1e-9));
Vector precisions = { SceneReplicationConfig::PRECISION_HALF };
Vector out = _round_trip(values, precisions);
Vector3 v = out[0];
- CHECK((Math::is_inf(v.x) || Math::is_nan(v.x)));
- CHECK((Math::is_inf(v.y) || Math::is_nan(v.y)));
+ CHECK(v.x == 65504.0f);
+ CHECK(v.y == -65504.0f);
CHECK(v.z == 0.0f);
}
+TEST_CASE("[Multiplayer][SceneReplication] Half precision preserves infinity and NaN") {
+ Vector values;
+ values.push_back(Vector3((float)INFINITY, -(float)INFINITY, (float)NAN));
+ Vector precisions = { SceneReplicationConfig::PRECISION_HALF };
+ Vector out = _round_trip(values, precisions);
+ Vector3 v = out[0];
+ CHECK((Math::is_inf(v.x) && v.x > 0.0f));
+ CHECK((Math::is_inf(v.y) && v.y < 0.0f));
+ CHECK(Math::is_nan(v.z));
+}
+
TEST_CASE("[Multiplayer][SceneReplication] Config precision setting persists and round-trips") {
Ref config;
config.instantiate();