feat(rbxl-import): импорт Roblox .rbxl карт в Rublox-проекты
All checks were successful
All checks were successful
Тест-фича для МИНа. Полное описание в 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:
parent
91d3f48b80
commit
c375ae01ac
13
.WORKTREE_NOTICE.md
Normal file
13
.WORKTREE_NOTICE.md
Normal 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
22
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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
100
rbxl-importer/CHANGELOG.md
Normal 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
111
rbxl-importer/README.md
Normal 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` поле пока заглушка.
|
||||
|
||||
## Авторские права
|
||||
|
||||
Эта тест-фича для **МИНа только**. Юзер подтверждает право использовать содержимое карты при загрузке.
|
||||
Не открывать для публичных пользователей без юр-проверки.
|
||||
91
rbxl-importer/sql/001_roblox_assets.sql
Normal file
91
rbxl-importer/sql/001_roblox_assets.sql
Normal 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
323
rbxl-importer/src/app.py
Normal 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)
|
||||
382
rbxl-importer/src/asset_downloader.py
Normal file
382
rbxl-importer/src/asset_downloader.py
Normal 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 (для mesh→glb, csg→glb) → /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}")
|
||||
101
rbxl-importer/src/asset_proxy.py
Normal file
101
rbxl-importer/src/asset_proxy.py
Normal 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}
|
||||
793
rbxl-importer/src/converter.py
Normal file
793
rbxl-importer/src/converter.py
Normal 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}')
|
||||
481
rbxl-importer/src/mesh_converter.py
Normal file
481
rbxl-importer/src/mesh_converter.py
Normal 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}")
|
||||
203
rbxl-importer/src/rbxl_binreader.py
Normal file
203
rbxl-importer/src/rbxl_binreader.py
Normal 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
|
||||
425
rbxl-importer/src/rbxl_parser.py
Normal file
425
rbxl-importer/src/rbxl_parser.py
Normal 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)}")
|
||||
125
rbxl-importer/src/rbxl_parser_v0.py
Normal file
125
rbxl-importer/src/rbxl_parser_v0.py
Normal 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))
|
||||
605
rbxl-importer/src/rbxl_types.py
Normal file
605
rbxl-importer/src/rbxl_types.py
Normal 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),
|
||||
)
|
||||
@ -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';
|
||||
|
||||
54
src/api/rbxlImporterApi.js
Normal file
54
src/api/rbxlImporterApi.js
Normal 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();
|
||||
}
|
||||
275
src/components/RbxlImportModal.jsx
Normal file
275
src/components/RbxlImportModal.jsx
Normal 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,
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user