feat(rbxl-import): импорт Roblox .rbxl карт в Rublox-проекты
All checks were successful
CI / Lint (pull_request) Successful in 1m6s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 26s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped

Тест-фича для МИНа. Полное описание в rbxl-importer/INFO_PROCESS.md.

Backend (rbxl-importer/ на VM 130 S1):
- Python-парсер Roblox Binary (28+ типов значений)
- Asset downloader через Marfusha proxy + .ROBLOSECURITY cookie
- Mesh→GLB конвертер (v1-v5)
- Converter Roblox-классов → project_data
- Flask API: /analyze + /create

Frontend:
- API.js + components/RbxlImportModal.jsx (drag-n-drop)

Тестовый импорт Easy Obby: project_id 2697,
2244 primitives + 742 lua-scripts + 5 ассетов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-07 18:23:06 +03:00
parent 91d3f48b80
commit c375ae01ac
18 changed files with 4107 additions and 2 deletions

13
.WORKTREE_NOTICE.md Normal file
View File

@ -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

22
package-lock.json generated
View File

@ -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",

View File

@ -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",

100
rbxl-importer/CHANGELOG.md Normal file
View File

@ -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.

111
rbxl-importer/README.md Normal file
View File

@ -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/<id> │ + 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/<project_id>` — открывается в студии.
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":"<from_analyze>", "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` поле пока заглушка.
## Авторские права
Эта тест-фича для **МИНа только**. Юзер подтверждает право использовать содержимое карты при загрузке.
Не открывать для публичных пользователей без юр-проверки.

View File

@ -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://<id>)
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/<sha256>.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;

323
rbxl-importer/src/app.py Normal file
View File

