This commit is contained in:
Joe 2026-02-28 06:11:16 -06:00
parent 89e7e0282d
commit 4985a3fede
17 changed files with 891 additions and 63 deletions

View file

@ -251,6 +251,13 @@ fn apply_update_child(context: &mut PatchApplyContext, tree: &mut RojoTree, patc
applied_patch.changed_metadata = Some(metadata);
}
// Capture old class name before the mutable borrow so we can update
// the script index afterward if the class changes.
let old_class_name = patch.changed_class_name.and_then(|_| {
tree.get_instance(patch.id)
.map(|inst| inst.class_name().to_string())
});
let mut instance = match tree.get_instance_mut(patch.id) {
Some(instance) => instance,
None => {
@ -307,6 +314,11 @@ fn apply_update_child(context: &mut PatchApplyContext, tree: &mut RojoTree, patc
applied_patch.changed_properties.insert(key, property_entry);
}
if let (Some(old_class), Some(new_class)) = (&old_class_name, &applied_patch.changed_class_name)
{
tree.update_script_tracking(patch.id, old_class, new_class.as_str());
}
defer_ref_properties(tree, patch.id, context);
context.applied_patch_set.updated.push(applied_patch)

View file

@ -1,5 +1,5 @@
use std::{
collections::{HashMap, VecDeque},
collections::{HashMap, HashSet, VecDeque},
path::{Path, PathBuf},
};
@ -12,6 +12,11 @@ use crate::{multimap::MultiMap, RojoRef};
use super::{InstanceMetadata, InstanceSnapshot, InstigatingSource};
#[inline]
pub(crate) fn is_script_class(class_name: &str) -> bool {
matches!(class_name, "Script" | "LocalScript" | "ModuleScript")
}
/// An expanded variant of rbx_dom_weak's `WeakDom` that tracks additional
/// metadata per instance that's Rojo-specific.
///
@ -39,6 +44,11 @@ pub struct RojoTree {
/// the same RojoRef for multiple different instances. An entry containing
/// multiple elements is an error condition that should be raised to the user.
specified_id_to_refs: MultiMap<RojoRef, Ref>,
/// Refs of all Script, LocalScript, and ModuleScript instances in the tree.
/// Maintained incrementally for fast scripts-only mode filtering in the
/// serve API without walking the entire tree.
script_refs: HashSet<Ref>,
}
impl RojoTree {
@ -52,6 +62,7 @@ impl RojoTree {
metadata_map: HashMap::new(),
path_to_ids: MultiMap::new(),
specified_id_to_refs: MultiMap::new(),
script_refs: HashSet::new(),
};
let root_ref = tree.inner.root_ref();
@ -124,6 +135,8 @@ impl RojoTree {
_ => Vec::new(),
};
let is_script = is_script_class(snapshot.class_name.as_ref());
let builder = InstanceBuilder::empty()
.with_class(snapshot.class_name)
.with_name(snapshot.name.into_owned())
@ -133,6 +146,10 @@ impl RojoTree {
let referent = self.inner.insert(parent_ref, builder);
self.insert_metadata(referent, snapshot.metadata);
if is_script {
self.script_refs.insert(referent);
}
for child in snapshot.children {
self.insert_instance(referent, child);
}
@ -146,6 +163,7 @@ impl RojoTree {
while let Some(id) = to_move.pop_front() {
self.remove_metadata(id);
self.script_refs.remove(&id);
if let Some(instance) = self.inner.get_by_ref(id) {
to_move.extend(instance.children().iter().copied());
@ -231,6 +249,20 @@ impl RojoTree {
self.specified_id_to_refs.insert(specified, id);
}
pub fn script_refs(&self) -> &HashSet<Ref> {
&self.script_refs
}
pub fn update_script_tracking(&mut self, id: Ref, old_class: &str, new_class: &str) {
let was_script = is_script_class(old_class);
let is_script = is_script_class(new_class);
if was_script && !is_script {
self.script_refs.remove(&id);
} else if !was_script && is_script {
self.script_refs.insert(id);
}
}
/// Looks up an instance by its filesystem-name path in the tree.
///
/// Each segment is matched against the **filesystem name** of child

View file

@ -393,6 +393,7 @@ impl ApiService {
game_id: self.serve_session.game_id(),
root_instance_id,
sync_source_only: true,
sync_scripts_only: self.serve_session.sync_scripts_only(),
ignore_hidden_services,
visible_services,
git_metadata,
@ -450,7 +451,7 @@ impl ApiService {
let body = request.into_body().collect().await.unwrap().to_bytes();
let request: WriteRequest = match deserialize_msgpack(&body) {
let mut request: WriteRequest = match deserialize_msgpack(&body) {
Ok(request) => request,
Err(err) => {
return msgpack(
@ -467,6 +468,24 @@ impl ApiService {
);
}
if self.serve_session.sync_scripts_only() {
let tree = self.serve_session.tree();
let before = request.updated.len();
request.updated.retain(|update| {
tree.get_instance(update.id)
.map(|inst| {
crate::snapshot::is_script_class(inst.class_name().as_str())
})
.unwrap_or(false)
});
let filtered = before - request.updated.len();
if filtered > 0 {
log::debug!(
"Scripts-only mode: filtered {filtered} non-script update(s) from write request"
);
}
}
// Process removed instances (syncback: delete files from Rojo filesystem)
// Phase 1: Gather paths with the tree lock held.
// Phase 2: Delete files without the lock.
@ -3101,39 +3120,43 @@ impl ApiService {
let mut instances = HashMap::new();
if scripts_only {
// In scripts-only mode, we need to:
// 1. Find all scripts
// 2. Include their ancestor chains (so the tree structure is valid)
// 3. Mark non-script ancestors with ignoreUnknownInstances: true
// 4. Filter children to only include instances in our set
// Use the script index to avoid walking the entire tree.
// For each script, walk UP to see if it's a descendant of a
// requested ID, collecting the ancestor chain along the way.
let mut included_ids: HashSet<Ref> = HashSet::new();
let requested_set: HashSet<Ref> = requested_ids.iter().copied().collect();
// First pass: collect all script IDs and their ancestors
for id in &requested_ids {
if let Some(instance) = tree.get_instance(*id) {
collect_scripts_and_ancestors(&tree, instance.id(), &mut included_ids);
for descendant in tree.descendants(*id) {
collect_scripts_and_ancestors(&tree, descendant.id(), &mut included_ids);
for &script_id in tree.script_refs() {
let mut chain = Vec::new();
let mut current = script_id;
let mut is_descendant = false;
while let Some(inst) = tree.get_instance(current) {
if included_ids.contains(&current) {
is_descendant = true;
break;
}
if requested_set.contains(&current) {
is_descendant = true;
chain.push(current);
break;
}
chain.push(current);
let parent = inst.parent();
if parent.is_none() {
break;
}
current = parent;
}
if is_descendant {
included_ids.extend(chain);
}
}
// Second pass: create Instance objects for included IDs
for id in &requested_ids {
if let Some(instance) = tree.get_instance(*id) {
if included_ids.contains(id) {
let inst = instance_for_scripts_only(instance, &included_ids);
instances.insert(*id, inst);
}
for descendant in tree.descendants(*id) {
let desc_id = descendant.id();
if included_ids.contains(&desc_id) {
let inst = instance_for_scripts_only(descendant, &included_ids);
instances.insert(desc_id, inst);
}
}
for &id in &included_ids {
if let Some(instance) = tree.get_instance(id) {
instances.insert(id, instance_for_scripts_only(instance, &included_ids));
}
}
} else {
@ -3532,17 +3555,13 @@ fn parent_requirements(class: &str) -> Option<&str> {
})
}
/// Checks if a class name is a script type (Script, LocalScript, ModuleScript).
#[inline]
fn is_script_class(class_name: &str) -> bool {
matches!(class_name, "Script" | "LocalScript" | "ModuleScript")
}
use crate::snapshot::is_script_class;
/// Filters a SubscribeMessage to only include scripts and their ancestors.
/// Non-script ancestors get ignoreUnknownInstances: true.
fn filter_subscribe_message_for_scripts(
tree: &crate::snapshot::RojoTree,
msg: &mut SubscribeMessage<'_>,
fn filter_subscribe_message_for_scripts<'a>(
tree: &'a crate::snapshot::RojoTree,
msg: &mut SubscribeMessage<'a>,
) {
use std::borrow::Cow;
@ -3574,9 +3593,19 @@ fn filter_subscribe_message_for_scripts(
// Filter added to only include IDs in our set
msg.added.retain(|id, _| included_ids.contains(id));
// ALL instances get ignoreUnknownInstances: true in scripts-only mode
// This ensures we only sync script content, never delete/modify other instances
// Non-scripts also have their properties cleared - they're just tree structure
// Inject ancestors that the plugin may not have yet. When a new script
// appears in a previously-pruned subtree, its ancestor containers exist
// in the tree but weren't part of this patch. Without injection the
// plugin receives an orphaned script with an unknown parent.
for id in included_ids.iter().copied().collect::<Vec<_>>() {
if !msg.added.contains_key(&id) {
if let Some(tree_inst) = tree.get_instance(id) {
let inst = instance_for_scripts_only(tree_inst, &included_ids);
msg.added.insert(id, inst);
}
}
}
for inst in msg.added.values_mut() {
inst.metadata = Some(InstanceMetadata {
ignore_unknown_instances: true,
@ -3612,29 +3641,6 @@ fn filter_subscribe_message_for_scripts(
});
}
/// Recursively collects a script instance and all its ancestors into the set.
/// Only adds to the set if the instance is a script (then adds ancestors too).
fn collect_scripts_and_ancestors(
tree: &crate::snapshot::RojoTree,
id: Ref,
included_ids: &mut HashSet<Ref>,
) {
if let Some(instance) = tree.get_instance(id) {
if is_script_class(instance.class_name().as_str()) {
// This is a script - add it and all ancestors
let mut current_id = id;
while let Some(inst) = tree.get_instance(current_id) {
included_ids.insert(current_id);
let parent_id = inst.parent();
if parent_id.is_none() || included_ids.contains(&parent_id) {
break;
}
current_id = parent_id;
}
}
}
}
/// Creates an Instance for scripts-only mode.
/// - Scripts get their properties synced normally
/// - Non-scripts only provide tree structure (no properties synced)

View file

@ -258,6 +258,10 @@ pub struct ServerInfoResponse {
/// Other property changes will be ignored.
#[serde(default)]
pub sync_source_only: bool,
/// When true, only script instances (and their ancestors for tree structure)
/// are synced. The plugin should skip non-script changes in two-way sync.
#[serde(default)]
pub sync_scripts_only: bool,
/// When true, hidden/internal services should be ignored during sync.
/// The plugin should not mark these services as "to delete" during forward sync.
#[serde(default)]