mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Implement live syncback service properties encoding and enhance syncback functionality
- Introduced a new `encodeService` helper in the plugin to serialize service properties, attributes, tags, and references alongside children data. - Updated the `performSyncback` function to utilize the new encoding method, ensuring all services are processed without child-only restrictions. - Extended the `ServiceChunk` struct to include fields for properties, attributes, tags, and references, improving data handling during syncback. - Modified the `build_dom_from_chunks` function to apply service properties and resolve references when reconstructing the service instances. - Added comprehensive tests to validate the new service properties handling, ensuring accurate synchronization and data integrity. These enhancements significantly improve the live syncback process, allowing for better service data management and consistency.
This commit is contained in:
parent
30e032b27e
commit
72ad84b5a7
16 changed files with 896 additions and 444 deletions
|
|
@ -139,6 +139,7 @@ fn run_live_syncback(project_path: &Path, payload: SyncbackPayload) -> anyhow::R
|
|||
|
||||
fn build_dom_from_chunks(payload: SyncbackPayload) -> anyhow::Result<WeakDom> {
|
||||
use crate::syncback::VISIBLE_SERVICES;
|
||||
use rbx_dom_weak::types::{Attributes, Tags};
|
||||
|
||||
let mut dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let root_ref = dom.root_ref();
|
||||
|
|
@ -147,7 +148,26 @@ fn build_dom_from_chunks(payload: SyncbackPayload) -> anyhow::Result<WeakDom> {
|
|||
let mut created_services: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
for chunk in &payload.services {
|
||||
let service_ref = dom.insert(root_ref, InstanceBuilder::new(&chunk.class_name));
|
||||
let mut builder = InstanceBuilder::new(&chunk.class_name);
|
||||
|
||||
for (key, value) in &chunk.properties {
|
||||
builder = builder.with_property(key.as_str(), value.clone());
|
||||
}
|
||||
|
||||
if !chunk.attributes.is_empty() {
|
||||
let mut attrs = Attributes::new();
|
||||
for (key, value) in &chunk.attributes {
|
||||
attrs.insert(key.clone(), value.clone());
|
||||
}
|
||||
builder = builder.with_property("Attributes", Variant::Attributes(attrs));
|
||||
}
|
||||
|
||||
if !chunk.tags.is_empty() {
|
||||
let tags: Tags = chunk.tags.iter().map(|s| s.as_str()).collect();
|
||||
builder = builder.with_property("Tags", Variant::Tags(tags));
|
||||
}
|
||||
|
||||
let service_ref = dom.insert(root_ref, builder);
|
||||
created_services.insert(chunk.class_name.clone());
|
||||
|
||||
if !chunk.data.is_empty() {
|
||||
|
|
@ -166,6 +186,22 @@ fn build_dom_from_chunks(payload: SyncbackPayload) -> anyhow::Result<WeakDom> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !chunk.refs.is_empty() {
|
||||
let children: Vec<Ref> = dom.get_by_ref(service_ref).unwrap().children().to_vec();
|
||||
for (prop_name, target) in &chunk.refs {
|
||||
let found = children.iter().find(|&&child_ref| {
|
||||
let child = dom.get_by_ref(child_ref).unwrap();
|
||||
child.name.as_str() == target.name && child.class.as_str() == target.class_name
|
||||
});
|
||||
if let Some(&child_ref) = found {
|
||||
let service = dom.get_by_ref_mut(service_ref).unwrap();
|
||||
service
|
||||
.properties
|
||||
.insert(prop_name.as_str().into(), Variant::Ref(child_ref));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for &service_name in VISIBLE_SERVICES {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use std::cell::RefCell;
|
|||
use std::collections::HashMap;
|
||||
|
||||
use rbx_dom_weak::types::Ref;
|
||||
use rbx_dom_weak::Ustr;
|
||||
|
||||
use crate::variant_eq::variant_eq;
|
||||
|
||||
|
|
@ -55,9 +56,6 @@ pub struct ForwardMatchResult {
|
|||
pub unmatched_snapshot: Vec<InstanceSnapshot>,
|
||||
/// Tree children with no match in the snapshot (to be removed).
|
||||
pub unmatched_tree: Vec<Ref>,
|
||||
/// Total cost of the matching (sum of all matched pair costs +
|
||||
/// unmatched penalties). Zero means no changes across anything.
|
||||
pub total_cost: u32,
|
||||
}
|
||||
|
||||
/// Match snapshot children to tree children, minimizing total changes.
|
||||
|
|
@ -72,34 +70,35 @@ pub fn match_forward(
|
|||
matched: Vec::new(),
|
||||
unmatched_snapshot: Vec::new(),
|
||||
unmatched_tree: Vec::new(),
|
||||
total_cost: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let snap_available: Vec<Option<InstanceSnapshot>> =
|
||||
snapshot_children.into_iter().map(Some).collect();
|
||||
let mut snap_matched: Vec<bool> = vec![false; snap_available.len()];
|
||||
let mut tree_available: Vec<bool> = vec![true; tree_children.len()];
|
||||
let mut matched: Vec<(usize, usize)> = Vec::new();
|
||||
let snap_len = snap_available.len();
|
||||
let tree_len = tree_children.len();
|
||||
let mut snap_matched: Vec<bool> = vec![false; snap_len];
|
||||
let mut tree_available: Vec<bool> = vec![true; tree_len];
|
||||
let mut matched: Vec<(usize, usize)> = Vec::with_capacity(snap_len.min(tree_len));
|
||||
|
||||
// ================================================================
|
||||
// Fast-path: Group by (Name, ClassName) -- 1:1 groups instant-match
|
||||
// ================================================================
|
||||
let mut snap_by_key: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||||
let mut snap_by_key: HashMap<(String, Ustr), Vec<usize>> = HashMap::with_capacity(snap_len);
|
||||
for (i, snap_opt) in snap_available.iter().enumerate() {
|
||||
if let Some(snap) = snap_opt {
|
||||
snap_by_key
|
||||
.entry((snap.name.to_string(), snap.class_name.to_string()))
|
||||
.entry((snap.name.to_string(), snap.class_name))
|
||||
.or_default()
|
||||
.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
let mut tree_by_key: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||||
let mut tree_by_key: HashMap<(String, Ustr), Vec<usize>> = HashMap::with_capacity(tree_len);
|
||||
for (i, &child_ref) in tree_children.iter().enumerate() {
|
||||
if let Some(inst) = tree.get_instance(child_ref) {
|
||||
tree_by_key
|
||||
.entry((inst.name().to_string(), inst.class_name().to_string()))
|
||||
.entry((inst.name().to_string(), inst.class_name()))
|
||||
.or_default()
|
||||
.push(i);
|
||||
}
|
||||
|
|
@ -122,14 +121,7 @@ pub fn match_forward(
|
|||
let snap_remaining = snap_matched.iter().filter(|&&m| !m).count();
|
||||
let tree_remaining = tree_available.iter().filter(|&&a| a).count();
|
||||
if snap_remaining == 0 || tree_remaining == 0 {
|
||||
return build_result(
|
||||
snap_available,
|
||||
tree_children,
|
||||
&tree_available,
|
||||
matched,
|
||||
tree,
|
||||
session,
|
||||
);
|
||||
return build_result(snap_available, tree_children, &tree_available, matched);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
|
|
@ -168,7 +160,8 @@ pub fn match_forward(
|
|||
}
|
||||
|
||||
// Score all (A, B) pairs using recursive change-count scoring
|
||||
let mut pairs: Vec<(u32, usize, usize)> = Vec::new();
|
||||
let mut pairs: Vec<(u32, usize, usize)> =
|
||||
Vec::with_capacity(avail_snap.len() * avail_tree.len());
|
||||
let mut best_so_far = u32::MAX;
|
||||
for &si in &avail_snap {
|
||||
let snap = match &snap_available[si] {
|
||||
|
|
@ -199,38 +192,19 @@ pub fn match_forward(
|
|||
}
|
||||
}
|
||||
|
||||
build_result(
|
||||
snap_available,
|
||||
tree_children,
|
||||
&tree_available,
|
||||
matched,
|
||||
tree,
|
||||
session,
|
||||
)
|
||||
build_result(snap_available, tree_children, &tree_available, matched)
|
||||
}
|
||||
|
||||
/// Build the final result, consuming the snapshot children.
|
||||
/// Computes `total_cost` for all matched pairs (using session cache).
|
||||
fn build_result(
|
||||
mut snap_available: Vec<Option<InstanceSnapshot>>,
|
||||
tree_children: &[Ref],
|
||||
tree_available: &[bool],
|
||||
matched_indices: Vec<(usize, usize)>,
|
||||
tree: &RojoTree,
|
||||
session: &MatchingSession,
|
||||
) -> ForwardMatchResult {
|
||||
let mut matched = Vec::with_capacity(matched_indices.len());
|
||||
let mut total_cost: u32 = 0;
|
||||
for (si, ti) in matched_indices {
|
||||
if let Some(snap) = snap_available[si].take() {
|
||||
total_cost = total_cost.saturating_add(compute_change_count(
|
||||
&snap,
|
||||
tree_children[ti],
|
||||
tree,
|
||||
u32::MAX,
|
||||
0,
|
||||
session,
|
||||
));
|
||||
matched.push((snap, tree_children[ti]));
|
||||
}
|
||||
}
|
||||
|
|
@ -244,15 +218,10 @@ fn build_result(
|
|||
.map(|(_, r)| *r)
|
||||
.collect();
|
||||
|
||||
total_cost = total_cost.saturating_add(
|
||||
(unmatched_snapshot.len() + unmatched_tree.len()) as u32 * UNMATCHED_PENALTY,
|
||||
);
|
||||
|
||||
ForwardMatchResult {
|
||||
matched,
|
||||
unmatched_snapshot,
|
||||
unmatched_tree,
|
||||
total_cost,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -275,32 +244,34 @@ fn match_children_for_scoring(
|
|||
depth: u32,
|
||||
session: &MatchingSession,
|
||||
) -> ScoringMatchResult {
|
||||
let mut snap_matched = vec![false; snap_children.len()];
|
||||
let mut tree_matched = vec![false; tree_children.len()];
|
||||
let mut matched = Vec::new();
|
||||
|
||||
if snap_children.is_empty() && tree_children.is_empty() {
|
||||
return ScoringMatchResult {
|
||||
matched,
|
||||
matched: Vec::new(),
|
||||
unmatched_snap: 0,
|
||||
unmatched_tree: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let snap_len = snap_children.len();
|
||||
let tree_len = tree_children.len();
|
||||
let mut snap_matched = vec![false; snap_len];
|
||||
let mut tree_matched = vec![false; tree_len];
|
||||
let mut matched: Vec<(usize, usize)> = Vec::with_capacity(snap_len.min(tree_len));
|
||||
|
||||
// Group by (Name, ClassName)
|
||||
let mut snap_by_key: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||||
let mut snap_by_key: HashMap<(String, Ustr), Vec<usize>> = HashMap::with_capacity(snap_len);
|
||||
for (i, snap) in snap_children.iter().enumerate() {
|
||||
snap_by_key
|
||||
.entry((snap.name.to_string(), snap.class_name.to_string()))
|
||||
.entry((snap.name.to_string(), snap.class_name))
|
||||
.or_default()
|
||||
.push(i);
|
||||
}
|
||||
|
||||
let mut tree_by_key: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||||
let mut tree_by_key: HashMap<(String, Ustr), Vec<usize>> = HashMap::with_capacity(tree_len);
|
||||
for (i, &child_ref) in tree_children.iter().enumerate() {
|
||||
if let Some(inst) = tree.get_instance(child_ref) {
|
||||
tree_by_key
|
||||
.entry((inst.name().to_string(), inst.class_name().to_string()))
|
||||
.entry((inst.name().to_string(), inst.class_name()))
|
||||
.or_default()
|
||||
.push(i);
|
||||
}
|
||||
|
|
@ -348,7 +319,8 @@ fn match_children_for_scoring(
|
|||
continue;
|
||||
}
|
||||
|
||||
let mut pairs: Vec<(u32, usize, usize)> = Vec::new();
|
||||
let mut pairs: Vec<(u32, usize, usize)> =
|
||||
Vec::with_capacity(avail_snap.len() * avail_tree.len());
|
||||
let mut best_so_far = u32::MAX;
|
||||
for &si in &avail_snap {
|
||||
for &ti in &avail_tree {
|
||||
|
|
@ -476,6 +448,7 @@ fn compute_change_count(
|
|||
/// Studio via two-way sync) has at their class defaults. To avoid inflating
|
||||
/// match costs with phantom diffs, properties present on only one side are
|
||||
/// skipped when their value matches the class default.
|
||||
#[inline]
|
||||
fn count_own_diffs(snap: &InstanceSnapshot, inst: &InstanceWithMeta) -> u32 {
|
||||
let mut cost: u32 = 0;
|
||||
|
||||
|
|
@ -1309,88 +1282,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_cost_zero_for_identical() {
|
||||
let snaps = vec![
|
||||
make_snapshot("Alpha", "Folder"),
|
||||
make_snapshot("Beta", "Folder"),
|
||||
];
|
||||
let (tree, children) = make_tree_with_children(&[("Alpha", "Folder"), ("Beta", "Folder")]);
|
||||
let result = match_forward(snaps, &children, &tree, &MatchingSession::new());
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert_eq!(
|
||||
result.total_cost, 0,
|
||||
"Identical instances should have zero cost"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_cost_nonzero_for_different() {
|
||||
use rbx_dom_weak::types::Variant;
|
||||
|
||||
let snaps = vec![make_snapshot_with_props(
|
||||
"Part",
|
||||
"Part",
|
||||
vec![("Transparency", Variant::Float32(0.5))],
|
||||
)];
|
||||
let (tree, children) = make_tree_from_snapshots(vec![make_snapshot_with_props(
|
||||
"Part",
|
||||
"Part",
|
||||
vec![("Transparency", Variant::Float32(0.0))],
|
||||
)]);
|
||||
let result = match_forward(snaps, &children, &tree, &MatchingSession::new());
|
||||
assert_eq!(result.matched.len(), 1);
|
||||
assert!(
|
||||
result.total_cost > 0,
|
||||
"Different properties should produce nonzero cost, got {}",
|
||||
result.total_cost
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_cost_includes_unmatched() {
|
||||
let snaps = vec![
|
||||
make_snapshot("A", "Folder"),
|
||||
make_snapshot("B", "Folder"),
|
||||
make_snapshot("C", "Folder"),
|
||||
];
|
||||
let (tree, children) = make_tree_with_children(&[("A", "Folder"), ("B", "Folder")]);
|
||||
let result = match_forward(snaps, &children, &tree, &MatchingSession::new());
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert_eq!(result.unmatched_snapshot.len(), 1);
|
||||
assert!(
|
||||
result.total_cost >= UNMATCHED_PENALTY,
|
||||
"Should include penalty for unmatched snapshot, got {}",
|
||||
result.total_cost
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_cost_sums_correctly() {
|
||||
use rbx_dom_weak::types::Variant;
|
||||
|
||||
let snaps = vec![
|
||||
make_snapshot("A", "Folder"),
|
||||
make_snapshot_with_props("B", "Part", vec![("Transparency", Variant::Float32(0.5))]),
|
||||
make_snapshot("C", "Folder"),
|
||||
];
|
||||
let tree_children_snaps = vec![
|
||||
make_snapshot("A", "Folder"),
|
||||
make_snapshot_with_props("B", "Part", vec![("Transparency", Variant::Float32(0.0))]),
|
||||
];
|
||||
let (tree, children) = make_tree_from_snapshots(tree_children_snaps);
|
||||
let result = match_forward(snaps, &children, &tree, &MatchingSession::new());
|
||||
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert_eq!(result.unmatched_snapshot.len(), 1);
|
||||
// total = matched costs (A=0 + B>=1) + unmatched (C=10000)
|
||||
assert!(
|
||||
result.total_cost >= UNMATCHED_PENALTY + 1,
|
||||
"Should be at least penalty + 1 diff, got {}",
|
||||
result.total_cost
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_cache_consistent() {
|
||||
let snaps1 = vec![make_snapshot("X", "Folder"), make_snapshot("Y", "Folder")];
|
||||
|
|
@ -1402,36 +1293,6 @@ mod tests {
|
|||
let r2 = match_forward(snaps2, &children, &tree, &session);
|
||||
|
||||
assert_eq!(r1.matched.len(), r2.matched.len());
|
||||
assert_eq!(r1.total_cost, r2.total_cost);
|
||||
assert_eq!(r1.unmatched_snapshot.len(), r2.unmatched_snapshot.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_cache_does_not_corrupt() {
|
||||
use rbx_dom_weak::types::Variant;
|
||||
|
||||
let session = MatchingSession::new();
|
||||
|
||||
// Group A: identical folders
|
||||
let snaps_a = vec![make_snapshot("A", "Folder")];
|
||||
let (tree_a, children_a) = make_tree_with_children(&[("A", "Folder")]);
|
||||
let ra = match_forward(snaps_a, &children_a, &tree_a, &session);
|
||||
assert_eq!(ra.total_cost, 0);
|
||||
|
||||
// Group B: parts with property diff
|
||||
let snaps_b = vec![make_snapshot_with_props(
|
||||
"B",
|
||||
"Part",
|
||||
vec![("Transparency", Variant::Float32(1.0))],
|
||||
)];
|
||||
let (tree_b, children_b) = make_tree_from_snapshots(vec![make_snapshot_with_props(
|
||||
"B",
|
||||
"Part",
|
||||
vec![("Transparency", Variant::Float32(0.0))],
|
||||
)]);
|
||||
let rb = match_forward(snaps_b, &children_b, &tree_b, &session);
|
||||
|
||||
assert_eq!(ra.total_cost, 0, "Group A should still be zero");
|
||||
assert!(rb.total_cost > 0, "Group B should have nonzero cost");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@
|
|||
//! to turn instance A into instance B.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use blake3::Hash;
|
||||
use rbx_dom_weak::{types::Ref, WeakDom};
|
||||
use rbx_dom_weak::types::Ref;
|
||||
use rbx_dom_weak::{Ustr, WeakDom};
|
||||
|
||||
use crate::variant_eq::variant_eq;
|
||||
|
||||
|
|
@ -50,7 +51,6 @@ pub struct MatchResult {
|
|||
pub matched: Vec<(Ref, Ref)>,
|
||||
pub unmatched_new: Vec<Ref>,
|
||||
pub unmatched_old: Vec<Ref>,
|
||||
pub total_cost: u32,
|
||||
}
|
||||
|
||||
/// Match new children to old children, minimizing total changes.
|
||||
|
|
@ -68,106 +68,80 @@ pub fn match_children(
|
|||
matched: Vec::new(),
|
||||
unmatched_new: Vec::new(),
|
||||
unmatched_old: Vec::new(),
|
||||
total_cost: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let mut matched: Vec<(Ref, Ref)> = Vec::new();
|
||||
let mut remaining_new: Vec<Ref> = new_children.to_vec();
|
||||
let mut remaining_old: Vec<Ref> = old_children.to_vec();
|
||||
let new_len = new_children.len();
|
||||
let old_len = old_children.len();
|
||||
let mut new_matched = vec![false; new_len];
|
||||
let mut old_matched = vec![false; old_len];
|
||||
let mut matched: Vec<(usize, usize)> = Vec::with_capacity(new_len.min(old_len));
|
||||
|
||||
// ================================================================
|
||||
// Fast-path: Group by (Name, ClassName) -- 1:1 groups instant-match
|
||||
// ================================================================
|
||||
let mut new_by_key: HashMap<(String, String), Vec<Ref>> = HashMap::new();
|
||||
for &r in &remaining_new {
|
||||
let mut new_by_key: HashMap<(String, Ustr), Vec<usize>> = HashMap::with_capacity(new_len);
|
||||
for (i, &r) in new_children.iter().enumerate() {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_key
|
||||
.entry((inst.name.clone(), inst.class.to_string()))
|
||||
.entry((inst.name.clone(), inst.class))
|
||||
.or_default()
|
||||
.push(r);
|
||||
.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
let mut old_by_key: HashMap<(String, String), Vec<Ref>> = HashMap::new();
|
||||
for &r in &remaining_old {
|
||||
let mut old_by_key: HashMap<(String, Ustr), Vec<usize>> = HashMap::with_capacity(old_len);
|
||||
for (i, &r) in old_children.iter().enumerate() {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_key
|
||||
.entry((inst.name.clone(), inst.class.to_string()))
|
||||
.entry((inst.name.clone(), inst.class))
|
||||
.or_default()
|
||||
.push(r);
|
||||
.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched_new: HashSet<Ref> = HashSet::new();
|
||||
let mut matched_old: HashSet<Ref> = HashSet::new();
|
||||
|
||||
// 1:1 groups: instant match
|
||||
// Sort keys for deterministic iteration
|
||||
let mut sorted_keys: Vec<_> = new_by_key.keys().cloned().collect();
|
||||
sorted_keys.sort();
|
||||
|
||||
for key in &sorted_keys {
|
||||
let Some(new_refs) = new_by_key.get(key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(old_refs) = old_by_key.get(key) else {
|
||||
continue;
|
||||
};
|
||||
if new_refs.len() == 1 && old_refs.len() == 1 {
|
||||
matched.push((new_refs[0], old_refs[0]));
|
||||
matched_new.insert(new_refs[0]);
|
||||
matched_old.insert(old_refs[0]);
|
||||
for (key, new_indices) in &new_by_key {
|
||||
if let Some(old_indices) = old_by_key.get(key) {
|
||||
if new_indices.len() == 1 && old_indices.len() == 1 {
|
||||
let ni = new_indices[0];
|
||||
let oi = old_indices[0];
|
||||
matched.push((ni, oi));
|
||||
new_matched[ni] = true;
|
||||
old_matched[oi] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remaining_new.retain(|r| !matched_new.contains(r));
|
||||
remaining_old.retain(|r| !matched_old.contains(r));
|
||||
let new_remaining = new_matched.iter().filter(|&&m| !m).count();
|
||||
let old_remaining = old_matched.iter().filter(|&&m| !m).count();
|
||||
|
||||
if remaining_new.is_empty() || remaining_old.is_empty() {
|
||||
let mut total_cost: u32 = 0;
|
||||
for &(new_ref, old_ref) in &matched {
|
||||
total_cost = total_cost.saturating_add(compute_change_count(
|
||||
new_ref,
|
||||
old_ref,
|
||||
new_dom,
|
||||
old_dom,
|
||||
new_hashes,
|
||||
old_hashes,
|
||||
u32::MAX,
|
||||
0,
|
||||
session,
|
||||
));
|
||||
}
|
||||
total_cost = total_cost
|
||||
.saturating_add((remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY);
|
||||
return MatchResult {
|
||||
if new_remaining == 0 || old_remaining == 0 {
|
||||
return build_result(
|
||||
new_children,
|
||||
old_children,
|
||||
&new_matched,
|
||||
&old_matched,
|
||||
matched,
|
||||
unmatched_new: remaining_new,
|
||||
unmatched_old: remaining_old,
|
||||
total_cost,
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Ambiguous groups: change-count scoring + greedy assignment
|
||||
// ================================================================
|
||||
for key in &sorted_keys {
|
||||
let Some(new_refs) = new_by_key.get(key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(old_refs) = old_by_key.get(key) else {
|
||||
for (key, new_indices) in &new_by_key {
|
||||
let Some(old_indices) = old_by_key.get(key) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let avail_new: Vec<Ref> = new_refs
|
||||
let avail_new: Vec<usize> = new_indices
|
||||
.iter()
|
||||
.filter(|r| !matched_new.contains(r))
|
||||
.filter(|&&ni| !new_matched[ni])
|
||||
.copied()
|
||||
.collect();
|
||||
let avail_old: Vec<Ref> = old_refs
|
||||
let avail_old: Vec<usize> = old_indices
|
||||
.iter()
|
||||
.filter(|r| !matched_old.contains(r))
|
||||
.filter(|&&oi| !old_matched[oi])
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
|
|
@ -175,24 +149,25 @@ pub fn match_children(
|
|||
continue;
|
||||
}
|
||||
|
||||
// Handle remaining 1:1 (from groups that had multiple but some were
|
||||
// already matched, leaving 1 on each side)
|
||||
if avail_new.len() == 1 && avail_old.len() == 1 {
|
||||
matched.push((avail_new[0], avail_old[0]));
|
||||
matched_new.insert(avail_new[0]);
|
||||
matched_old.insert(avail_old[0]);
|
||||
let ni = avail_new[0];
|
||||
let oi = avail_old[0];
|
||||
matched.push((ni, oi));
|
||||
new_matched[ni] = true;
|
||||
old_matched[oi] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Score all (A, B) pairs using recursive change-count scoring
|
||||
let mut pairs: Vec<(u32, Ref, Ref)> = Vec::new();
|
||||
let mut pairs: Vec<(u32, usize, usize)> =
|
||||
Vec::with_capacity(avail_new.len() * avail_old.len());
|
||||
let mut best_so_far = u32::MAX;
|
||||
|
||||
for &new_ref in &avail_new {
|
||||
for &old_ref in &avail_old {
|
||||
for &ni in &avail_new {
|
||||
for &oi in &avail_old {
|
||||
let cost = compute_change_count(
|
||||
new_ref,
|
||||
old_ref,
|
||||
new_children[ni],
|
||||
old_children[oi],
|
||||
new_dom,
|
||||
old_dom,
|
||||
new_hashes,
|
||||
|
|
@ -201,7 +176,7 @@ pub fn match_children(
|
|||
0,
|
||||
session,
|
||||
);
|
||||
pairs.push((cost, new_ref, old_ref));
|
||||
pairs.push((cost, ni, oi));
|
||||
if cost < best_so_far {
|
||||
best_so_far = cost;
|
||||
}
|
||||
|
|
@ -212,41 +187,56 @@ pub fn match_children(
|
|||
pairs.sort_by_key(|&(cost, _, _)| cost);
|
||||
|
||||
// Greedy assign
|
||||
for &(_, new_ref, old_ref) in &pairs {
|
||||
if matched_new.contains(&new_ref) || matched_old.contains(&old_ref) {
|
||||
for &(_, ni, oi) in &pairs {
|
||||
if new_matched[ni] || old_matched[oi] {
|
||||
continue;
|
||||
}
|
||||
matched.push((new_ref, old_ref));
|
||||
matched_new.insert(new_ref);
|
||||
matched_old.insert(old_ref);
|
||||
matched.push((ni, oi));
|
||||
new_matched[ni] = true;
|
||||
old_matched[oi] = true;
|
||||
}
|
||||
}
|
||||
|
||||
remaining_new.retain(|r| !matched_new.contains(r));
|
||||
remaining_old.retain(|r| !matched_old.contains(r));
|
||||
build_result(
|
||||
new_children,
|
||||
old_children,
|
||||
&new_matched,
|
||||
&old_matched,
|
||||
matched,
|
||||
)
|
||||
}
|
||||
|
||||
let mut total_cost: u32 = 0;
|
||||
for &(new_ref, old_ref) in &matched {
|
||||
total_cost = total_cost.saturating_add(compute_change_count(
|
||||
new_ref,
|
||||
old_ref,
|
||||
new_dom,
|
||||
old_dom,
|
||||
new_hashes,
|
||||
old_hashes,
|
||||
u32::MAX,
|
||||
0,
|
||||
session,
|
||||
));
|
||||
}
|
||||
total_cost = total_cost
|
||||
.saturating_add((remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY);
|
||||
/// Convert index-based matching results back to Ref-based MatchResult.
|
||||
fn build_result(
|
||||
new_children: &[Ref],
|
||||
old_children: &[Ref],
|
||||
new_matched: &[bool],
|
||||
old_matched: &[bool],
|
||||
matched_indices: Vec<(usize, usize)>,
|
||||
) -> MatchResult {
|
||||
let matched: Vec<(Ref, Ref)> = matched_indices
|
||||
.into_iter()
|
||||
.map(|(ni, oi)| (new_children[ni], old_children[oi]))
|
||||
.collect();
|
||||
|
||||
let unmatched_new: Vec<Ref> = new_matched
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, &m)| !m)
|
||||
.map(|(i, _)| new_children[i])
|
||||
.collect();
|
||||
|
||||
let unmatched_old: Vec<Ref> = old_matched
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, &m)| !m)
|
||||
.map(|(i, _)| old_children[i])
|
||||
.collect();
|
||||
|
||||
MatchResult {
|
||||
matched,
|
||||
unmatched_new: remaining_new,
|
||||
unmatched_old: remaining_old,
|
||||
total_cost,
|
||||
unmatched_new,
|
||||
unmatched_old,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,34 +260,36 @@ fn match_children_for_scoring(
|
|||
depth: u32,
|
||||
session: &MatchingSession,
|
||||
) -> ScoringMatchResult {
|
||||
let mut new_matched = vec![false; new_children.len()];
|
||||
let mut old_matched = vec![false; old_children.len()];
|
||||
let mut matched = Vec::new();
|
||||
|
||||
if new_children.is_empty() && old_children.is_empty() {
|
||||
return ScoringMatchResult {
|
||||
matched,
|
||||
matched: Vec::new(),
|
||||
unmatched_new: 0,
|
||||
unmatched_old: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let new_len = new_children.len();
|
||||
let old_len = old_children.len();
|
||||
let mut new_matched = vec![false; new_len];
|
||||
let mut old_matched = vec![false; old_len];
|
||||
let mut matched: Vec<(usize, usize)> = Vec::with_capacity(new_len.min(old_len));
|
||||
|
||||
// Group by (Name, ClassName)
|
||||
let mut new_by_key: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||||
let mut new_by_key: HashMap<(String, Ustr), Vec<usize>> = HashMap::with_capacity(new_len);
|
||||
for (i, &r) in new_children.iter().enumerate() {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_key
|
||||
.entry((inst.name.clone(), inst.class.to_string()))
|
||||
.entry((inst.name.clone(), inst.class))
|
||||
.or_default()
|
||||
.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
let mut old_by_key: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||||
let mut old_by_key: HashMap<(String, Ustr), Vec<usize>> = HashMap::with_capacity(old_len);
|
||||
for (i, &r) in old_children.iter().enumerate() {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_key
|
||||
.entry((inst.name.clone(), inst.class.to_string()))
|
||||
.entry((inst.name.clone(), inst.class))
|
||||
.or_default()
|
||||
.push(i);
|
||||
}
|
||||
|
|
@ -345,7 +337,8 @@ fn match_children_for_scoring(
|
|||
continue;
|
||||
}
|
||||
|
||||
let mut pairs: Vec<(u32, usize, usize)> = Vec::new();
|
||||
let mut pairs: Vec<(u32, usize, usize)> =
|
||||
Vec::with_capacity(avail_new.len() * avail_old.len());
|
||||
let mut best_so_far = u32::MAX;
|
||||
for &ni in &avail_new {
|
||||
for &oi in &avail_old {
|
||||
|
|
@ -502,6 +495,7 @@ fn compute_change_count(
|
|||
///
|
||||
/// Properties present on only one side are skipped when their value matches
|
||||
/// the class default (see `src/snapshot/matching.rs` for full rationale).
|
||||
#[inline]
|
||||
fn count_own_diffs(new_ref: Ref, old_ref: Ref, new_dom: &WeakDom, old_dom: &WeakDom) -> u32 {
|
||||
let new_inst = match new_dom.get_by_ref(new_ref) {
|
||||
Some(i) => i,
|
||||
|
|
@ -759,106 +753,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_cost_zero_identical() {
|
||||
let (new_dom, new_root, _, _) = build_test_dom();
|
||||
let (old_dom, old_root, _, _) = build_test_dom();
|
||||
|
||||
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
|
||||
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
|
||||
|
||||
let result = match_children(
|
||||
&new_children,
|
||||
&old_children,
|
||||
&new_dom,
|
||||
&old_dom,
|
||||
None,
|
||||
None,
|
||||
&MatchingSession::new(),
|
||||
);
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert_eq!(
|
||||
result.total_cost, 0,
|
||||
"Identical instances should have zero cost"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_cost_nonzero_different() {
|
||||
use rbx_dom_weak::types::Variant;
|
||||
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Part")
|
||||
.with_name("P")
|
||||
.with_property("Transparency", Variant::Float32(0.5)),
|
||||
);
|
||||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Part")
|
||||
.with_name("P")
|
||||
.with_property("Transparency", Variant::Float32(0.0)),
|
||||
);
|
||||
|
||||
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
|
||||
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
|
||||
|
||||
let result = match_children(
|
||||
&new_children,
|
||||
&old_children,
|
||||
&new_dom,
|
||||
&old_dom,
|
||||
None,
|
||||
None,
|
||||
&MatchingSession::new(),
|
||||
);
|
||||
assert_eq!(result.matched.len(), 1);
|
||||
assert!(
|
||||
result.total_cost > 0,
|
||||
"Different properties should produce nonzero cost, got {}",
|
||||
result.total_cost
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_cost_includes_unmatched() {
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(new_root, InstanceBuilder::new("Folder").with_name("A"));
|
||||
new_dom.insert(new_root, InstanceBuilder::new("Folder").with_name("B"));
|
||||
new_dom.insert(new_root, InstanceBuilder::new("Folder").with_name("C"));
|
||||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
old_dom.insert(old_root, InstanceBuilder::new("Folder").with_name("A"));
|
||||
old_dom.insert(old_root, InstanceBuilder::new("Folder").with_name("B"));
|
||||
|
||||
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
|
||||
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
|
||||
|
||||
let result = match_children(
|
||||
&new_children,
|
||||
&old_children,
|
||||
&new_dom,
|
||||
&old_dom,
|
||||
None,
|
||||
None,
|
||||
&MatchingSession::new(),
|
||||
);
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert_eq!(result.unmatched_new.len(), 1);
|
||||
assert!(
|
||||
result.total_cost >= UNMATCHED_PENALTY,
|
||||
"Should include penalty for unmatched, got {}",
|
||||
result.total_cost
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_cache_consistent_syncback() {
|
||||
let (new_dom, new_root, _, _) = build_test_dom();
|
||||
|
|
@ -887,6 +781,5 @@ mod tests {
|
|||
&session,
|
||||
);
|
||||
assert_eq!(r1.matched.len(), r2.matched.len());
|
||||
assert_eq!(r1.total_cost, r2.total_cost);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ macro_rules! approx_eq {
|
|||
|
||||
/// Compares two variants to determine if they're equal. This correctly takes
|
||||
/// float comparisons into account.
|
||||
#[inline]
|
||||
pub fn variant_eq(variant_a: &Variant, variant_b: &Variant) -> bool {
|
||||
if variant_a.ty() != variant_b.ty() {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -27,13 +27,31 @@ pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|||
/// Current protocol version, which is required to match.
|
||||
pub const PROTOCOL_VERSION: u64 = 6;
|
||||
|
||||
/// A single service's children serialized as rbxm binary data.
|
||||
/// A single service's children serialized as rbxm binary data, plus the
|
||||
/// service's own properties, attributes, tags, and Ref property hints.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceChunk {
|
||||
pub class_name: String,
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub data: Vec<u8>,
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, Variant>,
|
||||
#[serde(default)]
|
||||
pub attributes: HashMap<String, Variant>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub refs: HashMap<String, ServiceRef>,
|
||||
}
|
||||
|
||||
/// Describes a Ref property target by name and class so the server can resolve
|
||||
/// it among the service's cloned children.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceRef {
|
||||
pub name: String,
|
||||
pub class_name: String,
|
||||
}
|
||||
|
||||
/// Incoming request from the plugin for live syncback.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue