diff --git a/package-lock.json b/package-lock.json
index 6ec4679..eb4826d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "studio",
- "version": "2.1.0",
+ "version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "studio",
- "version": "2.1.0",
+ "version": "3.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
@@ -16,6 +16,7 @@
"@dimforge/rapier3d-compat": "^0.19.3",
"@uiw/react-codemirror": "^4.25.11",
"@vitejs/plugin-react": "^6.0.4",
+ "fzstd": "^0.1.1",
"gifenc": "^1.0.3",
"lucide-react": "^1.27.0",
"react": "^19.2.8",
@@ -3872,6 +3873,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/fzstd": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/fzstd/-/fzstd-0.1.1.tgz",
+ "integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==",
+ "license": "MIT"
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
diff --git a/package.json b/package.json
index e54ab7d..72c25f7 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"@dimforge/rapier3d-compat": "^0.19.3",
"@uiw/react-codemirror": "^4.25.11",
"@vitejs/plugin-react": "^6.0.4",
+ "fzstd": "^0.1.1",
"gifenc": "^1.0.3",
"lucide-react": "^1.27.0",
"react": "^19.2.8",
@@ -32,7 +33,7 @@
"wasmoon": "^1.16.0"
},
"name": "studio",
- "version": "2.1.0",
+ "version": "3.0.0",
"description": "

