From 70139f4ddaed267c68a998a10af0fae0724e0d8b Mon Sep 17 00:00:00 2001 From: Paulo Junior Date: Mon, 17 Aug 2026 14:44:13 -0300 Subject: [PATCH 1/2] add the .vrtx project format, default the export to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio v0.2.1 saves projects as .vrtx, not JSON: a "VRTX" magic and a version byte, then zstd-compressed bincode of ProjectData (with a leading inner schema byte). New resources/js/studio/vrtx.js is a small bincode codec for that schema — it decompresses with fzstd and, having no bincode compressor to hand, writes the zstd payload as a valid raw-block frame the Studio's decoder accepts. Verified against a file the real Studio wrote: our inner bincode is byte-identical. - Download now writes .vrtx by default; .json stays as a legacy option. - Import (upload and the start screen) sniffs the VRTX magic and reads either format. - The sun direction (sun_rotation) is a field of the format as of v0.2.1, so it rides in the document now instead of only in the map. Breaking format change, so a major bump to 3.0.0. Refs #17 --- package-lock.json | 7 + package.json | 3 +- resources/js/studio/App.jsx | 55 +++- resources/js/studio/MenuBar.jsx | 9 +- resources/js/studio/StartScreen.jsx | 8 +- resources/js/studio/vortexProject.js | 5 +- resources/js/studio/vrtx.js | 343 +++++++++++++++++++++++ scripts/fixtures/studio-0-2-project.json | 8 +- scripts/fixtures/studio-0-2-project.vrtx | Bin 0 -> 181 bytes scripts/project.test.mjs | 2 +- scripts/vrtx.test.mjs | 103 +++++++ 11 files changed, 519 insertions(+), 24 deletions(-) create mode 100644 resources/js/studio/vrtx.js create mode 100644 scripts/fixtures/studio-0-2-project.vrtx create mode 100644 scripts/vrtx.test.mjs diff --git a/package-lock.json b/package-lock.json index 6ec4679..69cce7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": "

\"Laravel

", "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({ 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 0000000000000000000000000000000000000000..e86f572675d077b0c81b13b7c16921b75e5a3a29 GIT binary patch literal 181 zcmV;m080N>QdC$0D77#B0BAi00K^6XAOJIDH#RV1H8f&oG-PIBVmLTrVq!64H#lTw zWn*GxGd2PM4O4JocWz8?V_|e@Z*D&TfWSbPmX { 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); +}); From 35afe5d84e80d429563a44676838c7fd1e131c47 Mon Sep 17 00:00:00 2001 From: Paulo Junior Date: Mon, 17 Aug 2026 14:50:20 -0300 Subject: [PATCH 2/2] set the version to 3.0.0 --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 69cce7e..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",