@ -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/<id>" }
Парсит конвертит скачивает ассеты создаёт запись в 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'<roblox!'):
return jsonify({'error': 'not a .rbxl binary file (missing <roblox! magic)'}), 400
# Парсим
try:
model = parse(blob)
except Exception as e:
return jsonify({'error': f'parse failed: {e}'}), 422
# Конвертим (без скачки ассетов — просто узнаем сколько их)
conv = Converter(model)
project_data = conv.convert()
# Лог импорта
rbxl_sha = hashlib.sha256(blob).hexdigest()
asset_ids = list(set(conv.stats.asset_ids_needed))
# Кладём blob во временный файл + сохраняем sha256 как preview_hash
preview_hash = rbxl_sha
if rds:
rds.setex(f'rbxl:blob:{preview_hash}', 1200, blob) # 20 минут
rds.setex(f'rbxl:project:{preview_hash}', 1200,
json.dumps(project_data).encode('utf-8'))
rds.setex(f'rbxl:assets:{preview_hash}', 1200,
json.dumps(asset_ids).encode('utf-8'))
# Отчёт
hist = class_histogram(model)
top = sorted(hist.items(), key=lambda x: -x[1])[:25]
report = {
'filename': upload.filename or 'unknown.rbxl',
'size_bytes': len(blob),
'version': model.version,
'class_count': model.class_count,
'instance_count': model.instance_count,
'top_classes': [{'class': c, 'count': n} for c, n in top],
'primitives_created': conv.stats.primitives_created,
'glb_models_created': conv.stats.glb_models_created,
'scripts_total': conv.stats.scripts_collected,
'scripts_skipped': conv.stats.scripts_skipped,
'parts_dropped': conv.stats.parts_dropped,
'assets_to_download': len(asset_ids),
'warnings': conv.stats.warnings[:30],
}
# Запись в roblox_imports (status='analyzed')
try:
with pg_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO roblox_imports "
"(user_id, rbxl_filename, rbxl_size, rbxl_sha256, "
" instance_count, class_count, assets_total, status) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, 'analyzed') "
"RETURNING id",
(user_id, report['filename'], report['size_bytes'], rbxl_sha,
report['instance_count'], report['class_count'], report['assets_to_download']),
)
import_id = cur.fetchone()[0]
conn.commit()
report['import_id'] = import_id
except Exception as e:
logger.warning(f'roblox_imports insert failed: {e}')
return jsonify({'preview_hash': preview_hash, 'report': report})
@app.post('/import/rbxl/create')
def create():
try:
user_id = auth_check(request)
except RuntimeError as e:
return jsonify({'error': str(e)}), 403
data = request.get_json(silent=True) or {}
preview_hash = data.get('preview_hash')
title = (data.get('title') or '').strip() or 'Импортировано из Roblox'
if not preview_hash:
return jsonify({'error': 'preview_hash required'}), 400
if not rds:
return jsonify({'error': 'redis unavailable, cannot retrieve preview'}), 503
blob_cached = rds.get(f'rbxl:blob:{preview_hash}')
project_cached = rds.get(f'rbxl:project:{preview_hash}')
if not project_cached:
return jsonify({'error': 'preview expired, please re-analyze'}), 410
project_data = json.loads(project_cached.decode('utf-8'))
asset_ids_raw = rds.get(f'rbxl:assets:{preview_hash}')
asset_ids = json.loads(asset_ids_raw.decode('utf-8')) if asset_ids_raw else []
# Скачиваем ассеты
dl = AssetDownloader(db_dsn=PG_DSN, storage_root=STORAGE_ROOT,
public_base=PUBLIC_ASSET_BASE)
asset_url_map = {} # rbx_id → public_url
failed = 0
for rbx_id in asset_ids:
try:
rec = dl.fetch_sync(int(rbx_id))
# Конвертим mesh → glb если это mesh
if rec.asset_kind == 'mesh' and not rec.converted_path:
try:
with open(rec.raw_path, 'rb') as f:
mesh_blob = f.read()
mesh = parse_roblox_mesh(mesh_blob)
glb = build_glb(mesh)
sha = hashlib.sha256(glb).hexdigest()
glb_dir = os.path.join(STORAGE_ROOT, 'converted', sha[:2])
os.makedirs(glb_dir, exist_ok=True)
glb_path = os.path.join(glb_dir, f'{sha}.glb')
if not os.path.exists(glb_path):
with open(glb_path, 'wb') as f:
f.write(glb)
public_glb = f'{PUBLIC_ASSET_BASE}/converted/{sha[:2]}/{sha}.glb'
asset_url_map[int(rbx_id)] = public_glb
# Обновляем БД
with pg_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE roblox_assets SET converted_path=%s, "
"converted_sha256=%s, converted_size_bytes=%s "
"WHERE rbx_asset_id=%s",
(glb_path, sha, len(glb), int(rbx_id)),
)
conn.commit()
except (MeshParseError, Exception) as me:
logger.warning(f'mesh→glb failed for {rbx_id}: {me}')
asset_url_map[int(rbx_id)] = rec.public_url
else:
asset_url_map[int(rbx_id)] = rec.public_url
except PendingDownload:
failed += 1
except Exception as e:
failed += 1
logger.warning(f'asset {rbx_id} download failed: {e}')
# Подставляем URLs в project_data
_resolve_asset_urls(project_data, asset_url_map)
# Создаём проект в kubikon3d_projects
# Используем bridge на user-service / storys API, ИЛИ напрямую в storys_db.
# Прямой INSERT — проще для MVP. id автогенерируется.
try:
with pg_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
# Возьмём следующий id из последовательности
cur.execute(
"INSERT INTO kubikon3d_projects "
"(user_id, title, description, project_data, "
" status, is_test, is_public, is_published, "
" feed_eligible, feed_blocked_forever, quality_state, "
" created_at, updated_at) "
"VALUES (%s, %s, %s, %s, "
" 'draft', true, false, false, "
" false, false, 'new', NOW(), NOW()) "
"RETURNING id",
(user_id, title,
f'Импортировано из .rbxl (rbxl-importer v0.1.0)',
json.dumps(project_data, ensure_ascii=False)),
)
project_id = cur.fetchone()['id']
conn.commit()
except Exception as e:
return jsonify({'error': f'create project failed: {e}'}), 500
# Обновляем roblox_imports
try:
with pg_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE roblox_imports SET project_id=%s, status='done', "
"finished_at=NOW(), assets_failed=%s "
"WHERE rbxl_sha256=%s",
(project_id, failed, preview_hash),
)
conn.commit()
except Exception:
pass
return jsonify({
'project_id': project_id,
'redirect': f'/edit/{project_id}',
'assets_downloaded': len(asset_url_map),
'assets_failed': failed,
})
def _resolve_asset_urls(project_data: dict, asset_map: dict) -> 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)

