@plumvery/rocas
v0.6.0
Published
Roblox Open Cloud Asset Sync
Maintainers
Readme
Roblox Open Cloud Asset Sync
Upload images, sounds, meshes, animations, and videos to Roblox through the Open Cloud Assets API — and get typed Luau or roblox-ts bindings back, automatically.
Quick start · CLI · Configuration · Studio plugin · Generated output · API reference
English | 日本語
Drop files into assets/, run rocas sync, and require them with full type safety:
local images = require(ReplicatedStorage.Shared.images)
imageLabel.Image = images.ui.button --> "rbxassetid://12345678"No manual asset ID copy-pasting, no stale IDs, no untyped string tables.
Features
- All asset types — images, sounds, meshes, animations, videos
- Hash-based change detection — uploads only what actually changed, tracked in lock files
- Config-aware re-sync — re-uploads when the
rocas.tomlcreator orassetTypechanges, even if the file is byte-for-byte identical - Stable IDs for models — editing a synced
Modelupdates the existing asset in place instead of minting a new ID - Fetch back —
rocas fetchdownloads what the lock files point at, so a repository can carry IDs instead of asset bodies - Recursive directory scanning — nested folders become nested generated objects
- Luau native by default — generates
--!stricttype-annotated.luauoutput - roblox-ts compatible — opt in to
.luau+.d.tspairs in an Asphalt-like shape - Studio plugin — browse, search, preview, and insert synced assets without leaving Studio
- Local preview mode — browse local assets in Studio without an Open Cloud API key
- CLI + library — use
rocas syncorrequire("@plumvery/rocas")
Requirements
| | |
|---|---|
| Node.js | 18 or newer |
| Roblox Open Cloud API key | Required for sync, watch, and fetch. Create one in the Creator Dashboard with Assets read/write permissions for your user or group. The read permission is what lets rocas resolve image IDs and download assets with rocas fetch. |
[!NOTE]
rocas pluginandrocas manifest --localwork entirely offline — no API key needed.
Install
Globally, as a CLI:
npm install -g @plumvery/rocasOr as a project dev dependency:
npm install --save-dev @plumvery/rocas[!NOTE] The package is scoped, but the command it installs is plain
rocas. The unscoped package name was rejected by the npm registry as too similar to existing packages.
[!NOTE] On npm 12,
rocas pluginneeds one extra step. npm 12 blocks a dependency's install scripts unless your project approves them, so thelz4native module thatrbxm-parserneeds is left unbuilt. Every other command works without it; only writing the Studio plugin as.rbxmdoes not. Either approve it once,npm install-scripts approve lz4or write the plugin as XML instead, which needs no native code:
rocas plugin --output rocas-studio-plugin.rbxmx.
Quick start
1. Create rocas.toml in your project root
[creator]
type = "user"
id = 123456789
[[sync]]
name = "images"
path = "assets/images"
output = "src/shared/images"
[[sync]]
name = "sounds"
path = "assets/sounds"
output = "src/shared/sounds"2. Add your API key to .env
ROCAS_API_KEY="your-open-cloud-api-key"[!TIP] Make sure
.envis listed in your.gitignore. Never commit an Open Cloud API key.
3. Sync
rocas syncAssets upload, and src/shared/images.luau + src/shared/sounds.luau are generated with the resulting asset IDs.
4. Keep it running while you work
rocas watchCLI
| Command | Description |
|---------|-------------|
| rocas sync | Upload changed assets and regenerate bindings |
| rocas watch | Watch asset directories and rocas.toml, syncing on change |
| rocas fetch | Download the assets listed in the lock files |
| rocas plugin | Generate the static Roblox Studio plugin |
| rocas plugin --module | Generate the plugin as a ModuleScript to keep in your project |
| rocas plugin --loader | Install the one-time loader that requires that module |
| rocas manifest | Generate a ReplicatedStorage manifest ModuleScript from lock files |
| rocas help | Show help |
Options
| Flag | Applies to | Default | Description |
|------|-----------|---------|-------------|
| --debounce <ms> | watch | 10000 | Debounce interval before a sync fires |
| --group <name> | fetch | every group | Only fetch this [[sync]] group; repeatable |
| --out, -o <dir> | fetch | .rocas-cache | Where to write the downloaded assets |
| --output, -o <path> | plugin | Studio local Plugins folder | Where to write the plugin |
| --module | plugin | src/server/RocasPlugin.luau | Write the plugin as a project ModuleScript instead of a baked file |
| --loader | plugin | rocas-loader.rbxm | Write the one-time loader that requires that module |
| --output, -o <path> | manifest | src/shared/RocasManifest.luau | Where to write the manifest module |
| --local | manifest | — | Build from local files instead of lock files; no upload, no API key |
Environment variables
| Variable | Description |
|----------|-------------|
| ROCAS_API_KEY | Roblox Open Cloud API key. Set it in .env or export it in your shell. |
Supported formats
| Type | Extensions | Roblox assetType |
|------|------------|--------------------|
| Image | .png .jpg .jpeg .bmp .tga | Decal |
| Audio | .mp3 .ogg .wav .flac | Audio |
| Model | .rbxm .rbxmx | Model |
| Mesh | .fbx .glb .gltf .obj | Model — needs allowConvertedFormats |
| Video | .mp4 .mov | Video |
Asset types are detected from the file extension, and can be overridden per group with assetType.
[!IMPORTANT] Mesh formats are not auto-detected. Roblox converts
.fbx,.glb,.gltf, and.objinto aModelon upload and never hands the original file back, sorocas fetchcan't restore them. rocas skips them with an explanation unless the group opts in:[[sync]] name = "meshes" path = "assets/meshes" allowConvertedFormats = true # without this, .fbx files are skipped
assetTypedoes not unlock them; it only says which type to upload as. The opt-in is its own switch on purpose, so that settingassetTypefor an unrelated reason can't quietly turn on one-way uploads.
Animations are .rbxm files too, and .rbxm syncs as a Model, so a group of animation exports has to say so:
[[sync]]
name = "animations"
path = "assets/animations"
assetType = "Animation"[!WARNING]
.rbxmand.rbxmxused to default toAnimation. A group that has been syncing them without an explicitassetTypesees the asset type change as a config change on the nextrocas sync, which re-uploads every one of them as a newModelwith a new asset ID. SetassetType = "Animation"on the group before syncing to keep the old behavior and the old IDs.
Image IDs
Open Cloud uploads images as Decal assets, and the ID it returns is the decal, not the image inside it. A decal ID works for Decal.Texture, but ImageLabel.Image, ImageButton.Image, ParticleEmitter.Texture, and friends want the image ID.
So after uploading an image, rocas downloads the decal and reads the image ID out of it, storing it as imageId in the lock file. Generated code, asset maps, and the Studio manifest all prefer imageId and fall back to the decal ID when it isn't there.
{
"ui/button.png": { "assetId": "12345679", "imageId": "12345678", "hash": "…", "config": "…" }
}This needs an API key with read access to Assets — Roblox has required authentication on asset delivery since April 2025. Images already in an older lock file are backfilled on the next rocas sync without re-uploading.
[!NOTE] Resolution never fails a sync. If the image ID can't be read (moderation still pending, key missing the read permission), rocas warns, keeps the decal ID, and retries on the next sync. After three failures in a row it stops trying for the rest of that group. Set
resolveImageIds = falseon a group to turn it off entirely.
Configuration
rocas.toml
[creator]
type = "user" # "user" or "group"
id = 123456789 # Roblox User ID or Group ID
[[sync]]
name = "images" # Group name, used for images.lock.json
path = "assets/images" # Directory to scan recursively
output = "src/shared/images" # Generates images.luau by default
# assetType = "Decal" # Optional: force asset type
# format = "luau" # "luau" (default) or "roblox-ts"
# stripExtensions = false # Remove file extensions from generated keys
# resolveImageIds = true # Resolve decal IDs to image IDs (default true)
# allowConvertedFormats = true # Allow .fbx/.glb/.gltf/.obj, which cannot be fetched backSee rocas.toml.example for a fully commented reference.
format
| Value | Output | Description |
|-------|--------|-------------|
| "luau" (default) | .luau | --!strict output with type annotations |
| "roblox-ts" | .luau + .d.ts | roblox-ts / Asphalt-compatible output |
stripExtensions
When true, file extensions are removed from generated keys:
-- stripExtensions = false (default)
images.ui["button.png"]
-- stripExtensions = true
images.ui.buttonChange detection
rocas keeps a <name>.lock.json inside each synced directory (for example assets/images/images.lock.json). An asset is skipped only when both of these match the lock:
- the file content hash, and
- a fingerprint of the upload-affecting
rocas.tomlconfig (the[creator]type/idand the resolvedassetType).
[!IMPORTANT] If you point
rocas.tomlat a different creator — say you change[creator].idfrom a group to your user — the nextrocas syncre-uploads every affected asset under the new creator, even though the files themselves are unchanged.
Updating in place
When only the file content changed — the creator and assetType still match the lock — rocas updates the existing asset instead of creating a new one, so the ID in your generated code stays put and Roblox keeps the old content as a previous version.
This only applies to the Model asset type. Open Cloud does not support content updates for Audio, Decal, Mesh, Video, or Animation, so editing one of those still uploads a new asset with a new ID.
[!NOTE]
.rbxmand.rbxmxdefault to theModelasset type, so editing one updates the existing asset in place. Binary.rbxmmodels do update in place — measured against the live API on 2026-09-04 — even though Roblox's asset guide still says content updates are limited to.fbx. A group carrying animation exports needsassetType = "Animation", and those still upload a new asset on every edit.
Editing output, format, or stripExtensions only regenerates code; it never forces a re-upload. rocas watch also watches rocas.toml itself, so saving a config change reloads it and triggers a sync.
Lock entries written by older versions of rocas have no config fingerprint. The first sync after upgrading records the current config as the baseline without re-uploading, so an upgrade alone never churns asset IDs.
If you need to force a full re-upload — for example, you changed the creator while still on a pre-fingerprint lock — delete the relevant *.lock.json and run rocas sync.
Fetching assets back
rocas fetch is sync in reverse: it reads the lock files and downloads what they point at.
rocas fetch # every group
rocas fetch --group models # one group
rocas fetch --out .rocas-cache # somewhere elseFiles land as <assetId>.<ext>, alongside a <group>.fetch.json recording what was downloaded. The extension comes from the bytes Roblox returns rather than the original file name, because they don't always agree — an image comes back as the image, a .fbx comes back as the .rbxm Roblox built from it. An asset whose lock assetId and hash are unchanged, and whose file is still on disk, is not downloaded again.
That is what makes it possible to keep asset bodies out of the repository: commit the lock files, gitignore the sources, and run rocas fetch after a clone. Two things bound how far that goes.
[!WARNING] Don't fetch into a synced path. A downloaded file is not byte-for-byte identical to the original, so the next
rocas syncreads it as changed. For aModelthat costs a pointless upload; forDecal,Audio, andVideoit mints a new asset ID and breaks every reference to the old one. The default output directory sits outside every synced path for exactly this reason, and rocas warns when--outpoints inside one.
[!NOTE] Mesh sources never come back. Uploading
.fbx(or.glb,.gltf,.obj) produces aModel; the original file is gone. Only.rbxm/.rbxmxround-trip, so a project that wants the bytes out of the repository should export models as.rbxm— which syncs as aModelwith no extra configuration.
For a single asset, fetchAssetContent returns the bytes directly:
const { fetchAssetContent } = require("@plumvery/rocas");
const rbxm = await fetchAssetContent(assetId, { apiKey });
const older = await fetchAssetContent(assetId, { apiKey, version: 3 });Roblox Studio plugin
Generate the plugin once:
rocas pluginThe plugin is static — you never need to regenerate it when assets change.
[!NOTE] By default rocas writes the generated
.rbxmdirectly into your Roblox Studio local Plugins folder, because Studio does not recognize.luaufiles there as local plugins. Use--output <path>to write it elsewhere;.luaand.rbxmxoutput paths are also supported.
Installing the plugin without npm
Artists and designers on the team usually want the browser, not the CLI. Every release carries a double-click installer for them:
| File | Platform | How to run it |
| --- | --- | --- |
| rocas-plugin-installer-windows.cmd | Windows | Double-click it. Windows says the publisher could not be verified — choose Run. |
| rocas-plugin-installer-macos.zip | macOS | Unzip, then right-click → Open → Open on the .command inside. A plain double-click is refused, because macOS blocks downloaded scripts that are not signed by a registered developer. |
| rocas-studio-plugin.rbxm | either | The plugin itself, if you would rather drop it into the Plugins folder by hand. |
Both installers carry the .rbxm inside them as base64 and write it to the same place rocas plugin would — %LOCALAPPDATA%\Roblox\Plugins on Windows, ~/Documents/Roblox/Plugins on macOS. They install nothing else, touch no other folder, and never reach the network. Restart Studio afterwards.
The plugin is static, so a copy taken from a release stays correct until the plugin itself changes. Grab the newest release when it does.
Browsing synced assets
Nothing to bake. The plugin reads the binding modules rocas sync generates, which Rojo or Argon already syncs into ReplicatedStorage. The module's variable name gives the asset type (images → Decal, sounds → Audio, animations → Animation, maps → Model) and the nested keys give each asset's path.
Because those modules are generated from your lock files, the browser lists every synced asset — including ones no script references yet, which show as Used in 0. Nothing filters rows by usage.
Only rocas-generated modules are read. Inline rbxassetid:// literals written by hand elsewhere are deliberately ignored: this is a browser for the assets rocas synced, and picking up arbitrary IDs made it something else.
Open the browser to search assets, preview images, inspect asset IDs, and click Insert to place references into the current place. It re-scans automatically when Rojo or Argon syncs a change.
Rows are grouped into a collapsible folder tree built from those nested paths, with a recursive asset count on each folder. Folders start collapsed; typing in the search box force-expands everything so a match is never hidden. Assets sort alphabetically within a folder.
Each row's preview square shows:
| Type | Preview |
|---|---|
| Image | the image itself |
| Model | Roblox's thumbnail (rbxthumb://type=Asset) |
| Audio | a play/stop button — click to preview the sound in Studio |
| anything else | a colored square with the type initial |
The colored type square sits behind every thumbnail, so an asset whose thumbnail is missing or still loading still reads as its type.
[!NOTE] An asset appears once its group's generated module is synced into
ReplicatedStorage. If you point codegen somewhere else, or have not runrocas syncsince adding a group, that group will not be listed.
A baked manifest is still supported and takes priority when present — it knows the group, source path, and declared asset type from rocas.toml:
rocas manifest --output src/shared/RocasManifest.luauEntries in the manifest override discovered assets with the same ID; anything the manifest does not cover stays as scanned.
Keeping the plugin source in your project
Instead of a baked plugin file, write the plugin as a ModuleScript that lives in your repository:
rocas plugin --moduleThat writes src/server/RocasPlugin.luau (ServerStorage, so the plugin code is never replicated to production clients). Sync it into the place with Rojo or Argon, then install the loader once:
rocas plugin --loaderThe loader is a small plugin that finds the RocasPlugin ModuleScript in ServerStorage or ReplicatedStorage and requires it, passing in the toolbar button and dock widget it owns. Editing the module in your project and letting Rojo sync it reloads the window immediately — no re-bake, no Studio restart.
[!NOTE] The loader itself still has to live in the Studio local Plugins folder: the
pluginglobal only exists for a plugin loaded from there. But it is installed once and never needs regenerating, because it contains no rocas logic of its own. Studio's own documented workflow ("Save as Local Plugin" from a ServerStorage script) copies your script into that folder on every change, which is the re-bake this avoids.
It also watches Script, LocalScript, and ModuleScript source changes inside Studio, updating each asset's usage count as matching asset IDs, paths, or file names appear in script source.
Browsing without uploading
rocas manifest --local --output src/shared/RocasManifest.luauLocal manifests scan the asset folders in rocas.toml directly and do not require ROCAS_API_KEY. In Studio, click Import to choose matching files, or Load on a single row.
[!WARNING] Local mode uses
File:GetTemporaryId()andrbxtemp://IDs. These references only work in the current Studio session — they are not shared, and not saved as permanent Roblox assets. Userocas sync+rocas manifestwhen you need durablerbxassetid://IDs for team, shared, or runtime use.
| Asset type | Insert target |
|------------|---------------|
| Decal / Image | Selected BasePart as a Decal, or StarterGui as an ImageLabel when no part is selected |
| Audio | SoundService as a Sound |
| Model / Mesh | Workspace through InsertService:LoadAsset |
| Animation | ReplicatedStorage/rocas Animations as an Animation |
| Video | StarterGui as a VideoFrame |
Generated output
Given this directory structure:
assets/images/
ui/
button.png
icon.png
fx/
spark.pngimages.luau:
--!strict
-- This file is auto-generated by rocas. Do not edit manually.
type ImagesType = {
fx: {
spark: string,
},
ui: {
button: string,
icon: string,
},
}
local images: ImagesType = {
fx = {
spark = "rbxassetid://12345678",
},
ui = {
button = "rbxassetid://23456789",
icon = "rbxassetid://34567890",
},
}
return imagesUsage:
local images = require(path.to.images)
imageLabel.Image = images.ui.buttonimages.luau:
-- This file is auto-generated by rocas. Do not edit manually.
local images = {
fx = {
["spark.png"] = "rbxassetid://12345678",
},
ui = {
["button.png"] = "rbxassetid://23456789",
["icon.png"] = "rbxassetid://34567890",
},
}
return imagesimages.d.ts:
// This file is auto-generated by rocas. Do not edit manually.
declare const images: {
fx: {
"spark.png": string
}
ui: {
"button.png": string
"icon.png": string
}
}
export = imagesUsage:
import images from "shared/images";
imageLabel.Image = images.ui["button.png"];Programmatic usage
const { loadConfig, loadEnv, syncAll } = require("@plumvery/rocas");
loadEnv();
const config = loadConfig();
await syncAll(config, process.env.ROCAS_API_KEY);Every export — sync, codegen, lock-file, and Studio plugin helpers — is documented in the API reference.
Codegen formats are pluggable. A format is any object with a name and a render function that returns the files to write:
const { registerCodegenFormat, listCodegenFormats } = require("@plumvery/rocas");
registerCodegenFormat({
name: "json",
render(lock, varName, { stripExtensions = false } = {}) {
return [{ extension: ".json", content: JSON.stringify(lock, null, 2) }];
},
});
listCodegenFormats(); //=> ["luau", "roblox-ts", "json"]Each returned entry is { extension, content }, and is written next to the group's output path. Once registered, the format can be selected per sync group with format = "json" in rocas.toml.
Development
git clone https://github.com/Plumvery/rocas.git
cd rocas
npm install
npm testThe rbxm-parser dependency pulls in lz4, a native module built with node-gyp. On macOS with Xcode selected as the active developer directory, node-gyp may fail to resolve the SDK headers:
../lib/binding/lz4_binding.cc:1:10: fatal error: 'string.h' file not foundPoint it at the SDK explicitly:
export SDKROOT="$(xcrun --show-sdk-path)"
npm installAdd that export to your shell profile to make it stick.
bin/
rocas.js CLI entry point
src/
index.js Public library surface
config.js .env + rocas.toml loading
sync.js Sync orchestration, extension → assetType mapping
upload.js Open Cloud Assets API client
asset-map.js Lock file reading and asset map building
codegen.js Code generation
formats/ Pluggable output formats (luau, roblox-ts)
studio-plugin.js Studio plugin and manifest generation
watch.js File watching
test/
test.js Test suite (node test/test.js)Contributing
Issues and pull requests are welcome — see CONTRIBUTING.md for the development workflow. Release history lives in CHANGELOG.md.
License
MIT © Plumvery