",
"main": "vite.config.js",
"directories": {
diff --git a/resources/js/studio/App.jsx b/resources/js/studio/App.jsx
index d7c4629..a66b5be 100644
--- a/resources/js/studio/App.jsx
+++ b/resources/js/studio/App.jsx
@@ -42,6 +42,7 @@ import { MAX_DIM, MAX_RES, buildVoxels, loadModel, voxelCost } from './model';
import { predict, record } from './estimate';
import { convertRoblox, importSummary } from './roblox';
import { fromProject, isProject, newProjectId, toProject } from './vortexProject';
+import { decodeVrtx, encodeVrtx, isVrtx } from './vrtx';
import {
AMBIENT, DEFAULT_LIGHTING, DEFAULT_POINT_LIGHT, DEFAULT_SPOT_LIGHT, SUN, isLightRef,
partLightOf, partLightRef, repairLighting,
@@ -1021,14 +1022,24 @@ export default function App() {
};
const mapNameFrom = (fileName) => (fileName ?? '')
- .replace(/\.json$/i, '')
+ .replace(/\.(json|vrtx)$/i, '')
.replace(/[^A-Za-z0-9_-]/g, '-')
.slice(0, 64) || 'roblox';
const importRobloxText = (text, fileName) => {
+ let doc;
+ try {
+ doc = JSON.parse(text);
+ } catch (e) {
+ flash(`Could not import: ${e.message ?? e}`);
+ return;
+ }
+ importDoc(doc, fileName);
+ };
+
+ const importDoc = (doc, fileName) => {
let result;
try {
- const doc = JSON.parse(text);
const room = mapCap - (mapName ? partsRef.current.length : 0);
result = isProject(doc) ? fromProject(doc, room) : convertRoblox(doc, room);
} catch (e) {
@@ -1079,7 +1090,12 @@ export default function App() {
const importRoblox = async (file) => {
try {
- importRobloxText(await file.text(), file.name);
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ if (isVrtx(bytes)) {
+ importDoc(decodeVrtx(bytes), file.name);
+ } else {
+ importRobloxText(new TextDecoder().decode(bytes), file.name);
+ }
} catch (e) {
flash(`Could not read ${file.name}: ${e.message ?? e}`);
}
@@ -1096,20 +1112,35 @@ export default function App() {
if (text) importRobloxText(text, null);
};
+ const saveBlob = (blob, fileName) => {
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = fileName;
+ a.click();
+ // Firefox cancels the download if the URL is revoked in the same tick.
+ setTimeout(() => URL.revokeObjectURL(a.href), 10000);
+ };
+
+ const currentProject = () => toProject(
+ parts, groups, projectId.current ?? newProjectId(), lighting,
+ );
+
+ // The default: the format the current Studio saves and opens.
const download = () => {
if (!mapName) return;
- const text = JSON.stringify(toProject(parts, groups, projectId.current ?? newProjectId(), lighting));
+ const bytes = encodeVrtx(currentProject());
+ saveBlob(new Blob([bytes], { type: 'application/octet-stream' }), `${mapName}.vrtx`);
+ };
+
+ // Kept for the older Studio and for the game, which still read the JSON.
+ const downloadJson = () => {
+ if (!mapName) return;
+ const text = JSON.stringify(currentProject());
if (text.length > ENGINE_MAX_BYTES) {
flash(`This map is ${(text.length / 1048576).toFixed(1)} MB, over the 10 MB the game `
+ 'accepts. Rebuild it with a lower Detail or a higher Merge angle.');
}
- const blob = new Blob([text], { type: 'application/json' });
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = `${mapName}.json`;
- a.click();
- // Firefox cancels the download if the URL is revoked in the same tick.
- setTimeout(() => URL.revokeObjectURL(a.href), 10000);
+ saveBlob(new Blob([text], { type: 'application/json' }), `${mapName}.json`);
};
const createNew = (name, teamId = null) => {
@@ -1520,7 +1551,7 @@ export default function App() {
hasMap={!!mapName} canEdit={canEdit}
hasSelection={!!selected} hasClipboard={!!clipboard.current}
onSave={save} canSave={canSaveToServer}
- onDownload={download} canDownload={!!mapName && canEdit}
+ onDownload={download} onDownloadJson={downloadJson} canDownload={!!mapName && canEdit}
onImportRoblox={importRoblox} onPasteRoblox={pasteRoblox}
canImport={!mapName || canEdit}
onUndo={undo} onRedo={redo}
diff --git a/resources/js/studio/MenuBar.jsx b/resources/js/studio/MenuBar.jsx
index c3bdf1a..c1fb95b 100644
--- a/resources/js/studio/MenuBar.jsx
+++ b/resources/js/studio/MenuBar.jsx
@@ -65,7 +65,7 @@ const ClosePop = ({ onClose }) => (
export default function MenuBar({
hasMap, canEdit, hasSelection, hasClipboard,
- onSave, canSave, onDownload, canDownload,
+ onSave, canSave, onDownload, onDownloadJson, canDownload,
onImportRoblox, onPasteRoblox, canImport,
onUndo, onRedo, onCopy, onPaste, onDuplicate, onDelete,
onSelectAll, onGroup, onUngroup,
@@ -109,7 +109,8 @@ export default function MenuBar({
const MENUS = [
['File', [
{ label: 'Save', shortcut: 'Ctrl+S', onClick: onSave, disabled: !canSave },
- { label: 'Download .json', onClick: onDownload, disabled: !canDownload },
+ { label: 'Download .vrtx', onClick: onDownload, disabled: !canDownload },
+ { label: 'Download .json (legacy)', onClick: onDownloadJson, disabled: !canDownload },
SEP,
...(mobile ? [] : [
{ label: 'Import Roblox place...', onClick: () => robloxRef.current?.click(), disabled: !canImport },
@@ -179,7 +180,7 @@ export default function MenuBar({
{
const file = e.target.files?.[0];
@@ -385,7 +386,7 @@ export default function MenuBar({
- Saved maps live in your anonymous session for 24 hours, counted from your last save
- Every save also keeps a copy in this browser, listed under On this device on the start screen
- - Use Download to keep a .json copy, and upload it back anytime
+ - Use Download to keep a .vrtx copy (or .json for older tools), and upload either back anytime
>
diff --git a/resources/js/studio/StartScreen.jsx b/resources/js/studio/StartScreen.jsx
index 4b9fe03..de29907 100644
--- a/resources/js/studio/StartScreen.jsx
+++ b/resources/js/studio/StartScreen.jsx
@@ -7,6 +7,7 @@ import MoveMap from './MoveMap';
import UserMenu from './UserMenu';
import Clothing from './clothing/Clothing';
import { fromProject, isProject } from './vortexProject';
+import { decodeVrtx, isVrtx } from './vrtx';
import { setTheme, useTheme } from './theme';
import { APP_VERSION } from './version';
@@ -145,7 +146,8 @@ export default function StartScreen({
e.target.value = '';
if (!file) return;
try {
- const doc = JSON.parse(await file.text());
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ const doc = isVrtx(bytes) ? decodeVrtx(bytes) : JSON.parse(new TextDecoder().decode(bytes));
let parts = doc;
let groups = null;
let carried = null;
@@ -157,7 +159,7 @@ export default function StartScreen({
throw new Error('not a map: expected a Vortex project or a JSON array of parts');
}
const name = file.name
- .replace(/\.json$/i, '')
+ .replace(/\.(json|vrtx)$/i, '')
.replace(/[^A-Za-z0-9_-]/g, '-')
.slice(0, 64) || 'uploaded';
onUpload(name, parts, groups, carried);
@@ -529,7 +531,7 @@ export default function StartScreen({
diff --git a/resources/js/studio/vortexProject.js b/resources/js/studio/vortexProject.js
index 60cbfa6..afba3d6 100644
--- a/resources/js/studio/vortexProject.js
+++ b/resources/js/studio/vortexProject.js
@@ -152,8 +152,9 @@ function lightingToProject(lighting) {
sun_color: colorTo(lit.sun_color),
sun_illuminance: num(lit.sun_illuminance, DEFAULT_ILLUMINANCE),
sun_shadow_maps_enabled: lit.sun_shadow_maps_enabled !== false,
- // Where the sun comes from is ours and has no field here, and a document with a field the
- // format does not know is refused whole, so it stays out and lives in the map instead.
+ // Since Studio v0.2.1 the sun direction is a field of its own, a quaternion, so it rides
+ // in the document now instead of only living in the map.
+ sun_rotation: quatFrom(lit.sun_rotation ?? DEFAULT_LIGHTING.sun_rotation),
};
}
diff --git a/resources/js/studio/vrtx.js b/resources/js/studio/vrtx.js
new file mode 100644
index 0000000..55e04c6
--- /dev/null
+++ b/resources/js/studio/vrtx.js
@@ -0,0 +1,343 @@
+// Reading and writing the official Vortex Studio `.vrtx` project file.
+//
+// As of Studio v0.2.1 a saved project is no longer JSON on disk. A `.vrtx` file is:
+//
+// "VRTX" 4 byte ASCII magic
+// 0x01 one byte container version
+// which decompresses to:
+// 0x01 one byte inner schema version
+// the ProjectData struct, bincode's classic little-endian
+// fixed-width-integer encoding (what bincode::serialize writes)
+//
+// The Studio still opens the old plain JSON, so we keep reading both; we only write
+// the new one by default. The object we encode and decode is exactly the one
+// `toProject` builds and `fromProject` consumes — this module is just the
+// container, the field mapping stays in vortexProject.js and is shared with JSON.
+
+import { decompress as zstdDecompress } from 'fzstd';
+
+const MAGIC = [0x56, 0x52, 0x54, 0x58]; // "VRTX"
+const CONTAINER_VERSION = 1;
+const SCHEMA_VERSION = 1;
+
+// Enums serialise as their *index*, not their name, so these arrays are the
+// on-disk meaning of each value and their order is the Studio's declaration order,
+// recovered from the binary and checked against a real save. Note `Smooth` sits at
+// index 0 of the materials — our own list starts at Plastic, so map through here.
+const MATERIALS = ['Smooth', 'Plastic', 'Wood', 'Metal', 'Grass', 'Ice', 'Paint'];
+const FACES = ['Front', 'Back', 'Top', 'Bottom', 'Left', 'Right'];
+const TEXTURE_KINDS = ['Studs', 'Inlets'];
+
+export class VrtxError extends Error {}
+
+// --- bincode primitives --------------------------------------------------
+//
+// A growable little-endian writer. Lengths are u64 (8 bytes), enum tags u32
+// (4 bytes), floats f32, bools one byte, Option a one byte 0/1 tag.
+class Writer {
+ constructor() {
+ this.buf = new Uint8Array(1024);
+ this.view = new DataView(this.buf.buffer);
+ this.len = 0;
+ }
+
+ _room(n) {
+ if (this.len + n <= this.buf.length) return;
+ let next = this.buf.length * 2;
+ while (next < this.len + n) next *= 2;
+ const grown = new Uint8Array(next);
+ grown.set(this.buf.subarray(0, this.len));
+ this.buf = grown;
+ this.view = new DataView(this.buf.buffer);
+ }
+
+ u8(v) { this._room(1); this.view.setUint8(this.len, v & 0xff); this.len += 1; }
+ bool(v) { this.u8(v ? 1 : 0); }
+ u32(v) { this._room(4); this.view.setUint32(this.len, v >>> 0, true); this.len += 4; }
+ len64(v) { this._room(8); this.view.setBigUint64(this.len, BigInt(v), true); this.len += 8; }
+ f32(v) { this._room(4); this.view.setFloat32(this.len, Number.isFinite(v) ? v : 0, true); this.len += 4; }
+
+ str(s) {
+ const bytes = new TextEncoder().encode(String(s ?? ''));
+ this.len64(bytes.length);
+ this._room(bytes.length);
+ this.buf.set(bytes, this.len);
+ this.len += bytes.length;
+ }
+
+ take() { return this.buf.slice(0, this.len); }
+}
+
+// A little-endian reader that refuses to run off the end.
+class Reader {
+ constructor(bytes) {
+ this.bytes = bytes;
+ this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ this.at = 0;
+ }
+
+ _need(n) {
+ if (this.at + n > this.bytes.length) {
+ throw new VrtxError('the .vrtx file ends in the middle of a value');
+ }
+ }
+
+ u8() { this._need(1); const v = this.view.getUint8(this.at); this.at += 1; return v; }
+ bool() { return this.u8() !== 0; }
+ u32() { this._need(4); const v = this.view.getUint32(this.at, true); this.at += 4; return v; }
+
+ len64() {
+ this._need(8);
+ const v = this.view.getBigUint64(this.at, true);
+ this.at += 8;
+ if (v > 0x7fffffffn) throw new VrtxError('a length in the .vrtx file is implausibly large');
+ return Number(v);
+ }
+
+ f32() { this._need(4); const v = this.view.getFloat32(this.at, true); this.at += 4; return v; }
+
+ str() {
+ const n = this.len64();
+ this._need(n);
+ const slice = this.bytes.subarray(this.at, this.at + n);
+ this.at += n;
+ return new TextDecoder().decode(slice);
+ }
+
+ option(readSome) {
+ const tag = this.u8();
+ if (tag === 0) return null;
+ if (tag !== 1) throw new VrtxError(`invalid Option tag ${tag} in the .vrtx file`);
+ return readSome();
+ }
+
+ vec(readItem) {
+ const n = this.len64();
+ const out = new Array(n);
+ for (let i = 0; i < n; i += 1) out[i] = readItem();
+ return out;
+ }
+
+ enumName(table, what) {
+ const i = this.u32();
+ if (i >= table.length) throw new VrtxError(`unknown ${what} index ${i} in the .vrtx file`);
+ return table[i];
+ }
+}
+
+const num = (v, fallback = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
+// An unknown material (e.g. the Studio's "Smooth", which we do not model) falls
+// back to the default so the file is still valid.
+const enumIndex = (table, name, fallback = 0) => {
+ const i = table.indexOf(name);
+ return i < 0 ? fallback : i;
+};
+
+// --- ProjectData ---------------------------------------------------------
+
+const writeVec3 = (w, v) => { w.f32(num(v?.x)); w.f32(num(v?.y)); w.f32(num(v?.z)); };
+const readVec3 = (r) => ({ x: r.f32(), y: r.f32(), z: r.f32() });
+
+const writeQuat = (w, q) => { w.f32(num(q?.x)); w.f32(num(q?.y)); w.f32(num(q?.z)); w.f32(num(q?.w, 1)); };
+const readQuat = (r) => ({ x: r.f32(), y: r.f32(), z: r.f32(), w: r.f32() });
+
+const writeColor = (w, c) => { w.f32(num(c?.r, 1)); w.f32(num(c?.g, 1)); w.f32(num(c?.b, 1)); w.f32(num(c?.a, 1)); };
+const readColor = (r) => ({ r: r.f32(), g: r.f32(), b: r.f32(), a: r.f32() });
+
+function writePointLight(w, l) {
+ writeColor(w, l.color);
+ w.f32(num(l.intensity));
+ w.f32(num(l.range));
+ w.bool(l.shadow_maps_enabled === true);
+}
+const readPointLight = (r) => ({
+ color: readColor(r), intensity: r.f32(), range: r.f32(), shadow_maps_enabled: r.bool(),
+});
+
+function writeSpotLight(w, l) {
+ writeColor(w, l.color);
+ w.f32(num(l.intensity));
+ w.f32(num(l.range));
+ w.bool(l.shadow_maps_enabled === true);
+ w.f32(num(l.angle));
+ w.u32(enumIndex(FACES, l.face));
+}
+const readSpotLight = (r) => ({
+ color: readColor(r),
+ intensity: r.f32(),
+ range: r.f32(),
+ shadow_maps_enabled: r.bool(),
+ angle: r.f32(),
+ face: r.enumName(FACES, 'spot light face'),
+});
+
+function writePart(w, p) {
+ w.str(p.name ?? '');
+ writeVec3(w, p.position);
+ writeQuat(w, p.rotation);
+ writeVec3(w, p.scale);
+ writeColor(w, p.color);
+ w.u32(enumIndex(MATERIALS, p.material ?? 'Plastic', enumIndex(MATERIALS, 'Plastic')));
+ // group is an Option index into the top-level groups array.
+ if (Number.isInteger(p.group) && p.group >= 0) { w.u8(1); w.len64(p.group); } else w.u8(0);
+ w.bool(p.cast_shadow !== false);
+ w.bool(p.anchored !== false);
+ w.bool(p.can_collide !== false);
+ w.bool(p.spawn_location === true);
+ w.bool(p.baseplate === true);
+ w.bool(p.custom_appearance === true);
+ w.bool(p.truss === true);
+ const textures = Array.isArray(p.textures) ? p.textures : [];
+ w.len64(textures.length);
+ for (const t of textures) {
+ w.u32(enumIndex(FACES, t.face));
+ w.u32(enumIndex(TEXTURE_KINDS, t.kind));
+ }
+ if (p.point_light) { w.u8(1); writePointLight(w, p.point_light); } else w.u8(0);
+ if (p.spot_light) { w.u8(1); writeSpotLight(w, p.spot_light); } else w.u8(0);
+}
+
+const readPart = (r) => ({
+ name: r.str(),
+ position: readVec3(r),
+ rotation: readQuat(r),
+ scale: readVec3(r),
+ color: readColor(r),
+ material: r.enumName(MATERIALS, 'material'),
+ group: r.option(() => r.len64()),
+ cast_shadow: r.bool(),
+ anchored: r.bool(),
+ can_collide: r.bool(),
+ spawn_location: r.bool(),
+ baseplate: r.bool(),
+ custom_appearance: r.bool(),
+ truss: r.bool(),
+ textures: r.vec(() => ({
+ face: r.enumName(FACES, 'texture face'),
+ kind: r.enumName(TEXTURE_KINDS, 'texture kind'),
+ })),
+ point_light: r.option(() => readPointLight(r)),
+ spot_light: r.option(() => readSpotLight(r)),
+});
+
+function writeLighting(w, l) {
+ const lit = l ?? {};
+ writeColor(w, lit.ambient_color);
+ w.f32(num(lit.brightness));
+ writeColor(w, lit.sun_color);
+ w.f32(num(lit.sun_illuminance));
+ w.bool(lit.sun_shadow_maps_enabled !== false);
+ writeQuat(w, lit.sun_rotation); // identity when absent
+}
+const readLighting = (r) => ({
+ ambient_color: readColor(r),
+ brightness: r.f32(),
+ sun_color: readColor(r),
+ sun_illuminance: r.f32(),
+ sun_shadow_maps_enabled: r.bool(),
+ sun_rotation: readQuat(r),
+});
+
+function writeGroup(w, g) {
+ w.str(g.name ?? '');
+ if (Number.isInteger(g.parent_group) && g.parent_group >= 0) { w.u8(1); w.len64(g.parent_group); } else w.u8(0);
+}
+const readGroup = (r) => ({ name: r.str(), parent_group: r.option(() => r.len64()) });
+
+function writeProject(w, project) {
+ w.str(project.project_id ?? '');
+ const parts = Array.isArray(project.parts) ? project.parts : [];
+ w.len64(parts.length);
+ for (const p of parts) writePart(w, p);
+ writeLighting(w, project.lighting);
+ const groups = Array.isArray(project.groups) ? project.groups : [];
+ w.len64(groups.length);
+ for (const g of groups) writeGroup(w, g);
+}
+
+const readProject = (r) => ({
+ project_id: r.str(),
+ parts: r.vec(() => readPart(r)),
+ lighting: readLighting(r),
+ groups: r.vec(() => readGroup(r)),
+});
+
+// --- zstd container ------------------------------------------------------
+//
+// Decompression uses fzstd. For compression we emit a valid zstd frame made of
+// raw (uncompressed) blocks: this needs no compressor dependency and any
+// spec-compliant zstd decoder — the Studio's included — accepts it.
+const ZSTD_MAGIC = [0x28, 0xb5, 0x2f, 0xfd];
+const RAW_BLOCK_MAX = 1 << 17; // 128 KiB, the largest a single block may hold
+
+function zstdWrapRaw(payload) {
+ const out = [];
+ out.push(...ZSTD_MAGIC);
+ // Frame header: Single_Segment set, Frame_Content_Size as a full 8-byte field
+ // (descriptor 0xE0), no window descriptor, no checksum, no dictionary.
+ out.push(0xe0);
+ const size = new Uint8Array(8);
+ new DataView(size.buffer).setBigUint64(0, BigInt(payload.length), true);
+ out.push(...size);
+ // Raw data blocks: header is 3 bytes little-endian = (len << 3) | (0 << 1) | last.
+ if (payload.length === 0) {
+ out.push(0x01, 0x00, 0x00); // a single empty last block
+ } else {
+ for (let off = 0; off < payload.length; off += RAW_BLOCK_MAX) {
+ const chunk = payload.subarray(off, Math.min(off + RAW_BLOCK_MAX, payload.length));
+ const last = off + RAW_BLOCK_MAX >= payload.length ? 1 : 0;
+ const header = (chunk.length << 3) | last;
+ out.push(header & 0xff, (header >> 8) & 0xff, (header >> 16) & 0xff);
+ out.push(...chunk);
+ }
+ }
+
+ return Uint8Array.from(out);
+}
+
+/** True when these bytes begin with the `VRTX` magic. */
+export function isVrtx(bytes) {
+ return !!bytes && bytes.length >= MAGIC.length && MAGIC.every((b, i) => bytes[i] === b);
+}
+
+/**
+ * Serialise the object `toProject` returns into `.vrtx` bytes.
+ * @returns {Uint8Array}
+ */
+export function encodeVrtx(project) {
+ const inner = new Writer();
+ inner.u8(SCHEMA_VERSION);
+ writeProject(inner, project);
+ const frame = zstdWrapRaw(inner.take());
+
+ const out = new Uint8Array(MAGIC.length + 1 + frame.length);
+ out.set(MAGIC, 0);
+ out[MAGIC.length] = CONTAINER_VERSION;
+ out.set(frame, MAGIC.length + 1);
+
+ return out;
+}
+
+/**
+ * Parse `.vrtx` bytes back into the same object shape `fromProject` consumes.
+ * Throws {@link VrtxError} on a bad magic, an unsupported version or a corrupt file.
+ */
+export function decodeVrtx(bytes) {
+ if (!isVrtx(bytes)) throw new VrtxError('not a .vrtx file');
+ if (bytes.length < MAGIC.length + 1) throw new VrtxError('the .vrtx file is truncated');
+ const container = bytes[MAGIC.length];
+ if (container !== CONTAINER_VERSION) throw new VrtxError(`unsupported .vrtx version ${container}`);
+
+ let payload;
+ try {
+ payload = zstdDecompress(bytes.subarray(MAGIC.length + 1));
+ } catch (e) {
+ throw new VrtxError(`the .vrtx file is not valid zstd: ${e.message ?? e}`);
+ }
+
+ const r = new Reader(payload);
+ const schema = r.u8();
+ if (schema !== SCHEMA_VERSION) throw new VrtxError(`unsupported .vrtx schema version ${schema}`);
+
+ return readProject(r);
+}
diff --git a/scripts/fixtures/studio-0-2-project.json b/scripts/fixtures/studio-0-2-project.json
index 8c2d5db..bd4b6c7 100644
--- a/scripts/fixtures/studio-0-2-project.json
+++ b/scripts/fixtures/studio-0-2-project.json
@@ -100,7 +100,13 @@
"a": 1.0
},
"sun_illuminance": 8000.0,
- "sun_shadow_maps_enabled": true
+ "sun_shadow_maps_enabled": true,
+ "sun_rotation": {
+ "x": -0.394473,
+ "y": 0.439168,
+ "z": 0.223347,
+ "w": 0.775654
+ }
},
"groups": []
}
\ No newline at end of file
diff --git a/scripts/fixtures/studio-0-2-project.vrtx b/scripts/fixtures/studio-0-2-project.vrtx
new file mode 100644
index 0000000..e86f572
Binary files /dev/null and b/scripts/fixtures/studio-0-2-project.vrtx differ
diff --git a/scripts/project.test.mjs b/scripts/project.test.mjs
index efb7058..83595a9 100644
--- a/scripts/project.test.mjs
+++ b/scripts/project.test.mjs
@@ -52,7 +52,7 @@ test('a list of suns is folded into the one sun the rig now has', () => {
const out = toProject(read.parts.map(withNewId), [], read.projectId, read.lighting);
assert.deepEqual(Object.keys(out.lighting).sort(), [
- 'ambient_color', 'brightness', 'sun_color', 'sun_illuminance', 'sun_shadow_maps_enabled',
+ 'ambient_color', 'brightness', 'sun_color', 'sun_illuminance', 'sun_rotation', 'sun_shadow_maps_enabled',
], 'and the rig is written with the fields the format has, no more');
assert.equal(out.lighting.sun_illuminance, DEFAULT_ILLUMINANCE);
assert.equal(Object.keys(out.lighting.ambient_color).sort().join(''), 'abgr');
diff --git a/scripts/vrtx.test.mjs b/scripts/vrtx.test.mjs
new file mode 100644
index 0000000..ea198ed
--- /dev/null
+++ b/scripts/vrtx.test.mjs
@@ -0,0 +1,103 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { decompress as zstd } from 'fzstd';
+import { decodeVrtx, encodeVrtx, isVrtx, VrtxError } from '../resources/js/studio/vrtx.js';
+
+// The bincode payload inside a .vrtx, past the 5-byte header and the zstd frame.
+const inner = (vrtx) => zstd(vrtx.subarray(5));
+
+const bytesOf = (name) => new Uint8Array(readFileSync(new URL(`./fixtures/${name}`, import.meta.url)));
+// The same project in both formats: the .vrtx was written by the real Studio v0.2.1.
+const sample = bytesOf('studio-0-2-project.vrtx');
+const ref = JSON.parse(readFileSync(new URL('./fixtures/studio-0-2-project.json', import.meta.url), 'utf8'));
+const near = (a, b, e = 1e-4) => Math.abs(a - b) <= e;
+
+test('a real Studio .vrtx decodes to the same project as its JSON twin', () => {
+ assert.ok(isVrtx(sample));
+ const doc = decodeVrtx(sample);
+
+ assert.equal(doc.project_id, ref.project_id);
+ assert.equal(doc.parts.length, ref.parts.length);
+ for (let i = 0; i < ref.parts.length; i += 1) {
+ const a = doc.parts[i];
+ const b = ref.parts[i];
+ assert.equal(a.name, b.name);
+ assert.equal(a.material, b.material);
+ assert.ok(near(a.position.x, b.position.x) && near(a.position.y, b.position.y));
+ assert.ok(near(a.color.r, b.color.r) && near(a.color.g, b.color.g));
+ assert.equal(a.baseplate, b.baseplate);
+ assert.equal(a.spawn_location, b.spawn_location);
+ assert.deepEqual(a.textures, b.textures);
+ assert.equal(a.point_light, null);
+ assert.equal(a.spot_light, null);
+ }
+ assert.ok(near(doc.lighting.brightness, ref.lighting.brightness));
+ assert.ok(near(doc.lighting.sun_illuminance, ref.lighting.sun_illuminance));
+ // The sun direction is a field the JSON did not carry; it must be a unit quaternion.
+ const q = doc.lighting.sun_rotation;
+ assert.ok(near(Math.hypot(q.x, q.y, q.z, q.w), 1, 2e-3));
+});
+
+test('our encoder reproduces the exact bincode the Studio wrote', () => {
+ // We store the zstd payload uncompressed (a valid raw-block frame), so the
+ // container bytes differ from the Studio's compressed frame. What must match —
+ // and does — is the bincode inside, the strongest evidence our file opens there.
+ const doc = decodeVrtx(sample);
+ const again = encodeVrtx(doc);
+ assert.deepEqual([...inner(again)], [...inner(sample)]);
+});
+
+test('encode then decode round trips an arbitrary project', () => {
+ const project = {
+ project_id: 'a'.repeat(32),
+ parts: [{
+ name: 'Lamp',
+ position: { x: 1, y: 2, z: 3 },
+ rotation: { x: 0, y: 0, z: 0, w: 1 },
+ scale: { x: 4, y: 4, z: 4 },
+ color: { r: 0.5, g: 0.25, b: 0.125, a: 1 },
+ material: 'Wood',
+ group: 0,
+ cast_shadow: true,
+ anchored: false,
+ can_collide: true,
+ spawn_location: false,
+ baseplate: false,
+ custom_appearance: false,
+ truss: false,
+ textures: [{ face: 'Top', kind: 'Studs' }],
+ point_light: null,
+ spot_light: {
+ color: { r: 1, g: 1, b: 1, a: 1 },
+ intensity: 800,
+ range: 20,
+ shadow_maps_enabled: true,
+ angle: 0.6,
+ face: 'Bottom',
+ },
+ }],
+ lighting: {
+ ambient_color: { r: 1, g: 1, b: 1, a: 1 },
+ brightness: 2000,
+ sun_color: { r: 1, g: 1, b: 1, a: 1 },
+ sun_illuminance: 8000,
+ sun_shadow_maps_enabled: true,
+ sun_rotation: { x: 0, y: 0, z: 0, w: 1 },
+ },
+ groups: [{ name: 'Fixtures', parent_group: null }],
+ };
+ const doc = decodeVrtx(encodeVrtx(project));
+ assert.equal(doc.parts[0].material, 'Wood');
+ assert.equal(doc.parts[0].group, 0);
+ assert.equal(doc.parts[0].anchored, false);
+ assert.equal(doc.parts[0].spot_light.face, 'Bottom');
+ assert.ok(near(doc.parts[0].spot_light.angle, 0.6));
+ assert.deepEqual(doc.parts[0].textures, [{ face: 'Top', kind: 'Studs' }]);
+ assert.equal(doc.groups[0].name, 'Fixtures');
+});
+
+test('a non-vrtx buffer is rejected and not mistaken for one', () => {
+ assert.equal(isVrtx(new Uint8Array([0x7b, 0x22, 0x70])), false); // starts with {"p
+ assert.throws(() => decodeVrtx(new Uint8Array([1, 2, 3, 4, 5])), VrtxError);
+});