View File

@ -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/<sha256[:2]>/<sha256>.<ext>
converted (для meshglb, csgglb) /opt/roblox-assets/converted/<sha256>.glb
Источник: Roblox AssetDelivery API.
- https://assetdelivery.roblox.com/v1/asset?id=<id> бинарь (с редиректом на CDN)
- https://assetdelivery.roblox.com/v2/assetId/<id> 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'<?xml':
return ('animation', '.xml')
return ('unknown', '.bin')
def _save_raw(self, sha256: str, ext: str, data: bytes) -> 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}")

View File

@ -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=<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}

View File

@ -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}')

View File

@ -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.
Структура:
<num_faces>\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('<H', r.read(2))[0]
vertex_size = struct.unpack('<B', r.read(1))[0]
face_size = struct.unpack('<B', r.read(1))[0]
num_vertices = struct.unpack('<I', r.read(4))[0]
num_faces = struct.unpack('<I', r.read(4))[0]
# пропускаем оставшиеся байты header'а если он больше 12
if header_size > 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('<H', r.read(2))[0]
vertex_size = struct.unpack('<B', r.read(1))[0]
face_size = struct.unpack('<B', r.read(1))[0]
_lod_type = struct.unpack('<H', r.read(2))[0]
num_vertices = struct.unpack('<I', r.read(4))[0]
num_faces = struct.unpack('<I', r.read(4))[0]
num_lods = struct.unpack('<H', r.read(2))[0]
# если header больше — дочитаем
consumed = 16
if header_size > 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('<H', r.read(2))[0]
_lod_type = struct.unpack('<H', r.read(2))[0]
num_vertices = struct.unpack('<I', r.read(4))[0]
num_faces = struct.unpack('<I', r.read(4))[0]
num_lods = struct.unpack('<H', r.read(2))[0]
num_bones = struct.unpack('<H', r.read(2))[0]
bone_names_size = struct.unpack('<I', r.read(4))[0]
num_subsets = struct.unpack('<H', r.read(2))[0]
_hq_lods = struct.unpack('<B', r.read(1))[0]
_unused = struct.unpack('<B', r.read(1))[0]
consumed = 22
if header_size > 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('<I', 2)) # version
out.extend(struct.pack('<I', total_len)) # length
# JSON chunk
out.extend(struct.pack('<I', len(json_bytes)))
out.extend(b'JSON')
out.extend(json_bytes)
# BIN chunk
out.extend(struct.pack('<I', len(bin_total)))
out.extend(b'BIN\x00')
out.extend(bin_total)
return bytes(out)
# ──────────────────────────────────────────────────────────────────────
# CLI
# ──────────────────────────────────────────────────────────────────────
def convert(input_path: str, output_path: str) -> 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}")

View File

@ -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('<H', self.read(2))[0]
def uint32(self) -> int:
return struct.unpack('<I', self.read(4))[0]
def uint64(self) -> int:
return struct.unpack('<Q', self.read(8))[0]
def int32(self) -> int:
return struct.unpack('<i', self.read(4))[0]
def int64(self) -> int:
return struct.unpack('<q', self.read(8))[0]
def float32(self) -> float:
return struct.unpack('<f', self.read(4))[0]
def float64(self) -> float:
return struct.unpack('<d', self.read(8))[0]
def bool(self) -> 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('<f', struct.pack('<I', standard))[0]
# ──────────────────────────────────────────────────────────────────────
# Высокоуровневые readers для interleaved-массивов
# ──────────────────────────────────────────────────────────────────────
def read_interleaved_int32_array(reader: BinReader, count: int) -> 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

View File

