Update protocol version to 6 and enhance API documentation

- Incremented the protocol version from 5 to 6 in the Atlas API documentation, reflecting the latest changes in server/plugin compatibility.
- Added new API endpoints for live patch streaming and updated the server info response structure to include additional fields such as `unexpectedPlaceIds` and `gameId`.
- Improved the WebSocket protocol documentation to align with the new versioning.
- Enhanced metadata handling in the syncback rules and clarified the structure of meta files, including the introduction of new properties and adjustments to existing ones.

These updates improve clarity and maintainability of the API documentation and ensure better alignment with the latest implementation changes.
This commit is contained in:
Joe 2026-02-11 18:24:30 -06:00
parent f798f3f5e8
commit 200c5ca219
4 changed files with 127 additions and 113 deletions

View file

@ -29,7 +29,7 @@ We are building a maintainable codebase. During audits, watch for duplicated log
The goal is clean, DRY code that is easy to reason about and hard to get wrong. If the same "compute meta path, check if name needs slugify, upsert/remove name field" sequence appears in 3+ places, that's a refactor candidate.
**Protocol Version:** 5 | Server/plugin MAJOR.MINOR must match
**Protocol Version:** 6 | Server/plugin MAJOR.MINOR must match
## Architecture
@ -50,19 +50,25 @@ Studio → ChangeBatcher → Plugin → WebSocket → Server → Filesystem
| `/api/write` | POST | Two-way sync: plugin sends changes |
| `/api/open/:id` | POST | Open script in editor (`{"cursor": [line, col]}`) |
| `/api/ref-patch/:id` | GET | Fix Ref properties after syncback |
| `/api/socket/:cursor` | GET | WebSocket upgrade for live patch streaming |
### Server Info Response (`GET /api/rojo`)
```json
{
"serverVersion": "7.7.0-rc.1",
"protocolVersion": 5,
"serverFork": "atlas",
"protocolVersion": 6,
"sessionId": "uuid",
"projectName": "MyProject",
"expectedPlaceIds": [123456],
"unexpectedPlaceIds": [111111],
"rootInstanceId": "00000000-0000-0000-0000-000000000000",
"syncSourceOnly": true,
"visibleServices": ["Workspace", "ServerScriptService"]
"visibleServices": ["Workspace", "ServerScriptService"],
"placeId": 123456,
"gameId": 789012,
"ignoreHiddenServices": true
}
```
@ -70,17 +76,18 @@ Studio → ChangeBatcher → Plugin → WebSocket → Server → Filesystem
```json
{
"sessionId": "uuid",
"removed": ["id-1"],
"added": { "id-2": { "id": "id-2", "className": "Folder", "name": "New", "properties": {}, "children": [] } },
"updated": [{ "id": "id-3", "changedName": "Renamed", "changedProperties": { "Source": "print('hi')" } }]
"added": { "id-2": { "parent": "parent-id", "className": "Folder", "name": "New", "properties": {}, "children": [] } },
"updated": [{ "id": "id-3", "changedName": "Renamed", "changedClassName": "Script", "changedProperties": { "Source": "print('hi')" }, "changedMetadata": {} }]
}
```
**Format detection:** `Name/init.luau` → directory | `Name.luau` → file | neither → use `has_children` (`.lua` also supported)
## WebSocket Protocol (v5)
## WebSocket Protocol (v6)
**Connection:** `ws://host:port/api/rojo`
**Connection:** `ws://host:port/api/socket/:cursor`
1. Connect → Receive `ServerInfo` handshake → Send `{type = "Subscribe"}` → Receive patches
@ -112,11 +119,15 @@ Studio → ChangeBatcher → Plugin → WebSocket → Server → Filesystem
```rust
pub struct ServeSession {
session_id: Uuid,
tree_handle: Arc<Mutex<RojoTree>>,
message_queue: MessageQueue,
project: Project,
vfs: Vfs,
session_id: SessionId,
tree: Arc<Mutex<RojoTree>>,
message_queue: Arc<MessageQueue<AppliedPatchSet>>,
root_project: Project,
vfs: Arc<Vfs>,
change_processor: ChangeProcessor,
start_time: Instant,
tree_mutation_sender: Sender<PatchSet>,
suppressed_paths: Arc<Mutex<HashMap<PathBuf, (usize, usize)>>>,
}
```
@ -223,8 +234,8 @@ for _, update in patch.updated do Reconciler.hydrate(instanceMap:fromId(update.i
│ Sends PatchSet to tree_mutation_sender channel │
│ ▼ │
│ ChangeProcessor::handle_tree_event() [src/change_processor.rs] │
│ │ For removed: fs::remove_file(instigating_source.path) │
│ │ For updated: if "Source" property → fs::write(path, value) │
│ │ For removed: fs::remove_file(path) + remove meta │
│ │ For updated: Source writes, renames, ClassName changes, properties │
│ ▼ │
│ apply_patch_set() → Updates RojoTree │
│ ▼ │
@ -510,54 +521,14 @@ true, false, 42, "string"
### Server-Side: ChangeProcessor (`src/change_processor.rs`)
Receives patches from plugin via `tree_mutation_receiver` channel:
Receives patches from plugin via `tree_mutation_receiver` channel. Handles removals, Source writes, renames (with slugify/dedup/meta lifecycle), ClassName changes (script type transitions via file renames), and property writes to meta/model files.
```rust
fn handle_tree_event(&self, patch_set: PatchSet) {
let mut tree = self.tree.lock().unwrap();
// Process removals
for &id in &patch_set.removed_instances {
if let Some(instance) = tree.get_instance(id) {
match &instance.metadata().instigating_source {
Some(InstigatingSource::Path(path)) => {
log::info!("Two-way sync: Removing file {}", path.display());
fs::remove_file(path)?;
}
Some(InstigatingSource::ProjectNode { .. }) => {
log::warn!("Cannot remove instance from project file");
}
None => {
log::warn!("Cannot remove instance without instigating source");
}
}
}
}
// Process updates
for update in &patch_set.updated_instances {
if let Some(instance) = tree.get_instance(update.id) {
// Currently only Source property is supported
if let Some(Variant::String(source)) = update.changed_properties.get("Source") {
match &instance.metadata().instigating_source {
Some(InstigatingSource::Path(path)) => {
log::info!("Two-way sync: Writing Source to {}", path.display());
fs::write(path, source)?;
}
_ => log::warn!("Cannot update instance without path source"),
}
}
// Other property changes logged but not written
if update.changed_name.is_some() { log::warn!("Cannot rename instances yet"); }
if update.changed_class_name.is_some() { log::warn!("Cannot change ClassName yet"); }
}
}
apply_patch_set(&mut tree, patch_set); // Update DOM
self.message_queue.push_messages(&[applied_patch]); // Broadcast to plugins
}
```
**Capabilities:**
- **Removals:** Deletes file + associated meta file via `InstigatingSource::Path`
- **Source writes:** Updates script file contents
- **Renames:** Slugifies new name, deduplicates against siblings, renames file on disk, creates/updates/removes meta `name` field as needed. Handles both standalone files and init-file directories.
- **ClassName changes:** Script-to-script transitions (e.g. ModuleScript → Script) via file extension renames
- **Property writes:** Attributes and other properties written to meta/model files via `syncback_updated_properties` in `api.rs`
**InstigatingSource:** Every instance in RojoTree tracks where it came from:
- `InstigatingSource::Path(PathBuf)` - Created from a file, can be written back
@ -620,15 +591,10 @@ end
| **Undo/redo triggers spurious changes** | Studio's ChangeHistoryService fires property changes during undo | Can cause unexpected writes or conflicts |
| **Rapid changes interleave** | 200ms batch window may split logically related changes | Partial updates can leave inconsistent state |
| **Reparenting not supported** | `Parent` property changes to non-nil values are ignored | Moving instances in Studio doesn't sync |
| **Renaming not supported** | `changedName` logged but not written | Renaming in Studio doesn't sync |
| **ClassName changes ignored** | Would require file type change | Changing script type doesn't sync |
| **Only Source property writes** | Other properties logged but not written to filesystem | Only script content syncs reliably |
| **Project node instances immutable** | InstigatingSource::ProjectNode cannot be modified | Instances from project file can't be changed |
| **Duplicate-named siblings skipped** | Path ambiguity makes reliable sync impossible | Instances with same-name siblings ignored |
| **Ref properties complex** | Instance references may become invalid when instances move | ObjectValue.Value may not sync correctly |
**Recommended workflow:** Use one-way sync (server → Studio) as primary. Use two-way sync only for script Source changes with careful attention to undo/redo behavior.
## Protocol Enforcement
```rust

View file

@ -101,6 +101,7 @@ Used in `tree` and recursively for all children.
| `*.lua` | ModuleScript | — | Legacy |
| `*.server.lua` | Script | Legacy | Legacy |
| `*.client.lua` | LocalScript | — | Legacy |
| `*.plugin.lua` | Script | Plugin | Legacy |
**Prefer `.luau`** — modern RunContext. `.lua` is legacy (still supported).
@ -199,6 +200,9 @@ Configure `syncbackRules` object for Roblox → filesystem sync:
| `syncCurrentCamera` | bool | `false` | Whether to sync `Workspace.CurrentCamera` |
| `syncUnscriptable` | bool | `true` | Whether to sync unscriptable properties |
| `ignoreReferents` | bool | `false` | Whether to skip Ref properties (e.g., `Model.PrimaryPart`) |
| `createIgnoreDirPaths` | bool | `false` | Whether `/**` in ignore paths also matches the directory itself |
| `ignoreHiddenServices` | bool | — | Override root-level `ignoreHiddenServices` for syncback |
| `warnDuplicateNames` | bool | `false` | Warn on duplicate child names during syncback |
```json5
{
@ -212,42 +216,70 @@ Configure `syncbackRules` object for Roblox → filesystem sync:
"ignoreClasses": ["Terrain", "Camera"],
"syncCurrentCamera": false,
"syncUnscriptable": true,
"ignoreReferents": false
"ignoreReferents": false,
"createIgnoreDirPaths": false,
"warnDuplicateNames": false
}
}
```
## Meta Files
Add metadata to file-based instances. Example: `MyScript.server.meta.json5` (for `MyScript.server.luau`)
Add metadata to file-based instances. Meta files use **camelCase keys** (no `$` prefix -- that's only for ProjectNode).
**Adjacent meta** (e.g. `MyScript.server.meta.json5` for `MyScript.server.luau`):
```json5
{
"$className": "Script", // Override (rare)
"$properties": { "Disabled": false },
"$attributes": { "Priority": 1 },
"$ignoreUnknownInstances": true
"name": "Key:Script", // Override filename-derived instance name (slugify support)
"id": "unique-id", // For Ref property linking
"properties": { "Disabled": false },
"attributes": { "Priority": 1 },
"ignoreUnknownInstances": true
}
```
**Meta files CAN'T:** Override name, set `$path`, have children.
**Directory meta** (e.g. `init.meta.json5` inside a directory):
```json5
{
"name": "My:Folder", // Override directory-derived instance name
"className": "Configuration", // Override class (directory meta only)
"id": "unique-id",
"properties": { "Anchored": true },
"attributes": { "Health": 100 },
"ignoreUnknownInstances": false
}
```
**Key differences from ProjectNode:** Meta uses `className`, `properties`, `attributes` (camelCase, no `$` prefix). Adjacent meta does NOT support `className`.
**`name` field (slugify support):** When an instance name contains forbidden filesystem characters (`: / ? * < > | " \`), syncback slugifies the filename (forbidden chars → `_`) and stores the real name in the `name` field. On forward sync, `name` overrides the filename-derived instance name. On collisions (e.g. `A/B` and `A_B` both slugify to `A_B`), `~1`, `~2` suffixes are appended and meta `name` fields distinguish them.
**Meta files CAN'T:** Set `$path`, have children.
## Model.json5 Format
Inline instance definition:
Inline instance definition. Like meta files, model files use **camelCase keys** (no `$` prefix).
```json5
{
"$className": "Tool",
"$properties": { "CanBeDropped": false },
"$attributes": { "Damage": 50 },
"Handle": {
"$className": "Part",
"$properties": { "Size": [1, 1, 3] }
"name": "What?Model", // Override filename-derived name (slugify support)
"className": "Tool",
"id": "unique-id",
"properties": { "CanBeDropped": false },
"attributes": { "Damage": 50 },
"children": {
"Handle": {
"className": "Part",
"properties": { "Size": [1, 1, 3] }
}
}
}
```
**Note:** Children are nested under a `"children"` key (not flattened like in ProjectNode).
## Property Formats
### Shorthand (Recommended)

View file

@ -52,6 +52,7 @@ syncback/
snapshot.rs - Convert instances → snapshots
fs_snapshot.rs - Filesystem representation
file_names.rs - Generate valid filenames
meta.rs - Meta file read/write helpers
property_filter.rs - Filter properties per syncback rules
ref_properties.rs - Handle Ref properties (cross-references)
hash/ - Content hashing for change detection
@ -119,18 +120,33 @@ pub struct SyncbackRules {
/// Roblox tree paths to ignore (e.g., "ServerStorage/Secrets")
pub ignore_trees: Vec<String>,
/// Filesystem paths to ignore (e.g., "src/client/*")
pub ignore_paths: Vec<PathBuf>,
/// Filesystem glob patterns to ignore (e.g., "src/client/*")
pub ignore_paths: Vec<String>,
/// Properties to not sync back per class
/// Example: {"BasePart": ["Color"], "Script": ["Disabled"]}
pub ignore_properties: HashMap<String, Vec<String>>,
pub ignore_properties: IndexMap<Ustr, Vec<Ustr>>,
/// Instance classes to skip entirely
pub ignore_classes: Vec<String>,
/// Whether to sync Workspace.CurrentCamera (default: false)
pub sync_current_camera: bool,
pub sync_current_camera: Option<bool>,
/// Whether to sync unscriptable properties (default: true)
pub sync_unscriptable: bool,
pub sync_unscriptable: Option<bool>,
/// Whether to skip Ref properties (default: false)
pub ignore_referents: Option<bool>,
/// Whether /** in ignore paths also matches the directory itself
pub create_ignore_dir_paths: Option<bool>,
/// Override root-level ignoreHiddenServices for syncback
pub ignore_hidden_services: Option<bool>,
/// Warn on duplicate child names during syncback
pub warn_duplicate_names: Option<bool>,
}
```
@ -207,6 +223,7 @@ pub struct SyncbackSnapshot<'sync> {
pub new: Ref, // New tree instance (Roblox file)
pub path: PathBuf, // Target filesystem path
pub middleware: Option<Middleware>, // How to write (Lua, Rbxm, etc.)
pub needs_meta_name: bool, // Filesystem name differs from instance name
}
```
@ -333,9 +350,9 @@ stats.record_duplicate_name(path, name)
stats.record_rbxm_fallback(path, reason)
stats.record_unknown_class(class_name)
stats.record_unknown_property(class, property)
stats.has_issues() // Any warnings?
stats.has_issues() // Any warnings?
stats.has_unknown_types() // Unknown classes/properties?
stats.log_summary() // Log final report
stats.log_summary() // Log final report
```
**Logged at end of syncback:**
@ -364,7 +381,7 @@ print('hello')
```json5
// src/MyScript.server.meta.json5 (for MyScript.server.luau)
{
"$properties": {
"properties": {
"Disabled": false
}
}
@ -407,12 +424,7 @@ Filesystem:
4. **Write permission** - Can't write to file
- Error reported, syncback continues for other files
**Error collection:**
```rust
stats.record_error(&format!("Failed to sync {}: {}", path.display(), error));
```
All errors collected and reported at end.
**Error propagation:** Errors are propagated via `anyhow::bail!`, not collected in stats. Fatal errors abort the syncback; non-fatal issues are logged as warnings.
## Integration with Plugin

View file

@ -294,7 +294,8 @@ Prefer JSON5 over JSON for comments and trailing commas:
### Loading a Project (Rust)
```rust
use librojo::Project;
let project = Project::load_from_slice(path, contents)?;
let project = Project::load_fuzzy(&vfs, path)?;
// or: Project::load_exact(path)?;
```
### Building Snapshots (Rust)
@ -312,26 +313,29 @@ Reconciler.applyPatch(instanceMap, patch)
## File Organization
```
src/ - Rust server/CLI
cli/ - Command implementations
snapshot/ - Core snapshot system
snapshot_middleware/ - File type handlers
syncback/ - Roblox → filesystem
web/ - HTTP/WebSocket server
src/ - Rust server/CLI
cli/ - Command implementations
snapshot/ - Core snapshot system
snapshot_middleware/ - File type handlers
syncback/ - Roblox → filesystem
web/ - HTTP/WebSocket server
change_processor.rs - Two-way sync filesystem writer
plugin/src/ - Lua Studio plugin
App/ - Roact UI components
Reconciler/ - Patch application logic
Config.lua - Version and protocol config
plugin/src/ - Lua Studio plugin
App/ - Roact UI components
Reconciler/ - Patch application logic
ChangeBatcher/ - Two-way sync patch creation
Config.lua - Version and protocol config
rbx-dom/ - Git submodule (separate repo)
rbx_binary/ - Binary format (rbxm/rbxl)
rbx_xml/ - XML format (rbxmx/rbxlx)
rbx_dom_weak/ - Instance representation
rbx_reflection/ - Property metadata
rbx-dom/ - Git submodule (separate repo)
rbx_binary/ - Binary format (rbxm/rbxl)
rbx_xml/ - XML format (rbxmx/rbxlx)
rbx_dom_weak/ - Instance representation
rbx_reflection/ - Property metadata
rbx_reflection_database/ - Reflection DB
tests/ - Rust integration tests
rojo-test/ - Test fixtures and utilities
tests/ - Rust integration tests
rojo-test/ - Test fixtures and utilities
```
## Anti-Patterns to Avoid
@ -349,7 +353,7 @@ rojo-test/ - Test fixtures and utilities
## Version Information
- **Protocol version:** 5 (`plugin/src/Config.lua`)
- **Protocol version:** 6 (`plugin/src/Config.lua`)
- **Server/Plugin must match:** MAJOR.MINOR (e.g., 7.7.x works with 7.7.y)
- **MSRV:** Rust 1.88
- **License:** MPL-2.0 (rbx-dom uses MIT)