Implement Syncback to support converting Roblox files to a Rojo project (#937)

This is a very large commit.
Consider checking the linked PR for more information.
This commit is contained in:
Micah 2025-11-19 09:21:33 -08:00 • committed by GitHub
parent 071b6e7e23
commit 9b5a07191b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
239 changed files with 5325 additions and 225 deletions

122
src/syncback/hash/mod.rs Normal file
View file

@ -0,0 +1,122 @@
//! Hashing utilities for a WeakDom.
mod variant;
pub use variant::*;
use blake3::{Hash, Hasher};
use rbx_dom_weak::{
types::{Ref, Variant},
Instance, Ustr, WeakDom,
};
use std::collections::HashMap;
use crate::{variant_eq::variant_eq, Project};
use super::{descendants, filter_properties_preallocated};
/// Returns a map of every `Ref` in the `WeakDom` to a hashed version of the
/// `Instance` it points to, including the properties and descendants of the
/// `Instance`.
///
/// The hashes **do** include the descendants of the Instances in them,
/// so they should only be used for comparing subtrees directly.
pub fn hash_tree(project: &Project, dom: &WeakDom, root_ref: Ref) -> HashMap<Ref, Hash> {
let mut order = descendants(dom, root_ref);
let mut map: HashMap<Ref, Hash> = HashMap::with_capacity(order.len());
let mut prop_list = Vec::with_capacity(2);
let mut child_hashes = Vec::new();
while let Some(referent) = order.pop() {
let inst = dom.get_by_ref(referent).unwrap();
let mut hasher = hash_inst_filtered(project, inst, &mut prop_list);
add_children(inst, &map, &mut child_hashes, &mut hasher);
map.insert(referent, hasher.finalize());
}
map
}
/// Hashes a single Instance from the provided WeakDom, if it exists.
///
/// This function filters properties using user-provided syncing rules from
/// the passed project.
#[inline]
pub fn hash_instance(project: &Project, dom: &WeakDom, referent: Ref) -> Option<Hash> {
let mut prop_list = Vec::with_capacity(2);
let inst = dom.get_by_ref(referent)?;
Some(hash_inst_filtered(project, inst, &mut prop_list).finalize())
}
/// Adds the hashes of children for an Instance to the provided Hasher.
fn add_children(
inst: &Instance,
map: &HashMap<Ref, Hash>,
child_hashes: &mut Vec<[u8; 32]>,
hasher: &mut Hasher,
) {
for child_ref in inst.children() {
if let Some(hash) = map.get(child_ref) {
child_hashes.push(*hash.as_bytes())
} else {
panic!("Invariant violated: child not hashed before parent")
}
}
child_hashes.sort_unstable();
for hash in child_hashes.drain(..) {
hasher.update(&hash);
}
}
/// Performs hashing on an Instance using a filtered property list.
/// Does not include the hashes of any children.
fn hash_inst_filtered<'inst>(
project: &Project,
inst: &'inst Instance,
prop_list: &mut Vec<(Ustr, &'inst Variant)>,
) -> Hasher {
filter_properties_preallocated(project, inst, prop_list);
hash_inst_prefilled(inst, prop_list)
}
/// Performs hashing on an Instance using a pre-filled list of properties.
/// It is assumed the property list is **not** sorted, so it is sorted in-line.
fn hash_inst_prefilled<'inst>(
inst: &'inst Instance,
prop_list: &mut Vec<(Ustr, &'inst Variant)>,
) -> Hasher {
let mut hasher = Hasher::new();
hasher.update(inst.name.as_bytes());
hasher.update(inst.class.as_bytes());
prop_list.sort_unstable_by_key(|(name, _)| *name);
let descriptor = rbx_reflection_database::get()
.unwrap()
.classes
.get(inst.class.as_str());
if let Some(descriptor) = descriptor {
for (name, value) in prop_list.drain(..) {
hasher.update(name.as_bytes());
if let Some(default) = descriptor.default_properties.get(name.as_str()) {
if !variant_eq(default, value) {
hash_variant(&mut hasher, value)
}
} else {
hash_variant(&mut hasher, value)
}
}
} else {
for (name, value) in prop_list.drain(..) {
hasher.update(name.as_bytes());
hash_variant(&mut hasher, value)
}
}
hasher
}

View file

@ -0,0 +1,212 @@
use blake3::Hasher;
use rbx_dom_weak::types::{ContentType, PhysicalProperties, Variant, Vector3};
macro_rules! round {
($value:expr) => {
(($value * 10.0).round() / 10.0)
};
}
macro_rules! n_hash {
($hash:ident, $($num:expr),*) => {
{$(
$hash.update(&($num).to_le_bytes());
)*}
};
}
macro_rules! hash {
($hash:ident, $value:expr) => {{
$hash.update($value);
}};
}
/// Places `value` into the provided hasher.
pub fn hash_variant(hasher: &mut Hasher, value: &Variant) {
// We need to round floats, though I'm not sure to what degree we can
// realistically do that.
match value {
Variant::Attributes(attrs) => {
let mut sorted: Vec<(&String, &Variant)> = attrs.iter().collect();
sorted.sort_unstable_by_key(|(name, _)| *name);
for (name, attribute) in sorted {
hasher.update(name.as_bytes());
hash_variant(hasher, attribute);
}
}
Variant::Axes(a) => hash!(hasher, &[a.bits()]),
Variant::BinaryString(bytes) => hash!(hasher, bytes.as_ref()),
Variant::Bool(bool) => hash!(hasher, &[*bool as u8]),
Variant::BrickColor(color) => n_hash!(hasher, *color as u16),
Variant::CFrame(cf) => {
vector_hash(hasher, cf.position);
vector_hash(hasher, cf.orientation.x);
vector_hash(hasher, cf.orientation.y);
vector_hash(hasher, cf.orientation.z);
}
Variant::Color3(color) => {
n_hash!(hasher, round!(color.r), round!(color.g), round!(color.b))
}
Variant::Color3uint8(color) => hash!(hasher, &[color.r, color.b, color.g]),
Variant::ColorSequence(seq) => {
let mut new = Vec::with_capacity(seq.keypoints.len());
for keypoint in &seq.keypoints {
new.push(keypoint);
}
new.sort_unstable_by(|a, b| round!(a.time).partial_cmp(&round!(b.time)).unwrap());
for keypoint in new {
n_hash!(
hasher,
round!(keypoint.time),
round!(keypoint.color.r),
round!(keypoint.color.g),
round!(keypoint.color.b)
)
}
}
Variant::Content(content) => match content.value() {
ContentType::None => {
hash!(hasher, &[0]);
}
ContentType::Uri(uri) => {
hash!(hasher, &[1]);
hash!(hasher, uri.as_bytes());
}
ContentType::Object(referent) => {
hash!(hasher, &[2]);
hash!(hasher, referent.to_string().as_bytes())
}
other => {
panic!("the ContentType {other:?} cannot be hashed as a Variant")
}
},
Variant::ContentId(content) => {
let s: &str = content.as_ref();
hash!(hasher, s.as_bytes())
}
Variant::Enum(e) => n_hash!(hasher, e.to_u32()),
Variant::Faces(f) => hash!(hasher, &[f.bits()]),
Variant::Float32(n) => n_hash!(hasher, round!(*n)),
Variant::Float64(n) => n_hash!(hasher, round!(n)),
Variant::Font(f) => {
n_hash!(hasher, f.weight as u16);
n_hash!(hasher, f.style as u8);
hash!(hasher, f.family.as_bytes());
if let Some(cache) = &f.cached_face_id {
hash!(hasher, &[0x01]);
hash!(hasher, cache.as_bytes());
} else {
hash!(hasher, &[0x00]);
}
}
Variant::Int32(n) => n_hash!(hasher, n),
Variant::Int64(n) => n_hash!(hasher, n),
Variant::MaterialColors(n) => hash!(hasher, n.encode().as_slice()),
Variant::NetAssetRef(net_asset) => hash!(hasher, net_asset.hash().as_bytes()),
Variant::NumberRange(nr) => n_hash!(hasher, round!(nr.max), round!(nr.min)),
Variant::NumberSequence(seq) => {
let mut new = Vec::with_capacity(seq.keypoints.len());
for keypoint in &seq.keypoints {
new.push(keypoint);
}
new.sort_unstable_by(|a, b| round!(a.time).partial_cmp(&round!(b.time)).unwrap());
for keypoint in new {
n_hash!(
hasher,
round!(keypoint.time),
round!(keypoint.value),
round!(keypoint.envelope)
)
}
}
Variant::OptionalCFrame(maybe_cf) => {
if let Some(cf) = maybe_cf {
hash!(hasher, &[0x01]);
vector_hash(hasher, cf.position);
vector_hash(hasher, cf.orientation.x);
vector_hash(hasher, cf.orientation.y);
vector_hash(hasher, cf.orientation.z);
} else {
hash!(hasher, &[0x00]);
}
}
Variant::PhysicalProperties(properties) => match properties {
PhysicalProperties::Default => hash!(hasher, &[0x00]),
PhysicalProperties::Custom(custom) => {
hash!(hasher, &[0x00]);
n_hash!(
hasher,
round!(custom.density()),
round!(custom.friction()),
round!(custom.elasticity()),
round!(custom.friction_weight()),
round!(custom.elasticity_weight()),
round!(custom.acoustic_absorption())
)
}
},
Variant::Ray(ray) => {
vector_hash(hasher, ray.origin);
vector_hash(hasher, ray.direction);
}
Variant::Rect(rect) => n_hash!(
hasher,
round!(rect.max.x),
round!(rect.max.y),
round!(rect.min.x),
round!(rect.min.y)
),
Variant::Ref(referent) => hash!(hasher, referent.to_string().as_bytes()),
Variant::Region3(region) => {
vector_hash(hasher, region.max);
vector_hash(hasher, region.min);
}
Variant::Region3int16(region) => {
n_hash!(
hasher,
region.max.x,
region.max.y,
region.max.z,
region.min.x,
region.min.y,
region.min.z
)
}
Variant::SecurityCapabilities(capabilities) => n_hash!(hasher, capabilities.bits()),
Variant::SharedString(sstr) => hash!(hasher, sstr.hash().as_bytes()),
Variant::String(str) => hash!(hasher, str.as_bytes()),
Variant::Tags(tags) => {
let mut dupe: Vec<&str> = tags.iter().collect();
dupe.sort_unstable();
for tag in dupe {
hash!(hasher, tag.as_bytes())
}
}
Variant::UDim(udim) => n_hash!(hasher, round!(udim.scale), udim.offset),
Variant::UDim2(udim) => n_hash!(
hasher,
round!(udim.y.scale),
udim.y.offset,
round!(udim.x.scale),
udim.x.offset
),
Variant::Vector2(v2) => n_hash!(hasher, round!(v2.x), round!(v2.y)),
Variant::Vector2int16(v2) => n_hash!(hasher, v2.x, v2.y),
Variant::Vector3(v3) => vector_hash(hasher, *v3),
Variant::Vector3int16(v3) => n_hash!(hasher, v3.x, v3.y, v3.z),
// Hashing UniqueId properties doesn't make sense
Variant::UniqueId(_) => (),
unknown => {
log::warn!(
"Encountered unknown Variant {:?} while hashing",
unknown.ty()
)
}
}
}
fn vector_hash(hasher: &mut Hasher, vector: Vector3) {
n_hash!(hasher, round!(vector.x), round!(vector.y), round!(vector.z))
}