@ -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'<roblox!\x89\xff\r\n\x1a\n'
class RbxlParseError(Exception):
pass
# ──────────────────────────────────────────────────────────────────────
# Промежуточные структуры (raw chunks)
# ──────────────────────────────────────────────────────────────────────
@dataclass
class RawChunk:
name: str
payload: bytes
@dataclass
class RawHeader:
version: int
class_count: int
instance_count: int
@dataclass
class InstChunk:
"""Описание одного класса (берётся из INST chunk)."""
class_id: int # порядковый индекс (=index в массиве)
class_name: str # 'Part', 'Script', 'MeshPart'
is_service: bool # сервис (Workspace, Lighting) или обычный объект
referent_ids: List[int] # для каждого инстанса его referent (referent — уникальный int32 id)
# ──────────────────────────────────────────────────────────────────────
# Высокоуровневая модель: Instance + RobloxModel
# ──────────────────────────────────────────────────────────────────────
@dataclass
class Instance:
"""Один объект Roblox-сцены, готовый к преобразованию в Rublox."""
referent: int # уникальный id из rbxl
class_name: str # 'Part', 'Script', ...
properties: Dict[str, Any] = field(default_factory=dict)
parent_referent: Optional[int] = None # None == root (DataModel)
children: List['Instance'] = field(default_factory=list)
@dataclass
class RobloxModel:
"""Полная разобранная модель."""
version: int
class_count: int
instance_count: int
instances: List[Instance] # плоский список
by_referent: Dict[int, Instance] # быстрый лукап по referent
roots: List[Instance] # инстансы без parent (children of DataModel)
shared_strings: List[bytes] # из SSTR chunk
meta: Dict[str, str] # из META chunk
warnings: List[str] # некритичные проблемы парсинга
# сырые chunks на случай если что-то надо допарсить отдельно
raw_chunks: List[RawChunk] = field(default_factory=list)
# ──────────────────────────────────────────────────────────────────────
# Read chunks (без decode значений)
# ──────────────────────────────────────────────────────────────────────
def _read_chunks(blob: bytes) -> 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('<HII', stream.read(10))
stream.read(8) # reserved
header = RawHeader(version, class_count, instance_count)
chunks = []
while True:
hdr = stream.read(16)
if len(hdr) < 16:
break
name_raw, compressed_len, uncompressed_len, _ = struct.unpack('<4sIII', hdr)
name = name_raw.decode('ascii', errors='replace').rstrip('\x00').rstrip()
if compressed_len == 0:
payload = stream.read(uncompressed_len)
else:
data = stream.read(compressed_len)
try:
payload = lz4.block.decompress(data, uncompressed_size=uncompressed_len)
except Exception as e:
raise RbxlParseError(f"LZ4 decompress failed for chunk {name!r}: {e}")
chunks.append(RawChunk(name, payload))
if name.startswith('END'):
break
return header, chunks
# ──────────────────────────────────────────────────────────────────────
# Decode INST chunk
# ──────────────────────────────────────────────────────────────────────
def _decode_inst_chunk(payload: bytes) -> 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('<I', c.payload[:4])[0]
ic = inst_chunks.get(class_id)
if not ic:
warnings.append(f"PROP for unknown class_id={class_id}")
continue
pc = decode_prop_chunk(c.payload, len(ic.referent_ids), shared_strings)
if len(pc.values) != len(ic.referent_ids):
warnings.append(
f"PROP {ic.class_name}.{pc.prop_name}: value count "
f"{len(pc.values)} != instance count {len(ic.referent_ids)}"
)
for ref, val in zip(ic.referent_ids, pc.values):
inst = by_referent.get(ref)
if inst:
inst.properties[pc.prop_name] = val
except Exception as e:
warnings.append(f"PROP decode failed: {e!r}")
# 6. PRNT — собираем дерево
for c in raw_chunks:
if c.name == 'PRNT':
try:
links = _decode_prnt_chunk(c.payload, header.instance_count)
for child_ref, parent_ref in links:
child = by_referent.get(child_ref)
if not child:
continue
if parent_ref == -1:
child.parent_referent = None
else:
child.parent_referent = parent_ref
parent = by_referent.get(parent_ref)
if parent:
parent.children.append(child)
except Exception as e:
warnings.append(f"PRNT decode failed: {e!r}")
# 7. roots
roots = [i for i in instances if i.parent_referent is None]
return RobloxModel(
version=header.version,
class_count=header.class_count,
instance_count=header.instance_count,
instances=instances,
by_referent=by_referent,
roots=roots,
shared_strings=shared_strings,
meta=meta,
warnings=warnings,
raw_chunks=raw_chunks,
)
# ──────────────────────────────────────────────────────────────────────
# Утилиты для отчёта
# ──────────────────────────────────────────────────────────────────────
def class_histogram(model: RobloxModel) -> 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)}")

