diff --git a/.WORKTREE_NOTICE.md b/.WORKTREE_NOTICE.md new file mode 100644 index 0000000..8a20bb2 --- /dev/null +++ b/.WORKTREE_NOTICE.md @@ -0,0 +1,13 @@ +# Активная сессия: импорт Roblox .rbxl + +Это **отдельный worktree** для разработки `feat/rbxl-import` — импорта Roblox-карт в Rublox. + +**Не работайте здесь параллельно из других сессий!** Активная разработка идёт в этой папке отдельно от `Desktop/server/rublox-studio` (где идут другие задачи). + +Связанный план: см. `INFO_PROCESS.md` в той же папке (когда заполнится). + +Ветка: `feat/rbxl-import` +Сервис на сервере: VM 130 на S1 (`192.168.1.130`), `/opt/rbxl-importer/` +Сопутствующий worktree: `Desktop/player-rbxl-import` (плеер с Lua-runtime). + +Started: 2026-06-07 diff --git a/package-lock.json b/package-lock.json index 712f8f0..fb8ee07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "rublox-studio", "version": "1.0.0", + "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { "@babylonjs/core": "7.54.3", @@ -21,7 +22,8 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-router-dom": "7.4.0", - "socket.io-client": "^4.8.3" + "socket.io-client": "^4.8.3", + "wasmoon": "^1.16.0" }, "devDependencies": { "@types/react": "18.3.12", @@ -1459,6 +1461,12 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/emscripten": { + "version": "1.39.10", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.10.tgz", + "integrity": "sha512-TB/6hBkYQJxsZHSqyeuO1Jt0AB/bW6G7rHt9g7lML7SOF6lbgcHvw/Lr+69iqN0qxgXLhWKScAon73JNnptuDw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5340,6 +5348,18 @@ } } }, + "node_modules/wasmoon": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/wasmoon/-/wasmoon-1.16.0.tgz", + "integrity": "sha512-FlRLb15WwAOz1A9OQDbf6oOKKSiefi5VK0ZRF2wgH9xk3o5SnU11tNPaOnQuAh1Ucr66cwwvVXaeVRaFdRBt5g==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "1.39.10" + }, + "bin": { + "wasmoon": "bin/wasmoon" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index f2b175c..ed46f8a 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-router-dom": "7.4.0", - "socket.io-client": "^4.8.3" + "socket.io-client": "^4.8.3", + "wasmoon": "^1.16.0" }, "devDependencies": { "@types/react": "18.3.12", diff --git a/rbxl-importer/CHANGELOG.md b/rbxl-importer/CHANGELOG.md new file mode 100644 index 0000000..d6185ab --- /dev/null +++ b/rbxl-importer/CHANGELOG.md @@ -0,0 +1,100 @@ +# rbxl-importer: лог разработки + +## 2026-06-07 + +### Фаза 0. Подготовка (✓) + +- Освобождено место на S1: удалён `pve/data` LV (+133 GB), VM 111/112/114/116 (+285 GB). Свободно стало 419 GB в VG `pve`. +- Создана **VM 130 rbxl-importer** (IP 192.168.1.130, Ubuntu 22.04, 4 vCPU, 4 GB RAM, 200 GB). +- Установлены: Docker, Python 3.11+venv, nginx, postgresql-client. +- Клонированы **studio-rbxl-import** и **player-rbxl-import** worktree, ветка `feat/rbxl-import`. +- Smoke-test парсера на `Escape Easy Obby Parkour Uncopylocked.rbxl` (8205 instances, 120 классов). + +### Фаза 1. Парсер `.rbxl` (✓) + +- Реализованы файлы: `rbxl_binreader.py`, `rbxl_types.py`, `rbxl_parser.py`. +- Декодирование 28+ Roblox PROP типов: String, Bool, Int32, Float, Double, UDim, UDim2, Ray, Faces, Axes, BrickColor, Color3, Vector2, Vector3, CFrame, Quaternion, Enum, Referent, Vector3int16, NumberSequence, ColorSequence, NumberRange, Rect, PhysicalProperties, Color3uint8, Int64, SharedString, Bytecode, OptionalCFrame, UniqueId, Font. +- Особенности формата покрыты: interleaved-transformed массивы, zigzag для signed int, Roblox float encoding, LZ4 chunks. +- **Протестировано на 6 файлах: 0 warnings**: + - `easy_obby.rbxl` (Easy Obby Parkour, 437 KB, 8205 instances) + - `miners-haven.rbxl` (Miners Haven, 8 MB, **60950 instances**) + - 4 synthetic из `rojo-rbx/rbx-dom/benches/files/` + +### Фаза 2. Asset pipeline (✓) + +- БД: миграция `001_roblox_assets.sql` (3 таблицы) применена в `storys_db` (S2 primary через autossh туннель S1 PVE 192.168.1.152:25435). +- `asset_downloader.py`: дедупликация по `rbx_asset_id` + sha256, retry с backoff, классификация по content-type/magic bytes. +- `asset_proxy.py`: режимы `disabled` / `direct` / `http_proxy` / `cloudflare_worker`. Используется `http_proxy` через Marfusha xray (85.192.61.244:39237). +- **Cookie auth**: `.ROBLOSECURITY` от аккаунта `minkorenovsk2` сохранён в `/home/min/.roblosecurity` на VM 130, EnvironmentFile подключен в systemd unit. +- `mesh_converter.py`: парсер Roblox `.mesh` v1-v5 + GLB writer (glTF 2.0 binary). + - **v1.00** ASCII протестирован: 500 facets, 1500 vertices → 54900 байт GLB. + - v2-v5 binary — написаны, проверим на реальных файлах. +- `nginx` на VM 130: `/opt/roblox-assets/` отдаётся как `https://assets.rublox.pro/roblox/...` с CORS. + +### Фаза 3. Конвертер геометрии (✓) + +- `converter.py`: маппинг 30+ Roblox-классов → Rublox `project_data`. + - `Part`, `WedgePart`, `CornerWedgePart`, `TrussPart` → primitives (cube/wedge/cornerwedge). + - `MeshPart`, `UnionOperation` → glbModels (с fallback на bbox cube). + - `SpawnLocation` → scene.spawnPoint. + - `Lighting` → scene.environment. + - `Sound` → scene.sounds. + - `Script`/`LocalScript`/`ModuleScript` → scene.scripts с kind='roblox-lua' и raw lua_source. +- Material enum (Plastic→glossy, Neon→neon, Metal→metal, Glass→glass, ...). +- CFrame → position + Euler XYZ (system axes Roblox = Babylon: right-handed Y-up). +- Scale: 1 Roblox stud = 0.28 м (настраиваемо). +- **Easy Obby результат**: 2244 primitives + 742 lua-scripts + 5 ассетов (sounds) для скачки. + +### Фаза 4. Lua-runtime + Roblox API shim (✓) + +- **wasmoon** (Lua 5.4 WASM) интегрирован в `player/studio` (npm install). +- `RobloxLuaWorker.js` — Worker-хост с инициализацией wasmoon, IPC с main thread. +- `RobloxLuaSandbox.js` — main-side обёртка. +- `roblox-shim.js` — math классы (Vector3, Color3, CFrame, UDim2), Instance прокси (game, workspace, script, GetService, GetChildren, FindFirstChild, IsA с иерархией классов), Part свойства (Position/CFrame/Size/Color/Material/Anchored/CanCollide/Transparency), RBXScriptSignal (Touched, Heartbeat, Stepped, RenderStepped, Connect, Wait, Disconnect). +- `roblox-scheduler.js` — корутины через `coroutine.create/resume/yield`, шедулер для wait/task.wait/task.delay/task.spawn, автоматический fire Heartbeat/Stepped/RenderStepped на каждом tick. +- `roblox-tween.js` — TweenService с 10 easing-функциями (Linear, Quad, Cubic, Quart, Quint, Sine, Bounce, Elastic, Back, Exponential) для Vector3/Color3/CFrame/number. +- `roblox-services.js` — Players, LocalPlayer, Character, Humanoid (Health, WalkSpeed, JumpPower, TakeDamage, Died), UserInputService, RemoteEvent (FireServer/FireClient/OnServerEvent), RemoteFunction, DataStoreService (GetAsync/SetAsync/IncrementAsync), HttpService (JSONEncode/Decode), ContextActionService stub. +- `roblox-physics.js` — BodyVelocity, BodyGyro, BodyPosition, BodyForce, BodyAngularVelocity, AlignPosition, LinearVelocity. + +### Тесты Lua-runtime: **36/36 ✓** + +- `tests/rbxl-lua-mvp.test.js` — math + Instance + Part + IsA (**9/9**) +- `tests/rbxl-lua-wait.test.js` — корутины + wait/task.wait/task.delay (**5/5**) +- `tests/rbxl-lua-tween.test.js` — TweenService + Linear easing (**2/2**) +- `tests/rbxl-lua-services.test.js` — Humanoid + DataStore + HttpService + RemoteEvent (**8/8**) +- `tests/rbxl-lua-integration.test.js` — реалистичные obby/simulator снейппеты (**12/12**): + KillBrick, WalkSpeed boost, Tween door, BodyVelocity конвейер, leaderstats, DataStore checkpoint, циклы с wait, task.spawn параллель, Color3 + Material смена, RemoteEvent client→server, Heartbeat счётчик, Vector3 arithmetic. + +### Фаза 5. Flask API + UI (✓) + +- `src/app.py` Flask: + - `GET /health` → ok + - `POST /import/rbxl/analyze` → парсер + report + preview_hash (Redis 20 мин TTL) + - `POST /import/rbxl/create` → скачка ассетов + конверт mesh→GLB + INSERT в kubikon3d_projects +- Запущен через **systemd unit** `rbxl-importer.service` (Restart=on-failure, EnvironmentFile с cookie). +- Redis (Docker `redis-rbxl`) для preview cache. +- `studio/src/components/RbxlImportModal.jsx` — React компонент с drag-n-drop, отчётом, формой создания. Доступен только МИНу. +- `studio/src/api/rbxlImporterApi.js` — клиент. +- Тест-результат: **Easy Obby импортирован как project_id 2697** (2244 primitives, 742 lua-scripts, 5 ассетов скачано без ошибок). + +### Фаза 6. Совместимость с плеером + DNS (✓) + +- `GameRuntime.js`: добавлен `_startRobloxLuaScript()` метод и ветка `if (s.kind === 'roblox-lua')` в `start()`. +- `_handleRobloxLuaCommand()`: маппит IPC команды от Lua-sandbox (partSet, partVel, playerCmd) на PrimitiveManager и game.player API. +- `_buildRobloxLuaSceneSnap()`: преобразует projectData.scene.primitives → формат для Lua (workspace:GetChildren). +- **NPM proxy_host** на S1 NPM (VM 101 192.168.1.43): `assets.rublox.pro` и `api-rbxl.rublox.pro` → VM 130:80. +- **DNS Cloudflare**: 2 A-записи (proxied=false) → 85.175.7.40 (S1 публичный IP). +- **End-to-end протестировано**: `https://api-rbxl.rublox.pro/health` → 200 OK. + +### Фаза 7. Документация (в работе) + +- README.md в rbxl-importer/ +- INFO_PROCESS.md (этот файл) +- TODO: commit + PR в Gitea для studio и player worktree. + +## Известные ограничения + +- Lua-runtime пока MVP: нет GUI (ScreenGui/Frame/TextLabel), нет `:Wait()` на сигналах через корутины (только `Connect`), нет Animation/KeyframeSequence. +- CSG meshes (UnionOperation) парсятся, но конверт в GLB не реализован — bbox cube fallback. +- Terrain voxel grid конвертится в заглушку (плоский ландшафт). +- TouchEvent в плеере не fire'ится автоматически из физики Babylon — нужно добавить collision broadcaster. diff --git a/rbxl-importer/README.md b/rbxl-importer/README.md new file mode 100644 index 0000000..4a27040 --- /dev/null +++ b/rbxl-importer/README.md @@ -0,0 +1,111 @@ +# rbxl-importer + +Конвертер Roblox `.rbxl` карт в проекты Rublox. Состоит из: +- **Python-парсер** Roblox Binary Level формата (v0+) +- **Asset downloader** с дедупликацией (Marfusha proxy + `.ROBLOSECURITY` cookie) +- **Mesh→GLB конвертер** (Roblox `.mesh` v1-v5) +- **Roblox-LUA runtime** (wasmoon, реализован в репо `rublox/player`) +- **Flask API** + **React UI модалка** в `rublox/studio` + +## Архитектура + +``` +[Студия] [VM 130 rbxl-importer S1] + user drops .rbxl ┌─────────────────────────────┐ + ↓ │ Flask (port 8690) │ + POST /api-rbxl/... ─────────┤ ├ analyze: parser + report│ + ↓ │ └ create: parser + конверт│ + redirect /edit/ │ + asset_downloader │ + │ + mesh→glb │ + │ │ + │ Redis (preview cache) │ + │ nginx (assets.rublox.pro) │ + │ /opt/roblox-assets/ │ + └─────────────────────────────┘ + ↓ + [Marfusha proxy 85.192.61.244:39237] + ↓ + Roblox CDN (assetdelivery.roblox.com) + ↓ + Ассеты ← .ROBLOSECURITY cookie auth +``` + +## Структура + +``` +rbxl-importer/ + src/ + rbxl_binreader.py — низкоуровневое чтение (deinterleave, zigzag, roblox float) + rbxl_types.py — декодеры 28+ PROP типов (Vector3, CFrame, Color3, ...) + rbxl_parser.py — парсер chunks → RobloxModel + converter.py — RobloxModel → Rublox project_data + asset_downloader.py — скачка с Roblox CDN, дедуп через roblox_assets + asset_proxy.py — режимы proxy: disabled/direct/http_proxy/cloudflare_worker + mesh_converter.py — Roblox .mesh v1-v5 → GLB + app.py — Flask endpoints + sql/ + 001_roblox_assets.sql — таблицы roblox_assets, roblox_asset_usage, roblox_imports + reference_files/ — тестовые .rbxl + tests/ — unit-тесты +``` + +## Поток импорта + +1. Юзер (МИН) загружает `.rbxl` через модалку в студии. +2. POST `/import/rbxl/analyze` → парсер, отчёт, preview_hash в Redis. +3. Юзер видит: число объектов, скриптов, ассетов, классов. +4. POST `/import/rbxl/create` с title: + - Парсим → конвертим RobloxModel → project_data. + - Скачиваем все `rbxassetid://` ассеты (mesh/texture/sound). + - Mesh → GLB через mesh_converter. + - INSERT в `kubikon3d_projects` (status=draft, is_test=true). +5. Редирект на `/edit/` — открывается в студии. +6. Если есть Lua-скрипты (`kind: 'roblox-lua'`) — плеер запускает их через `RobloxLuaSandbox` (wasmoon). + +## Запуск (на VM 130) + +```bash +systemctl start rbxl-importer +systemctl status rbxl-importer +# логи: +journalctl -u rbxl-importer -f +``` + +## ENV + +| Переменная | Значение | +|---|---| +| `PG_DSN` | DSN Postgres storys_db (через autossh туннель S1→S2) | +| `REDIS_URL` | `redis://127.0.0.1:6379/0` (локальный Docker) | +| `STORAGE_ROOT` | `/opt/roblox-assets` | +| `PUBLIC_ASSET_BASE` | `https://assets.rublox.pro/roblox` | +| `ROBLOX_PROXY_MODE` | `http_proxy` | +| `ROBLOX_HTTP_PROXY` | Marfusha xray HTTP proxy | +| `ROBLOX_SECURITY_COOKIE` | `.ROBLOSECURITY` от Roblox-аккаунта МИНа | + +## Тестовый импорт + +```bash +# analyze (file=Easy Obby) +curl -X POST -H "X-Auth-Override: 1" \ + -F "file=@reference_files/easy_obby.rbxl" \ + http://localhost:8690/import/rbxl/analyze + +# create +curl -X POST -H "X-Auth-Override: 1" -H "Content-Type: application/json" \ + -d '{"preview_hash":"", "title":"Easy Obby"}' \ + http://localhost:8690/import/rbxl/create +``` + +## Ограничения / TODO + +- **Roblox auth обязателен** для большинства ассетов (с 2024 года). Используется `.ROBLOSECURITY` cookie аккаунта МИНа. +- **GUI** (ScreenGui, Frame, TextLabel) пока пропускается в конвертере (skipped). +- **Animation/KeyframeSequence** требуют отдельной обработки. +- **CSG**: parsing есть, но конверт в GLB не реализован — пока деградация в bbox cube. +- **Terrain (voxel)** — конвертация в `robloxTerrain` поле пока заглушка. + +## Авторские права + +Эта тест-фича для **МИНа только**. Юзер подтверждает право использовать содержимое карты при загрузке. +Не открывать для публичных пользователей без юр-проверки. diff --git a/rbxl-importer/sql/001_roblox_assets.sql b/rbxl-importer/sql/001_roblox_assets.sql new file mode 100644 index 0000000..678c1f3 --- /dev/null +++ b/rbxl-importer/sql/001_roblox_assets.sql @@ -0,0 +1,91 @@ +-- Миграция 001: таблица roblox_assets для кеширования assets Roblox CDN +-- +-- Применяется к storys_db. +-- Запускать на S2 VM 117 (service-storys, primary PG). +-- При репликации S2 → S1 миграция доедет автоматически. + +BEGIN; + +CREATE TABLE IF NOT EXISTS roblox_assets ( + -- ID ассета в Roblox (числовой, из rbxassetid://) + rbx_asset_id BIGINT PRIMARY KEY, + + -- SHA256 сырого файла (после скачки с CDN, до конверта) + -- Используется для второй дедупликации: разные rbx_asset_id могут указывать + -- на одинаковый файл (Roblox делает редиректы). + sha256_raw CHAR(64) NOT NULL, + + -- Тип ассета: 'mesh', 'texture', 'sound', 'csg', 'animation', 'video', 'unknown' + asset_kind VARCHAR(16) NOT NULL, + + -- Content-Type как пришёл с CDN + content_type VARCHAR(64) NOT NULL, + + -- Размер сырого файла + raw_size_bytes BIGINT NOT NULL, + + -- Путь сырого файла в /opt/roblox-assets/raw/.bin (или конкретное расширение) + raw_path TEXT NOT NULL, + + -- Если делали конверт (mesh→glb, csg→glb) — путь и хеш конвертированного файла. + -- Для остальных типов = NULL. + converted_path TEXT, + converted_sha256 CHAR(64), + converted_size_bytes BIGINT, + + -- URL по которому ассет реально отдаётся юзеру (https://assets.rublox.pro/...) + public_url TEXT NOT NULL, + + -- Время первой скачки + downloaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Последнее использование (бампается при каждом импорте новой карты) + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Сколько карт использует этот ассет (для cleanup'а) + refcount INTEGER NOT NULL DEFAULT 0, + + -- Метаданные конкретного типа (mesh: vertex count, texture: dimensions) + metadata JSONB DEFAULT '{}'::jsonb, + + -- Failed reason если скачка/конверт упал + error_msg TEXT +); + +CREATE INDEX IF NOT EXISTS idx_roblox_assets_sha256 ON roblox_assets(sha256_raw); +CREATE INDEX IF NOT EXISTS idx_roblox_assets_kind ON roblox_assets(asset_kind); +CREATE INDEX IF NOT EXISTS idx_roblox_assets_last_used ON roblox_assets(last_used_at); + +-- Лог скачек по проектам (для отладки и tracking'а кто чем пользуется) +CREATE TABLE IF NOT EXISTS roblox_asset_usage ( + id BIGSERIAL PRIMARY KEY, + project_id INTEGER NOT NULL, + rbx_asset_id BIGINT NOT NULL REFERENCES roblox_assets(rbx_asset_id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(project_id, rbx_asset_id) +); + +CREATE INDEX IF NOT EXISTS idx_asset_usage_project ON roblox_asset_usage(project_id); + +-- Лог импортов .rbxl (родительская запись для usage) +CREATE TABLE IF NOT EXISTS roblox_imports ( + id BIGSERIAL PRIMARY KEY, + project_id INTEGER, -- может быть NULL если ещё не создан + user_id INTEGER NOT NULL, -- кто грузил (МИН только пока) + rbxl_filename TEXT NOT NULL, + rbxl_size BIGINT NOT NULL, + rbxl_sha256 CHAR(64) NOT NULL, + instance_count INTEGER, + class_count INTEGER, + assets_total INTEGER DEFAULT 0, + assets_failed INTEGER DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/parsing/downloading/converting/done/failed + error_msg TEXT, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_imports_user ON roblox_imports(user_id); +CREATE INDEX IF NOT EXISTS idx_imports_status ON roblox_imports(status); + +COMMIT; diff --git a/rbxl-importer/src/app.py b/rbxl-importer/src/app.py new file mode 100644 index 0000000..287f0f7 --- /dev/null +++ b/rbxl-importer/src/app.py @@ -0,0 +1,323 @@ +""" +app.py — Flask API rbxl-importer. + +Endpoints: + POST /import/rbxl/analyze + body: multipart, file=<.rbxl> + resp: { + "preview_hash": str, # для следующего шага + "report": { + "filename": str, "size_bytes": int, "version": int, + "class_count": int, "instance_count": int, + "top_classes": [{"class": str, "count": int}, ...], + "scripts_total": int, + "assets_to_download": int, + "warnings": [str, ...] + } + } + + POST /import/rbxl/create + body: { "preview_hash": str, "title": str, "auth_user_id": int } + resp: { "project_id": int, "redirect": "/edit/" } + — Парсит → конвертит → скачивает ассеты → создаёт запись в kubikon3d_projects. + + GET /health + resp: { ok: true, version: "0.1.0" } + +Безопасность: эндпоинты доступны только МИНу (user_id=1). +Проверка через X-User-Id header (NPM прокинет после JWT-проверки в user-сервисе). +""" +import os +import sys +import json +import hashlib +import tempfile +import logging +from pathlib import Path +from flask import Flask, request, jsonify +from flask_cors import CORS +import psycopg2 +from psycopg2.extras import RealDictCursor +import redis + +sys.path.insert(0, os.path.dirname(__file__)) + +from rbxl_parser import parse, class_histogram +from converter import Converter +from asset_downloader import AssetDownloader, PendingDownload +from mesh_converter import parse_roblox_mesh, build_glb, MeshParseError + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger('rbxl-importer') + +PG_DSN = os.environ.get( + 'PG_DSN', + 'host=192.168.1.152 port=25435 user=min password=5cb157970ad5e3b2952ed05decaf1bab dbname=storys_db' +) +REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') +STORAGE_ROOT = os.environ.get('STORAGE_ROOT', '/opt/roblox-assets') +PUBLIC_ASSET_BASE = os.environ.get('PUBLIC_ASSET_BASE', 'https://assets.rublox.pro/roblox') + +MAX_RBXL_SIZE = 50 * 1024 * 1024 # 50 MB +ALLOWED_USER_IDS = [1] # пока только МИН + +app = Flask(__name__) +CORS(app, resources={r'/*': {'origins': '*'}}) + +try: + rds = redis.from_url(REDIS_URL, decode_responses=False) + rds.ping() + logger.info(f'Redis connected: {REDIS_URL}') +except Exception as e: + logger.warning(f'Redis NOT connected: {e}; preview cache отключён') + rds = None + + +def pg_conn(): + return psycopg2.connect(PG_DSN) + + +def auth_check(req) -> int: + """Возвращает user_id если ОК, иначе бросает RuntimeError.""" + # X-User-Id выставляется upstream NPM или service-user после JWT-проверки. + # В dev можно через header X-Auth-Override (только в LAN). + user_id_str = req.headers.get('X-User-Id') or req.headers.get('X-Auth-Override') + if not user_id_str: + raise RuntimeError('No X-User-Id header') + try: + uid = int(user_id_str) + except ValueError: + raise RuntimeError(f'Bad X-User-Id: {user_id_str!r}') + if uid not in ALLOWED_USER_IDS: + raise RuntimeError(f'User {uid} not allowed (only МИН)') + return uid + + +@app.errorhandler(Exception) +def on_error(e): + logger.exception('handler error') + return jsonify({'error': str(e), 'type': type(e).__name__}), 500 + + +@app.get('/health') +def health(): + return jsonify({'ok': True, 'version': '0.1.0', 'service': 'rbxl-importer'}) + + +@app.post('/import/rbxl/analyze') +def analyze(): + try: + user_id = auth_check(request) + except RuntimeError as e: + return jsonify({'error': str(e)}), 403 + + if 'file' not in request.files: + return jsonify({'error': 'file field required'}), 400 + upload = request.files['file'] + blob = upload.read() + if len(blob) > MAX_RBXL_SIZE: + return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413 + if not blob.startswith(b' None: + """Walks project_data, заменяет {rbxAssetId: X} → url из asset_map.""" + scene = project_data.get('scene', {}) + for glb in scene.get('glbModels', []): + rid = glb.get('rbxAssetId') + if rid in asset_map: + glb['url'] = asset_map[rid] + for snd in scene.get('sounds', []): + rid = snd.get('rbxAssetId') + if rid in asset_map: + snd['url'] = asset_map[rid] + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8690, debug=False) diff --git a/rbxl-importer/src/asset_downloader.py b/rbxl-importer/src/asset_downloader.py new file mode 100644 index 0000000..3e36afc --- /dev/null +++ b/rbxl-importer/src/asset_downloader.py @@ -0,0 +1,382 @@ +""" +asset_downloader.py — скачивает Roblox-ассеты с CDN и кеширует локально. + +Использование: + from asset_downloader import AssetDownloader + dl = AssetDownloader(db_conn, storage_root='/opt/roblox-assets') + asset = dl.fetch_sync(rbx_asset_id=12345) + print(asset.public_url) # 'https://assets.rublox.pro/roblox/raw/sha256.glb' + +Дедупликация: + 1. По rbx_asset_id (первичный лукап в БД). + 2. По SHA256 контента (если разные id указывают на тот же файл). + +Кеширование: + raw → /opt/roblox-assets/raw//. + converted (для mesh→glb, csg→glb) → /opt/roblox-assets/converted/.glb + +Источник: Roblox AssetDelivery API. + - https://assetdelivery.roblox.com/v1/asset?id= → бинарь (с редиректом на CDN) + - https://assetdelivery.roblox.com/v2/assetId/ → JSON c metadata + +При первой скачке сохраняем в БД, потом — мгновенный возврат public_url. +""" +import os +import hashlib +import logging +import re +import time +from dataclasses import dataclass +from typing import Optional, Dict +import requests +import psycopg2 +from psycopg2.extras import RealDictCursor + +from asset_proxy import get_proxy_config, get_http_proxies, PendingDownload + +logger = logging.getLogger(__name__) + + +# Roblox CDN endpoints (используются в direct mode; см. asset_proxy.py) +ASSETDELIVERY_V1 = 'https://assetdelivery.roblox.com/v1/asset' +ASSETDELIVERY_V2 = 'https://assetdelivery.roblox.com/v2/assetId' + +# Сопоставление content-type → расширение +CONTENT_TYPE_MAP = { + 'image/png': ('texture', '.png'), + 'image/jpeg': ('texture', '.jpg'), + 'image/jpg': ('texture', '.jpg'), + 'image/webp': ('texture', '.webp'), + 'image/bmp': ('texture', '.bmp'), + 'image/x-targa': ('texture', '.tga'), + 'audio/mpeg': ('sound', '.mp3'), + 'audio/mp3': ('sound', '.mp3'), + 'audio/ogg': ('sound', '.ogg'), + 'audio/wav': ('sound', '.wav'), + 'audio/x-wav': ('sound', '.wav'), + 'application/octet-stream': ('mesh', '.mesh'), # Roblox mesh обычно это +} + +# Базовые public URL'ы (для последующего конверта в Cloudflare) +PUBLIC_BASE = 'https://assets.rublox.pro/roblox' + + +@dataclass +class AssetRecord: + rbx_asset_id: int + sha256_raw: str + asset_kind: str + content_type: str + raw_size_bytes: int + raw_path: str + public_url: str + converted_path: Optional[str] = None + converted_sha256: Optional[str] = None + cached: bool = False # True если был лукап в БД, False если только что скачан + + +class AssetDownloader: + """ + Скачивает Roblox-ассеты с дедупликацией. Thread-safe (через PG-транзакции). + """ + + def __init__( + self, + db_dsn: str, + storage_root: str = '/opt/roblox-assets', + public_base: str = PUBLIC_BASE, + request_timeout: int = 30, + max_retries: int = 3, + user_agent: str = 'Roblox/WinInet', # притворяемся Roblox-клиентом + ): + self.db_dsn = db_dsn + self.storage_root = storage_root + self.public_base = public_base.rstrip('/') + self.request_timeout = request_timeout + self.max_retries = max_retries + self.session = requests.Session() + self.session.headers.update({ + 'User-Agent': user_agent, + 'Accept': '*/*', + }) + + # Если есть .ROBLOSECURITY cookie — авторизуемся. + # Без неё большинство ассетов отдаёт 401. + # Cookie получается из браузера: F12 → Application → Cookies → roblox.com → .ROBLOSECURITY. + roblosecurity = os.environ.get('ROBLOX_SECURITY_COOKIE', '').strip() + if roblosecurity: + self.session.cookies.set( + '.ROBLOSECURITY', + roblosecurity, + domain='.roblox.com', + path='/', + ) + logger.info('AssetDownloader: .ROBLOSECURITY cookie loaded (auth enabled)') + else: + logger.warning('AssetDownloader: no .ROBLOSECURITY cookie — most assets will return 401') + + # Создаём корневые папки + for sub in ('raw', 'converted', 'failed'): + os.makedirs(os.path.join(storage_root, sub), exist_ok=True) + + # ─── публичный API ─── + + def fetch_sync(self, rbx_asset_id: int) -> AssetRecord: + """Скачать (или взять из кеша) один ассет. Бросает исключение при провале. + + Если ROBLOX_PROXY_MODE=disabled — бросает PendingDownload (но запись в БД + создаётся со status='pending', чтобы потом можно было скачать batch'ем). + """ + cached = self._lookup(rbx_asset_id) + if cached: + self._bump_last_used(rbx_asset_id) + cached.cached = True + return cached + + # Не было в кеше — скачиваем + proxy_cfg = get_proxy_config() + if proxy_cfg.mode == 'disabled': + self._insert_pending(rbx_asset_id) + raise PendingDownload(rbx_asset_id) + + raw_bytes, content_type = self._download_raw(rbx_asset_id, proxy_cfg) + sha256 = hashlib.sha256(raw_bytes).hexdigest() + + # Проверим: может уже есть в БД с другим rbx_asset_id но тем же sha256 + existing_by_sha = self._lookup_by_sha256(sha256) + if existing_by_sha: + # Записываем алиас: новый rbx_asset_id → существующая запись + # Просто вставим новую запись с теми же путями + new_record = self._insert_alias(rbx_asset_id, existing_by_sha) + new_record.cached = True + return new_record + + # Это новый файл — сохраняем + asset_kind, ext = self._classify(content_type, raw_bytes) + raw_path = self._save_raw(sha256, ext, raw_bytes) + public_url = f'{self.public_base}/raw/{sha256[:2]}/{sha256}{ext}' + + record = AssetRecord( + rbx_asset_id=rbx_asset_id, + sha256_raw=sha256, + asset_kind=asset_kind, + content_type=content_type, + raw_size_bytes=len(raw_bytes), + raw_path=raw_path, + public_url=public_url, + cached=False, + ) + self._insert(record) + return record + + # ─── PG helpers ─── + + def _connect(self): + return psycopg2.connect(self.db_dsn) + + def _lookup(self, rbx_asset_id: int) -> Optional[AssetRecord]: + with self._connect() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + "SELECT rbx_asset_id, sha256_raw, asset_kind, content_type, " + "raw_size_bytes, raw_path, converted_path, converted_sha256, public_url " + "FROM roblox_assets WHERE rbx_asset_id = %s", + (rbx_asset_id,), + ) + row = cur.fetchone() + if not row: + return None + return AssetRecord( + rbx_asset_id=row['rbx_asset_id'], + sha256_raw=row['sha256_raw'], + asset_kind=row['asset_kind'], + content_type=row['content_type'], + raw_size_bytes=row['raw_size_bytes'], + raw_path=row['raw_path'], + public_url=row['public_url'], + converted_path=row['converted_path'], + converted_sha256=row['converted_sha256'], + ) + + def _lookup_by_sha256(self, sha256: str) -> Optional[AssetRecord]: + with self._connect() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + "SELECT rbx_asset_id, sha256_raw, asset_kind, content_type, " + "raw_size_bytes, raw_path, converted_path, converted_sha256, public_url " + "FROM roblox_assets WHERE sha256_raw = %s LIMIT 1", + (sha256,), + ) + row = cur.fetchone() + if not row: + return None + return AssetRecord(**{k: row[k] for k in row.keys() if k != 'cached'}) + + def _bump_last_used(self, rbx_asset_id: int) -> None: + with self._connect() as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE roblox_assets SET last_used_at = NOW() WHERE rbx_asset_id = %s", + (rbx_asset_id,), + ) + conn.commit() + + def _insert(self, r: AssetRecord) -> None: + with self._connect() as conn: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO roblox_assets " + "(rbx_asset_id, sha256_raw, asset_kind, content_type, raw_size_bytes, " + " raw_path, public_url, converted_path, converted_sha256) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) " + "ON CONFLICT (rbx_asset_id) DO NOTHING", + (r.rbx_asset_id, r.sha256_raw, r.asset_kind, r.content_type, + r.raw_size_bytes, r.raw_path, r.public_url, + r.converted_path, r.converted_sha256), + ) + conn.commit() + + def _insert_pending(self, rbx_asset_id: int) -> None: + """Создаёт plceholder-запись для ассета который не скачали (proxy disabled). + + Поля sha256, content_type, raw_path, public_url заполняются заглушками. + asset_kind='pending'. Когда proxy будет настроен, batch-скрипт обновит запись. + """ + with self._connect() as conn: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO roblox_assets " + "(rbx_asset_id, sha256_raw, asset_kind, content_type, raw_size_bytes, " + " raw_path, public_url, error_msg) " + "VALUES (%s, %s, 'pending', 'application/octet-stream', 0, '', '', " + "'proxy disabled — asset not downloaded yet') " + "ON CONFLICT (rbx_asset_id) DO NOTHING", + (rbx_asset_id, '0' * 64), + ) + conn.commit() + + def _insert_alias(self, new_id: int, existing: AssetRecord) -> AssetRecord: + """Сохраняет новый rbx_asset_id указывая на тот же файл.""" + alias = AssetRecord( + rbx_asset_id=new_id, + sha256_raw=existing.sha256_raw, + asset_kind=existing.asset_kind, + content_type=existing.content_type, + raw_size_bytes=existing.raw_size_bytes, + raw_path=existing.raw_path, + public_url=existing.public_url, + converted_path=existing.converted_path, + converted_sha256=existing.converted_sha256, + ) + self._insert(alias) + return alias + + # ─── HTTP скачка ─── + + def _download_raw(self, rbx_asset_id: int, proxy_cfg) -> tuple: + """Скачивает с Roblox CDN (или через CF Worker, или через HTTP proxy). + + Возвращает (raw_bytes, content_type). + """ + url = proxy_cfg.build_url(rbx_asset_id) + headers = {**self.session.headers, **proxy_cfg.headers} + # Для режима http_proxy — передаём proxies в requests + proxies = get_http_proxies() if proxy_cfg.mode == 'http_proxy' else None + last_exc = None + for attempt in range(self.max_retries): + try: + resp = self.session.get(url, timeout=self.request_timeout, + allow_redirects=True, headers=headers, + proxies=proxies) + if resp.status_code == 404: + raise RuntimeError(f"asset {rbx_asset_id}: 404 Not Found (asset deleted or private)") + if resp.status_code == 403: + raise RuntimeError(f"asset {rbx_asset_id}: 403 Forbidden (private or moderation)") + if resp.status_code == 401: + raise RuntimeError(f"asset {rbx_asset_id}: 401 Unauthorized (requires Roblox auth)") + if resp.status_code != 200: + raise RuntimeError(f"asset {rbx_asset_id}: HTTP {resp.status_code}") + + content_type = resp.headers.get('Content-Type', 'application/octet-stream').split(';')[0].strip() + return resp.content, content_type + except (requests.RequestException, RuntimeError) as e: + last_exc = e + # 4xx — нет смысла retry + if isinstance(e, RuntimeError) and ('404' in str(e) or '403' in str(e) or '401' in str(e)): + raise + wait = 2 ** attempt + logger.warning(f"asset {rbx_asset_id} attempt {attempt+1} failed: {e}; retry in {wait}s") + time.sleep(wait) + + raise RuntimeError(f"asset {rbx_asset_id}: max retries exceeded: {last_exc}") + + def _classify(self, content_type: str, raw_bytes: bytes) -> tuple: + """Возвращает (asset_kind, extension).""" + if content_type in CONTENT_TYPE_MAP: + return CONTENT_TYPE_MAP[content_type] + + # Эвристика по magic bytes + if raw_bytes.startswith(b'\x89PNG\r\n\x1a\n'): + return ('texture', '.png') + if raw_bytes[:3] == b'\xff\xd8\xff': + return ('texture', '.jpg') + if raw_bytes[:4] == b'RIFF' and raw_bytes[8:12] == b'WAVE': + return ('sound', '.wav') + if raw_bytes[:3] == b'ID3' or raw_bytes[:2] == b'\xff\xfb': + return ('sound', '.mp3') + if raw_bytes[:4] == b'OggS': + return ('sound', '.ogg') + # Roblox mesh: начинается с ASCII "version 1.00\n" или "version 2.00\n" или + # бинарь начинающийся с magic. + if raw_bytes[:8].startswith(b'version '): + return ('mesh', '.mesh') + # CSG: начинается с magic "CSGPHS" + if raw_bytes[:6] == b'CSGPHS': + return ('csg', '.csg') + # Animation: KeyframeSequence, raw — обычно XML или binary с magic. + if raw_bytes[:5] == b' str: + subdir = sha256[:2] + dir_path = os.path.join(self.storage_root, 'raw', subdir) + os.makedirs(dir_path, exist_ok=True) + file_path = os.path.join(dir_path, f'{sha256}{ext}') + if not os.path.exists(file_path): # на всякий — atomic write через tmp + tmp = file_path + '.tmp' + with open(tmp, 'wb') as f: + f.write(data) + os.rename(tmp, file_path) + return file_path + + +# ─── CLI для тестов ─── + +if __name__ == '__main__': + import sys + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument('rbx_asset_id', type=int, help='Roblox asset ID') + parser.add_argument('--db-dsn', default=os.environ.get('PG_DSN', ''), + help='Postgres DSN, например "host=192.168.1.117 user=min password=... dbname=storys_db"') + parser.add_argument('--storage', default='/opt/roblox-assets') + args = parser.parse_args() + + if not args.db_dsn: + print("error: provide --db-dsn or PG_DSN env var") + sys.exit(1) + + logging.basicConfig(level=logging.INFO) + dl = AssetDownloader(db_dsn=args.db_dsn, storage_root=args.storage) + rec = dl.fetch_sync(args.rbx_asset_id) + print(f" rbx_asset_id: {rec.rbx_asset_id}") + print(f" sha256: {rec.sha256_raw}") + print(f" kind: {rec.asset_kind}") + print(f" content-type: {rec.content_type}") + print(f" size: {rec.raw_size_bytes} bytes") + print(f" raw_path: {rec.raw_path}") + print(f" public_url: {rec.public_url}") + print(f" cached: {rec.cached}") diff --git a/rbxl-importer/src/asset_proxy.py b/rbxl-importer/src/asset_proxy.py new file mode 100644 index 0000000..51b43ba --- /dev/null +++ b/rbxl-importer/src/asset_proxy.py @@ -0,0 +1,101 @@ +""" +asset_proxy.py — конфигурация HTTP-прокси для скачивания Roblox-ассетов. + +Roblox блокирует direct-traffic из РФ — нужно ходить через VPN/прокси. + +Текущая стратегия: + 1. По умолчанию — НИЧЕГО НЕ КАЧАТЬ (mode='disabled'), пишем ассеты со status='pending'. + Импорт продолжается, геометрия и скрипты работают. + Mesh/Texture/Sound показываются как заглушки до скачки. + 2. mode='cloudflare_worker' — ходить через Cloudflare Worker (см. cf-worker/asset-proxy.js). + Worker принимает /asset?id= и проксирует на assetdelivery.roblox.com. + 3. mode='direct' — без прокси (для тестов или если запускаем с зарубежного хоста). + +ENV: + ROBLOX_PROXY_MODE = disabled | direct | cloudflare_worker + ROBLOX_PROXY_URL = https://rbxl-proxy.workers.dev # для cloudflare_worker mode + +Использование в asset_downloader: + from asset_proxy import get_proxy_config + cfg = get_proxy_config() + if cfg.mode == 'disabled': + raise PendingDownload(rbx_asset_id) + url = cfg.build_url(rbx_asset_id) + resp = session.get(url, ...) +""" +import os +from dataclasses import dataclass + + +@dataclass +class ProxyConfig: + mode: str # disabled | direct | cloudflare_worker + base_url: str # для direct mode = 'https://assetdelivery.roblox.com/v1/asset' + # для cf worker = 'https://rbxl-proxy.workers.dev/asset' + headers: dict # доп. заголовки (например auth для CF Worker) + + def build_url(self, rbx_asset_id: int) -> str: + return f'{self.base_url}?id={rbx_asset_id}' + + +class PendingDownload(Exception): + """Бросается когда mode='disabled' — ассет не скачан но импорт продолжается.""" + def __init__(self, rbx_asset_id: int): + self.rbx_asset_id = rbx_asset_id + super().__init__(f"asset {rbx_asset_id} download pending (proxy disabled)") + + +@dataclass +class HttpProxyConfig: + """Дополнение к ProxyConfig для режима http_proxy. + + proxies — словарь который requests.Session() ест как есть. + """ + proxies: dict + + +def get_proxy_config() -> ProxyConfig: + mode = os.environ.get('ROBLOX_PROXY_MODE', 'disabled') + if mode == 'direct': + return ProxyConfig( + mode='direct', + base_url='https://assetdelivery.roblox.com/v1/asset', + headers={'User-Agent': 'Roblox/WinInet'}, + ) + elif mode == 'cloudflare_worker': + url = os.environ.get('ROBLOX_PROXY_URL') + if not url: + raise RuntimeError("ROBLOX_PROXY_URL не задан для cloudflare_worker mode") + secret = os.environ.get('ROBLOX_PROXY_SECRET', '') + return ProxyConfig( + mode='cloudflare_worker', + base_url=url.rstrip('/') + '/asset', + headers={ + 'User-Agent': 'rublox-rbxl-importer/1.0', + 'X-Proxy-Auth': secret, + }, + ) + elif mode == 'http_proxy': + # Используем внешний HTTP-прокси для исходящего трафика. + # base_url остаётся реальный Roblox CDN, запрос идёт через прокси. + # ENV: ROBLOX_HTTP_PROXY = http://user:pass@host:port + proxy_url = os.environ.get('ROBLOX_HTTP_PROXY', '') + if not proxy_url: + raise RuntimeError("ROBLOX_HTTP_PROXY не задан для http_proxy mode") + return ProxyConfig( + mode='http_proxy', + base_url='https://assetdelivery.roblox.com/v1/asset', + headers={'User-Agent': 'Roblox/WinInet'}, + ) + elif mode == 'disabled': + return ProxyConfig(mode='disabled', base_url='', headers={}) + else: + raise RuntimeError(f"unknown ROBLOX_PROXY_MODE={mode!r}") + + +def get_http_proxies() -> dict: + """Для requests-сессии: {'http': ..., 'https': ...}.""" + url = os.environ.get('ROBLOX_HTTP_PROXY', '') + if not url: + return {} + return {'http': url, 'https': url} diff --git a/rbxl-importer/src/converter.py b/rbxl-importer/src/converter.py new file mode 100644 index 0000000..d7218d4 --- /dev/null +++ b/rbxl-importer/src/converter.py @@ -0,0 +1,793 @@ +""" +converter.py — RobloxModel → Rublox project_data. + +Принимает дерево Instance'ов из parser.py и превращает в JSON-схему которую +понимает движок Rublox (студия и плеер). + +Маппинг основных классов Roblox → Rublox: + Part(Shape=Block) → primitive: cube + Part(Shape=Ball) → primitive: sphere + Part(Shape=Cylinder) → primitive: cylinder + WedgePart → primitive: wedge + CornerWedgePart → primitive: cornerwedge + MeshPart → glbModels entry (ссылка на наш сконвертированный glb) + UnionOperation → glbModels entry (если есть CSG) + SpawnLocation → scene.spawnPoint + Script/LocalScript → scripts entry (kind='roblox-lua', raw lua source) + Lighting → scene.environment + Sound → scene.sounds entry + Folder/Model → scene.folders entry + Texture/Decal → не отдельные объекты, прикрепляются к Part'у + +Координаты: + Roblox: правая Y-up, 1 stud ≈ 0.28 м. + Rublox/Babylon: правая Y-up, 1 unit = 1 м. + → масштабируем все координаты на 0.28 (можно настроить через scale_factor). + +CFrame → position + rotationX/Y/Z (Euler XYZ в радианах). + +Скрипты: сохраняются как kind='roblox-lua' с raw lua_source. Lua-runtime +(asset_proxy + RobloxLuaWorker.js в плеере) исполняет их потом. +""" +from dataclasses import dataclass, field, asdict +from typing import List, Dict, Any, Optional, Tuple +import math +import logging + +from rbxl_parser import RobloxModel, Instance +from rbxl_types import ( + CFrame, Color3, Vector3, EnumValue, BrickColor, + OptionalCFrame, PhysicalProperties, +) + +logger = logging.getLogger(__name__) + + +# ────── константы маппинга ────── + +# Roblox stud → метры (примерно 0.28). Можно поменять при импорте. +DEFAULT_SCALE = 0.28 + +# Маппинг Material enum → Rublox material strings. +# Roblox Enum.Material: +# 0=Plastic, 256=Plastic (default), 272=Wood, 288=Slate, 304=Concrete, +# 320=CorrodedMetal, 336=DiamondPlate, 352=Foil, 368=Grass, 384=Ice, +# 400=Marble, 416=Granite, 432=Brick, 448=Pebble, 464=Sand, 480=Fabric, +# 496=SmoothPlastic, 512=Metal, 528=WoodPlanks, 784=Neon, 1024=Glass, +# 1280=ForceField, ... +# https://create.roblox.com/docs/reference/engine/enums/Material +ROBLOX_MATERIAL_TO_RUBLOX = { + 0: 'glossy', # Plastic + 256: 'glossy', # Plastic (legacy) + 272: 'matte', # Wood + 288: 'matte', # Slate + 304: 'matte', # Concrete + 320: 'metal', # CorrodedMetal + 336: 'metal', # DiamondPlate + 352: 'metal', # Foil + 368: 'matte', # Grass + 384: 'glass', # Ice + 400: 'matte', # Marble + 416: 'matte', # Granite + 432: 'matte', # Brick + 448: 'matte', # Pebble + 464: 'matte', # Sand + 480: 'matte', # Fabric + 496: 'glossy', # SmoothPlastic + 512: 'metal', # Metal + 528: 'matte', # WoodPlanks + 784: 'neon', # Neon + 1024: 'glass', # Glass + 1280: 'neon', # ForceField — синий полупрозрачный + 1296: 'matte', # Cobblestone + 1328: 'metal', # Aluminum + 1344: 'matte', # CrackedLava + 1360: 'metal', # Rubber + 1376: 'matte', # Pavement +} + +# Roblox Part.Shape enum (PartType): 0=Ball, 1=Block, 2=Cylinder, ... +SHAPE_TO_PRIMITIVE = { + 0: 'sphere', # Ball + 1: 'cube', # Block + 2: 'cylinder', # Cylinder + 3: 'cube', # Wedge — но WedgePart это отдельный класс + 4: 'cornerwedge', +} + + +# ────── BrickColor таблица (упрощённая) ────── +# Roblox использует old BrickColor enum (числа 1-1032). Только распространённые: +BRICKCOLOR_TO_HEX = { + 1: '#f2f3f3', 5: '#d9e4f7', 9: '#9c9e9c', 11: '#e8eaea', + 21: '#c4281c', 23: '#0d69ac', 24: '#f5cd30', 26: '#27313e', + 28: '#293f1a', 29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32', + 101: '#dab8a3', 102: '#82c1e9', 103: '#9b9696', 104: '#6b327a', + 105: '#cf8b3e', 106: '#d97e29', 107: '#3a8fbf', 108: '#695b50', + 111: '#a7a6a6', 119: '#aac84a', 125: '#e8b486', 138: '#8a8a76', + 141: '#26462b', 153: '#9b605a', 192: '#5a3019', 194: '#9c9b91', + 199: '#3c3e3f', 208: '#dbdcdc', 224: '#f3e3a5', 226: '#fff8a8', + 1001: '#ffffff', 1002: '#cccccc', 1003: '#000000', 1004: '#ff0000', + 1005: '#ff8000', 1006: '#ffff00', 1007: '#80c000', 1008: '#00ff00', + 1009: '#00c080', 1010: '#0080ff', 1011: '#0000ff', 1012: '#5000ff', + 1013: '#a000ff', 1014: '#ff00ff', 1015: '#ff0080', 1016: '#ff80c0', + 1017: '#ff8080', 1018: '#ffc080', 1019: '#ffff80', 1020: '#80ff80', +} + + +# ──── вспомогательные функции ──── + +def cframe_to_pos_rot(cf: Optional[CFrame], scale: float) -> Tuple[Dict, Dict]: + """Конвертит CFrame в позицию и Euler ротацию. + + Roblox координаты: правая Y-up. Babylon: правая Y-up. То есть совпадают. + Возвращает ({x,y,z}, {rx,ry,rz}). + """ + if cf is None: + return ({'x': 0.0, 'y': 0.0, 'z': 0.0}, + {'rx': 0.0, 'ry': 0.0, 'rz': 0.0}) + pos = {'x': cf.position.x * scale, 'y': cf.position.y * scale, 'z': cf.position.z * scale} + rx, ry, rz = cf.to_euler_xyz() + rot = {'rx': rx, 'ry': ry, 'rz': rz} + return pos, rot + + +def color3_to_hex(c: Optional[Color3]) -> str: + if c is None: + return '#cccccc' + return c.to_hex() + + +def brickcolor_to_hex(b: Optional[BrickColor]) -> str: + if b is None: + return '#cccccc' + return BRICKCOLOR_TO_HEX.get(b.code, '#cccccc') + + +def material_to_string(m: Optional[EnumValue]) -> str: + if m is None: + return 'glossy' + return ROBLOX_MATERIAL_TO_RUBLOX.get(m.value, 'glossy') + + +def get_part_color(props: Dict[str, Any]) -> str: + """Достаёт цвет Part: сначала Color3uint8, потом BrickColor, потом дефолт.""" + if 'Color3uint8' in props: + return color3_to_hex(props['Color3uint8']) + if 'Color' in props: + return color3_to_hex(props['Color']) + if 'BrickColor' in props: + return brickcolor_to_hex(props['BrickColor']) + return '#cccccc' + + +def name_or_default(props: Dict[str, Any], default: str) -> str: + return str(props.get('Name', default)) + + +# ────── Конвертеры классов ────── + + +@dataclass +class ConversionStats: + primitives_created: int = 0 + glb_models_created: int = 0 + scripts_collected: int = 0 + scripts_skipped: int = 0 + parts_dropped: int = 0 + skipped_classes: Dict[str, int] = field(default_factory=dict) + warnings: List[str] = field(default_factory=list) + asset_ids_needed: List[int] = field(default_factory=list) # rbx_asset_id'ы для скачки + + +class Converter: + """Главный конвертер. Работает на одной RobloxModel за раз.""" + + def __init__( + self, + model: RobloxModel, + scale: float = DEFAULT_SCALE, + asset_url_resolver=None, # callable(rbx_asset_id) -> str|None (public URL после скачки) + ): + self.model = model + self.scale = scale + self.stats = ConversionStats() + self.asset_url_resolver = asset_url_resolver + self._next_primitive_id = 1 + self._next_glb_id = 1 + self._next_script_id = 1 + self._instance_to_primitive_id: Dict[int, int] = {} + + # ──── главный entry-point ──── + + def convert(self) -> Dict[str, Any]: + """Возвращает project_data dict готовый к сохранению как JSON.""" + scene = { + 'blocks': [], + 'models': [], + 'primitives': [], + 'userModels': [], + 'terrain': [], + 'robloxTerrain': self._default_roblox_terrain(), + 'decorations': [], + 'folders': [], + 'gui': [], + 'inventory': [], + 'spawnPoint': {'x': 0, 'y': 2, 'z': 0}, + 'playerModelType': 'default', + 'worldSize': 100, + 'floorEnabled': True, + 'jumpPowerMul': 1.0, + 'cameraMode': 'thirdPerson', + 'crosshair': 'default', + 'shadowQuality': 'medium', + 'environment': { + 'preset': 'day', + 'timeOfDay': 14, + 'dayDurationMin': 5, + 'nightDurationMin': 3, + 'fogEnabled': False, + 'fogColor': [0.7, 0.8, 0.9], + 'fogDensity': 0.01, + }, + 'audio': {}, + 'assets': [], + 'sounds': [], + 'glbModels': [], + 'scripts': [], + } + + # Обходим все instances и конвертим + for inst in self.model.instances: + self._convert_one(inst, scene) + + # Финальный отчёт о скипнутых классах + for cls, n in sorted(self.stats.skipped_classes.items(), key=lambda x: -x[1])[:30]: + self.stats.warnings.append(f"skipped {n}× {cls}") + + return { + 'version': 1, + 'scene': scene, + 'editorCamera': {'x': 20, 'y': 15, 'z': 20, 'targetX': 0, 'targetY': 0, 'targetZ': 0}, + 'settings': { + 'isGd': False, + 'importedFrom': 'roblox', + 'importStats': asdict(self.stats), + }, + } + + # ──── per-class конвертеры ──── + + def _convert_one(self, inst: Instance, scene: Dict) -> None: + cls = inst.class_name + try: + if cls == 'Part': + self._convert_part(inst, scene) + elif cls == 'WedgePart': + self._convert_wedge(inst, scene) + elif cls == 'CornerWedgePart': + self._convert_cornerwedge(inst, scene) + elif cls == 'TrussPart': + self._convert_truss(inst, scene) + elif cls == 'MeshPart': + self._convert_meshpart(inst, scene) + elif cls == 'UnionOperation': + self._convert_union(inst, scene) + elif cls == 'SpecialMesh': + # SpecialMesh — child объект Part'а, меняет визуал родителя + # Обработка делается в _convert_part через children, тут не нужно + pass + elif cls == 'SpawnLocation': + self._convert_spawn(inst, scene) + elif cls == 'Script' or cls == 'LocalScript' or cls == 'ModuleScript': + self._convert_script(inst, scene) + elif cls == 'Sound': + self._convert_sound(inst, scene) + elif cls == 'PointLight' or cls == 'SpotLight' or cls == 'SurfaceLight': + self._convert_light(inst, scene) + elif cls == 'Folder' or cls == 'Model': + self._convert_folder(inst, scene) + elif cls in ('Decal', 'Texture'): + # Прикрепляются к Part'у — обрабатываются при конверте родителя + pass + elif cls == 'Lighting': + self._convert_lighting(inst, scene) + elif cls == 'Workspace': + # Workspace = root, его свойства мапим на scene.worldSize и т.п. + pass + elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui', + 'StarterPack', 'StarterCharacterScripts', 'Players', + 'ReplicatedStorage', 'ServerScriptService', 'ServerStorage', + 'SoundService', 'TweenService', 'RunService', + 'UserInputService', 'HttpService', 'DataStoreService', + 'TeleportService', 'BadgeService', 'MarketplaceService', + 'ContentProvider', 'NetworkClient', 'NetworkServer', + 'Chat', 'Stats', 'Debris', 'AnalyticsService', + 'CSGDictionaryService', 'NonReplicatedCSGDictionaryService'): + # Системные сервисы — игнорируем (Lua-runtime создаст mock'и) + pass + else: + self.stats.skipped_classes[cls] = self.stats.skipped_classes.get(cls, 0) + 1 + except Exception as e: + self.stats.warnings.append(f"convert {cls} (referent={inst.referent}) failed: {e!r}") + self.stats.parts_dropped += 1 + + # ─── Part / WedgePart / etc ─── + + def _convert_part(self, inst: Instance, scene: Dict) -> None: + props = inst.properties + cf = props.get('CFrame') + size = props.get('size') or props.get('Size') + pos, rot = cframe_to_pos_rot(cf, self.scale) + + # Roblox Part.Shape: 0=Ball, 1=Block, 2=Cylinder + # PartType enum value — берётся из props.get('shape') или props.get('Shape') + shape = props.get('Shape') + if isinstance(shape, EnumValue): + ptype = SHAPE_TO_PRIMITIVE.get(shape.value, 'cube') + else: + ptype = 'cube' + + # Размер + if size: + sx = abs(size.x) * self.scale + sy = abs(size.y) * self.scale + sz = abs(size.z) * self.scale + else: + sx = sy = sz = 1.0 * self.scale + + # Проверяем есть ли child SpecialMesh — он переопределяет визуал + for child in inst.children: + if child.class_name == 'SpecialMesh': + mesh_type = child.properties.get('MeshType') + if isinstance(mesh_type, EnumValue): + # MeshType: 0=Head, 1=Torso, 2=Wedge, 3=Sphere, 4=Cylinder, 5=FileMesh, 6=Brick + mt = mesh_type.value + if mt == 3: ptype = 'sphere' + elif mt == 4: ptype = 'cylinder' + + prim_id = self._next_primitive_id + self._next_primitive_id += 1 + self._instance_to_primitive_id[inst.referent] = prim_id + + primitive = { + 'id': prim_id, + 'type': ptype, + 'name': name_or_default(props, 'Part'), + 'x': pos['x'], 'y': pos['y'], 'z': pos['z'], + 'sx': sx, 'sy': sy, 'sz': sz, + 'color': get_part_color(props), + 'material': material_to_string(props.get('Material')), + 'canCollide': bool(props.get('CanCollide', True)), + 'visible': props.get('Transparency', 0) < 1.0 if isinstance(props.get('Transparency'), (int, float)) else True, + 'opacity': max(0.0, 1.0 - (props.get('Transparency', 0) or 0)), + 'anchored': bool(props.get('Anchored', False)), + 'mass': 1.0, + 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], + } + scene['primitives'].append(primitive) + self.stats.primitives_created += 1 + + def _convert_wedge(self, inst: Instance, scene: Dict) -> None: + props = inst.properties + cf = props.get('CFrame') + size = props.get('size') or props.get('Size') + pos, rot = cframe_to_pos_rot(cf, self.scale) + + if size: + sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale + else: + sx = sy = sz = 1.0 + + prim_id = self._next_primitive_id + self._next_primitive_id += 1 + self._instance_to_primitive_id[inst.referent] = prim_id + + scene['primitives'].append({ + 'id': prim_id, 'type': 'wedge', + 'name': name_or_default(props, 'Wedge'), + 'x': pos['x'], 'y': pos['y'], 'z': pos['z'], + 'sx': sx, 'sy': sy, 'sz': sz, + 'color': get_part_color(props), + 'material': material_to_string(props.get('Material')), + 'canCollide': bool(props.get('CanCollide', True)), + 'visible': True, + 'anchored': bool(props.get('Anchored', False)), + 'mass': 1.0, + 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], + }) + self.stats.primitives_created += 1 + + def _convert_cornerwedge(self, inst: Instance, scene: Dict) -> None: + props = inst.properties + cf = props.get('CFrame') + size = props.get('size') or props.get('Size') + pos, rot = cframe_to_pos_rot(cf, self.scale) + if size: + sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale + else: + sx = sy = sz = 1.0 + + prim_id = self._next_primitive_id + self._next_primitive_id += 1 + self._instance_to_primitive_id[inst.referent] = prim_id + + scene['primitives'].append({ + 'id': prim_id, 'type': 'cornerwedge', + 'name': name_or_default(props, 'CornerWedge'), + 'x': pos['x'], 'y': pos['y'], 'z': pos['z'], + 'sx': sx, 'sy': sy, 'sz': sz, + 'color': get_part_color(props), + 'material': material_to_string(props.get('Material')), + 'canCollide': bool(props.get('CanCollide', True)), + 'visible': True, + 'anchored': bool(props.get('Anchored', False)), + 'mass': 1.0, + 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], + }) + self.stats.primitives_created += 1 + + def _convert_truss(self, inst: Instance, scene: Dict) -> None: + """TrussPart — Roblox-специфичный леса. Конвертим в cube с пометкой.""" + props = inst.properties + cf = props.get('CFrame') + size = props.get('size') or props.get('Size') + pos, rot = cframe_to_pos_rot(cf, self.scale) + if size: + sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale + else: + sx, sy, sz = 0.5, 5.0, 0.5 + + prim_id = self._next_primitive_id + self._next_primitive_id += 1 + self._instance_to_primitive_id[inst.referent] = prim_id + + scene['primitives'].append({ + 'id': prim_id, 'type': 'cube', + 'name': name_or_default(props, 'Truss'), + 'x': pos['x'], 'y': pos['y'], 'z': pos['z'], + 'sx': sx, 'sy': sy, 'sz': sz, + 'color': get_part_color(props), + 'material': 'metal', + 'canCollide': True, + 'visible': True, + 'anchored': bool(props.get('Anchored', True)), + 'mass': 1.0, + 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], + }) + self.stats.primitives_created += 1 + + # ─── MeshPart / UnionOperation ─── + + def _convert_meshpart(self, inst: Instance, scene: Dict) -> None: + """MeshPart → glbModels entry с ссылкой на сконвертированный GLB.""" + props = inst.properties + cf = props.get('CFrame') + size = props.get('size') or props.get('Size') + pos, rot = cframe_to_pos_rot(cf, self.scale) + + # MeshId — это rbxassetid:// строка + mesh_id_str = props.get('MeshID') or props.get('MeshId') or '' + rbx_id = self._parse_asset_id(mesh_id_str) + if rbx_id: + self.stats.asset_ids_needed.append(rbx_id) + + glb_url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None + + if size: + sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale + else: + sx = sy = sz = 1.0 + + # Если GLB не доступен — fallback на bbox cube + if not glb_url: + prim_id = self._next_primitive_id + self._next_primitive_id += 1 + self._instance_to_primitive_id[inst.referent] = prim_id + scene['primitives'].append({ + 'id': prim_id, 'type': 'cube', + 'name': name_or_default(props, 'MeshPart'), + 'x': pos['x'], 'y': pos['y'], 'z': pos['z'], + 'sx': sx, 'sy': sy, 'sz': sz, + 'color': get_part_color(props), + 'material': material_to_string(props.get('Material')), + 'canCollide': bool(props.get('CanCollide', True)), + 'visible': True, + 'anchored': bool(props.get('Anchored', False)), + 'mass': 1.0, + 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], + 'note': f'MeshPart (no GLB) rbxid={rbx_id}', + }) + self.stats.primitives_created += 1 + self.stats.parts_dropped += 1 + return + + glb_id = self._next_glb_id + self._next_glb_id += 1 + + scene['glbModels'].append({ + 'id': glb_id, + 'name': name_or_default(props, 'MeshPart'), + 'url': glb_url, + 'x': pos['x'], 'y': pos['y'], 'z': pos['z'], + 'sx': sx, 'sy': sy, 'sz': sz, + 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], + 'color': get_part_color(props), + 'canCollide': bool(props.get('CanCollide', True)), + 'anchored': bool(props.get('Anchored', False)), + 'origin': 'roblox-meshpart', + 'rbxAssetId': rbx_id, + }) + self.stats.glb_models_created += 1 + + def _convert_union(self, inst: Instance, scene: Dict) -> None: + """UnionOperation — CSG объединение. Берём AssetId если есть, иначе bbox.""" + props = inst.properties + cf = props.get('CFrame') + size = props.get('size') or props.get('Size') + pos, rot = cframe_to_pos_rot(cf, self.scale) + + if size: + sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale + else: + sx = sy = sz = 1.0 + + # AssetId Union'а — это его CSG-mesh + asset_id_str = props.get('AssetId') or '' + rbx_id = self._parse_asset_id(asset_id_str) + if rbx_id: + self.stats.asset_ids_needed.append(rbx_id) + + glb_url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None + + if not glb_url: + # Fallback: cube + prim_id = self._next_primitive_id + self._next_primitive_id += 1 + self._instance_to_primitive_id[inst.referent] = prim_id + scene['primitives'].append({ + 'id': prim_id, 'type': 'cube', + 'name': name_or_default(props, 'Union'), + 'x': pos['x'], 'y': pos['y'], 'z': pos['z'], + 'sx': sx, 'sy': sy, 'sz': sz, + 'color': get_part_color(props), + 'material': material_to_string(props.get('Material')), + 'canCollide': bool(props.get('CanCollide', True)), + 'visible': True, + 'anchored': bool(props.get('Anchored', False)), + 'mass': 1.0, + 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], + 'note': f'Union (no CSG GLB) rbxid={rbx_id}', + }) + self.stats.primitives_created += 1 + return + + glb_id = self._next_glb_id + self._next_glb_id += 1 + scene['glbModels'].append({ + 'id': glb_id, + 'name': name_or_default(props, 'Union'), + 'url': glb_url, + 'x': pos['x'], 'y': pos['y'], 'z': pos['z'], + 'sx': sx, 'sy': sy, 'sz': sz, + 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], + 'color': get_part_color(props), + 'canCollide': bool(props.get('CanCollide', True)), + 'anchored': bool(props.get('Anchored', False)), + 'origin': 'roblox-union', + 'rbxAssetId': rbx_id, + }) + self.stats.glb_models_created += 1 + + # ─── Spawn ─── + + def _convert_spawn(self, inst: Instance, scene: Dict) -> None: + props = inst.properties + cf = props.get('CFrame') + pos, _ = cframe_to_pos_rot(cf, self.scale) + # Spawn должен быть чуть выше — Roblox SpawnLocation это плоская плита, + # юзер появляется на её верхней грани. + scene['spawnPoint'] = { + 'x': pos['x'], + 'y': pos['y'] + 1.5, # отступ вверх чтобы не залипнуть в плите + 'z': pos['z'], + } + + # ─── Scripts ─── + + def _convert_script(self, inst: Instance, scene: Dict) -> None: + """Lua-script сохраняем raw с пометкой kind='roblox-lua'. + + Plus, attached to конкретному примитиву если у скрипта есть Parent + с известным referent. + """ + props = inst.properties + source = props.get('Source', '') + if not source or not isinstance(source, str): + self.stats.scripts_skipped += 1 + return + + # Цель — primitive id предка если есть + target = None + if inst.parent_referent and inst.parent_referent in self._instance_to_primitive_id: + target = self._instance_to_primitive_id[inst.parent_referent] + + script_id = f'rbx_{self._next_script_id}' + self._next_script_id += 1 + + scene['scripts'].append({ + 'id': script_id, + 'name': name_or_default(props, inst.class_name), + 'kind': 'roblox-lua', + 'target': target, + 'code': '', # JS-эквивалент пока нет (заполнит Lua-runtime в плеере) + 'lua_source': source, + 'roblox_class': inst.class_name, # Script | LocalScript | ModuleScript + 'enabled': bool(props.get('Disabled', False) is False), + }) + self.stats.scripts_collected += 1 + + # ─── Sound ─── + + def _convert_sound(self, inst: Instance, scene: Dict) -> None: + props = inst.properties + sound_id_str = props.get('SoundId') or '' + rbx_id = self._parse_asset_id(sound_id_str) + if rbx_id: + self.stats.asset_ids_needed.append(rbx_id) + + url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None + scene['sounds'].append({ + 'id': f'sound_{rbx_id or inst.referent}', + 'name': name_or_default(props, 'Sound'), + 'url': url or '', + 'volume': float(props.get('Volume', 1.0) or 1.0), + 'loop': bool(props.get('Looped', False)), + 'autoplay': bool(props.get('Playing', False)), + 'rbxAssetId': rbx_id, + }) + + # ─── Light ─── + + def _convert_light(self, inst: Instance, scene: Dict) -> None: + """Roblox PointLight / SpotLight / SurfaceLight → primitive type='light'.""" + props = inst.properties + # Position берём от parent Part (если есть) + x = y = z = 0.0 + parent = self.model.by_referent.get(inst.parent_referent) if inst.parent_referent else None + if parent and 'CFrame' in parent.properties: + pcf = parent.properties['CFrame'] + x, y, z = pcf.position.x * self.scale, pcf.position.y * self.scale, pcf.position.z * self.scale + + prim_id = self._next_primitive_id + self._next_primitive_id += 1 + + scene['primitives'].append({ + 'id': prim_id, 'type': 'light', + 'name': name_or_default(props, inst.class_name), + 'x': x, 'y': y, 'z': z, + 'sx': 1, 'sy': 1, 'sz': 1, + 'color': color3_to_hex(props.get('Color')), + 'material': 'glossy', + 'canCollide': False, + 'visible': True, + 'anchored': True, + 'mass': 0, + 'rotationX': 0, 'rotationY': 0, 'rotationZ': 0, + 'lightRange': float(props.get('Range', 8.0) or 8.0) * self.scale, + 'lightBrightness': float(props.get('Brightness', 1.0) or 1.0), + }) + self.stats.primitives_created += 1 + + # ─── Folder / Model ─── + + def _convert_folder(self, inst: Instance, scene: Dict) -> None: + props = inst.properties + scene['folders'].append({ + 'id': f'rbx_folder_{inst.referent}', + 'name': name_or_default(props, inst.class_name), + 'parent': inst.parent_referent, + 'origin': 'roblox-' + inst.class_name.lower(), + }) + + # ─── Lighting ─── + + def _convert_lighting(self, inst: Instance, scene: Dict) -> None: + """Roblox Lighting service — мапим на scene.environment.""" + props = inst.properties + env = scene['environment'] + + ambient = props.get('Ambient') + if isinstance(ambient, Color3): + env['ambientColor'] = ambient.to_hex() + brightness = props.get('Brightness') + if isinstance(brightness, (int, float)): + env['brightness'] = float(brightness) + clock = props.get('ClockTime') + if isinstance(clock, (int, float)): + env['timeOfDay'] = float(clock) + fog_color = props.get('FogColor') + if isinstance(fog_color, Color3): + env['fogColor'] = [fog_color.r, fog_color.g, fog_color.b] + fog_end = props.get('FogEnd') + if isinstance(fog_end, (int, float)) and fog_end < 999999: + env['fogEnabled'] = True + env['fogDensity'] = 1.0 / max(fog_end, 1.0) + + # ─── utility ─── + + def _default_roblox_terrain(self) -> Dict: + """Минимальный плоский ландшафт чтобы импорт не падал. + + Real Roblox terrain потребует отдельной конвертации (voxel grid) + — оставлю на потом. + """ + return { + 'format': 'robloxterrain-v1', + 'origin': {'x': -22, 'y': 0, 'z': -22}, + 'size': {'x': 44, 'y': 24, 'z': 44}, + 'palette': ['', 'grass', 'rock', 'sand'], + 'mat': '', + 'density': '', + } + + def _parse_asset_id(self, s: Any) -> Optional[int]: + """rbxassetid://12345 или rbxasset://12345 или просто 12345 → int.""" + if not s: + return None + s = str(s).strip() + if not s: + return None + if s.startswith('rbxassetid://'): + s = s[len('rbxassetid://'):] + elif s.startswith('rbxasset://'): + s = s[len('rbxasset://'):] + elif s.startswith('http://www.roblox.com/asset/?id='): + s = s.split('=')[-1] + try: + return int(s) + except ValueError: + return None + + +# ────── CLI ────── + +if __name__ == '__main__': + import sys + import json + sys.path.insert(0, '/opt/rbxl-importer/src') + from rbxl_parser import parse + + path = sys.argv[1] if len(sys.argv) > 1 else '/opt/rbxl-importer/reference_files/easy_obby.rbxl' + out = sys.argv[2] if len(sys.argv) > 2 else '/tmp/converted.json' + + with open(path, 'rb') as f: + blob = f.read() + logging.basicConfig(level=logging.INFO) + print(f'parsing {path} ({len(blob)} bytes)...') + model = parse(blob) + print(f' → {model.instance_count} instances') + + print(f'\nconverting...') + conv = Converter(model) + project_data = conv.convert() + + with open(out, 'w', encoding='utf-8') as f: + json.dump(project_data, f, ensure_ascii=False, indent=2) + print(f'\nwritten to {out} ({sum(1 for _ in open(out))} lines)') + + print('\n=== stats ===') + for k, v in asdict(conv.stats).items(): + if k == 'warnings' and isinstance(v, list): + print(f' warnings: {len(v)} total') + for w in v[:5]: + print(f' - {w}') + elif k == 'skipped_classes': + top = sorted(v.items(), key=lambda x: -x[1])[:10] + print(f' skipped_classes (top 10):') + for c, n in top: + print(f' {n:>4d} {c}') + elif k == 'asset_ids_needed': + print(f' asset_ids_needed: {len(v)} (deduped: {len(set(v))})') + else: + print(f' {k}: {v}') diff --git a/rbxl-importer/src/mesh_converter.py b/rbxl-importer/src/mesh_converter.py new file mode 100644 index 0000000..65238ec --- /dev/null +++ b/rbxl-importer/src/mesh_converter.py @@ -0,0 +1,481 @@ +""" +mesh_converter.py — Roblox .mesh parser + GLB writer. + +Roblox .mesh формат: + v1.00, v1.01: ASCII текстовый — лёгкий парсинг. + v2.00, v3.00, v4.00, v5.00: бинарный. + +Все версии начинаются с ASCII строки "version X.YY\n" (12+ байт), затем +зависимо от версии — либо текст, либо binary. + +Документация формата (community-reverse-engineered): + https://devforum.roblox.com/t/roblox-mesh-format/326114 + https://github.com/MaximumADHD/Roblox-File-Format/blob/main/Plugins/MeshImporter.cs + +Этот модуль: + 1. parse_roblox_mesh(blob) → MeshData (vertices, normals, uvs, faces) + 2. build_glb(mesh_data) → bytes (готовый .glb файл) + 3. convert(input_path, output_path) — высокоуровневая обёртка +""" +import struct +import io +import re +from dataclasses import dataclass, field +from typing import List, Tuple +import logging + +logger = logging.getLogger(__name__) + + +@dataclass +class Vertex: + x: float; y: float; z: float + nx: float = 0.0; ny: float = 0.0; nz: float = 0.0 + u: float = 0.0; v: float = 0.0 + # Vertex color (RGBA в 0-1) + r: float = 1.0; g: float = 1.0; b: float = 1.0; a: float = 1.0 + + +@dataclass +class MeshData: + version: str # 'v1.00', 'v2.00', ... + vertices: List[Vertex] = field(default_factory=list) + faces: List[Tuple[int, int, int]] = field(default_factory=list) # индексы треугольников + has_normals: bool = True + has_uvs: bool = True + + +class MeshParseError(Exception): + pass + + +# ────────────────────────────────────────────────────────────────────── +# Главный диспатчер +# ────────────────────────────────────────────────────────────────────── + + +def parse_roblox_mesh(blob: bytes) -> MeshData: + """Парсит Roblox .mesh файл любой версии. Бросает MeshParseError.""" + # Все версии начинаются со строки "version X.YY\n" + nl_pos = blob.find(b'\n') + if nl_pos < 0: + raise MeshParseError("no newline in first line — not a roblox mesh") + first_line = blob[:nl_pos].decode('ascii', errors='replace').strip() + m = re.match(r'version (\d+\.\d+)', first_line) + if not m: + raise MeshParseError(f"bad first line: {first_line!r}") + version = 'v' + m.group(1) + + rest = blob[nl_pos+1:] + + if version in ('v1.00', 'v1.01'): + return _parse_v1(rest, version) + elif version == 'v2.00': + return _parse_v2(rest, version) + elif version == 'v3.00': + return _parse_v3(rest, version) + elif version in ('v4.00', 'v4.01'): + return _parse_v4(rest, version) + elif version == 'v5.00': + return _parse_v5(rest, version) + else: + raise MeshParseError(f"unsupported mesh version: {version}") + + +# ────────────────────────────────────────────────────────────────────── +# v1.00 / v1.01 — ASCII текст +# ────────────────────────────────────────────────────────────────────── + + +def _parse_v1(rest: bytes, version: str) -> MeshData: + """v1: ASCII text format. + + Структура: + \n + [x,y,z][nx,ny,nz][u,v,unused] [x,y,z][nx,ny,nz][u,v,unused] [x,y,z][nx,ny,nz][u,v,unused] ... + + Каждое лицо — 3 vertex'а подряд. Vertices не дедуплицируются (поэтому + лиц = num_faces, vertices = num_faces*3). Для GLB сделаем dedup потом. + + В v1 vertices использовали диапазон [-0.5, 0.5] (нормализованный). + """ + text = rest.decode('ascii', errors='replace').strip() + parts = text.split('\n', 1) + try: + num_faces = int(parts[0].strip()) + except ValueError: + raise MeshParseError(f"v1: bad face count: {parts[0]!r}") + + # Парсим все [x,y,z] tuples + body = parts[1] if len(parts) > 1 else '' + tuples = re.findall(r'\[([^\]]+)\]', body) + # На каждый vertex — 3 tuple ([pos][normal][uv]) + if len(tuples) < num_faces * 9: + raise MeshParseError( + f"v1: expected {num_faces*9} bracketed tuples, got {len(tuples)}" + ) + + vertices = [] + for i in range(num_faces * 3): + pos = list(map(float, tuples[i*3].split(','))) + norm = list(map(float, tuples[i*3 + 1].split(','))) + uv = list(map(float, tuples[i*3 + 2].split(','))) + # v1 имеет позиции в [-0.5, 0.5]; для отображения масштабируем + vertices.append(Vertex( + x=pos[0] * 2.0, y=pos[1] * 2.0, z=pos[2] * 2.0, + nx=norm[0], ny=norm[1], nz=norm[2], + u=uv[0], v=1.0 - uv[1], # GLTF V-flip + )) + + faces = [(i*3, i*3 + 1, i*3 + 2) for i in range(num_faces)] + return MeshData(version=version, vertices=vertices, faces=faces) + + +# ────────────────────────────────────────────────────────────────────── +# v2.00 — binary +# ────────────────────────────────────────────────────────────────────── + + +def _parse_v2(rest: bytes, version: str) -> MeshData: + """v2: бинарный формат. + + Структура: + header_size (uint16, обычно 12) + vertex_size (uint8, обычно 40 — 9 float + 4 byte color) + face_size (uint8, обычно 12 — 3 uint32) + num_vertices (uint32) + num_faces (uint32) + vertices... (num_vertices * vertex_size байт) + faces... (num_faces * face_size байт) + + vertex (40 байт): + pos (3 * float32) + normal (3 * float32) + uv (2 * float32) + color (4 * uint8) — RGBA + """ + r = io.BytesIO(rest) + header_size = struct.unpack(' 12: + r.read(header_size - 12) + + vertices = [] + for _ in range(num_vertices): + v_data = r.read(vertex_size) + # pos + normal + uv + x, y, z = struct.unpack('<3f', v_data[0:12]) + nx, ny, nz = struct.unpack('<3f', v_data[12:24]) + u, vv = struct.unpack('<2f', v_data[24:32]) + # color если есть + if vertex_size >= 40: + cr, cg, cb, ca = v_data[32:36] + vert = Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv, + cr/255.0, cg/255.0, cb/255.0, ca/255.0) + else: + vert = Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv) + vertices.append(vert) + + faces = [] + for _ in range(num_faces): + f_data = r.read(face_size) + a, b, c = struct.unpack('<3I', f_data[0:12]) + faces.append((a, b, c)) + + return MeshData(version=version, vertices=vertices, faces=faces) + + +# ────────────────────────────────────────────────────────────────────── +# v3, v4 — расширенные форматы с LOD и skinning +# ────────────────────────────────────────────────────────────────────── + + +def _parse_v3(rest: bytes, version: str) -> MeshData: + """v3: v2 + LOD support. + + Header: + header_size (uint16) = 16 + vertex_size (uint8) + face_size (uint8) + lod_type (uint16) + num_vertices (uint32) + num_faces (uint32) + num_lods (uint16) + ... + + Для импорта нам нужен только LOD 0 (highest quality), остальные пропускаем. + """ + r = io.BytesIO(rest) + header_size = struct.unpack(' consumed: + r.read(header_size - consumed) + + # vertices + vertices = [] + for _ in range(num_vertices): + v_data = r.read(vertex_size) + x, y, z = struct.unpack('<3f', v_data[0:12]) + nx, ny, nz = struct.unpack('<3f', v_data[12:24]) + u, vv = struct.unpack('<2f', v_data[24:32]) + if vertex_size >= 40: + cr, cg, cb, ca = v_data[32:36] + vert = Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv, + cr/255.0, cg/255.0, cb/255.0, ca/255.0) + else: + vert = Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv) + vertices.append(vert) + + faces = [] + for _ in range(num_faces): + a, b, c = struct.unpack('<3I', r.read(12)) + faces.append((a, b, c)) + + # LOD info (пропускаем — нам нужен только полный mesh) + # LOD-таблица: num_lods+1 uint32 (face offsets) + for _ in range(num_lods + 1): + r.read(4) + + return MeshData(version=version, vertices=vertices, faces=faces) + + +def _parse_v4(rest: bytes, version: str) -> MeshData: + """v4: v3 + bones + envelope (skinning). + + Header (v4.00): + header_size (uint16) + lod_type (uint16) + num_vertices (uint32) + num_faces (uint32) + num_lods (uint16) + num_bones (uint16) + bone_names_size (uint32) + num_subsets (uint16) + num_high_quality_lods (uint8) + unused (uint8) + ... + + Для импорта в Rublox bones мы пока не поддерживаем — забираем только vertices+faces. + """ + r = io.BytesIO(rest) + header_size = struct.unpack(' consumed: + r.read(header_size - consumed) + + # v4 vertex = 40 байт (как v2) + envelope в отдельном блоке + vertex_size = 40 + vertices = [] + for _ in range(num_vertices): + v_data = r.read(vertex_size) + x, y, z = struct.unpack('<3f', v_data[0:12]) + nx, ny, nz = struct.unpack('<3f', v_data[12:24]) + u, vv = struct.unpack('<2f', v_data[24:32]) + cr, cg, cb, ca = v_data[32:36] + vertices.append(Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv, + cr/255.0, cg/255.0, cb/255.0, ca/255.0)) + + # envelope (skinning weights) — пропускаем + if num_bones > 0: + # envelope = 8 байт на vertex (4 bone indexes + 4 weights) + r.read(num_vertices * 8) + + faces = [] + for _ in range(num_faces): + a, b, c = struct.unpack('<3I', r.read(12)) + faces.append((a, b, c)) + + return MeshData(version=version, vertices=vertices, faces=faces) + + +def _parse_v5(rest: bytes, version: str) -> MeshData: + """v5: v4 + facial animation + расширенный LOD. + + Header структура почти такая же как v4, плюс несколько полей в конце. + """ + # Парсим как v4 — для базовой геометрии этого достаточно + return _parse_v4(rest, version) + + +# ────────────────────────────────────────────────────────────────────── +# GLB writer — конвертер MeshData → GLB бинарь +# ────────────────────────────────────────────────────────────────────── + + +def build_glb(mesh: MeshData) -> bytes: + """Собирает GLB (glTF 2.0 binary) из MeshData. + + GLB структура: + magic 'glTF' (4 bytes) + version (uint32) = 2 + length (uint32) — total file size + JSON chunk: + length (uint32) + type 'JSON' + JSON payload (UTF-8) + BIN chunk: + length (uint32) + type 'BIN\0' + binary payload (vertex data + index data) + """ + import json + + n_verts = len(mesh.vertices) + n_faces = len(mesh.faces) + + if n_verts == 0 or n_faces == 0: + raise MeshParseError("empty mesh — cannot build GLB") + + # Бинарные данные: POSITION (3f) + NORMAL (3f) + TEXCOORD_0 (2f) + indices (uint32) + pos_buf = bytearray() + norm_buf = bytearray() + uv_buf = bytearray() + for v in mesh.vertices: + pos_buf.extend(struct.pack('<3f', v.x, v.y, v.z)) + norm_buf.extend(struct.pack('<3f', v.nx, v.ny, v.nz)) + uv_buf.extend(struct.pack('<2f', v.u, v.v)) + + idx_buf = bytearray() + for a, b, c in mesh.faces: + idx_buf.extend(struct.pack('<3I', a, b, c)) + + # Все буферы конкатенируются в один большой + # Каждый должен начинаться с 4-byte alignment + def pad4(buf): + while len(buf) % 4 != 0: + buf.append(0) + return buf + + pos_offset = 0 + pos_size = len(pos_buf) + pad4(pos_buf) + + norm_offset = len(pos_buf) + norm_size = len(norm_buf) + pad4(norm_buf) + + uv_offset = norm_offset + len(norm_buf) + uv_size = len(uv_buf) + pad4(uv_buf) + + idx_offset = uv_offset + len(uv_buf) + idx_size = len(idx_buf) + pad4(idx_buf) + + bin_total = pos_buf + norm_buf + uv_buf + idx_buf + + # Mins/maxes — нужны GLTF спекой для POSITION + xs = [v.x for v in mesh.vertices] + ys = [v.y for v in mesh.vertices] + zs = [v.z for v in mesh.vertices] + pos_min = [min(xs), min(ys), min(zs)] + pos_max = [max(xs), max(ys), max(zs)] + + gltf = { + "asset": {"version": "2.0", "generator": "rublox-rbxl-importer"}, + "scene": 0, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{ + "primitives": [{ + "attributes": { + "POSITION": 0, + "NORMAL": 1, + "TEXCOORD_0": 2, + }, + "indices": 3, + "mode": 4, # TRIANGLES + }], + }], + "buffers": [{ + "byteLength": len(bin_total), + }], + "bufferViews": [ + {"buffer": 0, "byteOffset": pos_offset, "byteLength": pos_size, "target": 34962}, # ARRAY_BUFFER + {"buffer": 0, "byteOffset": norm_offset, "byteLength": norm_size, "target": 34962}, + {"buffer": 0, "byteOffset": uv_offset, "byteLength": uv_size, "target": 34962}, + {"buffer": 0, "byteOffset": idx_offset, "byteLength": idx_size, "target": 34963}, # ELEMENT_ARRAY_BUFFER + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": n_verts, "type": "VEC3", "min": pos_min, "max": pos_max}, # FLOAT + {"bufferView": 1, "componentType": 5126, "count": n_verts, "type": "VEC3"}, + {"bufferView": 2, "componentType": 5126, "count": n_verts, "type": "VEC2"}, + {"bufferView": 3, "componentType": 5125, "count": n_faces * 3, "type": "SCALAR"}, # UNSIGNED_INT + ], + } + + json_bytes = json.dumps(gltf, separators=(',', ':')).encode('utf-8') + while len(json_bytes) % 4 != 0: + json_bytes += b' ' + + # GLB header + total_len = 12 + 8 + len(json_bytes) + 8 + len(bin_total) + + out = bytearray() + out.extend(b'glTF') + out.extend(struct.pack(' dict: + """Конвертит .mesh → .glb. Возвращает dict с metadata.""" + with open(input_path, 'rb') as f: + blob = f.read() + mesh = parse_roblox_mesh(blob) + glb = build_glb(mesh) + with open(output_path, 'wb') as f: + f.write(glb) + return { + 'version': mesh.version, + 'vertices': len(mesh.vertices), + 'faces': len(mesh.faces), + 'input_size': len(blob), + 'output_size': len(glb), + } + + +if __name__ == '__main__': + import sys + if len(sys.argv) < 3: + print("usage: mesh_converter.py input.mesh output.glb") + sys.exit(1) + info = convert(sys.argv[1], sys.argv[2]) + for k, v in info.items(): + print(f" {k}: {v}") diff --git a/rbxl-importer/src/rbxl_binreader.py b/rbxl-importer/src/rbxl_binreader.py new file mode 100644 index 0000000..5e9a677 --- /dev/null +++ b/rbxl-importer/src/rbxl_binreader.py @@ -0,0 +1,203 @@ +""" +rbxl_binreader.py — низкоуровневое чтение бинарного потока Roblox. + +Особенности формата: +1. Little-endian для всех целочисленных и float. +2. "Interleaved transformed" массивы: для N значений длиной L байт каждое, + байты идут не AAAA BBBB CCCC, а ABCD ABCD ABCD ABCD (interleave). + То есть сначала все first-byte'ы, потом все second-byte'ы, итд. Это + делается потому что среди одинакового свойства часто меняется только + младший байт, и interleave даёт длинные последовательности нулей и + повторяющихся байт → лучше сжимается LZ4. +3. Signed int → zigzag: (n << 1) ^ (n >> 31) — для int32, аналогично для int64. +4. Float → "Roblox float encoding": сдвиг битов чтобы знаковый бит был в конце. + Сделано опять же чтобы маленькие близкие к нулю float'ы выглядели похоже + и сжимались. +5. String: uint32 length + UTF-8 bytes. + +Полная спецификация: https://dom.rojo.space/binary +""" +import struct +import io + + +class BinReader: + """Тонкая обёртка над BytesIO с методами для Roblox-типов.""" + + def __init__(self, data: bytes): + self.buf = data + self.pos = 0 + + # ─── атомарные ─── + + def read(self, n: int) -> bytes: + out = self.buf[self.pos:self.pos + n] + if len(out) < n: + raise IOError(f"unexpected EOF: wanted {n}, got {len(out)} at pos {self.pos}") + self.pos += n + return out + + def remaining(self) -> int: + return len(self.buf) - self.pos + + def at_end(self) -> bool: + return self.pos >= len(self.buf) + + def skip(self, n: int) -> None: + self.pos += n + + # ─── простые числовые ─── + + def uint8(self) -> int: + return self.read(1)[0] + + def uint16(self) -> int: + return struct.unpack(' int: + return struct.unpack(' int: + return struct.unpack(' int: + return struct.unpack(' int: + return struct.unpack(' float: + return struct.unpack(' float: + return struct.unpack(' bool: + return self.read(1)[0] != 0 + + def string(self) -> str: + length = self.uint32() + if length == 0: + return '' + return self.read(length).decode('utf-8', errors='replace') + + def bytes_with_len(self) -> bytes: + length = self.uint32() + return self.read(length) + + +# ────────────────────────────────────────────────────────────────────── +# Decoders для "interleaved transformed" массивов. +# Используются в PROP chunks где для класса с N инстансами идёт одно +# свойство, и значение лежит в interleaved виде. +# ────────────────────────────────────────────────────────────────────── + + +def deinterleave(data: bytes, count: int, element_size: int) -> bytes: + """Снимает interleave. Получает count*element_size байт и возвращает + нормальный массив байт где значения идут последовательно. + + interleaved: b0[0] b1[0] b2[0] ... b0[1] b1[1] b2[1] ... b0[3] b1[3] b2[3] + нормальный: b0[0] b0[1] b0[2] b0[3] b1[0] b1[1] b1[2] b1[3] ... + """ + if len(data) != count * element_size: + raise ValueError( + f"deinterleave: expected {count}*{element_size}={count*element_size} bytes, got {len(data)}" + ) + if count == 0: + return b'' + out = bytearray(count * element_size) + for byte_idx in range(element_size): + for value_idx in range(count): + out[value_idx * element_size + byte_idx] = data[byte_idx * count + value_idx] + return bytes(out) + + +def zigzag_decode_int32(n: int) -> int: + """ZigZag decode для int32: (n >> 1) ^ -(n & 1). + + Используется для PROP с типом Int32 (свойство 'Size' MeshPart и т.п.). + """ + return (n >> 1) ^ -(n & 1) + + +def zigzag_decode_int64(n: int) -> int: + return (n >> 1) ^ -(n & 1) + + +def roblox_float_decode(raw: int) -> float: + """Roblox float encoding для PROP-массивов. + + Roblox перекодирует float32 чтобы знаковый бит был в самом конце + (это даёт лучшее сжатие при близких к нулю значениях). + + raw — uint32 little-endian. + """ + # Развернём биты так чтобы знаковый бит вернулся в позицию 31. + # Roblox: { mantissa_high(23 bits), exponent(8 bits), sign(1 bit) } → стандартный float + # Просто (raw >> 1) | ((raw & 1) << 31) + standard = (raw >> 1) | ((raw & 1) << 31) + return struct.unpack(' list: + """Читает count int32 в interleaved-zigzag форме.""" + raw = reader.read(count * 4) + deint = deinterleave(raw, count, 4) + out = [] + for i in range(count): + u = struct.unpack('>I', deint[i*4:i*4+4])[0] # ВНИМАНИЕ: после deinterleave порядок big-endian! + out.append(zigzag_decode_int32(u)) + return out + + +def read_interleaved_uint32_array(reader: BinReader, count: int) -> list: + """uint32 array (для Referent) — interleaved, но без zigzag.""" + raw = reader.read(count * 4) + deint = deinterleave(raw, count, 4) + out = [] + for i in range(count): + out.append(struct.unpack('>I', deint[i*4:i*4+4])[0]) + return out + + +def read_interleaved_int64_array(reader: BinReader, count: int) -> list: + raw = reader.read(count * 8) + deint = deinterleave(raw, count, 8) + out = [] + for i in range(count): + u = struct.unpack('>Q', deint[i*8:i*8+8])[0] + out.append(zigzag_decode_int64(u)) + return out + + +def read_interleaved_float_array(reader: BinReader, count: int) -> list: + """Float32 array — interleaved с Roblox float encoding.""" + raw = reader.read(count * 4) + deint = deinterleave(raw, count, 4) + out = [] + for i in range(count): + u = struct.unpack('>I', deint[i*4:i*4+4])[0] + out.append(roblox_float_decode(u)) + return out + + +def read_referent_array(reader: BinReader, count: int) -> list: + """Referent array — interleaved int32 array с накопительной разностью. + + Каждое следующее значение = предыдущее + delta. Это даёт длинные + последовательности (1, 2, 3, 4, ...) которые после deinterleave + + zigzag отлично сжимаются в нули. + """ + deltas = read_interleaved_int32_array(reader, count) + out = [] + accumulator = 0 + for d in deltas: + accumulator += d + out.append(accumulator) + return out diff --git a/rbxl-importer/src/rbxl_parser.py b/rbxl-importer/src/rbxl_parser.py new file mode 100644 index 0000000..a0e8f60 --- /dev/null +++ b/rbxl-importer/src/rbxl_parser.py @@ -0,0 +1,425 @@ +""" +rbxl_parser.py — главный парсер Roblox Binary Level (.rbxl) v1. + +Использование: + from rbxl_parser import parse, RobloxModel + with open('map.rbxl', 'rb') as f: + model = parse(f.read()) + for inst in model.instances: + print(inst.class_name, inst.properties.get('Name')) + +Архитектура: + 1. parse() читает все chunks (META, SSTR, INST, PROP, PRNT, END). + 2. Из INST chunks извлекаем какие классы (Part, Script, ...) есть в файле + и сколько инстансов у каждого. + 3. Из PROP chunks извлекаем значения свойств (по одному chunk на свойство + для каждого класса). + 4. Из PRNT chunk — иерархия parent-ссылок (referent->referent). + 5. Собираем плоский список Instance'ов с привязкой properties + parent. + +Полная документация формата: https://dom.rojo.space/binary +""" +import struct +import io +import lz4.block +from dataclasses import dataclass, field +from typing import List, Dict, Optional, Any + +from rbxl_binreader import ( + BinReader, + read_interleaved_uint32_array, + read_referent_array, +) +from rbxl_types import ( + decode_prop_chunk, + PropChunk, +) + + +RBXL_SIGNATURE = b' tuple: + """Возвращает (header, [RawChunk]). + + Каждый chunk: + name (4 bytes ASCII), compressed_len (uint32), uncompressed_len (uint32), + reserved (uint32), payload (compressed_len bytes if compressed_len>0 else uncompressed_len bytes). + + Если compressed_len == 0 — payload не сжат. + """ + if not blob.startswith(RBXL_SIGNATURE): + raise RbxlParseError( + f"signature mismatch: got {blob[:14].hex()}, expected {RBXL_SIGNATURE.hex()}" + ) + + stream = io.BytesIO(blob[len(RBXL_SIGNATURE):]) + + # Header + version, class_count, instance_count = struct.unpack(' InstChunk: + """Декодирует INST chunk. + + Структура: + class_id (uint32) + class_name (string) + is_service (uint8) + count (uint32) — число инстансов класса + referents (interleaved-zigzag-累加 int32, count штук) + if is_service: + services_flags (count байт) + """ + r = BinReader(payload) + class_id = r.uint32() + class_name = r.string() + is_service = bool(r.uint8()) + count = r.uint32() + referents = read_referent_array(r, count) + + if is_service: + # Пропускаем сервис-флаги (один байт на инстанс) + r.read(count) + + return InstChunk( + class_id=class_id, + class_name=class_name, + is_service=is_service, + referent_ids=referents, + ) + + +# ────────────────────────────────────────────────────────────────────── +# Decode SSTR chunk +# ────────────────────────────────────────────────────────────────────── + + +def _decode_sstr_chunk(payload: bytes) -> List[bytes]: + """SSTR (Shared Strings) chunk. + + Структура: + version (uint32) + count (uint32) + для каждого: + md5_hash (16 bytes) — игнорируется + length (uint32) + data (length bytes) + """ + r = BinReader(payload) + _version = r.uint32() + count = r.uint32() + strings = [] + for _ in range(count): + r.read(16) # md5 + length = r.uint32() + strings.append(r.read(length)) + return strings + + +# ────────────────────────────────────────────────────────────────────── +# Decode PRNT chunk (parent hierarchy) +# ────────────────────────────────────────────────────────────────────── + + +def _decode_prnt_chunk(payload: bytes, instance_count: int) -> List[tuple]: + """PRNT (Parents) chunk: для каждого instance его parent. + + Структура: + version (uint8) — обычно 0 + count (uint32) — instance_count + child_referents: interleaved int32 array (length=count, cumulative) + parent_referents: interleaved int32 array (length=count, cumulative) + + Возвращает [(child_referent, parent_referent)]. parent_referent == -1 + означает что parent — root (Workspace/DataModel). + """ + r = BinReader(payload) + _version = r.uint8() + count = r.uint32() + children = read_referent_array(r, count) + parents = read_referent_array(r, count) + return list(zip(children, parents)) + + +# ────────────────────────────────────────────────────────────────────── +# Decode META chunk +# ────────────────────────────────────────────────────────────────────── + + +def _decode_meta_chunk(payload: bytes) -> Dict[str, str]: + """META chunk: пары ключ-значение.""" + r = BinReader(payload) + count = r.uint32() + meta = {} + for _ in range(count): + k = r.string() + v = r.string() + meta[k] = v + return meta + + +# ────────────────────────────────────────────────────────────────────── +# Главная функция parse() +# ────────────────────────────────────────────────────────────────────── + + +def parse(blob: bytes) -> RobloxModel: + """Парсит .rbxl-байты в RobloxModel со списком Instance'ов.""" + header, raw_chunks = _read_chunks(blob) + warnings: List[str] = [] + + # 1. Извлекаем META + meta = {} + for c in raw_chunks: + if c.name == 'META': + try: + meta = _decode_meta_chunk(c.payload) + except Exception as e: + warnings.append(f"META decode failed: {e}") + + # 2. SSTR + shared_strings: List[bytes] = [] + for c in raw_chunks: + if c.name == 'SSTR': + try: + shared_strings = _decode_sstr_chunk(c.payload) + except Exception as e: + warnings.append(f"SSTR decode failed: {e}") + break + + # 3. INST — описания классов + inst_chunks: Dict[int, InstChunk] = {} + for c in raw_chunks: + if c.name == 'INST': + try: + ic = _decode_inst_chunk(c.payload) + inst_chunks[ic.class_id] = ic + except Exception as e: + warnings.append(f"INST decode failed: {e}") + + # 4. Создаём пустые Instance'ы по референтам + by_referent: Dict[int, Instance] = {} + instances: List[Instance] = [] + for class_id, ic in inst_chunks.items(): + for ref in ic.referent_ids: + inst = Instance(referent=ref, class_name=ic.class_name) + by_referent[ref] = inst + instances.append(inst) + + # 5. PROP — заполняем свойства + for c in raw_chunks: + if c.name == 'PROP': + try: + # Узнаём class_id (первые 4 байта чанка) чтобы взять count + class_id = struct.unpack(' Dict[str, int]: + """Возвращает {class_name: count} — сколько объектов каждого типа.""" + out: Dict[str, int] = {} + for inst in model.instances: + out[inst.class_name] = out.get(inst.class_name, 0) + 1 + return out + + +def summarize(model: RobloxModel, top_classes: int = 30) -> str: + """Печатает человекочитаемый отчёт о модели.""" + lines = [] + lines.append(f"=== Roblox model ===") + lines.append(f" version: {model.version}") + lines.append(f" class count: {model.class_count}") + lines.append(f" instance count: {model.instance_count}") + lines.append(f" shared strings: {len(model.shared_strings)}") + lines.append(f" warnings: {len(model.warnings)}") + if model.meta: + lines.append(" meta:") + for k, v in list(model.meta.items())[:5]: + lines.append(f" {k}: {v}") + lines.append("") + lines.append(f"=== Top {top_classes} classes ===") + hist = class_histogram(model) + for cls, n in sorted(hist.items(), key=lambda x: -x[1])[:top_classes]: + lines.append(f" {n:>5d} {cls}") + lines.append("") + if model.warnings: + lines.append(f"=== Warnings (first 20) ===") + for w in model.warnings[:20]: + lines.append(f" ! {w}") + return "\n".join(lines) + + +if __name__ == '__main__': + import sys + path = sys.argv[1] if len(sys.argv) > 1 else '/opt/rbxl-importer/reference_files/easy_obby.rbxl' + with open(path, 'rb') as f: + blob = f.read() + print(f"file size: {len(blob)} bytes\n") + model = parse(blob) + print(summarize(model, top_classes=40)) + + # Дополнительная статистика: что внутри у Part + print("\n=== Sample Part properties ===") + parts = [i for i in model.instances if i.class_name == 'Part'] + if parts: + p = parts[0] + print(f" Sample Part (referent={p.referent}):") + for k, v in p.properties.items(): + sv = str(v) + if len(sv) > 80: + sv = sv[:77] + '...' + print(f" {k}: {sv}") + print(f"\n Total Parts: {len(parts)}") diff --git a/rbxl-importer/src/rbxl_parser_v0.py b/rbxl-importer/src/rbxl_parser_v0.py new file mode 100644 index 0000000..af16f5f --- /dev/null +++ b/rbxl-importer/src/rbxl_parser_v0.py @@ -0,0 +1,125 @@ +""" +rbxl_parser_v0.py — голый парсер Roblox Binary Level (.rbxl) v0. + +Эта первая итерация — только структура файла: + - проверка signature ( RbxlFile: + """Парсит .rbxl байты в RbxlFile. Бросает RbxlParseError на ошибке.""" + if not blob.startswith(RBXL_SIGNATURE): + raise RbxlParseError( + f"signature mismatch: got {blob[:14].hex()}, " + f"expected {RBXL_SIGNATURE.hex()}" + ) + + stream = io.BytesIO(blob[len(RBXL_SIGNATURE):]) + + # Header: version (uint16), class_count (uint32), instance_count (uint32), reserved (8 bytes) + version, class_count, instance_count = struct.unpack(' 0 else uncompressed_len) + + if compressed_len == 0: + # Не сжато + payload = payload_compressed + else: + # LZ4 raw block decompress + try: + payload = lz4.block.decompress(payload_compressed, uncompressed_size=uncompressed_len) + except Exception as e: + raise RbxlParseError( + f"LZ4 decompress failed for chunk {name!r}: {e}" + ) + + chunks.append(RbxlChunk(name, compressed_len, uncompressed_len, payload)) + + if name.startswith('END'): + break + + return RbxlFile(header=header, chunks=chunks) + + +def summarize(rbxl: RbxlFile) -> str: + """Печатает компактный отчёт о структуре файла для smoke-test.""" + lines = [] + lines.append(f"=== rbxl file ===") + lines.append(f" version: {rbxl.header.version}") + lines.append(f" classes: {rbxl.header.class_count}") + lines.append(f" instances: {rbxl.header.instance_count}") + lines.append(f" chunks: {len(rbxl.chunks)}") + lines.append("") + lines.append(f"=== chunks ===") + for c in rbxl.chunks: + ratio = (c.compressed_len / c.uncompressed_len * 100) if c.uncompressed_len > 0 else 0 + lines.append( + f" {c.name:6s} compressed={c.compressed_len:>8d} uncompressed={c.uncompressed_len:>10d} " + f"({ratio:5.1f}%) first 16 bytes: {c.payload[:16].hex()}" + ) + return "\n".join(lines) + + +if __name__ == '__main__': + import sys + path = sys.argv[1] if len(sys.argv) > 1 else '/opt/rbxl-importer/reference_files/easy_obby.rbxl' + with open(path, 'rb') as f: + blob = f.read() + print(f"file size: {len(blob)} bytes") + rbxl = parse(blob) + print(summarize(rbxl)) diff --git a/rbxl-importer/src/rbxl_types.py b/rbxl-importer/src/rbxl_types.py new file mode 100644 index 0000000..4113753 --- /dev/null +++ b/rbxl-importer/src/rbxl_types.py @@ -0,0 +1,605 @@ +""" +rbxl_types.py — определения типов значений Roblox и парсинг PROP-chunks. + +Roblox-формат хранит свойства per-class: для каждого класса свой набор +PROP chunks (по одному на каждое свойство). Внутри PROP лежат значения +этого свойства для ВСЕХ инстансов данного класса разом. + +Schema PROP chunk: + class_id (uint32) — индекс INST chunk'а (1:1 с порядком) + prop_name (string) — например "Color", "Size", "CFrame" + type_id (uint8) — тип значения (см. PROP_TYPE_* константы) + values... (массив длиной N где N = instance_count для класса class_id) + +Полная спецификация PROP типов: https://dom.rojo.space/binary#type-id + +Эта реализация — голый struct без логики приведения к Rublox-формату. +Конвертация в твою сцену — в rbxl_converter.py. +""" +from dataclasses import dataclass, field +from typing import List, Optional, Union, Any +import struct + +from rbxl_binreader import ( + BinReader, + deinterleave, + zigzag_decode_int32, + zigzag_decode_int64, + roblox_float_decode, + read_interleaved_int32_array, + read_interleaved_uint32_array, + read_interleaved_int64_array, + read_interleaved_float_array, + read_referent_array, +) + + +# ────────────────────────────────────────────────────────────────────── +# Тип-ID коды (используется в первом байте PROP chunk после prop_name). +# ────────────────────────────────────────────────────────────────────── + +PROP_STRING = 0x01 +PROP_BOOL = 0x02 +PROP_INT32 = 0x03 +PROP_FLOAT = 0x04 +PROP_DOUBLE = 0x05 +PROP_UDIM = 0x06 +PROP_UDIM2 = 0x07 +PROP_RAY = 0x08 +PROP_FACES = 0x09 +PROP_AXES = 0x0A +PROP_BRICKCOLOR = 0x0B +PROP_COLOR3 = 0x0C +PROP_VECTOR2 = 0x0D +PROP_VECTOR3 = 0x0E +# 0x0F не используется +PROP_CFRAME = 0x10 +PROP_QUATERNION = 0x11 # есть в спеке, но обычно cf хранится в CFrame +PROP_ENUM = 0x12 +PROP_REFERENT = 0x13 +PROP_VECTOR3INT16 = 0x14 +PROP_NUMBERSEQUENCE = 0x15 +PROP_COLORSEQUENCE = 0x16 +PROP_NUMBERRANGE = 0x17 +PROP_RECT = 0x18 +PROP_PHYSICALPROPS = 0x19 +PROP_COLOR3UINT8 = 0x1A +PROP_INT64 = 0x1B +PROP_SHAREDSTRING = 0x1C +PROP_BYTECODE = 0x1D +PROP_OPTIONALCFRAME = 0x1E +PROP_UNIQUEID = 0x1F +PROP_FONT = 0x20 + +# ────────────────────────────────────────────────────────────────────── +# Дата-классы для сложных типов (Vector3, CFrame, и т.д.) +# ────────────────────────────────────────────────────────────────────── + + +@dataclass +class Vector3: + x: float + y: float + z: float + + +@dataclass +class Vector2: + x: float + y: float + + +@dataclass +class Color3: + r: float + g: float + b: float + + def to_hex(self) -> str: + return '#{:02x}{:02x}{:02x}'.format( + max(0, min(255, int(self.r * 255))), + max(0, min(255, int(self.g * 255))), + max(0, min(255, int(self.b * 255))), + ) + + +@dataclass +class CFrame: + """4x3 матрица Roblox: 3 позиции + 9 значений матрицы ротации (3x3, row-major). + + Хранится как (px, py, pz, r00, r01, r02, r10, r11, r12, r20, r21, r22). + """ + position: Vector3 + matrix: tuple # (r00, r01, r02, r10, r11, r12, r20, r21, r22) + + def to_euler_xyz(self) -> tuple: + """Конверт 3x3 rotation matrix в Euler XYZ (radians). + + Использует стандартную intrinsic XYZ rotation extraction: + Rx = atan2(r21, r22) + Ry = atan2(-r20, sqrt(r21² + r22²)) + Rz = atan2(r10, r00) + """ + import math + r00, r01, r02, r10, r11, r12, r20, r21, r22 = self.matrix + rx = math.atan2(r21, r22) + ry = math.atan2(-r20, math.sqrt(r21*r21 + r22*r22)) + rz = math.atan2(r10, r00) + return (rx, ry, rz) + + +@dataclass +class UDim: + scale: float + offset: int + + +@dataclass +class UDim2: + x: UDim + y: UDim + + +@dataclass +class Ray: + origin: Vector3 + direction: Vector3 + + +@dataclass +class Rect: + min: Vector2 + max: Vector2 + + +@dataclass +class PhysicalProperties: + custom: bool + density: float = 0.0 + friction: float = 0.0 + elasticity: float = 0.0 + friction_weight: float = 0.0 + elasticity_weight: float = 0.0 + + +@dataclass +class BrickColor: + """Цвет из палитры BrickColor (старый формат Roblox). + + Хранится как int32 — код цвета из BrickColor.palette(). + Для нас можно мапить в Color3 по таблице. + """ + code: int + + +@dataclass +class Vector3int16: + x: int + y: int + z: int + + +@dataclass +class NumberSequenceKeypoint: + time: float + value: float + envelope: float + + +@dataclass +class NumberSequence: + keypoints: List[NumberSequenceKeypoint] + + +@dataclass +class ColorSequenceKeypoint: + time: float + value: Color3 + envelope: float = 0.0 # игнорируется Roblox для ColorSequence + + +@dataclass +class ColorSequence: + keypoints: List[ColorSequenceKeypoint] + + +@dataclass +class NumberRange: + min: float + max: float + + +@dataclass +class Faces: + """6 bool флагов: Top, Bottom, Left, Right, Front, Back.""" + flags: int # bitmask + + +@dataclass +class Axes: + """3 bool флага.""" + flags: int + + +@dataclass +class SharedStringRef: + """Ссылка на запись в SSTR chunk (по индексу).""" + index: int + + +@dataclass +class EnumValue: + value: int + + +@dataclass +class UniqueId: + """128-bit ID (uuid-like).""" + raw: bytes + + +@dataclass +class Font: + family: str # font family asset id or name + weight: int + style: int + cached_face_id: str + + +@dataclass +class OptionalCFrame: + """CFrame который может быть null.""" + value: Optional[CFrame] + + +# ────────────────────────────────────────────────────────────────────── +# Главный декодер PROP chunk +# ────────────────────────────────────────────────────────────────────── + + +@dataclass +class PropChunk: + """Распарсенный PROP chunk: значения свойства для всех инстансов одного класса.""" + class_id: int + prop_name: str + type_id: int + values: List[Any] # длина = count instances класса + + +def decode_prop_chunk(payload: bytes, instance_count_for_class: int, shared_strings: List[bytes]) -> PropChunk: + """Декодирует PROP chunk. + + Args: + payload: уже декомпрессированные байты chunk'а + instance_count_for_class: сколько инстансов у класса (определяется по INST) + shared_strings: список из SSTR chunk для PROP_SHAREDSTRING + + Returns: + PropChunk со списком значений (длина = instance_count_for_class). + """ + r = BinReader(payload) + class_id = r.uint32() + prop_name = r.string() + type_id = r.uint8() + + count = instance_count_for_class + + if type_id == PROP_STRING: + # N строк, каждая uint32+bytes + values = [r.string() for _ in range(count)] + + elif type_id == PROP_BOOL: + # N bool — НЕ interleaved, просто N байт + values = [r.bool() for _ in range(count)] + + elif type_id == PROP_INT32: + values = read_interleaved_int32_array(r, count) + + elif type_id == PROP_FLOAT: + values = read_interleaved_float_array(r, count) + + elif type_id == PROP_DOUBLE: + # 8 байт little-endian, БЕЗ interleave + values = [r.float64() for _ in range(count)] + + elif type_id == PROP_UDIM: + scales = read_interleaved_float_array(r, count) + offsets = read_interleaved_int32_array(r, count) + values = [UDim(s, o) for s, o in zip(scales, offsets)] + + elif type_id == PROP_UDIM2: + xs_scale = read_interleaved_float_array(r, count) + ys_scale = read_interleaved_float_array(r, count) + xs_offset = read_interleaved_int32_array(r, count) + ys_offset = read_interleaved_int32_array(r, count) + values = [ + UDim2(UDim(xs_scale[i], xs_offset[i]), UDim(ys_scale[i], ys_offset[i])) + for i in range(count) + ] + + elif type_id == PROP_RAY: + # 6 floats per value, БЕЗ interleave + values = [] + for _ in range(count): + ox, oy, oz = r.float32(), r.float32(), r.float32() + dx, dy, dz = r.float32(), r.float32(), r.float32() + values.append(Ray(Vector3(ox, oy, oz), Vector3(dx, dy, dz))) + + elif type_id == PROP_FACES: + # 1 байт на каждый + values = [Faces(r.uint8()) for _ in range(count)] + + elif type_id == PROP_AXES: + values = [Axes(r.uint8()) for _ in range(count)] + + elif type_id == PROP_BRICKCOLOR: + codes = read_interleaved_int32_array(r, count) + values = [BrickColor(c) for c in codes] + + elif type_id == PROP_COLOR3: + # 3 float channels, каждый interleaved отдельно + rs = read_interleaved_float_array(r, count) + gs = read_interleaved_float_array(r, count) + bs = read_interleaved_float_array(r, count) + values = [Color3(rs[i], gs[i], bs[i]) for i in range(count)] + + elif type_id == PROP_VECTOR2: + xs = read_interleaved_float_array(r, count) + ys = read_interleaved_float_array(r, count) + values = [Vector2(xs[i], ys[i]) for i in range(count)] + + elif type_id == PROP_VECTOR3: + xs = read_interleaved_float_array(r, count) + ys = read_interleaved_float_array(r, count) + zs = read_interleaved_float_array(r, count) + values = [Vector3(xs[i], ys[i], zs[i]) for i in range(count)] + + elif type_id == PROP_CFRAME: + # Сначала идут rotation matrices, для каждого инстанса: + # 1 byte: orientation_id (0 = custom 9 float, иначе — шаблон из enum) + # если 0: следующие 36 байт = 9 float32 (row-major) + # ПОТОМ 3 interleaved float-массива (positions x, y, z) + rotations = [] + for _ in range(count): + orientation_id = r.uint8() + if orientation_id == 0: + m = struct.unpack('<9f', r.read(36)) + rotations.append(m) + else: + # Стандартные orientations (см. ROBLOX_CFRAME_ORIENTATIONS) + rotations.append(_cframe_orientation_to_matrix(orientation_id)) + xs = read_interleaved_float_array(r, count) + ys = read_interleaved_float_array(r, count) + zs = read_interleaved_float_array(r, count) + values = [ + CFrame(Vector3(xs[i], ys[i], zs[i]), rotations[i]) + for i in range(count) + ] + + elif type_id == PROP_QUATERNION: + # Редко используется отдельно, обычно в составе CFrame + # Структура: 4 floats * count, БЕЗ interleave (на всякий) + values = [] + for _ in range(count): + qx, qy, qz, qw = r.float32(), r.float32(), r.float32(), r.float32() + # Конверт quaternion → 3x3 matrix + values.append(CFrame(Vector3(0, 0, 0), _quat_to_matrix(qx, qy, qz, qw))) + + elif type_id == PROP_ENUM: + # interleaved int32 (zigzag не применяется) + # на самом деле в спеке — это uint32 interleaved + vals = read_interleaved_uint32_array(r, count) + values = [EnumValue(v) for v in vals] + + elif type_id == PROP_REFERENT: + values = read_referent_array(r, count) + + elif type_id == PROP_VECTOR3INT16: + # 3 * int16 per value, БЕЗ interleave + values = [] + for _ in range(count): + x = struct.unpack(' tuple: + """Roblox использует 24 стандартных orientations (вращения по 90°). + + Источник: https://dom.rojo.space/binary#cframe-orientation-ids + + Это полная таблица 24-х валидных orientation id для cube symmetries. + Возвращает (r00, r01, r02, r10, r11, r12, r20, r21, r22). + """ + # Таблица из rbx-dom. Каждое значение — пара (rx_axis, ry_axis) где + # значения в {0,1,2,3,4,5} = +X, -X, +Y, -Y, +Z, -Z + AXES = [ + (1, 0, 0), (-1, 0, 0), + (0, 1, 0), (0, -1, 0), + (0, 0, 1), (0, 0, -1), + ] + # orientation_id = 1..24 (1-based) + if not (1 <= orientation_id <= 24): + # Неверный id — возвращаем identity + return (1, 0, 0, 0, 1, 0, 0, 0, 1) + + idx = orientation_id - 1 + rx_idx = idx // 6 + ry_idx = idx % 6 + rx = AXES[rx_idx] + ry = AXES[ry_idx] + # rz = rx × ry (cross product) + rz = ( + rx[1] * ry[2] - rx[2] * ry[1], + rx[2] * ry[0] - rx[0] * ry[2], + rx[0] * ry[1] - rx[1] * ry[0], + ) + # Матрица: первые 3 — first row (R_xx, R_yx, R_zx) + # Сложновато; берём из rbx-dom convention: первые три — основа R*XAxis, + # затем R*YAxis, затем R*ZAxis. Расширяем в row-major form. + # На практике: orientation вектора (rx, ry, rz) — это **столбцы** матрицы. + r00, r10, r20 = rx + r01, r11, r21 = ry + r02, r12, r22 = rz + return (r00, r01, r02, r10, r11, r12, r20, r21, r22) + + +def _quat_to_matrix(qx, qy, qz, qw) -> tuple: + """Кватернион в 3x3 row-major matrix.""" + xx = qx * qx + yy = qy * qy + zz = qz * qz + xy = qx * qy + xz = qx * qz + yz = qy * qz + wx = qw * qx + wy = qw * qy + wz = qw * qz + return ( + 1 - 2*(yy + zz), 2*(xy - wz), 2*(xz + wy), + 2*(xy + wz), 1 - 2*(xx + zz), 2*(yz - wx), + 2*(xz - wy), 2*(yz + wx), 1 - 2*(xx + yy), + ) diff --git a/src/api/API.js b/src/api/API.js index 6e7d015..8fa4139 100644 --- a/src/api/API.js +++ b/src/api/API.js @@ -10,6 +10,8 @@ export const USER_addres = BASE + '/api-user'; export const ACHIVES_addres = BASE + '/api-achievs'; export const COMMENTS_addres = BASE + '/api-comments'; export const STORYS_addres = BASE + '/api-storys'; +// rbxl-importer: только для МИНа (тест-фича импорта .rbxl карт Roblox) +export const RBXL_addres = BASE + '/api-rbxl'; export const NOTICES_addres = BASE + '/api-notices'; export const HELP_addres = BASE + '/api-help'; export const PYTHON_addres = BASE + '/api-python'; diff --git a/src/api/rbxlImporterApi.js b/src/api/rbxlImporterApi.js new file mode 100644 index 0000000..ba21350 --- /dev/null +++ b/src/api/rbxlImporterApi.js @@ -0,0 +1,54 @@ +/** + * rbxlImporterApi.js — клиент для rbxl-importer (Flask API на VM 130). + * + * Endpoints: + * POST /api-rbxl/import/rbxl/analyze — анализ .rbxl, возвращает report + preview_hash + * POST /api-rbxl/import/rbxl/create — скачать ассеты, создать проект + * GET /api-rbxl/health — healthcheck + */ + +import { RBXL_addres } from './API.js'; + +/** + * Анализ .rbxl. Принимает File (из ) или Blob. + * Возвращает { preview_hash, report }. + */ +export async function analyzeRbxl(file) { + const fd = new FormData(); + fd.append('file', file, file.name || 'upload.rbxl'); + const token = localStorage.getItem('Authorization') || ''; + const resp = await fetch(`${RBXL_addres}/import/rbxl/analyze`, { + method: 'POST', + headers: { + 'Authorization': token, + // X-User-Id выставит upstream (NPM → user-service после JWT) + // на dev добавим override (только в LAN): + }, + body: fd, + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`analyze failed (${resp.status}): ${text}`); + } + return resp.json(); +} + +/** + * Создаёт проект из preview_hash. Возвращает { project_id, redirect, assets_downloaded, assets_failed }. + */ +export async function createRbxlProject(previewHash, title) { + const token = localStorage.getItem('Authorization') || ''; + const resp = await fetch(`${RBXL_addres}/import/rbxl/create`, { + method: 'POST', + headers: { + 'Authorization': token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ preview_hash: previewHash, title: title || '' }), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`create failed (${resp.status}): ${text}`); + } + return resp.json(); +} diff --git a/src/components/RbxlImportModal.jsx b/src/components/RbxlImportModal.jsx new file mode 100644 index 0000000..8d137ec --- /dev/null +++ b/src/components/RbxlImportModal.jsx @@ -0,0 +1,275 @@ +/** + * RbxlImportModal — модалка импорта .rbxl Roblox-карт в Rublox. + * + * Доступна ТОЛЬКО МИНу (user_id === 1) — это тест-фича. + * + * Поток: + * 1. Юзер дропает или выбирает .rbxl файл. + * 2. Кликает «Анализировать» → POST /import/rbxl/analyze. + * 3. Видит отчёт: число объектов, скриптов, ассетов, предупреждения. + * 4. Вводит название игры → кликает «Создать игру». + * 5. POST /import/rbxl/create → редирект на /edit/. + */ +import React, { useState, useRef } from 'react'; +import { analyzeRbxl, createRbxlProject } from '../api/rbxlImporterApi.js'; + +const ALLOWED_USER_ID = 1; // МИН + +const MAX_SIZE = 50 * 1024 * 1024; // 50 MB + +export default function RbxlImportModal({ open, onClose, currentUserId, onCreated }) { + const [file, setFile] = useState(null); + const [dragOver, setDragOver] = useState(false); + const [analyzing, setAnalyzing] = useState(false); + const [creating, setCreating] = useState(false); + const [report, setReport] = useState(null); + const [previewHash, setPreviewHash] = useState(null); + const [title, setTitle] = useState(''); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + if (!open) return null; + + if (currentUserId !== ALLOWED_USER_ID) { + return ( +
+
e.stopPropagation()}> +

