Enhance live syncback process with improved reference handling and float precision

- Implemented ObjectValue carriers for service-level references, allowing for accurate serialization of non-direct child references during syncback.
- Updated `encodeService` to include explicit handling of attributes and tags, ensuring they are properly encoded and preventing data loss.
- Refactored `build_dom_from_chunks` to manage ObjectValue carriers and resolve references correctly, enhancing data integrity.
- Truncated float values to f32 precision in `UnresolvedValue::from_variant`, aligning with rbxl storage requirements and improving consistency.

These changes address critical issues in the live syncback process, enhancing reliability and data accuracy.
This commit is contained in:
Joe 2026-02-18 09:11:59 -06:00
parent 7dc4f52793
commit 39c8bd41f4
8 changed files with 342 additions and 121 deletions

View file

@ -14,52 +14,182 @@ todos:
- id: fix-float-rounding
content: Truncate all float values to f32 in UnresolvedValue::from_variant for rbxl parity
status: completed
- id: fix-index-refs
content: Change service-level refs to index-based with ObjectValue carriers (childCount + refTargetCount, 1-based index, 0=nil)
status: completed
isProject: false
---
# Live Syncback Fixes
## Issue 1: Workspace Attributes/Properties Lost
## Issues 1-4: DONE
**Root cause:** `encodeService.lua` iterates `classDescriptor.properties` but the Luau RbxDom database may not list `Attributes` and `Tags` as enumerable properties. `encodeInstance.lua` has explicit `encodeAttributes()` / `encodeTags()` helpers using `instance:GetAttributes()` / `instance:GetTags()` directly.
## Issue 5: Index-Based Service Refs via ObjectValue Carriers
**Fix:** Add explicit Attributes and Tags encoding to `[plugin/src/ChangeBatcher/encodeService.lua](plugin/src/ChangeBatcher/encodeService.lua)` after the property loop. Add `if propertyName == "Attributes" or propertyName == "Tags" then continue end` to the main loop to avoid double-encoding.
**Problem:** Service Ref targets aren't always direct children. Can't include arbitrary instances directly in the rbxm without duplicating or breaking serialization.
## Issue 2: Lighting.ClockTime Appearing
**Solution:** Create temporary ObjectValue instances as Ref carriers. The ObjectValue's `Value` property IS a Ref that gets preserved in the rbxm because both the ObjectValue and the target are in the same blob.
**Root cause:** Luau DB marks ClockTime as serializable; Rust DB marks it as DoesNotSerialize. Plugin sends it, CLI syncback filters it.
**Layout:**
**Fix:** In `[src/cli/serve.rs](src/cli/serve.rs)` `build_dom_from_chunks`, filter each plugin-sent property through `should_property_serialize` from the Rust reflection DB before applying it to the builder.
```
allChildren = [
...svc1_children, ...svc1_objectValues,
...svc2_children, ...svc2_objectValues,
...
]
```
## Issue 3: Rojo_Ref_CurrentCamera Leaking (pre-existing bug)
### Plugin -- `[plugin/src/ChangeBatcher/encodeService.lua](plugin/src/ChangeBatcher/encodeService.lua)`
**Root cause:** `collect_referents` captures `Workspace.CurrentCamera` -> Camera ref before camera is destroyed. `link_referents` writes `Rojo_Ref_CurrentCamera` without checking target still exists.
Return metadata + list of ObjectValue carrier instances to append:
**Fix:** In `[src/syncback/mod.rs](src/syncback/mod.rs)`, after camera destruction (line 222), filter `deferred_referents.path_links` to remove entries whose target ref no longer exists.
```lua
local children = service:GetChildren()
local refTargets = {} -- ObjectValue carriers
## Issue 4: Float Precision
for propertyName ... do
if descriptor.dataType == "Ref" then
local readOk, target = descriptor:read(service)
if readOk and target then
-- Check if target is a direct child
for i, child in children do
if child == target then
refs[propertyName] = i -- 1-based into children range
goto found
end
end
-- Not a child: create ObjectValue carrier
local carrier = Instance.new("ObjectValue")
carrier.Name = propertyName
carrier.Value = target
table.insert(refTargets, carrier)
refs[propertyName] = #children + #refTargets
::found::
end
continue
end
end
**Root cause:** Live syncback sends Float64 from Lua; rbxl stores Float32. Tiny diffs cause properties to fail default comparison.
chunk.childCount = #children
chunk.refTargetCount = #refTargets
return chunk, refTargets
```
**Fix:** In `[src/resolution.rs](src/resolution.rs)` `UnresolvedValue::from_variant`, truncate ALL float values to f32 precision. This is the single chokepoint for json5 writes:
### Plugin -- `[plugin/src/App/init.lua](plugin/src/App/init.lua)` `performSyncback`
- **Float32(n)** (line 67): already f32, `n as f64` is fine (no change needed)
- **Float64(n)** (line 68): change to `(n as f32) as f64` to truncate
- **Vector2** (line 82): `(vector.x as f32) as f64` etc
- **Vector3** (line 85): same
- **CFrame** (lines 90-103): all 12 components
- **Color3** (line 88): already f32, fine
- **UDim/UDim2**: fall through to `FullyQualified` -- add a `round_variant` helper that truncates float components to f32 before wrapping
Append children + ObjectValue carriers, clean up after serialization:
Helper:
```lua
local allRefTargets = {}
for _, className in SYNCBACK_SERVICES do
local ok, service = pcall(game.FindService, game, className)
if ok and service then
local chunk, refTargets = encodeService(service)
for _, child in service:GetChildren() do
table.insert(allChildren, child)
end
for _, carrier in refTargets do
table.insert(allChildren, carrier)
table.insert(allRefTargets, carrier)
end
table.insert(services, chunk)
end
end
local data = buffer.create(0)
if #allChildren > 0 then
data = SerializationService:SerializeInstancesAsync(allChildren)
end
-- Clean up temporary ObjectValue carriers
for _, carrier in allRefTargets do
carrier:Destroy()
end
```
### Server -- `[src/web/interface.rs](src/web/interface.rs)`
```rust
fn truncate_to_f32(v: f64) -> f64 {
(v as f32) as f64
pub struct ServiceChunk {
pub class_name: String,
#[serde(default)]
pub child_count: u32,
#[serde(default)]
pub ref_target_count: u32,
#[serde(default)]
pub properties: HashMap<String, Variant>,
#[serde(default)]
pub refs: HashMap<String, u32>, // prop_name -> 1-based index, 0=nil
}
```
Remove `ServiceRef` struct.
### Server -- `[src/cli/serve.rs](src/cli/serve.rs)` `build_dom_from_chunks`
```rust
let child_count = chunk.child_count as usize;
let ref_count = chunk.ref_target_count as usize;
let total = child_count + ref_count;
let end = (cursor + total).min(cloned_children.len());
let service_range = &cloned_children[cursor..end];
// Parent only the real children
for &child_ref in &service_range[..child_count.min(service_range.len())] {
dom.transfer_within(child_ref, service_ref);
}
// Resolve refs using the full range
let mut carriers_to_destroy = Vec::new();
if !chunk.refs.is_empty() {
for (prop_name, &idx) in &chunk.refs {
if idx == 0 || (idx as usize - 1) >= service_range.len() {
continue;
}
let carrier_ref = service_range[idx as usize - 1];
// If in the ref target range, it's an ObjectValue carrier
if (idx as usize) > child_count {
let carrier = dom.get_by_ref(carrier_ref).unwrap();
if let Some(Variant::Ref(actual_target)) = carrier.properties.get("Value") {
let service = dom.get_by_ref_mut(service_ref).unwrap();
service.properties.insert(
prop_name.as_str().into(),
Variant::Ref(*actual_target),
);
}
carriers_to_destroy.push(carrier_ref);
} else {
// Direct child ref
let service = dom.get_by_ref_mut(service_ref).unwrap();
service.properties.insert(
prop_name.as_str().into(),
Variant::Ref(carrier_ref),
);
}
}
}
// Destroy ObjectValue carriers
for carrier_ref in carriers_to_destroy {
dom.destroy(carrier_ref);
}
cursor = end;
```
### Test helpers -- `[tests/rojo_test/serve_util.rs](tests/rojo_test/serve_util.rs)`
- `ServiceEntry` gets `ref_targets: Vec<InstanceBuilder>` for ObjectValue carriers
- `make_service_chunk_full` refs param: `Vec<(&str, u32)>` (prop_name, 1-based index)
- `build_syncback_request` appends ref_targets after children per service
- `make_rbxl_from_chunks` resolves refs: direct children by index, carriers by reading `Value` property
### Tests
- Update `parity_camera_not_synced` to use index-based ref with ObjectValue carrier
- Existing tests that don't use refs continue to work (empty refs map, refTargetCount=0)
## Deferred: Matching Parity
Matching differences between live and CLI syncback are likely downstream of the above fixes (extra properties inflating scores, missing attributes, float diffs). Revisit after these four fixes land. If matching is still off, consider reusing the previously-served tree instead of creating a fresh `ServeSession::new_oneshot` in `run_live_syncback`.
Revisit after these fixes land.