View File

@ -0,0 +1,125 @@
"""
rbxl_parser_v0.py голый парсер Roblox Binary Level (.rbxl) v0.
Эта первая итерация только структура файла:
- проверка signature (<roblox!\x89\xff\r\n\x1a\n)
- чтение header (version, class count, instance count)
- чтение chunks: name (4 байт), compressed_len, uncompressed_len, reserved, payload
- LZ4-декомпрессия payload
- выдача списка chunks с распакованным содержимым
Здесь НЕ ДЕЛАЕМ декодирование INST/PROP per-type только видим что есть в файле.
Это нужно для первого smoke-test'а на твоей Easy Obby.
Полная спецификация: https://dom.rojo.space/binary
"""
import struct
import io
import lz4.block
from dataclasses import dataclass, field
from typing import List, Optional
RBXL_SIGNATURE = b'<roblox!\x89\xff\r\n\x1a\n'
@dataclass
class RbxlChunk:
name: str # 'META', 'SSTR', 'INST', 'PROP', 'PRNT', 'SIGN', 'END '
compressed_len: int
uncompressed_len: int
payload: bytes # уже декомпрессированный
@dataclass
class RbxlHeader:
version: int # обычно 0
class_count: int # сколько разных классов (Part, Script, ...)
instance_count: int # сколько объектов всего
@dataclass
class RbxlFile:
header: RbxlHeader
chunks: List[RbxlChunk] = field(default_factory=list)
class RbxlParseError(Exception):
pass
def parse(blob: bytes) -> 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('<HII', stream.read(10))
stream.read(8) # reserved
header = RbxlHeader(version, class_count, instance_count)
chunks = []
while True:
# Chunk header: name (4 bytes), compressed_len (uint32), uncompressed_len (uint32), reserved (uint32)
header_bytes = stream.read(16)
if len(header_bytes) < 16:
break
name_raw, compressed_len, uncompressed_len, _ = struct.unpack('<4sIII', header_bytes)
name = name_raw.decode('ascii', errors='replace').rstrip('\x00').rstrip()
# Payload
payload_compressed = stream.read(compressed_len if compressed_len > 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))

View File

