From 1bf32f09209e9fee766d6748eea7332d0f07c89a Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 2 Feb 2026 12:27:08 -0600 Subject: [PATCH] Update references to UserGeneratedLLC in documentation and enhance project file specifications - Changed all instances of `rojo-rbx` to `UserGeneratedLLC` in the rbx-dom and rojo documentation to reflect the new repository ownership. - Updated project file format specifications to prioritize `.project.json5` over `.project.json`, clarifying the preferred file extensions and their usage. - Revised syncback documentation to reflect changes in file extensions from `.lua` to `.luau`, ensuring consistency across the project. - Improved clarity in the API and communication architecture documentation, emphasizing the new structure and endpoints. --- .cursor/rules/rbx-dom.mdc | 12 +- .cursor/rules/rojo-api.mdc | 1035 +++++++++++++++---------------- .cursor/rules/rojo-project.mdc | 920 ++++++++------------------- .cursor/rules/rojo-syncback.mdc | 26 +- .cursor/rules/rojo.mdc | 11 +- .cursor/rules/src.mdc | 42 +- 6 files changed, 782 insertions(+), 1264 deletions(-) diff --git a/.cursor/rules/rbx-dom.mdc b/.cursor/rules/rbx-dom.mdc index 561b0a9a..a807d4b8 100644 --- a/.cursor/rules/rbx-dom.mdc +++ b/.cursor/rules/rbx-dom.mdc @@ -8,7 +8,7 @@ globs: # rbx-dom Submodule -**This is a Git submodule** linking to https://github.com/rojo-rbx/rbx-dom - a separate project with its own repository, CI/CD, and release cycle. +**This is a Git submodule** linking to https://github.com/UserGeneratedLLC/rbx-dom - a separate project with its own repository, CI/CD, and release cycle. **Critical:** When working on rbx-dom code, refer to `rbx-dom/README.md` and follow that project's conventions, not Rojo's. @@ -65,7 +65,7 @@ A collection of cross-platform libraries enabling any software to interact with ```bash # Clone Rojo with submodules -git clone --recursive https://github.com/rojo-rbx/rojo +git clone --recursive https://github.com/UserGeneratedLLC/rojo # Or update existing submodules git submodule update --init --recursive @@ -105,7 +105,7 @@ Since rbx-dom is a separate repository: 1. **Fork and clone rbx-dom separately** for development 2. **Make changes in the rbx-dom repository** (not the submodule) -3. **Open PRs against rojo-rbx/rbx-dom** (not rojo-rbx/rojo) +3. **Open PRs against UserGeneratedLLC/rbx-dom** (not UserGeneratedLLC/rojo) 4. **Update Rojo's submodule** only after rbx-dom changes are merged ### Testing Changes @@ -152,7 +152,7 @@ rbx-dom packages must be published **in dependency order**: 4. `rbx_binary` and `rbx_xml` (depend on all above) **Only maintainers can publish.** If you need a new rbx-dom release: -1. Open issue in rojo-rbx/rbx-dom +1. Open issue in UserGeneratedLLC/rbx-dom 2. Explain what features/fixes you need 3. Maintainers will coordinate release @@ -168,10 +168,10 @@ git submodule update --init --recursive cd rbx-dom && git checkout main && git pull ``` -**Property bugs:** Check `rbx-dom/test-files/` for similar cases, file issue in rojo-rbx/rbx-dom +**Property bugs:** Check `rbx-dom/test-files/` for similar cases, file issue in UserGeneratedLLC/rbx-dom **Outdated reflection:** File issue requesting reflection database update (maintainers run `rbx_reflector`) ## Contributing to rbx-dom -See https://github.com/rojo-rbx/rbx-dom. Remember: rbx-dom is designed for projects beyond Rojo, so changes should be general-purpose. +See https://github.com/UserGeneratedLLC/rbx-dom. Remember: rbx-dom is designed for projects beyond Rojo, so changes should be general-purpose. diff --git a/.cursor/rules/rojo-api.mdc b/.cursor/rules/rojo-api.mdc index 65ff21b6..90a57439 100644 --- a/.cursor/rules/rojo-api.mdc +++ b/.cursor/rules/rojo-api.mdc @@ -8,454 +8,288 @@ globs: - "plugin/src/ApiContext.lua" --- -# Rojo API & Communication Architecture +# Rojo API & Communication -Communication between Rojo server (Rust) and Studio plugin (Lua) via HTTP/WebSocket protocol. +**Protocol Version:** 5 | Server/plugin MAJOR.MINOR must match -**Protocol Version:** 5 (current) -**Must match:** Server and plugin MAJOR.MINOR versions must match - -## Architecture Overview +## Architecture ``` -Filesystem Changes - ↓ -Server (src/serve_session.rs) - ↓ Generate patches -WebSocket (src/web/api.rs) - ↓ Send JSON/MessagePack -Plugin (plugin/src/ServeSession.lua) - ↓ Apply patches -Studio DataModel +Filesystem → Server (serve_session.rs) → WebSocket (web/api.rs) → Plugin (ServeSession.lua) → Studio + +Two-way (experimental): +Studio → ChangeBatcher → Plugin → WebSocket → Server → Filesystem ``` -**Two-way sync (experimental):** -``` -Studio Changes - ↓ -ChangeBatcher (plugin/src/ChangeBatcher/) - ↓ Collect changes -Plugin ServeSession - ↓ Send patches -WebSocket - ↓ Receive updates -Server (src/web/api.rs) - ↓ Write to filesystem -Filesystem -``` +## HTTP Endpoints (`src/web/api.rs`) -## HTTP API Endpoints (`src/web/api.rs`) +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/api/rojo` | GET | Server info (version, sessionId, projectName, rootInstanceId, servePlaceIds) | +| `/api/read/:id` | GET | Read instance tree from ID | +| `/api/serialize/:id` | GET | Instance tree as msgpack | +| `/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 | -### `GET /api/rojo` - -**Returns server info:** +### Server Info Response (`GET /api/rojo`) ```json { "serverVersion": "7.7.0-rc.1", "protocolVersion": 5, - "sessionId": "uuid-here", + "sessionId": "uuid", "projectName": "MyProject", - "expectedPlaceIds": [123456], // Optional: allowed places - "unexpectedPlaceIds": [789012], // Optional: blocked places - "placeId": null, // Set by plugin on connect - "gameId": null, // Set by plugin on connect + "expectedPlaceIds": [123456], "rootInstanceId": "00000000-0000-0000-0000-000000000000", - "syncSourceOnly": true, // Only sync script sources - "ignoreHiddenServices": true, // Filter hidden services - "visibleServices": ["Workspace", "ServerScriptService", ...] + "syncSourceOnly": true, + "visibleServices": ["Workspace", "ServerScriptService"] } ``` -**Plugin uses this to:** -1. Check protocol version compatibility -2. Get session ID for all subsequent requests -3. Validate place ID matches expected -4. Determine root instance for tree building +### Write Request (`POST /api/write`) -### `GET /api/socket/:cursor` - -**WebSocket upgrade endpoint.** - -**Deprecated:** Protocol v4 used long-polling. Protocol v5 uses WebSocket (see below). - -### `GET /api/read/:id` - -**Reads instance tree starting from ID.** - -**Query params:** -- `id` - Instance ID to read from - -**Returns:** Instance tree JSON structure - -**Use case:** Initial tree fetch, or fetching subtree - -### `GET /api/serialize/:id` - -**Serializes instance tree to msgpack for plugin.** - -**Returns:** Encoded instance tree that plugin can decode - -### `POST /api/write` - -**Two-way sync: Plugin sends changes to server.** - -**Request body:** ```json { - "removed": ["instance-id-1"], - "added": { - "instance-id-2": { - "id": "instance-id-2", - "className": "Folder", - "name": "NewFolder", - "properties": {}, - "children": [] - } - }, - "updated": [ - { - "id": "instance-id-3", - "changedName": "RenamedScript", - "changedProperties": { - "Source": "print('updated')" - } - } - ] + "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')" } }] } ``` -**Server response:** -```json -{ - "success": true, - "errors": [] -} -``` - -**Server action:** Write changes to filesystem based on patch - -**Format detection:** Server checks existing file structure: -- If `Name/init.lua` exists → preserve directory structure -- If `Name.lua` exists → preserve single file -- If neither exists → use `has_children` to decide - -### `POST /api/open/:id` - -**Opens script in external editor.** - -**Body:** `{"cursor": [line, column]}` (optional) - -**Action:** Uses `opener` crate to open file in default text editor - -### `GET /api/ref-patch/:id` - -**Gets patch for fixing Ref properties after syncback.** - -**Use case:** After syncback, some Ref properties may be unlinked. This endpoint helps plugin restore them. +**Format detection:** `Name/init.luau` → directory | `Name.luau` → file | neither → use `has_children` (`.lua` also supported) ## WebSocket Protocol (v5) -### Connection Flow +**Connection:** `ws://host:port/api/rojo` -**Plugin initiates:** +1. Connect → Receive `ServerInfo` handshake → Send `{type = "Subscribe"}` → Receive patches -1. **Connect** to `ws://host:port/api/rojo` -2. **Receive handshake** from server -3. **Send subscribe message** -4. **Receive patches** as filesystem changes -5. **Send writes** (if two-way sync enabled) - -### Handshake (Server → Plugin) - -**Packet type:** `ServerInfo` - -```lua -{ - type = "ServerInfo", - protocolVersion = 5, - serverVersion = "7.7.0-rc.1", - sessionId = "uuid", - projectName = "MyProject", - rootInstanceId = "00000000-0000-0000-0000-000000000000", - -- ... other fields from /api/rojo -} -``` - -**Plugin validates:** -- Protocol version matches -- Server version compatible (MAJOR.MINOR) -- Session ID is new (not stale connection) - -### Subscribe Message (Plugin → Server) - -```lua -{ - type = "Subscribe" -} -``` - -**Server action:** Add plugin to subscribers list, send initial tree - -### Patch Messages (Server → Plugin) - -**Packet type:** `ApplyPatch` +### Patch Message (`ApplyPatch`) ```lua { type = "ApplyPatch", sessionId = "uuid", patch = { - removed = {"instance-id-1", "instance-id-2"}, -- Vec + removed = {"id-1", "id-2"}, added = { - ["instance-id-3"] = { -- HashMap - id = "instance-id-3", - className = "Folder", - name = "NewFolder", - properties = {}, - metadata = { - ignoreUnknownInstances = false, - }, - children = {"child-id-1", "child-id-2"}, + ["id-3"] = { + id = "id-3", className = "Folder", name = "New", + properties = {}, metadata = { ignoreUnknownInstances = false }, + children = {"child-1"}, }, }, - updated = { -- Vec - { - id = "instance-id-4", - changedName = "RenamedScript", - changedClassName = nil, -- rare - changedProperties = { - Source = "print('new code')", - Disabled = false, - }, - changedMetadata = nil, - }, + updated = { + { id = "id-4", changedName = "Renamed", changedProperties = { Source = "code" } }, }, } } ``` -**Message format:** MessagePack-encoded for efficiency - -### Write Messages (Plugin → Server) - -**For two-way sync only.** - -Same format as `POST /api/write` but sent over WebSocket. +**Format:** MessagePack (~30% smaller than JSON, ~2x faster) ## Server ServeSession (`src/serve_session.rs`) -**Manages active live-sync session.** - ```rust pub struct ServeSession { - session_id: Uuid, // Unique session ID - tree_handle: Arc>, // Instance tree - message_queue: MessageQueue, // Patch queue - project: Project, // Project config - vfs: Vfs, // Virtual filesystem - // ... other state + session_id: Uuid, + tree_handle: Arc>, + message_queue: MessageQueue, + project: Project, + vfs: Vfs, } ``` -### Session Lifecycle +**Lifecycle:** Create → Watch filesystem → Generate patches on changes → Broadcast to plugins → Shutdown -1. **Creation** - `rojo serve` starts, creates session -2. **Filesystem watching** - VFS monitors for changes -3. **Patch generation** - Changes trigger snapshot diffs -4. **Broadcast** - Patches sent to all connected plugins -5. **Shutdown** - Server stops, sessions terminated +**Message Queue:** Batches changes (~100ms window), cursor system for reconnect recovery -### Message Queue - -**Batches patches:** -- Collects changes over small time window (~100ms) -- Combines multiple file changes into single patch -- Prevents flooding plugin with rapid updates - -**Cursor system:** -- Each patch has incrementing cursor -- Plugin tracks last received cursor -- Can request missed patches on reconnect - -### WebSocket Handling - -**Per-connection state:** -```rust -struct WebSocketConnection { - sender: WebSocketSender, - receiver: WebSocketReceiver, - session: Arc, -} -``` - -**Message loop:** +**WebSocket loop:** ```rust loop { select! { - // Receive from plugin - msg = receiver.next() => { - handle_plugin_message(msg); - } - // Send to plugin - patch = message_queue.recv() => { - sender.send(patch).await; - } + msg = receiver.next() => handle_plugin_message(msg), + patch = message_queue.recv() => sender.send(patch).await, } } ``` ## Plugin ServeSession (`plugin/src/ServeSession.lua`) -**Manages plugin-side connection.** - ```lua ServeSession = { - __status = "NotStarted", -- Connection state - __apiContext = ApiContext, -- HTTP/WebSocket client - __twoWaySync = false, -- Enable two-way sync - __syncSourceOnly = false, -- Only sync scripts - __reconciler = Reconciler, -- Applies patches - __instanceMap = InstanceMap, -- Server ID ↔ Studio Instance - __changeBatcher = ChangeBatcher, -- Collects Studio changes - __serverInfo = nil, -- Server handshake data - __confirmingPatch = nil, -- Patch awaiting confirmation - __isConfirming = false, -- In confirmation dialog + __status = "NotStarted", -- NotStarted → Connecting → Connected → Disconnected + __apiContext = ApiContext, + __twoWaySync = false, + __reconciler = Reconciler, + __instanceMap = InstanceMap, + __changeBatcher = ChangeBatcher, + __confirmingPatch = nil, + __isConfirming = false, -- Prevents stacking dialogs, changes during review } ``` -### Connection States - -```lua -Status = { - NotStarted = "NotStarted", -- Initial state - Connecting = "Connecting", -- Connecting to server - Connected = "Connected", -- Active connection - Disconnected = "Disconnected", -- Connection lost -} -``` - -**State transitions:** -``` -NotStarted → Connecting → Connected → Disconnected - ↑_____________| -``` - ### Patch Confirmation -**User setting:** `confirmationBehavior` -- `"Always"` (default) - Confirm every patch -- `"Initial"` - Confirm first patch only per project -- `"Large Changes"` - Confirm if > N instances changed (threshold configurable) -- `"Unlisted PlaceId"` - Confirm if place ID not in servePlaceIds -- `"Never"` - Never confirm, auto-accept all patches +**Setting `confirmationBehavior`:** `"Always"` (default) | `"Initial"` | `"Large Changes"` | `"Unlisted PlaceId"` | `"Never"` -**Confirmation flow:** - -1. **Receive patch** from server -2. **Check confirmation setting** -3. **If confirm needed:** - - Show `PatchVisualizer` UI - - **Pause ChangeBatcher** (prevent Studio changes during review) - - Wait for user action (Apply/Cancel) - - **Resume ChangeBatcher** after confirmation -4. **Apply patch** via Reconciler -5. **Record applied patch** to undo/redo system - -**Defense-in-depth:** `__isConfirming` flag prevents: -- Multiple confirmation dialogs stacking -- Patches being applied during confirmation -- ChangeBatcher sending changes mid-confirmation +**Flow:** Receive patch → Check setting → Show PatchVisualizer (pause ChangeBatcher) → User confirms → Apply via Reconciler → Resume ChangeBatcher ### Patch Application -**Via Reconciler:** +```lua +-- Remove +for _, id in patch.removed do instanceMap:fromId(id):Destroy(); instanceMap:removeId(id) end +-- Add +for id, data in patch.added do instanceMap:insert(id, Reconciler.reify(data)) end +-- Update +for _, update in patch.updated do Reconciler.hydrate(instanceMap:fromId(update.id), update) end +``` + +## Two-Way Sync (Experimental) + +**Setting:** `twoWaySync` (default: true) | Syncs Studio changes → filesystem in real-time + +### Complete Data Flow + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ PLUGIN SIDE │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Studio Instance │ +│ │ PropertyChanged / DescendantAdded / etc. │ +│ ▼ │ +│ InstanceMap.__connectSignals() │ +│ │ Fires onInstanceChanged(instance, propertyName) │ +│ │ (skipped if RunService:IsRunning() or instance is paused) │ +│ ▼ │ +│ ServeSession.onInstanceChanged() │ +│ │ Guards: if not self.__twoWaySync then return end │ +│ ▼ │ +│ ChangeBatcher:add(instance, propertyName) │ +│ │ Stores in __pendingPropertyChanges[instance][propertyName] = true │ +│ ▼ │ +│ ChangeBatcher:__cycle(dt) [RenderStepped, every frame] │ +│ │ Accumulates dt until >= BATCH_INTERVAL (0.2s) │ +│ │ Guards: if __paused then return end │ +│ ▼ │ +│ ChangeBatcher:__flush() │ +│ │ Calls createPatchSet(instanceMap, pendingChanges, syncSourceOnly) │ +│ │ Clears __pendingPropertyChanges │ +│ ▼ │ +│ onChangesFlushed(patch) │ +│ │ Guards: if Settings:get("oneShotSync") then return end │ +│ ▼ │ +│ ApiContext:write(patch) → HTTP POST /api/write (MessagePack) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ SERVER SIDE │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ handle_api_write() [src/web/api.rs] │ +│ │ Validates session_id │ +│ │ Deserializes MessagePack → WriteRequest │ +│ ▼ │ +│ For each removed: syncback_removed_instance() │ +│ │ Looks up InstigatingSource::Path → fs::remove_file() │ +│ ▼ │ +│ For each added: syncback_added_instance() │ +│ │ Determines file format, writes to filesystem │ +│ ▼ │ +│ 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) │ +│ ▼ │ +│ apply_patch_set() → Updates RojoTree │ +│ ▼ │ +│ message_queue.push_messages() → Broadcasts to all plugins │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### InstanceMap: Change Detection Foundation (`plugin/src/InstanceMap.lua`) + +The InstanceMap is a bidirectional map (ID ↔ Instance) that also hooks property change signals: ```lua --- 1. Remove instances -for _, id in ipairs(patch.removed) do - local instance = instanceMap:fromId(id) - if instance then - instance:Destroy() - instanceMap:removeId(id) +InstanceMap = { + fromIds = {}, -- Server ID → Studio Instance + fromInstances = {}, -- Studio Instance → Server ID + pausedUpdateInstances = {}, -- Set of instances to ignore changes from + instancesToSignal = {}, -- Instance → RBXScriptConnection(s) + onInstanceChanged = fn, -- Callback: (instance, propertyName) → void +} +``` + +**Signal Connection:** +```lua +function InstanceMap:__connectSignals(instance) + if instance:IsA("ValueBase") then + -- ValueBase.Changed only fires for Value, need explicit signals + signals = { + instance:GetPropertyChangedSignal("Name"):Connect(...), + instance:GetPropertyChangedSignal("Value"):Connect(...), + instance:GetPropertyChangedSignal("Parent"):Connect(...), + } + else + -- Regular instances use Changed event (fires for any property) + instance.Changed:Connect(function(propertyName) + self:__maybeFireInstanceChanged(instance, propertyName) + end) end end +``` --- 2. Add instances (via reify) -for id, data in pairs(patch.added) do - local instance = Reconciler.reify(data) - instanceMap:insert(id, instance) -end - --- 3. Update instances (via hydrate) -for id, update in pairs(patch.updated) do - local instance = instanceMap:fromId(id) - Reconciler.hydrate(instance, update) +**Change Guards:** +```lua +function InstanceMap:__maybeFireInstanceChanged(instance, propertyName) + if self.pausedUpdateInstances[instance] then return end -- Paused during reconciliation + if self.onInstanceChanged == nil then return end -- No callback registered + if RunService:IsRunning() then return end -- Don't sync during Play mode + self.onInstanceChanged(instance, propertyName) end ``` -**Error handling:** -- Collect all errors without stopping -- Show errors in patch visualizer -- Continue applying remaining changes - -## Two-Way Sync Architecture - -**Experimental feature.** Syncs Studio changes → filesystem. - -### Overview - -``` -Studio Event (PropertyChanged, DescendantAdded, etc.) - ↓ -InstanceMap tracks change - ↓ -ChangeBatcher collects (debounced) - ↓ -Creates patch from changes - ↓ -ServeSession sends via WebSocket - ↓ -Server writes to filesystem -``` +**Pause Mechanism:** When applying incoming patches, instances are paused to prevent their changes from being sent back to the server (feedback loop prevention). ### ChangeBatcher (`plugin/src/ChangeBatcher/`) -**Purpose:** Collect and batch Studio changes before sending to server. - -**Core files:** -- `init.lua` - Main batcher logic -- `createPatchSet.lua` - Generate patch from changes -- `encodeInstance.lua` - Encode instance to server format -- `encodeProperty.lua` - Encode property value -- `encodePatchUpdate.lua` - Encode instance update - -### Change Tracking - -**InstanceMap hooks:** +| File | Purpose | +|------|---------| +| `init.lua` | Main batcher: accumulator, RenderStepped loop, pause/resume | +| `createPatchSet.lua` | Converts pending changes → PatchSet | +| `encodePatchUpdate.lua` | Encodes property updates for existing instances | +| `encodeInstance.lua` | Full instance encoding (for additions/syncback) | +| `encodeProperty.lua` | Single property value encoding via RbxDom | +**Core State:** ```lua -local instanceMap = InstanceMap.new(function(instance, propertyName) - if twoWaySync then - changeBatcher:add(instance, propertyName) - end -end) +ChangeBatcher = { + __accumulator = 0, -- Time since last flush + __pendingPropertyChanges = {}, -- Instance → { PropertyName → true } + __syncSourceOnly = false, -- Only sync Source property + __paused = false, -- Pause during confirmation + __renderSteppedConnection = conn, -- Frame loop connection + __instanceMap = instanceMap, + __onChangesFlushed = callback, +} ``` -**When property changes:** -1. InstanceMap fires callback -2. ChangeBatcher records `(instance, propertyName)` pair -3. Waits for debounce interval (200ms) -4. Flushes batch to create patch - -### Batch Interval - +**Batch Cycle (every frame):** ```lua local BATCH_INTERVAL = 0.2 -- 200ms function ChangeBatcher:__cycle(dt) - self.__accumulator += dt + if self.__paused then return end + self.__accumulator += dt if self.__accumulator >= BATCH_INTERVAL then self.__accumulator -= BATCH_INTERVAL local patch = self:__flush() @@ -463,246 +297,359 @@ function ChangeBatcher:__cycle(dt) self.__onChangesFlushed(patch) end end + + self.__instanceMap:unpauseAllInstances() -- Resume paused instances end ``` -**Why batch?** -- Studio fires many property changes rapidly -- Batching reduces server load -- Combines related changes (e.g., Position.X, Position.Y, Position.Z) +**Why 200ms batch interval?** +- Studio fires many rapid property changes (e.g., dragging a part fires Position.X, Y, Z separately) +- Batching combines related changes into single patch +- Reduces server load and WebSocket traffic +- Still feels responsive to users -### Pause Mechanism - -**Critical for confirmation dialog:** +### createPatchSet.lua: Patch Generation ```lua -function ChangeBatcher:pause() - self.__paused = true -end - -function ChangeBatcher:resume() - self.__paused = false +function createPatchSet(instanceMap, propertyChanges, syncSourceOnly) + local patch = PatchSet.newEmpty() + + for instance, properties in propertyChanges do + local instanceId = instanceMap.fromInstances[instance] + if instanceId == nil then + Log.warn("Ignoring change for unknown instance") + continue + end + + if properties.Parent then + if instance.Parent == nil then + -- Instance was deleted in Studio + table.insert(patch.removed, instanceId) + else + Log.warn("Cannot sync non-nil Parent changes yet") -- Reparenting not supported + end + else + if syncSourceOnly then + -- Server in source-only mode: only sync Source property + if properties.Source then + local update = encodePatchUpdate(instance, instanceId, { Source = true }) + if update then table.insert(patch.updated, update) end + end + else + local update = encodePatchUpdate(instance, instanceId, properties) + if update then table.insert(patch.updated, update) end + end + end + end + + return patch end ``` -**When paused:** -- No changes collected -- No patches generated -- Prevents sending Studio changes while user reviews incoming patch - -### Encoding - -**Instance encoding** (`encodeInstance.lua`): +### encodePatchUpdate.lua: Update Encoding ```lua -{ - id = serverId, - className = instance.ClassName, - name = instance.Name, - properties = { - -- Encoded properties - }, - children = {childId1, childId2}, -- Just IDs, not full data +function encodePatchUpdate(instance, instanceId, properties) + local update = { + id = instanceId, + changedProperties = {}, + } + + for propertyName in properties do + if propertyName == "Name" then + update.changedName = instance.Name + else + local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName) + if not descriptor then continue end + + local success, encoded = encodeProperty(instance, propertyName, descriptor) + if success then + update.changedProperties[propertyName] = encoded + end + end + end + + if next(update.changedProperties) == nil and update.changedName == nil then + return nil -- No actual changes + end + + return update +end +``` + +### encodeInstance.lua: Full Instance Encoding (Syncback/Additions) + +Used when pulling new instances from Studio to filesystem. **Critical: handles duplicate detection.** + +```lua +-- Duplicate handling (matches server's ref_properties.rs::is_path_unique) +local function isPathUnique(instance) + local current = instance + while current do + if hasDuplicateSiblings(current) then + return false -- Any duplicate in ancestor chain = ambiguous path + end + current = current.Parent + if current == game then break end + end + return true +end + +function encodeInstance(instance, parentId, _skipPathCheck) + -- Skip instances with ambiguous paths (duplicate-named siblings) + if not _skipPathCheck and not isPathUnique(instance) then + Log.warn("Skipped '{}' - path contains duplicate-named siblings", instance:GetFullName()) + return nil + end + + local properties = {} + + -- Encode Attributes and Tags (work on all instance types) + encodeAttributes(instance, properties) + encodeTags(instance, properties) + + -- For scripts, Source is required + if SCRIPT_CLASS_NAMES[instance.ClassName] then + local success, encoded = encodeProperty(instance, "Source", descriptor) + if not success then return nil end + properties.Source = encoded + end + + -- Encode all serializable properties + local classDescriptor = RbxDom.findClassDescriptor(instance.ClassName) + for propertyName, meta in classDescriptor.properties do + if SKIP_PROPERTIES[propertyName] then continue end -- Parent, Name, Archivable + if SKIP_PROPERTIES_BY_CLASS[instance.ClassName]?[propertyName] then continue end -- Source, ScriptGuid, etc. + + local isReadable = meta.scriptability == "ReadWrite" or meta.scriptability == "Read" + local doesSerialize = meta.serialization ~= "DoesNotSerialize" + + if isReadable and doesSerialize then + local success, encoded = encodeProperty(instance, propertyName, descriptor) + if success and encoded ~= nil then + properties[propertyName] = encoded + end + end + end + + -- Recursively encode children (skipping duplicates at each level) + local children = {} + local childDuplicates = findDuplicateNames(instance:GetChildren()) + for _, child in instance:GetChildren() do + if childDuplicates[child.Name] then continue end + local encoded = encodeInstance(child, nil, true) -- Skip path check, already validated + if encoded then table.insert(children, encoded) end + end + + return { + parent = parentId, + name = instance.Name, + className = instance.ClassName, + properties = properties, + children = children, + } +end +``` + +**Properties skipped per class:** +| Class | Skipped Properties | Reason | +|-------|-------------------|--------| +| Script/LocalScript/ModuleScript | Source, ScriptGuid, RunContext | Encoded in file contents/suffix | +| LocalizationTable | Contents | Encoded in .csv file | +| StringValue | Value | Encoded in .txt file | + +### encodeProperty.lua: Value Encoding + +```lua +function encodeProperty(instance, propertyName, descriptor) + local readSuccess, value = descriptor:read(instance) + if not readSuccess then return false, nil end + + local dataType = descriptor.dataType + local encodeSuccess, encoded = RbxDom.EncodedValue.encode(value, dataType) + if not encodeSuccess then return false, nil end + + return true, encoded +end +``` + +**Encoded value format (via RbxDom):** +```lua +-- Primitives +true, false, 42, "string" + +-- Roblox types +{ Vector3 = {1, 2, 3} } +{ CFrame = { position = {0,0,0}, orientation = {...} } } +{ UDim2 = {{0, 100}, {0, 50}} } +{ Color3 = {1, 0.5, 0} } +{ BrickColor = 194 } +{ Ref = "server-instance-id" } -- Object references +{ Attributes = { key = value, ... } } +{ Tags = {"tag1", "tag2"} } +``` + +### Server-Side: ChangeProcessor (`src/change_processor.rs`) + +Receives patches from plugin via `tree_mutation_receiver` channel: + +```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 } ``` -**Property encoding** (`encodeProperty.lua`): +**InstigatingSource:** Every instance in RojoTree tracks where it came from: +- `InstigatingSource::Path(PathBuf)` - Created from a file, can be written back +- `InstigatingSource::ProjectNode { path, name, node, parent_class }` - Defined in project file, **cannot be modified via two-way sync** + +### Pause Mechanism: Critical for Consistency + +**Why pause is essential:** +1. **During incoming patch confirmation:** User is reviewing changes. If ChangeBatcher sends Studio changes mid-review, it creates race conditions. +2. **During patch application:** Reconciler modifies instances, triggering property change events. Without pause, these would be sent back as "changes" (feedback loop). +3. **Defense-in-depth:** `__isConfirming` flag in ServeSession prevents multiple dialogs and ensures atomicity. ```lua --- Simple types -Disabled = false - --- Complex types -Position = {Vector3 = {1, 2, 3}} -Size = {UDim2 = {{0, 100}, {0, 50}}} - --- Ref properties -ObjectValue.Value = {Ref = "target-server-id"} +-- In ServeSession, when showing confirmation dialog: +self.__changeBatcher:pause() +-- ... show PatchVisualizer, wait for user ... +self.__changeBatcher:resume() ``` -**Uses `rbx_dom_lua`** for type encoding compatible with server. +```lua +-- In Reconciler, when modifying instances: +instanceMap:pauseInstance(instance) +instance.SomeProperty = newValue -- Won't trigger ChangeBatcher +-- Later, in ChangeBatcher:__cycle(): +instanceMap:unpauseAllInstances() +``` -### Server-Side Handling - -**On receiving write patch:** - -1. **Validate session ID** - Ensure not stale -2. **Decode patch** - Parse MessagePack -3. **Match instances to files** - Use InstanceMap equivalent -4. **Write changes:** - - Scripts → Update `.lua` file contents - - Properties → Update `.meta.json` files - - New instances → Create new files - - Removed instances → Delete files -5. **Detect format:** - - Check if `Name/init.lua` exists (directory) - - Check if `Name.lua` exists (standalone) - - Use `has_children` if neither exists - -### Known Issues (Experimental) - -**Two-way sync limitations:** - -1. **Undo/redo conflicts** - Studio's undo can trigger spurious changes -2. **Property race conditions** - Rapid changes can interleave badly -3. **Format ambiguity** - Hard to determine if instance should be file or directory -4. **Type serialization** - Some property types don't roundtrip well -5. **Ref properties** - Complex to handle when instances move - -**Workarounds:** -- Disable during complex operations -- Use manual pull for safety-critical changes -- Prefer one-way sync (server → Studio) as primary workflow - -## One-Shot Mode +### One-Shot Mode Integration **Setting:** `oneShotSync` (default: true) -**Behavior:** -- Blocks automatic outgoing writes from ChangeBatcher -- Always requires explicit confirmation for patches -- **Disconnects automatically after initial sync completes** -- No sync lock is acquired (multiple users can view) - -**Use case:** -- Quick one-time sync without maintaining connection -- Review changes before applying -- Safer for production environments - -**Implementation:** +One-shot mode adds an extra guard in `onChangesFlushed`: ```lua -function onChangesFlushed(patch) +local function onChangesFlushed(patch) if Settings:get("oneShotSync") then - Log.info("One-shot mode: blocking automatic write") - return -- Don't send to server + Log.info("One-shot mode: blocking automatic outgoing write from ChangeBatcher") + return -- Silently discard patch end - self.__apiContext:write(patch) end ``` -## Message Format +**Behavior:** +- ChangeBatcher still collects changes (needed for diff visualization) +- Automatic writes are blocked +- Only explicit "pull" selections in confirmation dialog can write +- Disconnects after initial sync completes +- No sync lock acquired (multiple users can view same place) -**MessagePack encoding:** -- Smaller than JSON -- Faster to parse -- Binary-safe (handles raw bytes) +**Use cases:** +- Quick preview of changes without risk of accidental writes +- Safe for production environments +- Team viewing same project simultaneously + +### Known Limitations (Detailed) + +| Issue | Cause | Impact | +|-------|-------|--------| +| **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 -**Serialization:** ```rust -// Server (Rust) -let msgpack = serialize_msgpack(&packet)?; -websocket.send(Message::Binary(msgpack)).await?; +// Server +if client_protocol != PROTOCOL_VERSION { return error("Protocol mismatch") } ``` ```lua --- Plugin (Lua) -local msgpack = require(Packages.msgpack) -local packet = msgpack.decode(message.Data) +-- Plugin +if serverInfo.protocolVersion ~= Config.protocolVersion then showError("Protocol mismatch") end ``` -## Protocol Version Enforcement - -**Server checks plugin version:** - -```rust -if client_protocol != PROTOCOL_VERSION { - return error_response(format!( - "Protocol mismatch: server {}, client {}", - PROTOCOL_VERSION, client_protocol - )); -} -``` - -**Plugin checks server version:** - -```lua -if serverInfo.protocolVersion ~= Config.protocolVersion then - return showError(string.format( - "Protocol mismatch: plugin %d, server %d", - Config.protocolVersion, serverInfo.protocolVersion - )) -end -``` - -**Breaking changes require:** Bump `protocolVersion` in both `src/web/interface.rs` and `plugin/src/Config.lua` +**Breaking changes:** Bump `protocolVersion` in `src/web/interface.rs` and `plugin/src/Config.lua` ## Session Management -**Session ID lifecycle:** - -1. **Server starts** - Generate new UUID for session -2. **Plugin connects** - Receives session ID in handshake -3. **All requests** - Include session ID for validation -4. **Server restart** - New session ID generated -5. **Plugin detects** - Old session ID rejected, reconnects - -**Stale session handling:** - -```rust -if request_session_id != current_session_id { - return error_response("Session expired. Please reconnect."); -} -``` - -**Plugin reconnects automatically** on session mismatch. - -## Performance Optimization - -**Patch combining:** -- Multiple rapid filesystem changes combined -- Reduces WebSocket message count -- Smooths out jittery updates - -**MessagePack benefits:** -- ~30% smaller than JSON -- ~2x faster to parse -- Handles binary data (scripts with UTF-8) - -**WebSocket vs Long-Polling:** -- Protocol v4: Long-polling (1 request per patch) -- Protocol v5: WebSocket (persistent connection) -- **Result:** ~10x reduction in overhead +1. Server starts → Generate UUID +2. Plugin connects → Receives session ID in handshake +3. All requests include session ID +4. Server restart → New session ID → Plugin auto-reconnects on mismatch ## Debugging -**Server-side:** ```bash +# Server RUST_LOG=rojo::web=debug,rojo::serve_session=debug rojo serve ``` -**Shows:** -- WebSocket connections/disconnections -- Patches sent -- Write requests received -- Session validation - -**Plugin-side:** ```lua +-- Plugin Settings:set("logLevel", "Trace") ``` -**Shows:** -- Connection state changes -- Patches received -- ChangeBatcher activity -- Property encoding +Browser: `http://localhost:34872` → Network tab → WebSocket messages -**Browser dev tools:** -- Open `http://localhost:34872` -- Inspect WebSocket messages in Network tab -- See raw MessagePack payloads +## Key Files -## Key Files to Reference - -**Server:** -- `src/web/api.rs` - HTTP/WebSocket endpoints -- `src/web/interface.rs` - Packet definitions -- `src/serve_session.rs` - Session state management - -**Plugin:** -- `plugin/src/ServeSession.lua` - Connection management -- `plugin/src/ApiContext.lua` - HTTP/WebSocket client -- `plugin/src/ChangeBatcher/` - Two-way sync implementation -- `plugin/src/Reconciler/` - Patch application +| Server | Plugin | +|--------|--------| +| `src/web/api.rs` - Endpoints | `plugin/src/ServeSession.lua` - Connection | +| `src/web/interface.rs` - Packets | `plugin/src/ApiContext.lua` - HTTP/WS client | +| `src/serve_session.rs` - Session | `plugin/src/ChangeBatcher/` - Two-way sync | +| | `plugin/src/Reconciler/` - Patch application | diff --git a/.cursor/rules/rojo-project.mdc b/.cursor/rules/rojo-project.mdc index 2987b796..3c757787 100644 --- a/.cursor/rules/rojo-project.mdc +++ b/.cursor/rules/rojo-project.mdc @@ -1,312 +1,250 @@ --- -description: "Project file format specification: .project.json schema, all file extensions, middleware types, init files, sync rules, and complete property reference." +description: "Project file format specification: .project.json5 schema, file extensions, middleware types, init files, sync rules, and property reference." globs: - - "**/*.project.json" - "**/*.project.json5" - - "**/*.meta.json" + - "**/*.project.json" - "**/*.meta.json5" - - "**/*.model.json" + - "**/*.meta.json" - "**/*.model.json5" + - "**/*.model.json" --- -# Rojo Project File Format Specification - -Complete reference for Rojo project files, file type mappings, and middleware system. +# Rojo Project File Format ## Project File Schema -### File Names +**Recognized files:** `*.project.json5` (preferred) | `*.project.json` (legacy) -**Recognized project files (in priority order):** -1. `*.project.json5` - Preferred (supports comments, trailing commas) -2. `*.project.json` - Legacy (strict JSON) +**Special:** `default.project.json5` inherits parent folder name. -**Special names:** -- `default.project.json5` - Instance inherits parent folder name -- `default.project.json` - Legacy equivalent -- `MyGame.project.json5` - Instance named "MyGame" - -### Root Schema +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes* | Instance name (*optional if `default.project.json5`) | +| `tree` | ProjectNode | Yes | Root instance tree structure | +| `servePort` | number | No | Port for `rojo serve` (default: 34872) | +| `serveAddress` | string | No | Address to bind (default: `"127.0.0.1"`) | +| `servePlaceIds` | number[] | No | Only allow sync to these place IDs | +| `blockedPlaceIds` | number[] | No | Block sync to these place IDs | +| `placeId` | number | No | Set place ID on plugin connect | +| `gameId` | number | No | Set game/universe ID on plugin connect | +| `globIgnorePaths` | string[] | No | Glob patterns to ignore during sync | +| `syncRules` | SyncRule[] | No | Custom file type mappings | +| `syncbackRules` | SyncbackRules | No | Syncback configuration | +| `syncScriptsOnly` | bool | No | Only sync script instances (default: false) | +| `ignoreHiddenServices` | bool | No | Ignore internal services (default: true) | ```json5 { - // Optional: JSON Schema reference - "$schema": "https://raw.githubusercontent.com/rojo-rbx/vscode-rojo/master/schemas/project.schema.json", - - // REQUIRED: Instance name (optional if default.project.json) - "name": "MyProject", - - // REQUIRED: Instance tree structure - "tree": { - // See ProjectNode schema below - }, - - // Optional: Default port for `rojo serve` - "servePort": 34872, - - // Optional: Allowed place IDs for live sync - "servePlaceIds": [123456, 789012], - - // Optional: Blocked place IDs (cannot sync to these) - "blockedPlaceIds": [111111], - - // Optional: Set place ID on connect - "placeId": 123456, - - // Optional: Set game ID on connect - "gameId": 789012, - - // Optional: Default address for `rojo serve` - "serveAddress": "0.0.0.0", // or "127.0.0.1", etc. - - // Optional: Glob patterns to ignore - "globIgnorePaths": [ - "**/*.md", - "**/.git/**", - "**/node_modules/**" - ], - - // Optional: Custom file type mappings - "syncRules": [ - { - "pattern": "*.foo", - "use": "text", - "suffix": ".foo", - "exclude": "*.test.foo" - } - ], - - // Optional: Syncback configuration - "syncbackRules": { - "ignoreTrees": ["ServerStorage/Secrets"], - "ignorePaths": ["src/client/*"], - "ignoreProperties": { - "BasePart": ["Color"] - }, - "syncCurrentCamera": false, - "syncUnscriptable": true - }, - - // Optional: Only sync script instances (default: false) - "syncScriptsOnly": false, - - // Optional: Ignore hidden services (default: true) - "ignoreHiddenServices": true + "$schema": "https://raw.githubusercontent.com/UserGeneratedLLC/vscode-rojo/master/schemas/project.schema.json", + "name": "MyProject", + "tree": { /* ProjectNode */ }, + "servePort": 34872, + "serveAddress": "0.0.0.0", + "servePlaceIds": [123456], + "blockedPlaceIds": [111111], + "placeId": 123456, + "gameId": 789012, + "globIgnorePaths": ["**/*.md", "**/node_modules/**"], + "syncRules": [/* SyncRule */], + "syncbackRules": { /* SyncbackRules */ }, + "syncScriptsOnly": false, + "ignoreHiddenServices": true } ``` ## ProjectNode Schema -**Used in `tree` field and recursively for children.** +Used in `tree` and recursively for all children. + +| Field | Type | Description | +|-------|------|-------------| +| `$className` | string | Instance class (required for non-services) | +| `$path` | string | Filesystem path to sync from | +| `$id` | string | Unique ID for Ref property linking | +| `$properties` | object | Instance properties to set | +| `$attributes` | object | Instance attributes to set | +| `$ignoreUnknownInstances` | bool | Whether to ignore instances not in project during sync | + +**Keys without `$` prefix are child instances.** ```json5 { - // Optional: Instance class (inferred for services, required for others) - "$className": "Folder", - - // Optional: ID for Ref property linking - "$id": "unique-identifier", - - // Optional: Path to filesystem - "$path": "src/MyFolder", - - // Optional: Instance properties - "$properties": { - "Anchored": true, - "Position": [0, 5, 0], // Shorthand Vector3 - "BrickColor": "Bright red", - "Size": { // Explicit type - "Vector3": [4, 1, 2] - } - }, - - // Optional: Instance attributes - "$attributes": { - "Health": 100, - "MaxSpeed": 16.5, - "IsEnemy": false - }, - - // Optional: Ignore unknown instances during sync (default: varies) - "$ignoreUnknownInstances": false, - - // Children (no $ prefix = child instances) - "ChildFolder": { - "$className": "Folder" - }, - - "MyScript": { - "$path": "src/MyScript.lua" - } + "$className": "Folder", + "$path": "src/MyFolder", + "$id": "unique-id", + "$properties": { "Anchored": true }, + "$attributes": { "Health": 100 }, + "$ignoreUnknownInstances": false, + + // Children + "ChildFolder": { "$className": "Folder" }, + "MyScript": { "$path": "src/MyScript.luau" } } ``` -### Reserved Names - -**ALL keys starting with `$` are reserved:** -- `$className` - Instance class -- `$path` - Filesystem path -- `$properties` - Instance properties -- `$attributes` - Instance attributes -- `$id` - Ref linking ID -- `$ignoreUnknownInstances` - Sync behavior -- `$keepUnknownInstances` - Legacy (use `$ignoreUnknownInstances`) - -**Keys WITHOUT `$` are children.** +**Services auto-inferred:** Workspace, ReplicatedStorage, ServerScriptService, ServerStorage, StarterGui, StarterPlayer, Lighting, Teams, Chat, SoundService, LocalizationService, TestService, etc. ## File Extension Mappings -### Script Files +### Scripts -| Extension | Middleware | Result | RunContext | -|-----------|-----------|--------|------------| -| `*.lua` | `Lua` | ModuleScript | N/A | -| `*.luau` | `Lua` | ModuleScript | N/A | -| `*.server.luau` | `ServerScript` | Script | Server | -| `*.server.lua` | `ServerScript` | Script | Legacy | -| `*.client.luau` | `ClientScript` | Script | Client | -| `*.client.lua` | `ClientScript` | LocalScript | N/A | -| `*.plugin.lua` | `PluginScript` | Script | Plugin | -| `*.plugin.luau` | `PluginScript` | Script | Plugin | -| `*.local.lua` | `LocalScript` | LocalScript | N/A | -| `*.local.luau` | `LocalScript` | LocalScript | N/A | -| `*.legacy.lua` | `LegacyScript` | Script | Legacy | -| `*.legacy.luau` | `LegacyScript` | Script | Legacy | +| Extension | Result | RunContext | Note | +|-----------|--------|------------|------| +| `*.luau` | ModuleScript | — | Preferred | +| `*.server.luau` | Script | Server | Preferred | +| `*.client.luau` | Script | Client | Preferred | +| `*.plugin.luau` | Script | Plugin | | +| `*.local.luau` | LocalScript | — | | +| `*.legacy.luau` | Script | Legacy | | +| `*.lua` | ModuleScript | — | Legacy | +| `*.server.lua` | Script | Legacy | Legacy | +| `*.client.lua` | LocalScript | — | Legacy | -**Note:** `.lua` and `.luau` extensions produce different results for server/client scripts: -- `.server.luau` → Script with RunContext.Server (modern) -- `.server.lua` → Script with RunContext.Legacy (backwards compat) -- `.client.luau` → Script with RunContext.Client (modern) -- `.client.lua` → LocalScript (backwards compat) +**Prefer `.luau`** — modern RunContext. `.lua` is legacy (still supported). -### Model Files +### Models & Data -| Extension | Middleware | Format | Use Case | -|-----------|-----------|--------|----------| -| `*.rbxm` | `Rbxm` | Binary | Production models | -| `*.rbxmx` | `Rbxmx` | XML | Debugging, diffs | -| `*.model.json` | `JsonModel` | JSON | Simple instances | -| `*.model.json5` | `JsonModel` | JSON5 | Simple instances (comments) | +| Extension | Result | Note | +|-----------|--------|------| +| `*.rbxm` | Binary model | | +| `*.rbxmx` | XML model | | +| `*.model.json5` | JSON model | Preferred | +| `*.model.json` | JSON model | Legacy | +| `*.txt` | StringValue | | +| `*.csv` | LocalizationTable | | +| `*.json5` | ModuleScript (Lua table) | Preferred | +| `*.json` | ModuleScript (Lua table) | Legacy | +| `*.toml` | ModuleScript (Lua table) | | +| `*.yaml`, `*.yml` | ModuleScript (Lua table) | | -### Data Files +### Meta & Project -| Extension | Middleware | Result | Content | -|-----------|-----------|--------|---------| -| `*.txt` | `Txt` | StringValue | File contents → `Value` | -| `*.csv` | `Csv` | LocalizationTable | CSV → localization data | -| `*.toml` | `Toml` | ModuleScript | TOML → Lua table | -| `*.json` | `Json` | ModuleScript | JSON → Lua table | -| `*.json5` | `Json` | ModuleScript | JSON5 → Lua table | -| `*.yaml` | `Yaml` | ModuleScript | YAML → Lua table | -| `*.yml` | `Yaml` | ModuleScript | YAML → Lua table | +| Extension | Purpose | Note | +|-----------|---------|------| +| `*.meta.json5` | Add properties/attributes to sibling | Preferred | +| `*.meta.json` | Add properties/attributes to sibling | Legacy | +| `*.project.json5` | Nested project | Preferred | +| `*.project.json` | Nested project | Legacy | -### Meta Files +## Init File Priority -| Extension | Purpose | Example | -|-----------|---------|---------| -| `*.meta.json` | Add metadata | `MyScript.meta.json` | -| `*.meta.json5` | Add metadata | `MyScript.meta.json5` | +When directory has multiple init files, first match wins: -**Meta files add:** -- Properties -- Attributes -- ClassName override - -### Project Files - -| Extension | Purpose | -|-----------|---------| -| `*.project.json` | Nested project (strict JSON) | -| `*.project.json5` | Nested project (preferred) | - -**Special:** `default.project.json5` in directory acts as init file. - -## Init Files - -**Init files** name instance after parent directory, enabling nested structures. - -### Priority Order - -**When directory has multiple init files, Rojo uses first match:** - -1. `default.project.json5` → Nested project (CHECKED FIRST) +1. `default.project.json5` → Nested project (preferred) 2. `default.project.json` → Nested project (legacy) -3. `init.luau` → ModuleScript -4. `init.server.luau` → Script (RunContext.Server) -5. `init.client.luau` → Script (RunContext.Client) -6. `init.plugin.luau` → Script (RunContext.Plugin) +3. `init.luau` → ModuleScript (preferred) +4. `init.server.luau` → Script (Server) +5. `init.client.luau` → Script (Client) +6. `init.plugin.luau` → Script (Plugin) 7. `init.local.luau` → LocalScript -8. `init.legacy.luau` → Script (RunContext.Legacy) +8. `init.legacy.luau` → Script (Legacy) 9. `init.csv` → LocalizationTable -10. `init.lua` → ModuleScript -11. `init.server.lua` → Script (RunContext.Legacy) -12. `init.client.lua` → LocalScript +10. `init.lua` → ModuleScript (legacy) +11. `init.server.lua` → Script (Legacy) +12. `init.client.lua` → LocalScript (legacy) -**No init file?** → Directory becomes Folder (or inferred service). +**No init file?** → Directory becomes Folder. **Prefer `.luau` and `.json5` extensions.** -**Important:** Project files take highest priority. Legacy `.lua` extensions produce different script types than modern `.luau`. +## Sync Rules Schema -### Example: Init File Structure +Custom file type handling in `syncRules` array: -``` -MyModule/ - init.luau ← ModuleScript named "MyModule" - types.luau ← Child ModuleScript "types" - utils.luau ← Child ModuleScript "utils" -``` - -**Equivalent to:** -``` -ModuleScript "MyModule" - ├─ ModuleScript "types" - └─ ModuleScript "utils" -``` - -## Service Inference - -**Services don't require `$className`:** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `pattern` | string | Yes | Glob pattern to match files | +| `use` | string | Yes | Middleware type (see table below) | +| `suffix` | string | No | Extension to strip from instance name | +| `exclude` | string | No | Glob pattern to exclude from matching | ```json5 { - "name": "MyGame", - "tree": { - // Inferred as Workspace service - "Workspace": { - "$path": "src/workspace" - }, - - // Inferred as ServerScriptService - "ServerScriptService": { - "$path": "src/server" - }, - - // Must specify (not a service) - "MyFolder": { - "$className": "Folder", - "$path": "src/modules" - } - } + "syncRules": [{ + "pattern": "*.foo", + "use": "moduleScript", + "suffix": ".foo", + "exclude": "*.test.foo" + }] } ``` -**Recognized services:** -- Workspace -- Lighting -- ReplicatedFirst -- ReplicatedStorage -- ServerScriptService -- ServerStorage -- StarterGui -- StarterPack -- StarterPlayer -- Teams -- SoundService -- Chat -- LocalizationService -- TestService -- And all other DataModel services +### `use` Values -**Special:** `Terrain` is also inferred. +| Value | Result | +|-------|--------| +| `serverScript` | Script (Server) | +| `clientScript` | Script (Client) | +| `moduleScript` | ModuleScript | +| `pluginScript` | Script (Plugin) | +| `localScript` | LocalScript | +| `legacyScript` | Script (Legacy) | +| `json`, `toml`, `yaml` | ModuleScript | +| `csv` | LocalizationTable | +| `text` | StringValue | +| `jsonModel`, `rbxm`, `rbxmx` | Instance tree | +| `project` | Nested project | +| `ignore` | Skip file | + +## Syncback Rules Schema + +Configure `syncbackRules` object for Roblox → filesystem sync: + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `ignoreTrees` | string[] | `[]` | Roblox tree paths to skip (e.g., `"ServerStorage/Secrets"`) | +| `ignorePaths` | string[] | `[]` | Filesystem glob patterns to skip (e.g., `"src/generated/*"`) | +| `ignoreProperties` | object | `{}` | Properties to skip per class: `{"ClassName": ["Prop1", "Prop2"]}` | +| `ignoreClasses` | string[] | `[]` | Instance classes to skip entirely | +| `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`) | + +```json5 +{ + "syncbackRules": { + "ignoreTrees": ["ServerStorage/Secrets", "Workspace/Camera"], + "ignorePaths": ["src/generated/*", "src/temp/**"], + "ignoreProperties": { + "BasePart": ["Color", "BrickColor"], + "Script": ["Disabled"] + }, + "ignoreClasses": ["Terrain", "Camera"], + "syncCurrentCamera": false, + "syncUnscriptable": true, + "ignoreReferents": false + } +} +``` + +## Meta Files + +Add metadata to file-based instances. Example: `MyScript.server.meta.json5` (for `MyScript.server.luau`) + +```json5 +{ + "$className": "Script", // Override (rare) + "$properties": { "Disabled": false }, + "$attributes": { "Priority": 1 }, + "$ignoreUnknownInstances": true +} +``` + +**Meta files CAN'T:** Override name, set `$path`, have children. + +## Model.json5 Format + +Inline instance definition: + +```json5 +{ + "$className": "Tool", + "$properties": { "CanBeDropped": false }, + "$attributes": { "Damage": 50 }, + "Handle": { + "$className": "Part", + "$properties": { "Size": [1, 1, 3] } + } +} +``` ## Property Formats @@ -314,457 +252,85 @@ ModuleScript "MyModule" ```json5 { - "$properties": { - // Primitives - "Anchored": true, - "Disabled": false, - "Name": "MyPart", // Warning: Don't set Name in properties! - - // Numbers - "Health": 100, - "Speed": 16.5, - - // Strings - "Source": "print('hello')", - - // Vector types (arrays) - "Position": [0, 5, 0], // Vector3 - "Size": [4, 1, 2], // Vector3 - "Velocity": [1, 0, 0], // Vector3 - "ImageRectSize": [100, 100], // Vector2 - - // Color3 (normalized RGB) - "Color": [1, 0, 0], // Red - "BrickColor": "Bright red", // BrickColor name - - // UDim/UDim2 (nested objects) - "Size": { - "X": {"Scale": 1, "Offset": 0}, - "Y": {"Scale": 0, "Offset": 100} - }, - - // CFrame (special syntax) - "CFrame": [0, 5, 0], // Position only - "CFrame": { - "position": [0, 5, 0], - "orientation": [ - [1, 0, 0], - [0, 1, 0], - [0, 0, 1] - ] - }, - - // Enums (string names) - "Shape": "Ball", - "Material": "Plastic", - "FormFactor": "Symmetric", - - // Arrays - "Tags": ["Collectible", "PowerUp"], - } + "$properties": { + "Anchored": true, // Bool + "Health": 100, // Number + "Position": [0, 5, 0], // Vector3 + "ImageRectSize": [100, 100], // Vector2 + "Color": [1, 0, 0], // Color3 (normalized RGB) + "BrickColor": "Bright red", // BrickColor name + "Shape": "Ball", // Enum name + "Tags": ["Collectible", "PowerUp"], // Array + "Size": { "X": {"Scale": 1, "Offset": 0}, "Y": {"Scale": 0, "Offset": 100} }, // UDim2 + "CFrame": { "position": [0, 5, 0], "orientation": [[1,0,0],[0,1,0],[0,0,1]] } // CFrame + } } ``` -### Explicit Type (When Needed) +### Explicit Type + +Use when ambiguous or specifying enum by number: ```json5 { - "$properties": { - // Explicit Vector3 - "Position": { - "Vector3": [0, 5, 0] - }, - - // Explicit Color3 - "Color": { - "Color3": [1, 0, 0] - }, - - // Explicit Enum (by number) - "Shape": { - "Enum": 1 // Ball = 1 - }, - - // Explicit BrickColor (by number) - "BrickColor": { - "BrickColor": 21 // Bright red = 21 - } - } + "$properties": { + "Position": { "Vector3": [0, 5, 0] }, + "Shape": { "Enum": 1 }, + "BrickColor": { "BrickColor": 21 } + } } ``` -**When to use explicit:** -- Ambiguous types -- Enum by number instead of name -- Overriding inferred type - ## Attributes +Same format as properties. Limitations: no Ref, no NumberSequence/ColorSequence, enums use string names only. + ```json5 { - "$attributes": { - // Primitives - "Health": 100, - "Speed": 16.5, - "IsEnemy": true, - "Description": "A powerful weapon", - - // Supported types - "Position": [0, 5, 0], // Vector3 - "Color": [1, 0, 0], // Color3 - "MyFont": { // Font - "family": "rbxasset://fonts/families/SourceSansPro.json", - "weight": "Regular", - "style": "Normal" - }, - "MyCFrame": { // CFrame - "position": [0, 0, 0], - "orientation": [[1,0,0],[0,1,0],[0,0,1]] - }, - - // Enums (string names only) - "Material": "Plastic" - } + "$attributes": { + "Health": 100, + "Position": [0, 5, 0], + "MyFont": { "family": "rbxasset://fonts/families/SourceSansPro.json", "weight": "Regular", "style": "Normal" } + } } ``` -**Attribute limitations:** -- No Ref properties -- No complex types (NumberSequence, ColorSequence, etc.) -- Enums must use string names (not numbers) - ## Ref Property Linking -**Problem:** Instances can reference other instances (e.g., `ObjectValue.Value`). - -**Solution:** Use `$id` and special attributes: +Link instances via `$id` and special attributes: ```json5 { - "tree": { - "Target": { - "$className": "Part", - "$id": "myTarget" - }, - - "Pointer": { - "$className": "ObjectValue", - "$id": "myPointer", - "$attributes": { - // Links Value property to instance with $id "myTarget" - "Rojo_Target_Value": "myTarget" - } - } + "tree": { + "Target": { "$className": "Part", "$id": "myTarget" }, + "Pointer": { + "$className": "ObjectValue", + "$attributes": { "Rojo_Target_Value": "myTarget" } } + } } ``` **Attribute format:** `Rojo_Target_` -**Plugin handles linking:** -1. Reads `$id` from instances -2. Reads `Rojo_Target_*` attributes -3. Sets Ref properties to correct instances +## Property Types -## Sync Rules +`String`, `Bool`, `Int32`, `Int64`, `Float32`, `Float64`, `Vector2`, `Vector3`, `Vector2int16`, `Vector3int16`, `UDim`, `UDim2`, `CFrame`, `Color3`, `Color3uint8`, `BrickColor`, `NumberRange`, `Rect`, `NumberSequence`, `ColorSequence`, `Enum`, `Ref`, `Content`, `Font`, `PhysicalProperties`, `Axes`, `Faces`, `Tags`, `Attributes` -**Custom file type handling:** +See `rbx_reflection_database` for property listings per class. -```json5 -{ - "syncRules": [ - { - // Required: Glob pattern (relative to project) - "pattern": "*.foo", - - // Required: How to treat files - "use": "text", - - // Optional: What to strip from filename - "suffix": ".foo", - - // Optional: Exclude pattern - "exclude": "*.test.foo" - } - ] -} -``` +## Validation Errors -### Available `use` Types +1. Unknown fields (except children) +2. Reserved names starting with `$` as children +3. Missing `$className` or `$path` +4. `$path` to non-existent file +5. Invalid property types +6. Invalid glob syntax +7. Duplicate `$id` -| `use` Value | Result | Notes | -|-------------|--------|-------| -| `serverScript` | Script (RunContext.Server) | Server-side script | -| `clientScript` | Script (RunContext.Client) | Client-side script | -| `moduleScript` | ModuleScript | Shared module | -| `pluginScript` | Script (RunContext.Plugin) | Plugin script | -| `localScript` | LocalScript | Legacy client script | -| `legacyScript` | Script (RunContext.Legacy) | Legacy server script | -| `json` | ModuleScript | JSON → Lua table | -| `toml` | ModuleScript | TOML → Lua table | -| `yaml` | ModuleScript | YAML → Lua table | -| `csv` | LocalizationTable | CSV localization | -| `text` | StringValue | Plain text | -| `jsonModel` | Instance tree | JSON model format | -| `rbxm` | Instance tree | Binary model | -| `rbxmx` | Instance tree | XML model | -| `project` | Nested project | Project file | -| `ignore` | (nothing) | Skip file entirely | +## Source Files -**Note:** These are the only valid `use` values. The Middleware enum uses camelCase serialization. - -### Example: Custom Extension - -```json5 -{ - "syncRules": [ - { - "pattern": "*.component.tsx", - "use": "moduleScript", - "suffix": ".component.tsx" - } - ] -} -``` - -**Result:** `Button.component.tsx` → ModuleScript named "Button" - -## Syncback Rules - -```json5 -{ - "syncbackRules": { - // Ignore Roblox tree paths - "ignoreTrees": [ - "ServerStorage/AdminTools", - "Workspace/Camera" - ], - - // Ignore filesystem paths (globs) - "ignorePaths": [ - "src/generated/*", - "src/temp/**" - ], - - // Ignore properties by class - "ignoreProperties": { - "BasePart": ["Color", "BrickColor"], - "Script": ["Disabled"], - "LocalScript": ["Disabled"] - }, - - // Ignore entire classes during syncback - "ignoreClasses": [ - "Terrain", - "Camera" - ], - - // Sync Workspace.CurrentCamera? (default: false) - "syncCurrentCamera": false, - - // Sync unscriptable properties? (default: true) - "syncUnscriptable": true, - - // Skip Ref properties like Model.PrimaryPart? (default: false) - "ignoreReferents": false - } -} -``` - -## Meta File Format - -**Purpose:** Add metadata to instances created from files. - -**Example:** `MyScript.server.lua` + `MyScript.server.meta.json5` - -```json5 -// MyScript.server.meta.json5 -{ - // Override className (rare) - "$className": "Script", - - // Add properties - "$properties": { - "Disabled": false, - "RunContext": "Server" - }, - - // Add attributes - "$attributes": { - "Priority": 1, - "LoadOrder": "First" - }, - - // Ignore unknown children during sync - "$ignoreUnknownInstances": true -} -``` - -**Meta files CAN'T:** -- Override instance name (name comes from filename) -- Set `$path` (path is the .lua file) -- Have children (children come from directory) - -**Meta files CAN:** -- Override `$className` (if middleware allows) -- Add `$properties` -- Add `$attributes` -- Set `$ignoreUnknownInstances` - -## Model.json Format - -**Inline instance definition:** - -```json5 -// Weapon.model.json5 -{ - // Required: Class name - "$className": "Tool", - - // Optional: Properties - "$properties": { - "CanBeDropped": false, - "RequiresHandle": true - }, - - // Optional: Attributes - "$attributes": { - "Damage": 50, - "FireRate": 0.5 - }, - - // Optional: Children - "Handle": { - "$className": "Part", - "$properties": { - "Size": [1, 1, 3], - "Anchored": false - } - }, - - "Script": { - "$className": "Script", - "$properties": { - "Source": "print('tool equipped')" - } - } -} -``` - -**Use case:** Small instances that don't need separate files. - -## Common Patterns - -### Multi-Place Game - -```json5 -{ - "name": "MyGame", - "servePlaceIds": [123456, 789012], // Both places allowed - "tree": { - "ReplicatedStorage": { - "$path": "src/shared" // Shared code - }, - "ServerScriptService": { - "$path": "src/server" // Server code - } - } -} -``` - -### Library/Module - -```json5 -{ - "name": "MyLibrary", - "tree": { - "$className": "ModuleScript", - "$path": "src/init.lua" - } -} -``` - -**Result:** Single ModuleScript named "MyLibrary" - -### Plugin - -```json5 -{ - "name": "MyPlugin", - "tree": { - "$className": "Script", - "$path": "src/plugin.server.lua", - "$properties": { - "RunContext": "Plugin" - } - } -} -``` - -## Validation Rules - -**Rojo validates projects and provides errors:** - -1. **Unknown fields** - Keys not recognized (except children) -2. **Reserved names starting with `$`** - Can't use as child names -3. **`$className` without `$path`** - Must set one or both -4. **`$path` pointing to non-existent file** - File must exist -5. **Invalid property types** - Type doesn't match property -6. **Glob pattern syntax** - Must be valid glob -7. **Duplicate `$id`** - IDs must be unique - -## Property Type Reference - -**Complete list of supported property types:** - -- `String` - Text -- `Bool` - true/false -- `Int32`, `Int64` - Integers -- `Float32`, `Float64` - Decimals -- `Vector2`, `Vector3` - Vectors -- `Vector2int16`, `Vector3int16` - Integer vectors -- `UDim`, `UDim2` - UI dimensions -- `CFrame` - Coordinate frame -- `Color3`, `Color3uint8` - Colors -- `BrickColor` - Legacy colors -- `NumberRange` - Min/max range -- `Rect` - Rectangle -- `NumberSequence` - Animated number -- `ColorSequence` - Animated color -- `Enum` - Enumeration -- `Ref` - Instance reference -- `Content` - Asset URL -- `Font` - Font description -- `PhysicalProperties` - Part physics -- `Axes`, `Faces` - Part faces/axes -- `Tags` - CollectionService tags -- `Attributes` - Instance attributes - -**See rbx_reflection_database for complete property listings per class.** - -## Debugging Project Files - -**Validate with:** -```bash -rojo build project.json5 -o test.rbxmx -``` - -**Check for:** -- Syntax errors (JSON5 parser) -- Unknown fields (validation) -- Missing files (path resolution) -- Type mismatches (property validation) - -**Format project file:** -```bash -rojo fmt-project project.json5 -``` - -Formats JSON, normalizes structure, sorts keys. - -## Key Files to Reference - -- `src/project.rs` - Project parsing and validation -- `src/snapshot_middleware/mod.rs` - File extension mappings +- `src/project.rs` - Project parsing +- `src/snapshot_middleware/mod.rs` - Extension mappings - `src/snapshot/` - Instance tree structure -- `examples/` - Example project files (if exists) diff --git a/.cursor/rules/rojo-syncback.mdc b/.cursor/rules/rojo-syncback.mdc index 87c3cff4..5eb54eeb 100644 --- a/.cursor/rules/rojo-syncback.mdc +++ b/.cursor/rules/rojo-syncback.mdc @@ -71,8 +71,8 @@ pub fn syncback_loop( **Default behavior.** Preserves existing file structure: -- If `src/MyScript.lua` exists, update it -- If `src/MyFolder/init.lua` exists (directory structure), preserve it +- If `src/MyScript.luau` exists, update it +- If `src/MyFolder/init.luau` exists (directory structure), preserve it - Only creates new files when instances don't match anything - Compares hashes to detect actual changes (avoids rewriting unchanged files) @@ -119,7 +119,7 @@ pub struct SyncbackRules { - Filters out `Name`, `Parent` (always) - Filters out properties in `ignore_properties` - Filters out unscriptable properties if `sync_unscriptable: false` -- Filters out middleware-specific properties (e.g., `Script.Source` when writing to `.lua` file) +- Filters out middleware-specific properties (e.g., `Script.Source` when writing to `.luau` file) ### Hidden Services @@ -198,11 +198,11 @@ pub struct SyncbackSnapshot<'sync> { ```rust // Join child to current path snapshot.with_joined_path(new_ref, old_ref)? -// Result: parent_path/ChildName.lua +// Result: parent_path/ChildName.luau // Use base path snapshot.with_base_path(&base, new_ref, old_ref)? -// Result: base/ChildName.lua +// Result: base/ChildName.luau // Explicit path snapshot.with_new_path(path, new_ref, old_ref) @@ -213,7 +213,7 @@ snapshot.with_new_path(path, new_ref, old_ref) - Uses instance name - Adds appropriate extension based on middleware - Encodes Windows-invalid chars if enabled -- Example: `Script` named "My Script" → `My Script.server.lua` +- Example: `Script` named "My Script" → `My Script.server.luau` ### Property Filtering @@ -232,7 +232,7 @@ snapshot.get_path_filtered_properties(new_ref) **Decision tree:** 1. **Old instance exists?** → Use same middleware as before (preserve structure) -2. **Script type?** → Choose appropriate Lua middleware (`.server.lua`, `.client.lua`, etc.) +2. **Script type?** → Choose appropriate Lua middleware (`.server.luau`, `.client.luau`, etc.) 3. **Model/container?** → Choose directory or rbxm based on complexity 4. **Special types?** → CSV for LocalizationTable, TXT for StringValue 5. **Fallback** → Rbxm or JsonModel @@ -334,14 +334,14 @@ Script "MyScript" ``` **Syncback produces:** -```lua --- src/MyScript.server.lua +```luau +-- src/MyScript.server.luau print('hello') ``` **Meta file (if needed):** ```json5 -// src/MyScript.server.meta.json5 +// src/MyScript.server.meta.json5 (for MyScript.server.luau) { "$properties": { "Disabled": false @@ -352,7 +352,7 @@ print('hello') ### Syncing a Model **Simple model (few children):** → `.rbxm` or `.rbxmx` file -**Complex model (many children):** → Directory with `init.meta.json5` +**Complex model (many children):** → Directory with `init.meta.json5` or `init.luau` ### Syncing Nested Folders @@ -365,9 +365,9 @@ Roblox: Filesystem: src/Modules/ - Utils.lua + Utils.luau Math/ - Add.lua + Add.luau ``` ## Error Handling diff --git a/.cursor/rules/rojo.mdc b/.cursor/rules/rojo.mdc index adb25e7d..1197ba7a 100644 --- a/.cursor/rules/rojo.mdc +++ b/.cursor/rules/rojo.mdc @@ -33,11 +33,11 @@ This avoids manual tree manipulation bugs and handles complex cases (large model ## Project Files -`.project.json` or `.project.json5` (JSON5 preferred for comments): +`.project.json5` (preferred) | `.project.json` (legacy): ```json5 { - name: "MyProject", // Optional if default.project.json (inherits folder name) + name: "MyProject", // Optional if default.project.json5 (inherits folder name) tree: { $path: "src" }, @@ -50,8 +50,9 @@ This avoids manual tree manipulation bugs and handles complex cases (large model **Key conventions:** - Services don't need explicit `$className` (inferred) -- `default.project.json` inherits parent folder name +- `default.project.json5` inherits parent folder name - Projects can be nested (each has independent sync rules) +- **Use `.luau` for scripts, `.json5` for config** (`.lua`/`.json` are legacy but supported) ## Contributing @@ -66,7 +67,7 @@ For plugin work: Luau LSP, Studio with "Reload plugins on file change" ### Initial Setup ```bash -git clone --recursive https://github.com/rojo-rbx/rojo +git clone --recursive https://github.com/UserGeneratedLLC/rojo cd rojo rokit install # Installs tools from rokit.toml cargo test # Verify setup @@ -127,7 +128,7 @@ Every PR updates `CHANGELOG.md`: * Made a change to Rojo ([#1234]) -[#1234]: https://github.com/rojo-rbx/rojo/pull/1234 +[#1234]: https://github.com/UserGeneratedLLC/rojo/pull/1234 ``` Keep link definitions in ascending order. diff --git a/.cursor/rules/src.mdc b/.cursor/rules/src.mdc index c522c0d5..4c7a03d3 100644 --- a/.cursor/rules/src.mdc +++ b/.cursor/rules/src.mdc @@ -38,25 +38,29 @@ pub struct InstanceSnapshot { Transforms files → `InstanceSnapshot`. Main entry: `snapshot_from_vfs()` -| File | Handler | Result | -|------|---------|--------| -| `*.lua` / `*.luau` | `lua.rs` | ModuleScript | -| `*.server.luau` | `lua.rs` | Script (RunContext.Server) | -| `*.server.lua` | `lua.rs` | Script (RunContext.Legacy) | -| `*.client.luau` | `lua.rs` | Script (RunContext.Client) | -| `*.client.lua` | `lua.rs` | LocalScript | -| `*.plugin.lua(u)` | `lua.rs` | Script (RunContext.Plugin) | -| `*.local.lua(u)` | `lua.rs` | LocalScript | -| `*.legacy.lua(u)` | `lua.rs` | Script (RunContext.Legacy) | -| `*.rbxm` / `*.rbxmx` | `rbxm.rs` / `rbxmx.rs` | Binary/XML model tree | -| `*.model.json` | `json_model.rs` | JSON instance | -| `*.project.json` | `project.rs` | Nested project | -| `*.csv` / `*.txt` | `csv.rs` / `txt.rs` | LocalizationTable / StringValue | -| `*.toml` / `*.json` / `*.yaml` | Data → ModuleScript | -| Directory | `dir.rs` | Folder or inferred service | -| `*.meta.json` | `meta_file.rs` | Metadata overlay | +| File | Handler | Result | Note | +|------|---------|--------|------| +| `*.luau` | `lua.rs` | ModuleScript | Preferred | +| `*.server.luau` | `lua.rs` | Script (RunContext.Server) | Preferred | +| `*.client.luau` | `lua.rs` | Script (RunContext.Client) | Preferred | +| `*.plugin.luau` | `lua.rs` | Script (RunContext.Plugin) | | +| `*.local.luau` | `lua.rs` | LocalScript | | +| `*.legacy.luau` | `lua.rs` | Script (RunContext.Legacy) | | +| `*.lua` | `lua.rs` | ModuleScript | Legacy | +| `*.server.lua` | `lua.rs` | Script (RunContext.Legacy) | Legacy | +| `*.client.lua` | `lua.rs` | LocalScript | Legacy | +| `*.rbxm` / `*.rbxmx` | `rbxm.rs` / `rbxmx.rs` | Binary/XML model tree | | +| `*.model.json5` | `json_model.rs` | JSON instance | Preferred | +| `*.model.json` | `json_model.rs` | JSON instance | Legacy | +| `*.project.json5` | `project.rs` | Nested project | Preferred | +| `*.project.json` | `project.rs` | Nested project | Legacy | +| `*.csv` / `*.txt` | `csv.rs` / `txt.rs` | LocalizationTable / StringValue | | +| `*.toml` / `*.json5` / `*.yaml` | Data → ModuleScript | | +| Directory | `dir.rs` | Folder or inferred service | | +| `*.meta.json5` | `meta_file.rs` | Metadata overlay | Preferred | +| `*.meta.json` | `meta_file.rs` | Metadata overlay | Legacy | -**Init files:** `init.lua`, `init.server.lua`, etc. → Instance named after parent directory +**Init files:** `init.luau`, `init.server.luau`, etc. → Instance named after parent directory (`.lua` legacy supported) **Context passed to middleware:** @@ -94,7 +98,7 @@ Reverse: Roblox file → filesystem ### 6. Project System (`src/project.rs`) -Parse/validate `.project.json`, handle nested projects, manage sync rules. +Parse/validate `.project.json5` (or legacy `.project.json`), handle nested projects, manage sync rules. ## Command Implementations