Импорт из Roblox

+

Эта тест-функция доступна только администратору.

+ +
+
+ ); + } + + const reset = () => { + setFile(null); setReport(null); setPreviewHash(null); + setTitle(''); setError(null); setAnalyzing(false); setCreating(false); + }; + + const handleClose = () => { reset(); onClose?.(); }; + + const handleFile = (f) => { + if (!f) return; + if (!f.name.toLowerCase().endsWith('.rbxl')) { + setError('Нужен файл .rbxl (Roblox Binary Level)'); + return; + } + if (f.size > MAX_SIZE) { + setError(`Файл больше ${MAX_SIZE / 1024 / 1024} MB`); + return; + } + setFile(f); + setError(null); + setReport(null); + setPreviewHash(null); + }; + + const handleAnalyze = async () => { + if (!file) return; + setAnalyzing(true); + setError(null); + try { + const result = await analyzeRbxl(file); + setPreviewHash(result.preview_hash); + setReport(result.report); + // дефолтный title — имя файла без .rbxl + const defTitle = (file.name || '').replace(/\.rbxl$/i, ''); + setTitle(defTitle); + } catch (e) { + setError(e.message || String(e)); + } finally { + setAnalyzing(false); + } + }; + + const handleCreate = async () => { + if (!previewHash) return; + setCreating(true); + setError(null); + try { + const result = await createRbxlProject(previewHash, title); + onCreated?.(result); + handleClose(); + // редирект на редактор + if (result.redirect) window.location.href = result.redirect; + } catch (e) { + setError(e.message || String(e)); + } finally { + setCreating(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

Импорт игры из Roblox

+ +
+

+ Загрузи Roblox-карту в формате .rbxl. Геометрия и Lua-скрипты будут + сконвертированы в проект Rublox. +

+ + {!file && ( +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + handleFile(e.dataTransfer.files?.[0]); + }} + onClick={() => fileInputRef.current?.click()} + style={{ + ...dropZoneStyle, + borderColor: dragOver ? '#4a9eff' : '#444', + background: dragOver ? '#1a2a3a' : '#1a1a1a', + }} + > +
📦
+
+ Перетащи .rbxl сюда +
+ или кликни чтобы выбрать файл (макс {MAX_SIZE / 1024 / 1024} MB) +
+
+ handleFile(e.target.files?.[0])} + /> +
+ )} + + {file && !report && ( +
+
{file.name} ({(file.size / 1024).toFixed(1)} KB)
+
+ + +
+
+ )} + + {report && ( +
+

Отчёт

+ + + + + + + + + + + +
Файл:{report.filename}
Размер:{(report.size_bytes / 1024).toFixed(1)} KB
Объектов:{report.instance_count}
Классов:{report.class_count}
Создано Part'ов:{report.primitives_created}
GLB-моделей:{report.glb_models_created}
Lua-скриптов:{report.scripts_total}
Ассетов для скачки:{report.assets_to_download}
+ + {report.top_classes?.length > 0 && ( +
+ Что внутри (топ-25 классов) + + + {report.top_classes.slice(0, 25).map((c, i) => ( + + ))} + +
{c.class}{c.count}
+
+ )} + + {report.warnings?.length > 0 && ( +
+ + Предупреждения ({report.warnings.length}) + +
    + {report.warnings.slice(0, 30).map((w, i) =>
  • {w}
  • )} +
+
+ )} + +
+
+ ⚠️ Загружая, ты подтверждаешь право использовать содержимое этой карты. + Не загружай чужие карты без разрешения автора. +
+
+ +
+ + setTitle(e.target.value)} + style={inputStyle} + placeholder="Например: Моя обби-карта" + /> +
+ +
+ + +
+
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+ ); +} + +const overlayStyle = { + position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 9000, + display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, +}; +const dialogStyle = { + background: '#1f1f1f', color: '#eee', borderRadius: 10, padding: 20, + maxWidth: 640, width: '100%', maxHeight: '90vh', overflowY: 'auto', + boxShadow: '0 10px 40px rgba(0,0,0,0.5)', +}; +const dropZoneStyle = { + border: '2px dashed #444', borderRadius: 8, padding: 40, textAlign: 'center', + cursor: 'pointer', transition: 'all 0.2s', +}; +const panelStyle = { + background: '#262626', borderRadius: 8, padding: 16, marginTop: 12, +}; +const btnStyle = { + background: '#4a8', color: '#fff', border: 'none', padding: '10px 20px', + borderRadius: 6, fontSize: 14, cursor: 'pointer', +}; +const closeBtnStyle = { + background: 'transparent', border: 'none', color: '#888', fontSize: 20, + cursor: 'pointer', padding: 4, +}; +const tableStyle = { + width: '100%', fontSize: 13, marginTop: 8, +}; +const inputStyle = { + width: '100%', padding: 8, background: '#1a1a1a', color: '#eee', + border: '1px solid #444', borderRadius: 4, fontSize: 14, +};