@ -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('<h', r.read(2))[0]
y = struct.unpack('<h', r.read(2))[0]
z = struct.unpack('<h', r.read(2))[0]
values.append(Vector3int16(x, y, z))
elif type_id == PROP_NUMBERSEQUENCE:
# Для каждого instance: uint32 keypoint_count, потом keypoint_count * 12 bytes
# (time, value, envelope) each float32
values = []
for _ in range(count):
kp_count = r.uint32()
keypoints = []
for _ in range(kp_count):
t = r.float32()
v = r.float32()
env = r.float32()
keypoints.append(NumberSequenceKeypoint(t, v, env))
values.append(NumberSequence(keypoints))
elif type_id == PROP_COLORSEQUENCE:
values = []
for _ in range(count):
kp_count = r.uint32()
keypoints = []
for _ in range(kp_count):
t = r.float32()
cr = r.float32()
cg = r.float32()
cb = r.float32()
# envelope (всегда 0 для ColorSequence, но 4 байта в файле)
_env = r.float32()
keypoints.append(ColorSequenceKeypoint(t, Color3(cr, cg, cb), 0.0))
values.append(ColorSequence(keypoints))
elif type_id == PROP_NUMBERRANGE:
# 2 float per value, БЕЗ interleave
values = []
for _ in range(count):
mn = r.float32()
mx = r.float32()
values.append(NumberRange(mn, mx))
elif type_id == PROP_RECT:
xmin = read_interleaved_float_array(r, count)
ymin = read_interleaved_float_array(r, count)
xmax = read_interleaved_float_array(r, count)
ymax = read_interleaved_float_array(r, count)
values = [
Rect(Vector2(xmin[i], ymin[i]), Vector2(xmax[i], ymax[i]))
for i in range(count)
]
elif type_id == PROP_PHYSICALPROPS:
values = []
for _ in range(count):
is_custom = r.bool()
if is_custom:
density = r.float32()
friction = r.float32()
elasticity = r.float32()
fw = r.float32()
ew = r.float32()
values.append(PhysicalProperties(True, density, friction, elasticity, fw, ew))
else:
values.append(PhysicalProperties(False))
elif type_id == PROP_COLOR3UINT8:
# 3 байта на цвет (uint8 каждый), интерливированные
rs = r.read(count)
gs = r.read(count)
bs = r.read(count)
values = [Color3(rs[i]/255.0, gs[i]/255.0, bs[i]/255.0) for i in range(count)]
elif type_id == PROP_INT64:
values = read_interleaved_int64_array(r, count)
elif type_id == PROP_SHAREDSTRING:
# interleaved uint32 индексы в SSTR
idxs = read_interleaved_uint32_array(r, count)
values = []
for i in idxs:
if i < len(shared_strings):
values.append(shared_strings[i])
else:
values.append(b'')
elif type_id == PROP_BYTECODE:
# Для каждого instance: uint32 length + bytes (compiled Lua bytecode)
values = []
for _ in range(count):
length = r.uint32()
values.append(r.read(length))
elif type_id == PROP_OPTIONALCFRAME:
# OptionalCFrame в rbx-dom spec:
# 1 byte: type_id (всегда 0x10 = CFrame)
# далее структура как у обычного PROP_CFRAME:
# N orientation_ids (по 1 байту каждый)
# N rotations (если orientation_id=0 → 36 байт row-major)
# interleaved x[N], y[N], z[N] float32
# ПОТОМ:
# 1 byte: type_id (0x02 = Bool — флаги has_value)
# N bytes: has_value flags
#
# Источник: https://dom.rojo.space/binary#optionalcframe
_inner_type_cframe = r.uint8() # 0x10
rotations = []
for _ in range(count):
orientation_id = r.uint8()
if orientation_id == 0:
m = struct.unpack('<9f', r.read(36))
rotations.append(m)
else:
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)
_inner_type_bool = r.uint8() # 0x02
has_values = [r.bool() for _ in range(count)]
values = []
for i in range(count):
if has_values[i]:
cf = CFrame(Vector3(xs[i], ys[i], zs[i]), rotations[i])
values.append(OptionalCFrame(cf))
else:
values.append(OptionalCFrame(None))
elif type_id == PROP_UNIQUEID:
# 16 байт на каждый, БЕЗ interleave (предположительно)
values = [UniqueId(r.read(16)) for _ in range(count)]
elif type_id == PROP_FONT:
# Для каждого: family (string), weight (uint16), style (uint8), cached_face_id (string)
values = []
for _ in range(count):
family = r.string()
weight = r.uint16()
style = r.uint8()
cached = r.string()
values.append(Font(family, weight, style, cached))
else:
# Неизвестный тип — пропускаем, ставим заглушки
values = [None] * count
return PropChunk(class_id=class_id, prop_name=prop_name, type_id=type_id, values=values)
def _cframe_orientation_to_matrix(orientation_id: int) -> 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),
)

View File

@ -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';

View File

@ -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 (из <input type="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();
}

View File

@ -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/<new_id>.
*/
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 (
<div style={overlayStyle} onClick={onClose}>
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
<h2 style={{ marginTop: 0 }}>Импорт из Roblox</h2>
<p>Эта тест-функция доступна только администратору.</p>
<button style={btnStyle} onClick={onClose}>Закрыть</button>
</div>
</div>
);
}
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 (
<div style={overlayStyle} onClick={handleClose}>
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<h2 style={{ marginTop: 0 }}>Импорт игры из Roblox</h2>
<button style={closeBtnStyle} onClick={handleClose}></button>
</div>
<p style={{ color: '#888', fontSize: 13, marginTop: -8 }}>
Загрузи Roblox-карту в формате .rbxl. Геометрия и Lua-скрипты будут
сконвертированы в проект Rublox.
</p>
{!file && (
<div
onDragOver={(e) => { 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',
}}
>
<div style={{ fontSize: 48, opacity: 0.5 }}>📦</div>
<div style={{ marginTop: 8 }}>
<strong>Перетащи .rbxl сюда</strong>
<div style={{ color: '#888', fontSize: 13, marginTop: 4 }}>
или кликни чтобы выбрать файл (макс {MAX_SIZE / 1024 / 1024} MB)
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept=".rbxl"
style={{ display: 'none' }}
onChange={(e) => handleFile(e.target.files?.[0])}
/>
</div>
)}
{file && !report && (
<div style={panelStyle}>
<div><strong>{file.name}</strong> ({(file.size / 1024).toFixed(1)} KB)</div>
<div style={{ marginTop: 16 }}>
<button style={btnStyle} onClick={handleAnalyze} disabled={analyzing}>
{analyzing ? 'Анализирую…' : 'Анализировать'}
</button>
<button style={{ ...btnStyle, marginLeft: 8, background: '#444' }} onClick={reset}>
Выбрать другой файл
</button>
</div>
</div>
)}
{report && (
<div style={panelStyle}>
<h3 style={{ marginTop: 0 }}>Отчёт</h3>
<table style={tableStyle}>
<tbody>
<tr><td>Файл:</td><td><strong>{report.filename}</strong></td></tr>
<tr><td>Размер:</td><td>{(report.size_bytes / 1024).toFixed(1)} KB</td></tr>
<tr><td>Объектов:</td><td>{report.instance_count}</td></tr>
<tr><td>Классов:</td><td>{report.class_count}</td></tr>
<tr><td>Создано Part'ов:</td><td><strong>{report.primitives_created}</strong></td></tr>
<tr><td>GLB-моделей:</td><td>{report.glb_models_created}</td></tr>
<tr><td>Lua-скриптов:</td><td>{report.scripts_total}</td></tr>
<tr><td>Ассетов для скачки:</td><td>{report.assets_to_download}</td></tr>
</tbody>
</table>
{report.top_classes?.length > 0 && (
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: 'pointer' }}>Что внутри (топ-25 классов)</summary>
<table style={tableStyle}>
<tbody>
{report.top_classes.slice(0, 25).map((c, i) => (
<tr key={i}><td>{c.class}</td><td>{c.count}</td></tr>
))}
</tbody>
</table>
</details>
)}
{report.warnings?.length > 0 && (
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: 'pointer', color: '#f8a' }}>
Предупреждения ({report.warnings.length})
</summary>
<ul style={{ fontSize: 13, color: '#aaa' }}>
{report.warnings.slice(0, 30).map((w, i) => <li key={i}>{w}</li>)}
</ul>
</details>
)}
<div style={{ marginTop: 16, padding: 12, background: '#2a2a2a', borderRadius: 6 }}>
<div style={{ fontSize: 13, color: '#fa8' }}>
Загружая, ты подтверждаешь право использовать содержимое этой карты.
Не загружай чужие карты без разрешения автора.
</div>
</div>
<div style={{ marginTop: 16 }}>
<label style={{ display: 'block', marginBottom: 6 }}>Название игры:</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={inputStyle}
placeholder="Например: Моя обби-карта"
/>
</div>
<div style={{ marginTop: 16 }}>
<button
style={{ ...btnStyle, background: '#3a8' }}
onClick={handleCreate}
disabled={creating || !title.trim()}
>
{creating ? 'Создаю…' : '✨ Создать игру'}
</button>
<button style={{ ...btnStyle, marginLeft: 8, background: '#444' }} onClick={reset}>
Начать заново
</button>
</div>
</div>
)}
{error && (
<div style={{ marginTop: 12, padding: 12, background: '#3a1a1a', color: '#fa8', borderRadius: 6 }}>
{error}
</div>
)}
</div>
</div>
);
}
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,
};