Complete local voxel MVP with server, client, MCP, packages and world interchange
This commit is contained in:
@@ -1,18 +1,27 @@
|
||||
name: Core checks
|
||||
name: MVP checks
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
storage:
|
||||
mvp:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.96.0
|
||||
components: rustfmt, clippy
|
||||
- name: Verify storage and crash recovery
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Verify core, server, client and crash recovery
|
||||
run: bash scripts/verify.sh
|
||||
|
||||
- name: Install independent MCP test client
|
||||
run: npm install --prefix artifacts/mcp-sdk --no-package-lock @modelcontextprotocol/sdk@1.30.0
|
||||
- name: Verify real stdio MCP interoperability
|
||||
env:
|
||||
SHACRAFT_MCP_SDK: ${{ github.workspace }}/artifacts/mcp-sdk/node_modules/@modelcontextprotocol/sdk
|
||||
run: node scripts/check_mcp.mjs --binary target/debug/shacraft-server --output artifacts/mcp-sdk.json
|
||||
|
||||
@@ -7,3 +7,5 @@ __pycache__/
|
||||
*.pyc
|
||||
/graphify-out/
|
||||
|
||||
|
||||
/logs/
|
||||
|
||||
Generated
+1708
-16
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/shacraft-core", "crates/shacraft-tools"]
|
||||
members = ["crates/shacraft-core", "crates/shacraft-tools", "crates/shacraft-server", "crates/shacraft-mcp", "crates/shacraft-content", "crates/shacraft-compat"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -1,30 +1,60 @@
|
||||
# Shacraft Core
|
||||
|
||||
Самостоятельный открытый воксельный движок на Rust. Главный приоритет — низкое и предсказуемое потребление **серверной оперативной памяти**. Сервер Shacraft служит первым практическим применением; ядро не привязано к его мирам, аккаунтам и минииграм.
|
||||
Самостоятельный открытый воксельный движок на Rust и работающий локальный MVP: сервер с несколькими мирами, собственный WebGL2-клиент, Spleef, отдельный MCP, каталог Java 26.2, пакеты расширений и конвертер миров. Приоритет — измеренное потребление **серверной RAM**.
|
||||
|
||||
Проект начинается заново локально: исходники из зависшей облачной среды не восстановлены. Сведения о ранее выполненных сборках и тестах не являются результатами этого репозитория.
|
||||
## Запустить
|
||||
|
||||
## Текущий результат: этап 1 — хранилище
|
||||
|
||||
Реализованы самостоятельная Rust-библиотека и локальный CLI: компактные секции, ограниченный кэш, сохранение, неизменяемые снимки, независимые экземпляры миров, атомарные правки, ревизии, идемпотентность, undo/reset и диагностика. Это основа движка; игровой сервер, графический клиент, MCP, каталог Minecraft и конвертер ещё предстоят.
|
||||
Требуются Rust 1.96+, C-компилятор для SQLite и браузер с WebGL2. Java и Node для игры не нужны. Первый запуск скачает Cargo-зависимости и соберёт release; следующие используют готовый бинарный файл.
|
||||
|
||||
```bash
|
||||
cd /home/emil/Desktop/shacraft-core
|
||||
bash scripts/verify.sh
|
||||
cargo run -p shacraft-tools -- --data data/demo demo
|
||||
bash scripts/run.sh --data data --listen 127.0.0.1:4000
|
||||
```
|
||||
|
||||
Для повторной демонстрации укажите новый пустой каталог. [Команды, измерение и JSON-lines API](docs/DEVELOPMENT.md). JSON-lines API предназначен для локальной проверки хранилища и не является MCP.
|
||||
Открыть **http://127.0.0.1:4000**. WASD — движение, пробел — прыжок, мышь — обзор, ЛКМ/ПКМ — убрать/поставить блок, E — библиотека, T — чат, F3 — диагностика. Если браузер не даёт захват мыши, обзор работает перетаскиванием. Мир выбирается слева сверху. Для Spleef два игрока входят в одну арену и запускают матч через меню. Лобби, галерея всех типов блоков и две арены создаются автоматически.
|
||||
|
||||
Код доступен под MIT или Apache-2.0 на выбор. Оригинальные ресурсы Minecraft в проект не входят.
|
||||
Вся работа хранится в `--data`. Для остановки используйте Ctrl+C. Подтверждённые правки сохраняются до ответа сервера; проверены аварийные остановки и восстановление.
|
||||
|
||||
## Контекст и порядок чтения
|
||||
## Что входит
|
||||
|
||||
1. [Требования](docs/REQUIREMENTS.md) — что запросил пользователь.
|
||||
2. [План](docs/PLAN.md) — этапы и условия перехода.
|
||||
3. [Решения](docs/DECISIONS.md) — принятые решения и их границы.
|
||||
4. [Интерфейсы](docs/CONTRACT.md) — рабочие контракты компонентов.
|
||||
5. [Текущее состояние](docs/STATUS.md) — проверенные результаты и ближайшая работа.
|
||||
6. [Память и хранение](docs/MEMORY_AND_STORAGE.md), [совместимость](docs/COMPATIBILITY.md), [приёмка](docs/ACCEPTANCE.md).
|
||||
- `shacraft-core`: компактные секции 16³, общий ограниченный кэш, SQLite/WAL/FULL, неизменяемые снимки, независимые изменения миров, ревизии, идемпотентные правки, undo/reset. Без графики и сети.
|
||||
- `shacraft-server`: авторитетные движение, формы столкновений и взаимодействия, WebSocket/HTTP, сохраняемые сущности и настройки, законченный цикл Spleef.
|
||||
- `client/`: собственный рендерер, библиотека материалов, игроки и сущности, чат, переключение миров, переподключение, загрузка и проверка пакетов.
|
||||
- `shacraft-mcp`: настоящий отдельный stdio MCP с 17 инструментами, ресурсами и prompt; строительство по плану, чтение, отмена, сущности, арены, метрики и PNG результата.
|
||||
- `shacraft-content`: **1 196 блоков, 32 366 состояний, 158 типов сущностей Java 26.2**, DataVersion 4903. Официальные идентификаторы и измеренные коллизии, авторские модели. Плюс блок trampoline.
|
||||
- `shacraft-compat`: Anvil и Sponge `.schem` v3 в обе стороны, типизированный NBT, сохранение оригинала, отчёты о преобразованиях и перенос правок сущностей.
|
||||
- `packages/`: собственные текстуры, звук, шейдер и исполняемый WASM-модуль прыжка с ограничениями памяти и fuel.
|
||||
|
||||
Работа ведётся локально в `/home/emil/Desktop/shacraft-core`. Продакшен-серверы и существующие проекты лаунчера не изменяются автоматически.
|
||||
Ванильные AI, redstone, инвентари и симуляция жидкостей не входят в этот MVP. Контекстные формы отмечены отдельно. Конвертер после правок использует явный `best-effort`; неизменённый оригинал может вернуть побайтно. Это не обещание полной совместимости с механиками Minecraft или модами NeoForge. Оригинальные ресурсы и код Minecraft не распространяются.
|
||||
|
||||
## MCP и конвертация
|
||||
|
||||
```bash
|
||||
cargo build --release --locked --workspace
|
||||
# В конфигурации MCP-клиента запускается этот процесс:
|
||||
target/release/shacraft-mcp --server http://127.0.0.1:4000 --data data
|
||||
|
||||
# Импорт в новый каталог, затем запуск сервера с --data data/imported:
|
||||
target/release/shacraft-compat import-anvil /path/to/java-world data/imported
|
||||
# Экспорт выполняется при остановленном сервере, в отсутствующий каталог:
|
||||
target/release/shacraft-compat export-anvil data/imported artifacts/java-export --mode best-effort
|
||||
```
|
||||
|
||||
Токен Control API создаётся в `data/control.token` (0600 на Unix) и не передаётся браузеру. [MCP](docs/MCP.md), [форматы и команды конвертера](docs/interop.md), [сервер и протокол](docs/SERVER.md), [расширение пакетами](docs/PACKAGES.md).
|
||||
|
||||
## Проверить
|
||||
|
||||
```bash
|
||||
# Rust, Clippy, аварийное восстановление, JS и реальные HTTP/WebSocket-клиенты:
|
||||
bash scripts/verify.sh
|
||||
# Release-стенд с заранее заданными бюджетами:
|
||||
cargo build --release --workspace --locked
|
||||
node scripts/benchmark_server.mjs --output artifacts/server-benchmark.json
|
||||
```
|
||||
|
||||
Node 22+ нужен для сетевых/JS-проверок, Python 3 — для проверки аварийного хранения. Настоящий MCP SDK и независимая Java/NBT-проверка описаны в [VERIFICATION](docs/VERIFICATION.md). Проверенные числа и ограничения находятся там; сравнение с Paper не заявляется без сопоставимого стенда.
|
||||
|
||||
## Контекст
|
||||
|
||||
[Требования](docs/REQUIREMENTS.md) · [исходный план](docs/PLAN.md) · [решения](docs/DECISIONS.md) · [контракты ядра](docs/CONTRACT.md) · [состояние и ограничения](docs/STATUS.md) · [контент](docs/CONTENT.md).
|
||||
|
||||
Проект заново реализован локально после зависания прежней среды. План сохранён до кода отдельным коммитом. Лицензия кода и собственных ресурсов — **MIT или Apache-2.0** на выбор. Существующий лаунчер и продакшен-серверы не изменяются этой поставкой.
|
||||
|
||||
+1151
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Shacraft · Мир</title>
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 32 32%27%3E%3Cpath fill=%27%2394ae81%27 d=%27M16 1 30 9v15L16 32 2 24V9z%27/%3E%3C/svg%3E"
|
||||
/>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas
|
||||
id="world-canvas"
|
||||
aria-label="Трёхмерный мир Shacraft. WASD — движение, пробел — прыжок, мышь — обзор."
|
||||
></canvas>
|
||||
<div id="labels" aria-hidden="true"></div>
|
||||
<header class="topbar">
|
||||
<div class="identity">
|
||||
<svg viewBox="0 0 28 32" width="27" height="30" aria-hidden="true">
|
||||
<path d="M14 1 27 8.5 14 16 1 8.5Z" fill="#c0d3a9" />
|
||||
<path d="M1 8.5 14 16v15L1 23.5Z" fill="#6d8560" />
|
||||
<path d="m14 16 13-7.5v15L14 31Z" fill="#94ae81" />
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Shacraft</strong
|
||||
><button id="world-button" class="world-name" title="Выбрать мир">
|
||||
Подключение… ▾
|
||||
</button>
|
||||
</div>
|
||||
<span id="connection" role="status">Загрузка</span>
|
||||
</div>
|
||||
<nav aria-label="Игровое меню">
|
||||
<button id="library-button">Блоки <kbd>E</kbd></button
|
||||
><button id="diagnostics-button" title="Открыть диагностику">
|
||||
Диагностика <kbd>F3</kbd></button
|
||||
><button id="menu-button" aria-label="Меню игры">☰</button>
|
||||
</nav>
|
||||
</header>
|
||||
<div id="crosshair" aria-hidden="true"></div>
|
||||
<div id="target-label"></div>
|
||||
<div id="match" hidden></div>
|
||||
<div id="toast" role="status" hidden></div>
|
||||
<div class="bottom-left">
|
||||
<div id="chat-log" aria-live="polite"></div>
|
||||
<form id="chat-form" hidden>
|
||||
<input
|
||||
id="chat-input"
|
||||
autocomplete="off"
|
||||
maxlength="240"
|
||||
aria-label="Сообщение в чат"
|
||||
placeholder="Сообщение · Enter — отправить, Esc — закрыть"
|
||||
/>
|
||||
</form>
|
||||
<div id="hints">
|
||||
<span><kbd>W A S D</kbd> движение</span
|
||||
><span><kbd>Пробел</kbd> прыжок</span
|
||||
><span><kbd>ЛКМ / ПКМ</kbd> убрать / поставить</span
|
||||
><span><kbd>T</kbd> чат</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<div id="selected-name"></div>
|
||||
<div id="hotbar" role="toolbar" aria-label="Быстрый выбор блоков"></div>
|
||||
<div id="play-hint">
|
||||
Нажмите на мир для управления · без захвата мыши: удерживайте и двигайте
|
||||
</div>
|
||||
</footer>
|
||||
<div id="fatal" hidden role="alert"></div>
|
||||
<aside id="menu" class="panel" hidden aria-label="Меню игры">
|
||||
<div class="panel-head">
|
||||
<h1>Меню игры</h1>
|
||||
<button data-close="menu" aria-label="Закрыть меню">×</button>
|
||||
</div>
|
||||
<label for="player-name">Имя игрока</label>
|
||||
<div class="inline">
|
||||
<input id="player-name" maxlength="24" autocomplete="off" /><button
|
||||
id="rename-button"
|
||||
>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
<button id="resume-button" class="primary wide">Вернуться в мир</button
|
||||
><button id="respawn-button" class="wide">
|
||||
Вернуться к точке появления</button
|
||||
><button id="spleef-button" class="wide">Начать Spleef</button>
|
||||
<p class="muted">
|
||||
В Spleef убирайте снег под соперником. Последний игрок на арене
|
||||
побеждает.
|
||||
</p>
|
||||
<div class="rule"></div>
|
||||
<label for="sensitivity">Чувствительность мыши</label
|
||||
><input
|
||||
type="range"
|
||||
id="sensitivity"
|
||||
min="0.0008"
|
||||
max="0.006"
|
||||
step="0.0002"
|
||||
value="0.0024"
|
||||
/>
|
||||
<p class="muted">
|
||||
Esc освобождает мышь. Перетаскивание работает и без захвата. Средняя
|
||||
кнопка выбирает блок под прицелом.
|
||||
</p>
|
||||
<button id="reconnect-button" class="wide">Переподключиться</button>
|
||||
</aside>
|
||||
<aside id="worlds" class="panel" hidden aria-label="Выбор мира">
|
||||
<div class="panel-head">
|
||||
<h1>Миры</h1>
|
||||
<button data-close="worlds" aria-label="Закрыть миры">×</button>
|
||||
</div>
|
||||
<p class="muted">Изменения каждого экземпляра сохраняются независимо.</p>
|
||||
<div id="world-list"></div>
|
||||
</aside>
|
||||
<aside
|
||||
id="library"
|
||||
class="panel library"
|
||||
hidden
|
||||
aria-label="Каталог блоков"
|
||||
>
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h1>Библиотека блоков</h1>
|
||||
<p id="catalog-count" class="muted">Каталог сервера</p>
|
||||
</div>
|
||||
<button data-close="library" aria-label="Закрыть библиотеку">×</button>
|
||||
</div>
|
||||
<input
|
||||
id="catalog-search"
|
||||
type="search"
|
||||
placeholder="Поиск: oak, stairs, glass…"
|
||||
aria-label="Поиск по каталогу"
|
||||
/>
|
||||
<p class="muted">
|
||||
Выберите блок для текущей ячейки. Свойства состояний показаны под
|
||||
названием.
|
||||
</p>
|
||||
<div id="catalog-list"></div>
|
||||
<button id="catalog-more" class="wide">Показать ещё</button>
|
||||
</aside>
|
||||
<aside
|
||||
id="diagnostics"
|
||||
class="panel diagnostics"
|
||||
hidden
|
||||
aria-label="Диагностика"
|
||||
>
|
||||
<div class="panel-head">
|
||||
<h1>Диагностика</h1>
|
||||
<button data-close="diagnostics" aria-label="Закрыть диагностику">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<dl id="debug-values"></dl>
|
||||
<div id="package-status" class="muted">Пакеты ещё не загружены</div>
|
||||
<button id="resync-button" class="wide">Сверить снимок мира</button>
|
||||
<p class="muted">
|
||||
Сервер подтверждает движение и правки. RAM процесса и полезные данные
|
||||
кэша — разные измерения.
|
||||
</p>
|
||||
</aside>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
export const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
export const key = (p) => `${p[0]},${p[1]},${p[2]}`;
|
||||
export const direction = (yaw, pitch) => [
|
||||
Math.sin(yaw) * Math.cos(pitch),
|
||||
Math.sin(pitch),
|
||||
-Math.cos(yaw) * Math.cos(pitch),
|
||||
];
|
||||
export function multiply(a, b) {
|
||||
const o = new Float32Array(16);
|
||||
for (let c = 0; c < 4; c++)
|
||||
for (let r = 0; r < 4; r++)
|
||||
for (let k = 0; k < 4; k++) o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
|
||||
return o;
|
||||
}
|
||||
export function perspective(fovy, aspect, near, far) {
|
||||
const f = 1 / Math.tan(fovy / 2),
|
||||
nf = 1 / (near - far);
|
||||
return new Float32Array([
|
||||
f / aspect,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
f,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
(far + near) * nf,
|
||||
-1,
|
||||
0,
|
||||
0,
|
||||
2 * far * near * nf,
|
||||
0,
|
||||
]);
|
||||
}
|
||||
export function viewMatrix(eye, yaw, pitch) {
|
||||
const d = direction(yaw, pitch),
|
||||
z = d.map((v) => -v),
|
||||
x = [Math.cos(yaw), 0, Math.sin(yaw)],
|
||||
y = [
|
||||
-Math.sin(yaw) * Math.sin(pitch),
|
||||
Math.cos(pitch),
|
||||
Math.cos(yaw) * Math.sin(pitch),
|
||||
];
|
||||
return new Float32Array([
|
||||
x[0],
|
||||
y[0],
|
||||
z[0],
|
||||
0,
|
||||
x[1],
|
||||
y[1],
|
||||
z[1],
|
||||
0,
|
||||
x[2],
|
||||
y[2],
|
||||
z[2],
|
||||
0,
|
||||
-dot(x, eye),
|
||||
-dot(y, eye),
|
||||
-dot(z, eye),
|
||||
1,
|
||||
]);
|
||||
}
|
||||
export function dot(a, b) {
|
||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
}
|
||||
export function project(p, m, w, h) {
|
||||
const x = m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
|
||||
y = m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
|
||||
z = m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
|
||||
q = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15];
|
||||
return q > 0 && z / q < 1
|
||||
? [((x / q + 1) * w) / 2, ((1 - y / q) * h) / 2]
|
||||
: null;
|
||||
}
|
||||
export const unitBox = { min: [0, 0, 0], max: [1, 1, 1] };
|
||||
export function normalizeBoxes(boxes) {
|
||||
return (boxes || [])
|
||||
.map((b) =>
|
||||
Array.isArray(b) ? { min: b.slice(0, 3), max: b.slice(3, 6) } : b,
|
||||
)
|
||||
.filter((b) => b.min?.length === 3 && b.max?.length === 3);
|
||||
}
|
||||
export function rayBox(origin, dir, min, max, reach) {
|
||||
let tmin = -Infinity,
|
||||
tmax = Infinity,
|
||||
normal = [0, 0, 0];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (Math.abs(dir[i]) < 1e-9) {
|
||||
if (origin[i] < min[i] || origin[i] > max[i]) return null;
|
||||
continue;
|
||||
}
|
||||
let a = (min[i] - origin[i]) / dir[i],
|
||||
b = (max[i] - origin[i]) / dir[i],
|
||||
sign = -Math.sign(dir[i]);
|
||||
if (a > b) [a, b] = [b, a];
|
||||
if (a > tmin) {
|
||||
tmin = a;
|
||||
normal = [0, 0, 0];
|
||||
normal[i] = sign;
|
||||
}
|
||||
tmax = Math.min(tmax, b);
|
||||
if (tmin > tmax) return null;
|
||||
}
|
||||
if (tmax < 0 || tmin > reach) return null;
|
||||
return { distance: Math.max(0, tmin), normal };
|
||||
}
|
||||
// A bounded DDA finds candidate cells; exact material boxes determine selection and face.
|
||||
export function raycast(origin, dir, blocks, materials, reach = 6) {
|
||||
const cell = origin.map(Math.floor),
|
||||
step = dir.map(Math.sign);
|
||||
const delta = dir.map((v) => (v === 0 ? Infinity : Math.abs(1 / v)));
|
||||
const next = dir.map((v, i) =>
|
||||
v === 0 ? Infinity : ((v > 0 ? cell[i] + 1 : cell[i]) - origin[i]) / v,
|
||||
);
|
||||
const visited = new Set();
|
||||
let best = null;
|
||||
for (let n = 0; n < 128; n++) {
|
||||
// Official shapes can extend outside their owning cell (e.g. a fence is 1.5 high).
|
||||
// Inspect neighboring owners once, but intersect their exact boxes in world space.
|
||||
for (let x = -3; x <= 3; x++)
|
||||
for (let y = -3; y <= 3; y++)
|
||||
for (let z = -3; z <= 3; z++) {
|
||||
const owner = [cell[0] + x, cell[1] + y, cell[2] + z],
|
||||
idKey = key(owner);
|
||||
if (visited.has(idKey)) continue;
|
||||
visited.add(idKey);
|
||||
const id = blocks.get(idKey);
|
||||
if (!id) continue;
|
||||
const mat = materials.get(id),
|
||||
boxes = mat ? mat.render : [unitBox];
|
||||
for (const box of boxes || [unitBox]) {
|
||||
const hit = rayBox(
|
||||
origin,
|
||||
dir,
|
||||
box.min.map((v, i) => v + owner[i]),
|
||||
box.max.map((v, i) => v + owner[i]),
|
||||
reach,
|
||||
);
|
||||
if (hit && (!best || hit.distance < best.distance))
|
||||
best = { ...hit, pos: owner, block: id };
|
||||
}
|
||||
}
|
||||
const axis =
|
||||
next[0] < next[1]
|
||||
? next[0] < next[2]
|
||||
? 0
|
||||
: 2
|
||||
: next[1] < next[2]
|
||||
? 1
|
||||
: 2;
|
||||
if (
|
||||
next[axis] > reach ||
|
||||
!Number.isFinite(next[axis]) ||
|
||||
(best && next[axis] > best.distance)
|
||||
)
|
||||
break;
|
||||
cell[axis] += step[axis];
|
||||
next[axis] += delta[axis];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
export function isFullCube(mat) {
|
||||
const b = mat?.render;
|
||||
return (
|
||||
b?.length === 1 &&
|
||||
b[0].min.every((v) => v === 0) &&
|
||||
b[0].max.every((v) => v === 1) &&
|
||||
!mat.transparent
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"shacraft-client","version":"0.1.0","private":true,"type":"module","scripts":{"test":"node --test tests/*.test.js"}}
|
||||
@@ -0,0 +1,569 @@
|
||||
import {
|
||||
multiply,
|
||||
perspective,
|
||||
viewMatrix,
|
||||
key,
|
||||
isFullCube,
|
||||
unitBox,
|
||||
project,
|
||||
} from "./math.js";
|
||||
const vertex = `#version 300 es
|
||||
precision highp float;layout(location=0) in vec3 aPos;layout(location=1) in vec3 aColor;layout(location=2) in vec3 aNormal;layout(location=3) in float aMaterial;layout(location=4) in float aOpacity;uniform mat4 uVP;out vec3 vPos;out vec3 vColor;out vec3 vNormal;flat out float vMaterial;out float vOpacity;void main(){vOpacity=aOpacity;vPos=aPos;vColor=aColor;vNormal=aNormal;vMaterial=aMaterial;gl_Position=uVP*vec4(aPos,1.);}`;
|
||||
const fragment = `#version 300 es
|
||||
precision highp float;in vec3 vPos;in vec3 vColor;in vec3 vNormal;flat in float vMaterial;in float vOpacity;uniform vec3 uEye;uniform sampler2D uTexture;uniform bool uTextured;uniform sampler2D uEffectTexture;uniform bool uEffectTextured;uniform float uPulse;out vec4 outColor;
|
||||
float hash(vec3 p){return fract(sin(dot(p,vec3(127.1,311.7,74.7)))*43758.5453);}
|
||||
vec3 shacraftBounceTint(vec3 color,float pulse){return color;}
|
||||
void main(){vec3 n=normalize(vNormal);vec2 uv=abs(n.y)>.5?vPos.xz:(abs(n.x)>.5?vPos.zy:vPos.xy);vec3 p=floor((vPos+n*.002)*16.);float noise=hash(p)*.14-.07;float pattern=1.;
|
||||
if(vMaterial>1.5&&vMaterial<2.5){pattern=.89+.11*step(.07,fract(uv.y*4.));pattern*=.9+.1*step(.055,fract(uv.x*.5+floor(uv.y*4.)*.5));}
|
||||
if(vMaterial>2.5&&vMaterial<3.5){pattern=.87+.13*step(.06,fract(uv.y*4.));pattern*=.84+.16*step(.045,fract(uv.x*2.+floor(uv.y*4.)*.5));}
|
||||
if(vMaterial>3.5&&vMaterial<4.5){pattern=.91+.09*sin(floor(uv.x*16.)*1.73+sin(floor(uv.y*16.)*.22));}
|
||||
float tex=uTextured?mix(.84,1.12,texture(uTexture,uv).r):1.;if(vMaterial>4.5&&uEffectTextured)tex=mix(.75,1.2,texture(uEffectTexture,uv).g);float diffuse=max(0.,dot(n,normalize(vec3(-.45,.86,.3))));float light=.69+diffuse*.34;vec3 color=clamp((vColor+noise)*pattern*tex*light,0.,1.);if(vMaterial>.5&&vMaterial<1.5&&n.y<.5)color=mix(color,vec3(.38,.29,.17),.65);
|
||||
if(vMaterial>4.5)color=shacraftBounceTint(color,uPulse);float fog=1.-exp(-pow(distance(vPos,uEye)/74.,2.));color=mix(color,vec3(.70,.80,.79),fog*.91);outColor=vec4(color,vOpacity);}`;
|
||||
const skyVert = `#version 300 es
|
||||
precision highp float;out vec2 vUV;void main(){vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2);vUV=p;gl_Position=vec4(p*2.-1.,1.,1.);}`;
|
||||
const skyFrag = `#version 300 es
|
||||
precision highp float;in vec2 vUV;uniform float uPitch;uniform float uYaw;uniform float uAspect;out vec4 outColor;void main(){float h=vUV.y+uPitch*.72;vec3 color=mix(vec3(.77,.83,.77),vec3(.38,.65,.78),smoothstep(.14,1.2,h));vec2 sun=vec2(.77-uYaw*.3,.82-uPitch*.72);float d=length((vUV-sun)*vec2(uAspect,1.));color=mix(color,vec3(1.,.98,.8),smoothstep(.048,.039,d)*.98);color+=vec3(.045,.025,0.)*exp(-d*7.);outColor=vec4(color,1.);}`;
|
||||
const lineVertex = `#version 300 es
|
||||
precision highp float;layout(location=0)in vec3 aPos;uniform mat4 uVP;void main(){gl_Position=uVP*vec4(aPos,1.);}`;
|
||||
const lineFragment = `#version 300 es
|
||||
precision highp float;out vec4 outColor;void main(){outColor=vec4(.13,.21,.12,1.);}`;
|
||||
const faces = [
|
||||
{
|
||||
n: [1, 0, 0],
|
||||
v: [
|
||||
[1, 0, 1],
|
||||
[1, 0, 0],
|
||||
[1, 1, 0],
|
||||
[1, 1, 1],
|
||||
],
|
||||
},
|
||||
{
|
||||
n: [-1, 0, 0],
|
||||
v: [
|
||||
[0, 0, 0],
|
||||
[0, 0, 1],
|
||||
[0, 1, 1],
|
||||
[0, 1, 0],
|
||||
],
|
||||
},
|
||||
{
|
||||
n: [0, 1, 0],
|
||||
v: [
|
||||
[0, 1, 1],
|
||||
[1, 1, 1],
|
||||
[1, 1, 0],
|
||||
[0, 1, 0],
|
||||
],
|
||||
},
|
||||
{
|
||||
n: [0, -1, 0],
|
||||
v: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
[1, 0, 1],
|
||||
[0, 0, 1],
|
||||
],
|
||||
},
|
||||
{
|
||||
n: [0, 0, 1],
|
||||
v: [
|
||||
[0, 0, 1],
|
||||
[1, 0, 1],
|
||||
[1, 1, 1],
|
||||
[0, 1, 1],
|
||||
],
|
||||
},
|
||||
{
|
||||
n: [0, 0, -1],
|
||||
v: [
|
||||
[1, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 1, 0],
|
||||
[1, 1, 0],
|
||||
],
|
||||
},
|
||||
];
|
||||
function program(gl, vs, fs) {
|
||||
const p = gl.createProgram();
|
||||
for (const [type, source] of [
|
||||
[gl.VERTEX_SHADER, vs],
|
||||
[gl.FRAGMENT_SHADER, fs],
|
||||
]) {
|
||||
const s = gl.createShader(type);
|
||||
gl.shaderSource(s, source);
|
||||
gl.compileShader(s);
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
|
||||
throw Error(gl.getShaderInfoLog(s));
|
||||
gl.attachShader(p, s);
|
||||
gl.deleteShader(s);
|
||||
}
|
||||
gl.linkProgram(p);
|
||||
if (!gl.getProgramParameter(p, gl.LINK_STATUS))
|
||||
throw Error(gl.getProgramInfoLog(p));
|
||||
return p;
|
||||
}
|
||||
function boxVertices(
|
||||
out,
|
||||
pos,
|
||||
box,
|
||||
color,
|
||||
material = 0,
|
||||
skip = () => false,
|
||||
yaw = 0,
|
||||
opacity = 1,
|
||||
) {
|
||||
for (let f = 0; f < 6; f++) {
|
||||
if (skip(faces[f].n)) continue;
|
||||
const face = faces[f],
|
||||
c = Math.cos(yaw),
|
||||
s = Math.sin(yaw),
|
||||
n = [
|
||||
face.n[0] * c - face.n[2] * s,
|
||||
face.n[1],
|
||||
face.n[0] * s + face.n[2] * c,
|
||||
];
|
||||
for (const i of [0, 1, 2, 0, 2, 3]) {
|
||||
const v = face.v[i].map(
|
||||
(v, k) => box.min[k] + v * (box.max[k] - box.min[k]),
|
||||
);
|
||||
out.push(
|
||||
pos[0] + v[0] * c - v[2] * s,
|
||||
pos[1] + v[1],
|
||||
pos[2] + v[0] * s + v[2] * c,
|
||||
...color,
|
||||
...n,
|
||||
material,
|
||||
opacity,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
function materialType(state = "", effect) {
|
||||
if (effect === "bounce") return 5;
|
||||
if (state.includes("grass_block")) return 1;
|
||||
if (state.includes("planks")) return 2;
|
||||
if (state.includes("brick")) return 3;
|
||||
if (/log|stem|wood/.test(state)) return 4;
|
||||
return 0;
|
||||
}
|
||||
const section = (p) => p.map((v) => Math.floor(v / 16)).join(",");
|
||||
export class Renderer {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
const gl = (this.gl = canvas.getContext("webgl2", {
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
preserveDrawingBuffer: true,
|
||||
powerPreference: "high-performance",
|
||||
}));
|
||||
if (!gl)
|
||||
throw Error(
|
||||
"WebGL2 недоступен. Включите аппаратное ускорение или откройте клиент в браузере с поддержкой WebGL2.",
|
||||
);
|
||||
this.program = program(gl, vertex, fragment);
|
||||
this.sky = program(gl, skyVert, skyFrag);
|
||||
this.line = program(gl, lineVertex, lineFragment);
|
||||
this.sections = new Map();
|
||||
this.dirty = new Set();
|
||||
this.blocks = new Map();
|
||||
this.materials = new Map();
|
||||
this.texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
new Uint8Array([180, 180, 180, 255]),
|
||||
);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
this.textured = false;
|
||||
this.effectTexture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
new Uint8Array([180, 180, 180, 255]),
|
||||
);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
this.effectTextured = false;
|
||||
this.bounceAt = -10000;
|
||||
this.dynamic = this.mesh([]);
|
||||
this.lines = this.mesh([], 3);
|
||||
this.triangles = 0;
|
||||
this.vp = new Float32Array(16);
|
||||
this.lost = false;
|
||||
canvas.addEventListener("webglcontextlost", (e) => {
|
||||
e.preventDefault();
|
||||
this.lost = true;
|
||||
});
|
||||
canvas.addEventListener("webglcontextrestored", () => location.reload());
|
||||
}
|
||||
mesh(data, stride = 11) {
|
||||
const gl = this.gl,
|
||||
vao = gl.createVertexArray(),
|
||||
buffer = gl.createBuffer();
|
||||
gl.bindVertexArray(vao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW);
|
||||
for (const [loc, size, offset] of stride === 3
|
||||
? [[0, 3, 0]]
|
||||
: [
|
||||
[0, 3, 0],
|
||||
[1, 3, 3],
|
||||
[2, 3, 6],
|
||||
[3, 1, 9],
|
||||
[4, 1, 10],
|
||||
]) {
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(
|
||||
loc,
|
||||
size,
|
||||
gl.FLOAT,
|
||||
false,
|
||||
stride * 4,
|
||||
offset * 4,
|
||||
);
|
||||
}
|
||||
return { vao, buffer, count: data.length / stride };
|
||||
}
|
||||
disposeMesh(mesh) {
|
||||
if (mesh.transparent) this.disposeMesh(mesh.transparent);
|
||||
this.gl.deleteBuffer(mesh.buffer);
|
||||
this.gl.deleteVertexArray(mesh.vao);
|
||||
}
|
||||
upload(mesh, data, stride = 11) {
|
||||
const gl = this.gl;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW);
|
||||
mesh.count = data.length / stride;
|
||||
}
|
||||
setBounceShader(source) {
|
||||
if (source.length > 32768) throw Error("Шейдер пакета слишком велик");
|
||||
const next = program(
|
||||
this.gl,
|
||||
vertex,
|
||||
fragment.replace(
|
||||
"vec3 shacraftBounceTint(vec3 color,float pulse){return color;}",
|
||||
source,
|
||||
),
|
||||
);
|
||||
this.gl.deleteProgram(this.program);
|
||||
this.program = next;
|
||||
}
|
||||
async setTexture(blob, effect = false) {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
this.gl.bindTexture(
|
||||
this.gl.TEXTURE_2D,
|
||||
effect ? this.effectTexture : this.texture,
|
||||
);
|
||||
this.gl.texImage2D(
|
||||
this.gl.TEXTURE_2D,
|
||||
0,
|
||||
this.gl.RGBA,
|
||||
this.gl.RGBA,
|
||||
this.gl.UNSIGNED_BYTE,
|
||||
bitmap,
|
||||
);
|
||||
this.gl.texParameteri(
|
||||
this.gl.TEXTURE_2D,
|
||||
this.gl.TEXTURE_WRAP_S,
|
||||
this.gl.REPEAT,
|
||||
);
|
||||
this.gl.texParameteri(
|
||||
this.gl.TEXTURE_2D,
|
||||
this.gl.TEXTURE_WRAP_T,
|
||||
this.gl.REPEAT,
|
||||
);
|
||||
bitmap.close();
|
||||
if (effect) this.effectTextured = true;
|
||||
else this.textured = true;
|
||||
}
|
||||
replace(blocks, materials) {
|
||||
for (const m of this.sections.values()) this.disposeMesh(m);
|
||||
this.sections.clear();
|
||||
this.dirty.clear();
|
||||
this.blocks = blocks;
|
||||
this.materials = materials;
|
||||
for (const k of blocks.keys())
|
||||
this.dirty.add(section(k.split(",").map(Number)));
|
||||
}
|
||||
change(changes) {
|
||||
for (const { pos } of changes) {
|
||||
this.dirty.add(section(pos));
|
||||
for (const f of faces)
|
||||
this.dirty.add(section(pos.map((v, i) => v + f.n[i])));
|
||||
}
|
||||
}
|
||||
invalidate() {
|
||||
for (const k of this.blocks.keys())
|
||||
this.dirty.add(section(k.split(",").map(Number)));
|
||||
}
|
||||
rebuild(id) {
|
||||
const base = id.split(",").map((v) => Number(v) * 16),
|
||||
vertices = [],
|
||||
transparent = [];
|
||||
for (let x = base[0]; x < base[0] + 16; x++)
|
||||
for (let y = base[1]; y < base[1] + 16; y++)
|
||||
for (let z = base[2]; z < base[2] + 16; z++) {
|
||||
const pos = [x, y, z],
|
||||
block = this.blocks.get(key(pos));
|
||||
if (!block) continue;
|
||||
const mat = this.materials.get(block) || {
|
||||
color: [150, 152, 142],
|
||||
render: [unitBox],
|
||||
},
|
||||
color = mat.color.map((v) => v / 255),
|
||||
full = isFullCube(mat);
|
||||
for (const box of mat.render || [unitBox]) {
|
||||
boxVertices(
|
||||
mat.opacity < 1 ? transparent : vertices,
|
||||
pos,
|
||||
box,
|
||||
color,
|
||||
materialType(mat.state, mat.effect),
|
||||
(n) =>
|
||||
full &&
|
||||
isFullCube(
|
||||
this.materials.get(
|
||||
this.blocks.get(key(pos.map((v, i) => v + n[i]))),
|
||||
),
|
||||
),
|
||||
0,
|
||||
mat.opacity ?? 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
let mesh = this.sections.get(id);
|
||||
if (mesh) this.upload(mesh, vertices);
|
||||
else if (vertices.length || transparent.length) {
|
||||
mesh = this.mesh(vertices);
|
||||
this.sections.set(id, mesh);
|
||||
}
|
||||
if (mesh) {
|
||||
if (mesh.transparent) this.upload(mesh.transparent, transparent);
|
||||
else if (transparent.length) mesh.transparent = this.mesh(transparent);
|
||||
}
|
||||
if (mesh && vertices.length === 0 && transparent.length === 0) {
|
||||
this.disposeMesh(mesh);
|
||||
this.sections.delete(id);
|
||||
}
|
||||
}
|
||||
updateSelection(hit) {
|
||||
const a = [];
|
||||
if (hit) {
|
||||
const mat = this.materials.get(hit.block),
|
||||
boxes = mat?.render?.length ? mat.render : [unitBox];
|
||||
for (const box of boxes) {
|
||||
const points = [];
|
||||
for (let i = 0; i < 8; i++)
|
||||
points.push(
|
||||
[0, 1, 2].map(
|
||||
(k) =>
|
||||
hit.pos[k] +
|
||||
((i >> k) & 1 ? box.max[k] + 0.003 : box.min[k] - 0.003),
|
||||
),
|
||||
);
|
||||
for (let i = 0; i < 8; i++)
|
||||
for (let axis = 0; axis < 3; axis++)
|
||||
if (!(i & (1 << axis)))
|
||||
a.push(...points[i], ...points[i | (1 << axis)]);
|
||||
}
|
||||
}
|
||||
this.upload(this.lines, a, 3);
|
||||
}
|
||||
draw(camera, players, entities, entityDefinitions) {
|
||||
if (this.lost) return;
|
||||
const gl = this.gl,
|
||||
ratio = Math.min(devicePixelRatio || 1, 1.75),
|
||||
w = Math.floor(this.canvas.clientWidth * ratio),
|
||||
h = Math.floor(this.canvas.clientHeight * ratio);
|
||||
if (this.canvas.width !== w || this.canvas.height !== h) {
|
||||
this.canvas.width = w;
|
||||
this.canvas.height = h;
|
||||
}
|
||||
gl.viewport(0, 0, w, h);
|
||||
gl.clearColor(0.65, 0.79, 0.8, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
gl.disable(gl.DEPTH_TEST);
|
||||
gl.disable(gl.CULL_FACE);
|
||||
gl.useProgram(this.sky);
|
||||
gl.uniform1f(gl.getUniformLocation(this.sky, "uPitch"), camera.pitch);
|
||||
gl.uniform1f(gl.getUniformLocation(this.sky, "uYaw"), camera.yaw);
|
||||
gl.uniform1f(gl.getUniformLocation(this.sky, "uAspect"), w / h);
|
||||
gl.bindVertexArray(null);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
this.vp = multiply(
|
||||
perspective(Math.PI / 2.7, w / h, 0.06, 190),
|
||||
viewMatrix(camera.eye, camera.yaw, camera.pitch),
|
||||
);
|
||||
const start = performance.now();
|
||||
let rebuilt = 0;
|
||||
for (const id of this.dirty) {
|
||||
this.rebuild(id);
|
||||
this.dirty.delete(id);
|
||||
rebuilt++;
|
||||
if (rebuilt >= 3 || performance.now() - start > 7) break;
|
||||
}
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
gl.enable(gl.CULL_FACE);
|
||||
gl.cullFace(gl.BACK);
|
||||
gl.frontFace(gl.CCW);
|
||||
gl.useProgram(this.program);
|
||||
gl.uniformMatrix4fv(
|
||||
gl.getUniformLocation(this.program, "uVP"),
|
||||
false,
|
||||
this.vp,
|
||||
);
|
||||
gl.uniform3fv(gl.getUniformLocation(this.program, "uEye"), camera.eye);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
||||
gl.uniform1i(gl.getUniformLocation(this.program, "uTexture"), 0);
|
||||
gl.uniform1i(
|
||||
gl.getUniformLocation(this.program, "uTextured"),
|
||||
this.textured ? 1 : 0,
|
||||
);
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
|
||||
gl.uniform1i(gl.getUniformLocation(this.program, "uEffectTexture"), 1);
|
||||
gl.uniform1i(
|
||||
gl.getUniformLocation(this.program, "uEffectTextured"),
|
||||
this.effectTextured ? 1 : 0,
|
||||
);
|
||||
gl.uniform1f(
|
||||
gl.getUniformLocation(this.program, "uPulse"),
|
||||
Math.max(0, 1 - (performance.now() - this.bounceAt) / 600),
|
||||
);
|
||||
this.triangles = 0;
|
||||
for (const [id, m] of this.sections) {
|
||||
const center = id.split(",").map((v) => Number(v) * 16 + 8);
|
||||
if (
|
||||
Math.hypot(center[0] - camera.eye[0], center[2] - camera.eye[2]) > 135
|
||||
)
|
||||
continue;
|
||||
gl.bindVertexArray(m.vao);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, m.count);
|
||||
this.triangles += m.count / 3;
|
||||
}
|
||||
const dynamic = [];
|
||||
for (const p of players) {
|
||||
const c = p.color || [0.29, 0.46, 0.36],
|
||||
yaw = p.yaw || 0;
|
||||
boxVertices(
|
||||
dynamic,
|
||||
p.position,
|
||||
{ min: [-0.21, 0.63, -0.13], max: [0.21, 1.27, 0.13] },
|
||||
c,
|
||||
2,
|
||||
() => false,
|
||||
yaw,
|
||||
);
|
||||
boxVertices(
|
||||
dynamic,
|
||||
p.position,
|
||||
{ min: [-0.23, 1.27, -0.23], max: [0.23, 1.73, 0.23] },
|
||||
[0.77, 0.61, 0.43],
|
||||
0,
|
||||
() => false,
|
||||
yaw,
|
||||
);
|
||||
for (const side of [-1, 1]) {
|
||||
boxVertices(
|
||||
dynamic,
|
||||
p.position,
|
||||
{
|
||||
min: [side * 0.13 - 0.085, 0.02, -0.09],
|
||||
max: [side * 0.13 + 0.085, 0.63, 0.09],
|
||||
},
|
||||
[0.25, 0.3, 0.32],
|
||||
0,
|
||||
() => false,
|
||||
yaw,
|
||||
);
|
||||
boxVertices(
|
||||
dynamic,
|
||||
p.position,
|
||||
{
|
||||
min: [side * 0.31 - 0.085, 0.63, -0.1],
|
||||
max: [side * 0.31 + 0.085, 1.24, 0.1],
|
||||
},
|
||||
c,
|
||||
0,
|
||||
() => false,
|
||||
yaw,
|
||||
);
|
||||
}
|
||||
boxVertices(
|
||||
dynamic,
|
||||
p.position,
|
||||
{ min: [-0.18, 1.56, -0.234], max: [0.18, 1.64, -0.226] },
|
||||
[0.13, 0.19, 0.15],
|
||||
0,
|
||||
() => false,
|
||||
yaw,
|
||||
);
|
||||
}
|
||||
for (const e of entities) {
|
||||
const def = entityDefinitions.get(e.kind || e.name) || {},
|
||||
color = (e.color || def.color || [192, 161, 123]).map((v) => v / 255),
|
||||
boxes = e.render ||
|
||||
def.render || [{ min: [-0.3, 0, -0.3], max: [0.3, 0.8, 0.3] }];
|
||||
for (const b of boxes)
|
||||
boxVertices(dynamic, e.position, b, color, 0, () => false, e.yaw || 0);
|
||||
}
|
||||
this.upload(this.dynamic, dynamic);
|
||||
gl.bindVertexArray(this.dynamic.vao);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, this.dynamic.count);
|
||||
this.triangles += this.dynamic.count / 3;
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.depthMask(false);
|
||||
const translucent = [...this.sections]
|
||||
.filter(([, m]) => m.transparent?.count)
|
||||
.sort((a, b) => {
|
||||
const dist = (id) =>
|
||||
Math.hypot(
|
||||
...id.split(",").map((v, i) => Number(v) * 16 + 8 - camera.eye[i]),
|
||||
);
|
||||
return dist(b[0]) - dist(a[0]);
|
||||
});
|
||||
for (const [, m] of translucent) {
|
||||
gl.bindVertexArray(m.transparent.vao);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, m.transparent.count);
|
||||
this.triangles += m.transparent.count / 3;
|
||||
}
|
||||
gl.depthMask(true);
|
||||
gl.disable(gl.BLEND);
|
||||
if (this.lines.count) {
|
||||
gl.useProgram(this.line);
|
||||
gl.uniformMatrix4fv(
|
||||
gl.getUniformLocation(this.line, "uVP"),
|
||||
false,
|
||||
this.vp,
|
||||
);
|
||||
gl.bindVertexArray(this.lines.vao);
|
||||
gl.drawArrays(gl.LINES, 0, this.lines.count);
|
||||
}
|
||||
gl.bindVertexArray(null);
|
||||
}
|
||||
project(p) {
|
||||
return project(
|
||||
p,
|
||||
this.vp,
|
||||
this.canvas.clientWidth,
|
||||
this.canvas.clientHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
:root {
|
||||
font-family: Inter, "Segoe UI", system-ui, sans-serif;
|
||||
color: #f0f2ed;
|
||||
background: #a8c6d0;
|
||||
font-size: 14px;
|
||||
--panel: rgba(28, 35, 31, 0.97);
|
||||
--line: #4a544b;
|
||||
--muted: #b2beb0;
|
||||
--accent: #c0d3a9;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
color: inherit;
|
||||
background: #39453b;
|
||||
border: 1px solid #5d6a5d;
|
||||
border-radius: 4px;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background: #4c5e4a;
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid #dfefba;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #253021;
|
||||
font-weight: 650;
|
||||
}
|
||||
input {
|
||||
background: #202821;
|
||||
color: #f4f4ef;
|
||||
border: 1px solid #576255;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
input[type="range"] {
|
||||
padding: 0;
|
||||
accent-color: #b9ce9e;
|
||||
}
|
||||
kbd {
|
||||
font-size: 10px;
|
||||
font-family: inherit;
|
||||
color: #d7dfd3;
|
||||
border: 1px solid #82907d75;
|
||||
border-radius: 3px;
|
||||
padding: 2px 4px;
|
||||
background: #17231c55;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button kbd {
|
||||
margin-left: 6px;
|
||||
}
|
||||
#world-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.topbar {
|
||||
position: absolute;
|
||||
inset: 22px 24px auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 4px #213a4380;
|
||||
}
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.identity > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.identity strong {
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.4px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.world-name {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
color: #e2ece0;
|
||||
text-shadow: 0 1px 3px #18241d;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.world-name:hover {
|
||||
background: none;
|
||||
color: white;
|
||||
}
|
||||
#connection {
|
||||
align-self: center;
|
||||
font-size: 11px;
|
||||
color: #edf4e9;
|
||||
background: #273b2cb5;
|
||||
padding: 5px 8px;
|
||||
border-radius: 3px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
#connection.bad {
|
||||
background: #713e30;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
nav button {
|
||||
background: #26362ed6;
|
||||
border-color: #e0efd333;
|
||||
box-shadow: 0 2px 8px #1d342615;
|
||||
font-size: 12px;
|
||||
}
|
||||
#menu-button {
|
||||
padding-inline: 13px;
|
||||
}
|
||||
#crosshair {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 1px 1px #112019);
|
||||
}
|
||||
#crosshair:before,
|
||||
#crosshair:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: #fffdf5b3;
|
||||
}
|
||||
#crosshair:before {
|
||||
left: 6px;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 13px;
|
||||
}
|
||||
#crosshair:after {
|
||||
left: 0;
|
||||
top: 6px;
|
||||
width: 13px;
|
||||
height: 1px;
|
||||
}
|
||||
#target-label {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: calc(50% + 28px);
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
text-shadow: 0 1px 3px #172f21;
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 23px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
#selected-name {
|
||||
font-size: 12px;
|
||||
color: #fffdf6;
|
||||
text-shadow: 0 1px 4px #163622;
|
||||
margin-bottom: 9px;
|
||||
min-height: 17px;
|
||||
}
|
||||
#hotbar {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
background: #1b2a22b8;
|
||||
border: 1px solid #e0e9d633;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 5px 20px #20352730;
|
||||
}
|
||||
.slot {
|
||||
width: 49px;
|
||||
height: 49px;
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
border: 1px solid #9cac9340;
|
||||
background: #46513b8c;
|
||||
border-radius: 2px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.slot.active {
|
||||
background: #dce7b82e;
|
||||
border: 2px solid #e6edbc;
|
||||
padding: 7px;
|
||||
}
|
||||
.slot small {
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 2px;
|
||||
font-size: 9px;
|
||||
color: #e8edde;
|
||||
}
|
||||
.swatch {
|
||||
display: block;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
image-rendering: pixelated;
|
||||
background: var(--block-color, #aaaaaa);
|
||||
box-shadow:
|
||||
inset -6px -5px 0 #0003,
|
||||
inset 4px 4px 0 #fff2;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
#play-hint {
|
||||
font-size: 11px;
|
||||
text-shadow: 0 1px 4px #15271c;
|
||||
margin-top: 9px;
|
||||
color: #e3e8d7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bottom-left {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
bottom: 25px;
|
||||
max-width: min(355px, 30vw);
|
||||
pointer-events: none;
|
||||
}
|
||||
#hints {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px 11px;
|
||||
color: #e1e9db;
|
||||
font-size: 10px;
|
||||
line-height: 1.7;
|
||||
text-shadow: 0 1px 3px #25392b;
|
||||
}
|
||||
#chat-log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 12px;
|
||||
text-shadow: 0 1px 3px #000;
|
||||
max-height: 180px;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
#chat-log div {
|
||||
background: #20332ba6;
|
||||
padding: 5px 8px;
|
||||
border-radius: 3px;
|
||||
width: fit-content;
|
||||
}
|
||||
#chat-log .system {
|
||||
color: #e3dfb5;
|
||||
}
|
||||
#chat-form {
|
||||
pointer-events: auto;
|
||||
margin-bottom: 10px;
|
||||
width: min(400px, 80vw);
|
||||
}
|
||||
#chat-input {
|
||||
font-size: 12px;
|
||||
background: #20352bec;
|
||||
}
|
||||
#labels {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.player-label {
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
background: #203629a8;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
.panel {
|
||||
position: absolute;
|
||||
top: 86px;
|
||||
right: 24px;
|
||||
width: 315px;
|
||||
max-height: calc(100vh - 116px);
|
||||
overflow: auto;
|
||||
background: var(--panel);
|
||||
border: 1px solid #93a38a66;
|
||||
box-shadow: 0 10px 30px #10281933;
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.panel-head h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
margin: 2px 0 0;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.panel-head button {
|
||||
background: none;
|
||||
border: 0;
|
||||
font-size: 24px;
|
||||
line-height: 20px;
|
||||
padding: 0 2px;
|
||||
color: #b6c4b1;
|
||||
}
|
||||
.panel-head p {
|
||||
margin: 5px 0 0;
|
||||
}
|
||||
.panel label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #c8d3c2;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wide {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.inline {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.inline input {
|
||||
min-width: 0;
|
||||
}
|
||||
.inline button {
|
||||
font-size: 11px;
|
||||
padding: 8px;
|
||||
}
|
||||
.rule {
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 20px 0;
|
||||
}
|
||||
.world-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
margin-top: 8px;
|
||||
background: #2c382f;
|
||||
}
|
||||
.world-row small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.world-row.current {
|
||||
border-color: #bacda4;
|
||||
}
|
||||
.library {
|
||||
width: min(610px, calc(100vw - 48px));
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.library #catalog-search {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
#catalog-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
max-height: 55vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.catalog-item {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
background: #2f3a30;
|
||||
padding: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.catalog-item .swatch {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.catalog-item > span:last-child {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.catalog-item small {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: #aebca6;
|
||||
margin-top: 5px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.catalog-empty {
|
||||
grid-column: 1/-1;
|
||||
color: var(--muted);
|
||||
padding: 20px 0;
|
||||
}
|
||||
.diagnostics dl {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 9px 10px;
|
||||
font-size: 12px;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.diagnostics dt {
|
||||
color: #aebeaa;
|
||||
}
|
||||
.diagnostics dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#toast {
|
||||
position: absolute;
|
||||
top: 90px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #263a2cf2;
|
||||
border: 1px solid #b7cba380;
|
||||
border-radius: 4px;
|
||||
padding: 11px 16px;
|
||||
font-size: 13px;
|
||||
max-width: min(580px, 90vw);
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 10px #0002;
|
||||
}
|
||||
#toast.error {
|
||||
background: #633c2ff5;
|
||||
border-color: #d0a68f;
|
||||
}
|
||||
#match {
|
||||
position: absolute;
|
||||
top: 92px;
|
||||
left: 24px;
|
||||
background: #26392cd6;
|
||||
border: 1px solid #e2e3c24a;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
max-width: 360px;
|
||||
}
|
||||
#fatal {
|
||||
position: absolute;
|
||||
inset: 30% 20% auto;
|
||||
background: #312e25ed;
|
||||
border: 1px solid #d4b696;
|
||||
padding: 30px;
|
||||
line-height: 1.6;
|
||||
border-radius: 5px;
|
||||
}
|
||||
body.locked .topbar,
|
||||
body.locked #hints,
|
||||
body.locked #play-hint {
|
||||
opacity: 0.35;
|
||||
}
|
||||
body.locked .topbar:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
@media (max-width: 850px) {
|
||||
.topbar {
|
||||
inset: 14px 14px auto;
|
||||
}
|
||||
.identity {
|
||||
gap: 8px;
|
||||
}
|
||||
#connection {
|
||||
margin-left: 1px;
|
||||
font-size: 10px;
|
||||
}
|
||||
nav button {
|
||||
font-size: 11px;
|
||||
padding: 9px 8px;
|
||||
}
|
||||
nav kbd {
|
||||
display: none;
|
||||
}
|
||||
.bottom-left {
|
||||
left: 14px;
|
||||
bottom: 115px;
|
||||
max-width: 75vw;
|
||||
}
|
||||
#hints {
|
||||
display: none;
|
||||
}
|
||||
footer {
|
||||
bottom: 14px;
|
||||
}
|
||||
.panel {
|
||||
right: 14px;
|
||||
top: 77px;
|
||||
max-height: calc(100vh - 94px);
|
||||
}
|
||||
.slot {
|
||||
width: 40px;
|
||||
height: 44px;
|
||||
}
|
||||
#play-hint {
|
||||
white-space: normal;
|
||||
width: 360px;
|
||||
font-size: 10px;
|
||||
}
|
||||
#catalog-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.identity strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
.identity svg {
|
||||
width: 22px;
|
||||
}
|
||||
.topbar {
|
||||
gap: 6px;
|
||||
}
|
||||
#connection {
|
||||
display: none;
|
||||
}
|
||||
#diagnostics-button {
|
||||
font-size: 0;
|
||||
}
|
||||
#diagnostics-button:before {
|
||||
content: "F3";
|
||||
font-size: 12px;
|
||||
}
|
||||
.slot {
|
||||
width: 34px;
|
||||
height: 40px;
|
||||
padding: 6px;
|
||||
}
|
||||
.swatch {
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
}
|
||||
.library {
|
||||
width: calc(100vw - 28px);
|
||||
}
|
||||
#target-label {
|
||||
max-width: 90vw;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#fatal {
|
||||
inset: 25% 14px auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
direction,
|
||||
raycast,
|
||||
rayBox,
|
||||
multiply,
|
||||
perspective,
|
||||
viewMatrix,
|
||||
project,
|
||||
unitBox,
|
||||
} from "../math.js";
|
||||
test("camera axes and world projection match the server coordinate convention", () => {
|
||||
assert.deepEqual(direction(0, 0), [0, 0, -1]);
|
||||
const east = direction(Math.PI / 2, 0);
|
||||
assert.ok(Math.abs(east[0] - 1) < 1e-8);
|
||||
const vp = multiply(
|
||||
perspective(Math.PI / 2, 1, 0.1, 100),
|
||||
viewMatrix([0, 2, 8], 0, 0),
|
||||
);
|
||||
const center = project([0, 2, 0], vp, 100, 100);
|
||||
assert.ok(Math.abs(center[0] - 50) < 1e-5 && Math.abs(center[1] - 50) < 1e-5);
|
||||
assert.equal(project([0, 2, 20], vp, 100, 100), null);
|
||||
});
|
||||
test("DDA resolves negative world coordinates and nearest entry face", () => {
|
||||
const blocks = new Map([
|
||||
["-1,0,-2", 1],
|
||||
["-1,0,-4", 1],
|
||||
]),
|
||||
materials = new Map([[1, { render: [unitBox] }]]);
|
||||
const hit = raycast([-0.5, 0.5, 0], [0, 0, -1], blocks, materials);
|
||||
assert.deepEqual(hit.pos, [-1, 0, -2]);
|
||||
assert.deepEqual(hit.normal, [0, 0, 1]);
|
||||
assert.equal(hit.distance, 1);
|
||||
});
|
||||
test("ray selection respects slab geometry and reach instead of selecting empty half", () => {
|
||||
const blocks = new Map([["0,0,-2", 1]]),
|
||||
materials = new Map([
|
||||
[1, { render: [{ min: [0, 0, 0], max: [1, 0.5, 1] }] }],
|
||||
]);
|
||||
assert.equal(raycast([0.5, 0.75, 0], [0, 0, -1], blocks, materials), null);
|
||||
assert.ok(raycast([0.5, 0.25, 0], [0, 0, -1], blocks, materials));
|
||||
assert.equal(
|
||||
raycast([0.5, 0.25, 10], [0, 0, -1], blocks, materials, 6),
|
||||
null,
|
||||
);
|
||||
});
|
||||
test("axis aligned ray slabs reject parallel paths outside a box", () => {
|
||||
assert.equal(rayBox([2, 0.5, 3], [0, 0, -1], [0, 0, 0], [1, 1, 1], 6), null);
|
||||
assert.equal(
|
||||
rayBox([0.5, 0.5, 3], [0, 0, -1], [0, 0, 0], [1, 1, 1], 6).distance,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("selection intersects a fence extension owned by the cell below", () => {
|
||||
const blocks = new Map([["0,0,-2", 1]]),
|
||||
materials = new Map([
|
||||
[1, { render: [{ min: [0.375, 0, 0.375], max: [0.625, 1.5, 0.625] }] }],
|
||||
]);
|
||||
const hit = raycast([0.5, 1.25, 0], [0, 0, -1], blocks, materials);
|
||||
assert.deepEqual(hit.pos, [0, 0, -2]);
|
||||
assert.deepEqual(hit.normal, [0, 0, 1]);
|
||||
assert.equal(hit.distance, 1.375);
|
||||
});
|
||||
test("package geometry is selectable across its documented three-block extent", () => {
|
||||
const blocks = new Map([["0,0,0", 1]]);
|
||||
const materials = new Map([[1, { render: [{min:[0,0,0],max:[1,3,1]}] }]]);
|
||||
const hit = raycast([0.5,2.5,4], [0,0,-1], blocks, materials, 6);
|
||||
assert.deepEqual(hit.pos,[0,0,0]);
|
||||
assert.deepEqual(hit.normal,[0,0,1]);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
<!doctype html><meta charset="utf-8" /><title>Shacraft renderer fixture</title
|
||||
><style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
canvas {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: block;
|
||||
}</style
|
||||
><canvas id="c"></canvas>
|
||||
<script type="module">
|
||||
import { Renderer } from "../renderer.js";
|
||||
const canvas = document.querySelector("canvas");
|
||||
try {
|
||||
const r = new Renderer(canvas),
|
||||
blocks = new Map(),
|
||||
mats = new Map([
|
||||
[
|
||||
1,
|
||||
{
|
||||
state: "minecraft:grass_block",
|
||||
color: [107, 151, 65],
|
||||
opacity: 1,
|
||||
render: [{ min: [0, 0, 0], max: [1, 1, 1] }],
|
||||
},
|
||||
],
|
||||
[
|
||||
2,
|
||||
{
|
||||
state: "minecraft:bricks",
|
||||
color: [169, 88, 68],
|
||||
opacity: 1,
|
||||
render: [{ min: [0, 0, 0], max: [1, 1, 1] }],
|
||||
},
|
||||
],
|
||||
[
|
||||
3,
|
||||
{
|
||||
state: "minecraft:glass",
|
||||
color: [169, 220, 219],
|
||||
opacity: 0.35,
|
||||
render: [{ min: [0, 0, 0], max: [1, 1, 1] }],
|
||||
},
|
||||
],
|
||||
]);
|
||||
for (let x = -16; x <= 16; x++)
|
||||
for (let z = -16; z <= 16; z++) blocks.set(`${x},0,${z}`, 1);
|
||||
for (let x = -3; x < 3; x++)
|
||||
for (let y = 1; y < 5; y++)
|
||||
blocks.set(
|
||||
`${x},${y},-7`,
|
||||
(x === 0 || x === 1) && y > 1 && y < 4 ? 3 : 2,
|
||||
);
|
||||
r.replace(blocks, mats);
|
||||
for (let n = 0; n < 24; n++) {
|
||||
r.draw({ eye: [7, 5, 12], yaw: -0.38, pitch: -0.2 }, [], [], new Map());
|
||||
}
|
||||
document.body.dataset.status = `ready:${r.triangles}`;
|
||||
} catch (error) {
|
||||
document.body.dataset.status = "error:" + error.message;
|
||||
document.body.textContent = error.stack;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "shacraft-compat"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
clap.workspace = true
|
||||
shacraft-core.workspace = true
|
||||
flate2 = "1"
|
||||
sha2 = "0.10"
|
||||
tempfile = "3"
|
||||
cesu8 = "1"
|
||||
lz4_flex = "0.13"
|
||||
xxhash-rust = { version = "0.8", features = ["xxh32"] }
|
||||
rustix = { version = "1", features = ["fs"] }
|
||||
rusqlite = { version = "=0.40.2", features = ["bundled"] }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1 @@
|
||||
own fixture; retain unknown file
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": "Own minimal fixtures; generated through public Java26.2 APIs and nbtlib2.0.4, no game assets",
|
||||
"minecraft_version": "26.2",
|
||||
"data_version": 4903,
|
||||
"java_runtime": "Microsoft OpenJDK25.0.1+8",
|
||||
"server_jar_sha1": "823e2250d24b3ddac457a60c92a6a941943fcd6a",
|
||||
"server_jar_sha256": "cdacdfb25898de5e4b4b0e5ddcc2722f77067e46605709c2d886c000ebb63ec5",
|
||||
"generator_files": [
|
||||
"scripts/compat_java.java",
|
||||
"scripts/compat_fixtures.py"
|
||||
],
|
||||
"files": {
|
||||
"java26_2-world/custom-metadata.txt": {
|
||||
"bytes": 33,
|
||||
"sha256": "9d6851a4b4ad57e9a1a49d3e709cfe4de698ddb1c9add85c3afd2e38bac90ac5"
|
||||
},
|
||||
"java26_2-world/data/minecraft/game_rules.dat": {
|
||||
"bytes": 821,
|
||||
"sha256": "566d8bf7c24388101264e650bcfad24f79446758adcaf505fe5276aa0484b9ba"
|
||||
},
|
||||
"java26_2-world/data/minecraft/world_gen_settings.dat": {
|
||||
"bytes": 301,
|
||||
"sha256": "0c100210ac72c9b390c1d64287e3a57bfb40cb39c2651948d38b4d223d5c296d"
|
||||
},
|
||||
"java26_2-world/entities/r.-1.0.mca": {
|
||||
"bytes": 12288,
|
||||
"sha256": "c53c1c8c0e75ad16d3a2c7744fc92e2df4115a0423972846796d8385be831a80"
|
||||
},
|
||||
"java26_2-world/level.dat": {
|
||||
"bytes": 309,
|
||||
"sha256": "e22ec9c3da109bdb6f6c0677712d8a7767eee0d4b2b45dc953113d34b40885df"
|
||||
},
|
||||
"java26_2-world/region/r.-1.0.mca": {
|
||||
"bytes": 12288,
|
||||
"sha256": "6fc56ee0177ca0ab8eab1c1858a0641ed2efaa8230f25e6f012ec4ff42dd9424"
|
||||
},
|
||||
"sponge-v3.schem": {
|
||||
"bytes": 648,
|
||||
"sha256": "cd4041dce3e12af60c80931b730d89b8fef844fe65e1c5dcca12ba6f656d5801"
|
||||
},
|
||||
"world-template/data/minecraft/game_rules.dat": {
|
||||
"bytes": 821,
|
||||
"sha256": "a2fe317c7838ab52ac17d1d4ad74491351783bdf71de5707d2a47328e849e646"
|
||||
},
|
||||
"world-template/data/minecraft/world_gen_settings.dat": {
|
||||
"bytes": 301,
|
||||
"sha256": "0c100210ac72c9b390c1d64287e3a57bfb40cb39c2651948d38b4d223d5c296d"
|
||||
},
|
||||
"world-template/level.dat": {
|
||||
"bytes": 312,
|
||||
"sha256": "b97ea770237150db83d5ed398086b1ca5d4d9de5a5a25fd167f971a89ae59cf4"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,677 @@
|
||||
//! Offline bridge to the server's persistent entity metadata. Original typed
|
||||
//! NBT is pinned per UUID; a bounded visual subset is editable by the server.
|
||||
use crate::{
|
||||
Mode, Provenance, Report, io,
|
||||
nbt::{self, Compound, Tag, compound, get},
|
||||
};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct Entity {
|
||||
pub id: String,
|
||||
pub world: String,
|
||||
pub kind: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entity_type: String,
|
||||
pub position: [f64; 3],
|
||||
pub yaw: f64,
|
||||
pub properties: Value,
|
||||
}
|
||||
type Entities = BTreeMap<String, Entity>;
|
||||
const MAX_ENTITIES: usize = 4096;
|
||||
pub(crate) fn fingerprint(entities: &Entities) -> Result<String> {
|
||||
Ok(format!(
|
||||
"{:x}",
|
||||
Sha256::digest(serde_json::to_vec(entities)?)
|
||||
))
|
||||
}
|
||||
pub(crate) fn read_native(root: &Path) -> Result<Entities> {
|
||||
let p = root.join("server.sqlite3");
|
||||
if !p.exists() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
let conn = Connection::open_with_flags(p, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
||||
let data: String = conn.query_row(
|
||||
"SELECT json FROM metadata WHERE id=1 AND length(json)<=8388608",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let value: Value = serde_json::from_str(&data)?;
|
||||
let entities: Entities =
|
||||
serde_json::from_value(value.get("entities").cloned().unwrap_or_else(|| json!({})))?;
|
||||
ensure!(
|
||||
entities.len() <= MAX_ENTITIES,
|
||||
"entity_limit: native entity count"
|
||||
);
|
||||
for (id, e) in &entities {
|
||||
ensure!(
|
||||
id.len() == 36
|
||||
&& id
|
||||
.bytes()
|
||||
.enumerate()
|
||||
.all(|(i, b)| if [8, 13, 18, 23].contains(&i) {
|
||||
b == b'-'
|
||||
} else {
|
||||
b.is_ascii_hexdigit()
|
||||
}),
|
||||
"entity_id: UUID string required"
|
||||
);
|
||||
ensure!(
|
||||
id == &e.id
|
||||
&& e.position
|
||||
.iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= 30_000_000.)
|
||||
&& e.yaw.is_finite(),
|
||||
"entity_invalid: identity/coordinates"
|
||||
);
|
||||
ensure!(
|
||||
serde_json::to_vec(&e.properties)?.len() <= 16384,
|
||||
"entity_limit: properties"
|
||||
);
|
||||
}
|
||||
Ok(entities)
|
||||
}
|
||||
pub(crate) fn changed(root: &Path, p: &Provenance) -> Result<bool> {
|
||||
if let Some(hash) = &p.entity_fingerprint {
|
||||
Ok(&fingerprint(&read_native(root)?)? != hash)
|
||||
} else {
|
||||
Ok(!read_native(root)?.is_empty())
|
||||
}
|
||||
}
|
||||
fn baseline(root: &Path, p: Option<&Provenance>) -> Result<Entities> {
|
||||
let path = root.join("compat/native-entities.json");
|
||||
if !path.exists() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
let result: Entities = serde_json::from_slice(&io::read_limited(&path, 8 * 1024 * 1024)?)?;
|
||||
ensure!(result.len() <= MAX_ENTITIES, "entity_limit: baseline");
|
||||
if let Some(hash) = p.and_then(|p| p.entity_fingerprint.as_ref()) {
|
||||
ensure!(
|
||||
&fingerprint(&result)? == hash,
|
||||
"provenance_corrupt: entity baseline hash"
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
fn uuid(tag: &Tag) -> Result<String> {
|
||||
let c = tag.compound()?;
|
||||
let bytes = if let Some(Tag::IntArray(uuid)) = c.get("UUID") {
|
||||
if uuid.len() == 4 {
|
||||
uuid.iter()
|
||||
.flat_map(|n| n.to_be_bytes())
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
anyhow::bail!("entity_uuid: length")
|
||||
}
|
||||
} else {
|
||||
Sha256::digest(nbt::encode("", tag)?)[..16].to_vec()
|
||||
};
|
||||
let hex = bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||
Ok(format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&hex[..8],
|
||||
&hex[8..12],
|
||||
&hex[12..16],
|
||||
&hex[16..20],
|
||||
&hex[20..]
|
||||
))
|
||||
}
|
||||
fn uuid_tag(id: &str) -> Tag {
|
||||
let hex = id.replace('-', "");
|
||||
let bytes = if hex.len() == 32 {
|
||||
(0..32)
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap_or_else(|_| Sha256::digest(id.as_bytes())[..16].to_vec())
|
||||
} else {
|
||||
Sha256::digest(id.as_bytes())[..16].to_vec()
|
||||
};
|
||||
Tag::IntArray(
|
||||
bytes
|
||||
.chunks_exact(4)
|
||||
.map(|b| i32::from_be_bytes(b.try_into().expect("four bytes")))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
fn number(t: &Tag) -> Result<f64> {
|
||||
match t {
|
||||
Tag::Double(v) => Ok(f64::from_bits(*v)),
|
||||
Tag::Float(v) => Ok(f32::from_bits(*v) as f64),
|
||||
_ => anyhow::bail!("entity_position: floating point expected"),
|
||||
}
|
||||
}
|
||||
fn from_tag(tag: &Tag, world: &str) -> Result<Entity> {
|
||||
let c = tag.compound()?;
|
||||
let kind = get(c, "id")?.string()?.to_owned();
|
||||
ensure!(kind.len() <= 512, "entity_limit: kind identifier");
|
||||
let pos = get(c, "Pos")?.list(6)?;
|
||||
ensure!(pos.len() == 3, "entity_position: 3 coordinates required");
|
||||
let position = [number(&pos[0])?, number(&pos[1])?, number(&pos[2])?];
|
||||
ensure!(
|
||||
position
|
||||
.iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= 30_000_000.),
|
||||
"entity_position: range"
|
||||
);
|
||||
let yaw = c
|
||||
.get("Rotation")
|
||||
.map(|r| r.list(5))
|
||||
.transpose()?
|
||||
.and_then(|r| r.first())
|
||||
.map(number)
|
||||
.transpose()?
|
||||
.unwrap_or(0.)
|
||||
.to_radians();
|
||||
ensure!(yaw.is_finite(), "entity_yaw: finite required");
|
||||
let mut properties = serde_json::Map::new();
|
||||
for key in [
|
||||
"Health",
|
||||
"CustomName",
|
||||
"NoGravity",
|
||||
"Invisible",
|
||||
"Invulnerable",
|
||||
"Glowing",
|
||||
"Silent",
|
||||
"CustomNameVisible",
|
||||
] {
|
||||
if let Some(t) = c.get(key) {
|
||||
let v = match t {
|
||||
Tag::Byte(v) => json!(*v != 0),
|
||||
Tag::Float(v) => json!(f32::from_bits(*v)),
|
||||
Tag::String(v) => json!(v),
|
||||
_ => continue,
|
||||
};
|
||||
properties.insert(key.into(), v);
|
||||
}
|
||||
}
|
||||
let id = uuid(tag)?;
|
||||
ensure!(
|
||||
serde_json::to_vec(&properties)?.len() <= 16384,
|
||||
"entity_limit: visual properties"
|
||||
);
|
||||
Ok(Entity {
|
||||
id,
|
||||
world: world.into(),
|
||||
entity_type: kind.clone(),
|
||||
kind,
|
||||
position,
|
||||
yaw,
|
||||
properties: Value::Object(properties),
|
||||
})
|
||||
}
|
||||
fn keep(
|
||||
root: &Path,
|
||||
tag: &Tag,
|
||||
world: &str,
|
||||
entities: &mut Entities,
|
||||
report: &mut Report,
|
||||
) -> Result<()> {
|
||||
let entity = match from_tag(tag, world) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
report.issue(
|
||||
"entity_visual_unavailable",
|
||||
"preserved",
|
||||
world,
|
||||
format!("Typed NBT retained; cannot bridge entity: {e}"),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let ambiguous = root.join("compat/ambiguous-uuids").join(&entity.id);
|
||||
if ambiguous.exists() {
|
||||
report.issue(
|
||||
"entity_duplicate_uuid",
|
||||
"preserved",
|
||||
&entity.id,
|
||||
"Ambiguous UUID excluded from editable subset; all records retained in source",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(previous) = entities.remove(&entity.id) {
|
||||
report.native_entity_bytes = report
|
||||
.native_entity_bytes
|
||||
.saturating_sub((serde_json::to_vec(&previous)?.len() + previous.id.len() + 8) as u64);
|
||||
fs::create_dir_all(ambiguous.parent().context("ambiguous UUID parent")?)?;
|
||||
fs::write(&ambiguous, b"ambiguous UUID; preserve source records\n")?;
|
||||
fs::remove_file(
|
||||
root.join("compat/entities")
|
||||
.join(format!("{}.nbt", entity.id)),
|
||||
)?;
|
||||
report.issue(
|
||||
"entity_duplicate_uuid",
|
||||
"preserved",
|
||||
&entity.id,
|
||||
"All duplicate UUID records remain in source; excluded from editable subset",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if entities.len() >= MAX_ENTITIES {
|
||||
report.issue("entity_visual_limit","preserved",&entity.id,"More than 4096 entities; additional records remain in disk source and roundtrip unchanged");
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = (serde_json::to_vec(&entity)?.len() + entity.id.len() + 8) as u64;
|
||||
if report.native_entity_bytes + bytes > 6 * 1024 * 1024 {
|
||||
report.issue("entity_visual_byte_limit","preserved",&entity.id,"Native entity subset exceeds 6 MiB; source NBT remains on disk. Reserved space keeps full server metadata below 8 MiB.");
|
||||
return Ok(());
|
||||
}
|
||||
report.native_entity_bytes += bytes;
|
||||
let path = root.join("compat/entities");
|
||||
fs::create_dir_all(&path)?;
|
||||
fs::write(
|
||||
path.join(format!("{}.nbt", entity.id)),
|
||||
nbt::encode("", tag)?,
|
||||
)?;
|
||||
entities.insert(entity.id.clone(), entity);
|
||||
Ok(())
|
||||
}
|
||||
fn save(root: &Path, p: &mut Provenance, entities: Entities) -> Result<()> {
|
||||
let worlds = p
|
||||
.worlds
|
||||
.keys()
|
||||
.map(|w| {
|
||||
let spawn = if let (Some(min), Some(max)) = (p.schem_min, p.schem_max) {
|
||||
[min[0] as f64 + 0.5, max[1] as f64 + 2., min[2] as f64 + 2.5]
|
||||
} else {
|
||||
[0., 80., 0.]
|
||||
};
|
||||
(w.clone(), json!({"spawn":spawn,"mode":"creative"}))
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
let value = json!({"worlds":worlds,"entities":entities,"revision":0});
|
||||
ensure!(
|
||||
serde_json::to_vec(&value)?.len() <= 8 * 1024 * 1024,
|
||||
"entity_limit: server metadata exceeds 8 MiB"
|
||||
);
|
||||
let conn = Connection::open(root.join("server.sqlite3"))?;
|
||||
conn.execute_batch("PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; CREATE TABLE metadata(id INTEGER PRIMARY KEY CHECK(id=1),json TEXT NOT NULL);")?;
|
||||
conn.execute(
|
||||
"INSERT INTO metadata(id,json)VALUES(1,?1)",
|
||||
[serde_json::to_string(&value)?],
|
||||
)?;
|
||||
p.entity_fingerprint = Some(fingerprint(&entities)?);
|
||||
for id in entities.keys() {
|
||||
let bytes = io::read_limited(
|
||||
&root.join("compat/entities").join(format!("{id}.nbt")),
|
||||
nbt::MAX_BYTES,
|
||||
)?;
|
||||
p.entity_blob_hashes
|
||||
.insert(id.clone(), format!("{:x}", Sha256::digest(bytes)));
|
||||
}
|
||||
io::json(&root.join("compat/native-entities.json"), &entities)?;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn import_anvil(
|
||||
root: &Path,
|
||||
original: &Path,
|
||||
p: &mut Provenance,
|
||||
report: &mut Report,
|
||||
) -> Result<()> {
|
||||
let mut entities = BTreeMap::new();
|
||||
for (world, dim) in &p.worlds {
|
||||
let base = if dim.path.is_empty() {
|
||||
original.to_owned()
|
||||
} else {
|
||||
io::safe_path(original, &dim.path)?
|
||||
};
|
||||
let folder = base.join("entities");
|
||||
if !folder.exists() {
|
||||
continue;
|
||||
}
|
||||
for e in fs::read_dir(folder)? {
|
||||
let path = e?.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("mca") {
|
||||
continue;
|
||||
}
|
||||
let mut r = crate::anvil::Region::open(&path, false)?;
|
||||
for i in 0..1024 {
|
||||
if let Some((_, tag)) = r.read(i)? {
|
||||
let tag = tag.compound()?;
|
||||
let version = get(tag, "DataVersion")?.int()?;
|
||||
ensure!(
|
||||
report.mode != Mode::Exact || version == crate::DATA_VERSION,
|
||||
"unsupported_data_version: entity chunk uses {version}"
|
||||
);
|
||||
if version != crate::DATA_VERSION {
|
||||
report.issue(
|
||||
"unverified_entity_version",
|
||||
"unsupported",
|
||||
world,
|
||||
format!(
|
||||
"Entity chunk DataVersion {version} is preserved without migration"
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(list) = tag.get("Entities") {
|
||||
for entity in list.list(10)? {
|
||||
keep(root, entity, world, &mut entities, report)?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
report.issue("entity_visual_bridge","preserved","server.sqlite3",format!("{} entities are editable through server/MCP; full original typed NBT is pinned on disk",entities.len()));
|
||||
save(root, p, entities)
|
||||
}
|
||||
fn schem_tag(t: &Tag, offset: [i32; 3]) -> Result<Tag> {
|
||||
let c = t.compound()?;
|
||||
let mut data = c
|
||||
.get("Data")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| compound([] as [(&str, Tag); 0]));
|
||||
let data = data.compound_mut()?;
|
||||
data.insert("id".into(), get(c, "Id")?.clone());
|
||||
let list = get(c, "Pos")?.list(6)?;
|
||||
ensure!(list.len() == 3, "schem_entity_position");
|
||||
let mut pos = Vec::new();
|
||||
for i in 0..3 {
|
||||
pos.push(Tag::Double(
|
||||
(number(&list[i])? + f64::from(offset[i])).to_bits(),
|
||||
))
|
||||
}
|
||||
data.insert("Pos".into(), Tag::List(6, pos));
|
||||
Ok(Tag::Compound(data.clone()))
|
||||
}
|
||||
pub(crate) fn import_schem(
|
||||
root: &Path,
|
||||
c: &Compound,
|
||||
p: &mut Provenance,
|
||||
report: &mut Report,
|
||||
) -> Result<()> {
|
||||
let mut entities = BTreeMap::new();
|
||||
let world = p.worlds.keys().next().context("schem world")?;
|
||||
if let Some(t) = c.get("Entities") {
|
||||
for entity in t.list(10)? {
|
||||
let tag = schem_tag(entity, p.schem_min.unwrap_or([0; 3]))?;
|
||||
keep(root, &tag, world, &mut entities, report)?
|
||||
}
|
||||
}
|
||||
save(root, p, entities)
|
||||
}
|
||||
|
||||
fn materialize(
|
||||
root: &Path,
|
||||
e: &Entity,
|
||||
base: Option<&Entity>,
|
||||
p: Option<&Provenance>,
|
||||
report: &mut Report,
|
||||
) -> Result<Tag> {
|
||||
let mut tag = if base.is_some() {
|
||||
{
|
||||
let bytes = io::read_limited(
|
||||
&root.join("compat/entities").join(format!("{}.nbt", e.id)),
|
||||
nbt::MAX_BYTES,
|
||||
)?;
|
||||
let hash = format!("{:x}", Sha256::digest(&bytes));
|
||||
ensure!(
|
||||
p.and_then(|p| p.entity_blob_hashes.get(&e.id)) == Some(&hash),
|
||||
"provenance_corrupt: entity blob hash"
|
||||
);
|
||||
nbt::decode(&bytes)?.1
|
||||
}
|
||||
} else {
|
||||
compound([("UUID", uuid_tag(&e.id))])
|
||||
};
|
||||
if base == Some(e) {
|
||||
return Ok(tag);
|
||||
}
|
||||
let c = tag.compound_mut()?;
|
||||
if let Some(base) = base
|
||||
&& let Some(old) = base.properties.as_object()
|
||||
{
|
||||
for key in old.keys() {
|
||||
if e.properties.get(key).is_none() {
|
||||
c.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
c.insert("id".into(), Tag::String(e.kind.clone()));
|
||||
c.insert(
|
||||
"Pos".into(),
|
||||
Tag::List(
|
||||
6,
|
||||
e.position
|
||||
.iter()
|
||||
.map(|v| Tag::Double(v.to_bits()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
let pitch = c
|
||||
.get("Rotation")
|
||||
.and_then(|t| t.list(5).ok())
|
||||
.and_then(|v| v.get(1))
|
||||
.cloned()
|
||||
.unwrap_or(Tag::Float(0));
|
||||
c.insert(
|
||||
"Rotation".into(),
|
||||
Tag::List(
|
||||
5,
|
||||
vec![Tag::Float((e.yaw.to_degrees() as f32).to_bits()), pitch],
|
||||
),
|
||||
);
|
||||
if let Some(props) = e.properties.as_object() {
|
||||
for (k, v) in props {
|
||||
let tag = match k.as_str() {
|
||||
"Health" => v
|
||||
.as_f64()
|
||||
.filter(|v| v.is_finite() && v.abs() <= f32::MAX as f64)
|
||||
.map(|v| Tag::Float((v as f32).to_bits())),
|
||||
"CustomName" => v.as_str().map(|v| Tag::String(v.into())),
|
||||
"NoGravity" | "Invisible" | "Invulnerable" | "Glowing" | "Silent"
|
||||
| "CustomNameVisible" => v.as_bool().map(|v| Tag::Byte(v as i8)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(tag) = tag {
|
||||
c.insert(k.clone(), tag);
|
||||
} else {
|
||||
ensure!(
|
||||
report.mode != Mode::Exact,
|
||||
"exact_entity_property: unsupported {k}"
|
||||
);
|
||||
report.issue("entity_property_unmapped","unsupported",&e.id,format!("Property {k} retained in shacraft-native-entities.json; not translated into vanilla NBT"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if base.is_some() {
|
||||
report.issue("entity_edited","approximated",&e.id,"Position/yaw/type and supported scalar properties applied; unknown original NBT retained; passenger/tether/AI relationships not simulated")
|
||||
} else {
|
||||
report.issue("entity_created","preserved",&e.id,"UUID, position, rotation, supported properties emitted; vanilla initializes omitted defaults")
|
||||
}
|
||||
Ok(tag)
|
||||
}
|
||||
pub(crate) fn export_anvil(
|
||||
root: &Path,
|
||||
out: &Path,
|
||||
p: Option<&Provenance>,
|
||||
worlds: &BTreeMap<String, String>,
|
||||
report: &mut Report,
|
||||
) -> Result<()> {
|
||||
let current = read_native(root)?;
|
||||
let base = baseline(root, p)?;
|
||||
let source_is_anvil = p.is_some_and(|p| p.format == "anvil");
|
||||
let scratch = tempfile::Builder::new()
|
||||
.prefix(".entity-export-")
|
||||
.tempdir_in(out)?;
|
||||
// Remove only the editable baseline subset. Unbridged records remain intact.
|
||||
for (world, dim) in worlds {
|
||||
let dir = if dim.is_empty() {
|
||||
out.to_owned()
|
||||
} else {
|
||||
io::safe_path(out, dim)?
|
||||
};
|
||||
let folder = dir.join("entities");
|
||||
if !folder.exists() {
|
||||
continue;
|
||||
}
|
||||
for e in fs::read_dir(&folder)? {
|
||||
let path = e?.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("mca") {
|
||||
continue;
|
||||
}
|
||||
let mut r = crate::anvil::Region::open(&path, true)?;
|
||||
for i in 0..1024 {
|
||||
let Some((name, mut tag)) = r.read(i)? else {
|
||||
continue;
|
||||
};
|
||||
if let Some(Tag::List(_, list)) = tag.compound_mut()?.get_mut("Entities") {
|
||||
let before = list.len();
|
||||
list.retain(|t| {
|
||||
uuid(t)
|
||||
.ok()
|
||||
.and_then(|id| base.get(&id))
|
||||
.is_none_or(|b| &b.world != world || current.get(&b.id) == Some(b))
|
||||
});
|
||||
if list.len() != before {
|
||||
r.write(i, &name, &tag)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, e) in ¤t {
|
||||
let Some(dim) = worlds.get(&e.world) else {
|
||||
continue;
|
||||
};
|
||||
if source_is_anvil && base.get(id) == Some(e) {
|
||||
continue;
|
||||
}
|
||||
let tag = materialize(root, e, base.get(id), p, report)?;
|
||||
if !e.kind.starts_with("minecraft:") {
|
||||
report.issue(
|
||||
"nonvanilla_entity",
|
||||
"unsupported",
|
||||
id,
|
||||
"Preserved entity id requires matching target mod; vanilla may discard it",
|
||||
);
|
||||
}
|
||||
fs::write(
|
||||
scratch.path().join(format!("{id}.nbt")),
|
||||
nbt::encode("", &tag)?,
|
||||
)?;
|
||||
let (x, z) = (
|
||||
(e.position[0].floor() as i32).div_euclid(16),
|
||||
(e.position[2].floor() as i32).div_euclid(16),
|
||||
);
|
||||
let dir = if dim.is_empty() {
|
||||
out.to_owned()
|
||||
} else {
|
||||
io::safe_path(out, dim)?
|
||||
};
|
||||
let path =
|
||||
dir.join("entities")
|
||||
.join(format!("r.{}.{}.mca", x.div_euclid(32), z.div_euclid(32)));
|
||||
let mut r = if path.exists() {
|
||||
crate::anvil::Region::open(&path, true)?
|
||||
} else {
|
||||
crate::anvil::Region::create(&path)?
|
||||
};
|
||||
let index = (x.rem_euclid(32) + z.rem_euclid(32) * 32) as usize;
|
||||
let (name, mut chunk) = r.read(index)?.unwrap_or_else(|| {
|
||||
(
|
||||
"".into(),
|
||||
compound([
|
||||
("DataVersion", Tag::Int(crate::DATA_VERSION)),
|
||||
("Position", Tag::IntArray(vec![x, z])),
|
||||
("Entities", Tag::List(10, vec![])),
|
||||
]),
|
||||
)
|
||||
});
|
||||
let c = chunk.compound_mut()?;
|
||||
let list = c.entry("Entities".into()).or_insert(Tag::List(10, vec![]));
|
||||
if let Tag::List(kind, list) = list {
|
||||
ensure!(*kind == 10 || list.is_empty(), "entity_list_type");
|
||||
*kind = 10;
|
||||
list.push(tag);
|
||||
} else {
|
||||
anyhow::bail!("entity_list_type")
|
||||
};
|
||||
r.write(index, &name, &chunk)?;
|
||||
}
|
||||
for (id, e) in &base {
|
||||
if worlds.contains_key(&e.world) && !current.contains_key(id) {
|
||||
report.issue(
|
||||
"entity_removed",
|
||||
"dropped",
|
||||
id,
|
||||
"Removed via native metadata; original typed NBT remains in source sidecar",
|
||||
)
|
||||
}
|
||||
}
|
||||
let saved = io::available(out, "shacraft-native-entities", ".json")?;
|
||||
io::json(&saved, ¤t)?;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn export_schem(
|
||||
root: &Path,
|
||||
world: &str,
|
||||
c: &mut Compound,
|
||||
offset: [i32; 3],
|
||||
p: Option<&Provenance>,
|
||||
report: &mut Report,
|
||||
) -> Result<()> {
|
||||
let current = read_native(root)?;
|
||||
let base = baseline(root, p)?;
|
||||
let had_entities = c.contains_key("Entities");
|
||||
let mut source = match c.remove("Entities") {
|
||||
Some(Tag::List(_, v)) => v,
|
||||
None => vec![],
|
||||
_ => anyhow::bail!("schem_entities_type"),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut original_wrappers = BTreeMap::new();
|
||||
for entity in source.drain(..) {
|
||||
let tag = schem_tag(&entity, offset)?;
|
||||
let id = uuid(&tag)?;
|
||||
if base.contains_key(&id) {
|
||||
original_wrappers.insert(id, entity);
|
||||
continue;
|
||||
}
|
||||
out.push(entity);
|
||||
}
|
||||
for (id, e) in ¤t {
|
||||
if e.world != world {
|
||||
continue;
|
||||
}
|
||||
if base.get(id) == Some(e)
|
||||
&& let Some(wrapper) = original_wrappers.remove(id)
|
||||
{
|
||||
out.push(wrapper);
|
||||
continue;
|
||||
}
|
||||
let tag = materialize(root, e, base.get(id), p, report)?;
|
||||
let mut data = tag.compound()?.clone();
|
||||
data.remove("id");
|
||||
data.remove("Pos");
|
||||
let mut wrapper = original_wrappers
|
||||
.remove(id)
|
||||
.unwrap_or_else(|| compound([] as [(&str, Tag); 0]));
|
||||
let updated = compound([
|
||||
("Id", Tag::String(e.kind.clone())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(
|
||||
6,
|
||||
e.position
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| Tag::Double((v - f64::from(offset[i])).to_bits()))
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
("Data", Tag::Compound(data)),
|
||||
]);
|
||||
wrapper.compound_mut()?.extend(updated.compound()?.clone());
|
||||
out.push(wrapper);
|
||||
}
|
||||
if !out.is_empty() || had_entities || !base.is_empty() {
|
||||
c.insert("Entities".into(), Tag::List(10, out));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
pub(crate) fn read_limited(p: &Path, max: usize) -> Result<Vec<u8>> {
|
||||
let mut b = Vec::new();
|
||||
File::open(p)?.take(max as u64 + 1).read_to_end(&mut b)?;
|
||||
ensure!(b.len() <= max, "input_limit: file {}", p.display());
|
||||
Ok(b)
|
||||
}
|
||||
pub(crate) fn inflate(r: impl Read) -> Result<Vec<u8>> {
|
||||
let mut b = Vec::new();
|
||||
r.take(super::nbt::MAX_BYTES as u64 + 1)
|
||||
.read_to_end(&mut b)?;
|
||||
ensure!(
|
||||
b.len() <= super::nbt::MAX_BYTES,
|
||||
"input_limit: decompressed NBT exceeds16MiB"
|
||||
);
|
||||
Ok(b)
|
||||
}
|
||||
pub(crate) fn gzip_read(p: &Path) -> Result<Vec<u8>> {
|
||||
inflate(flate2::read::GzDecoder::new(File::open(p)?))
|
||||
}
|
||||
pub(crate) fn gzip_write(p: &Path, b: &[u8]) -> Result<()> {
|
||||
let f = OpenOptions::new().write(true).create_new(true).open(p)?;
|
||||
let mut w = flate2::write::GzEncoder::new(f, flate2::Compression::default());
|
||||
w.write_all(b)?;
|
||||
w.finish()?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn json(p: &Path, v: &impl Serialize) -> Result<()> {
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(p)?;
|
||||
serde_json::to_writer_pretty(&mut f, v)?;
|
||||
f.write_all(b"\n")?;
|
||||
f.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn available(root: &Path, stem: &str, extension: &str) -> Result<PathBuf> {
|
||||
for i in 0..10000 {
|
||||
let suffix = if i == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{i}")
|
||||
};
|
||||
let p = root.join(format!("{stem}{suffix}{extension}"));
|
||||
if !p.exists() {
|
||||
return Ok(p);
|
||||
}
|
||||
}
|
||||
bail!("output_limit: too many reserved filenames")
|
||||
}
|
||||
pub(crate) fn stage(dest: &Path) -> Result<tempfile::TempDir> {
|
||||
ensure!(
|
||||
!dest.exists(),
|
||||
"destination_exists: refusing overwrite {}",
|
||||
dest.display()
|
||||
);
|
||||
let parent = dest
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
fs::create_dir_all(parent)?;
|
||||
Ok(tempfile::Builder::new()
|
||||
.prefix(".shacraft-convert-")
|
||||
.tempdir_in(parent)?)
|
||||
}
|
||||
pub(crate) fn publish(stage: tempfile::TempDir, dest: &Path) -> Result<()> {
|
||||
sync_tree(stage.path(), 0)?;
|
||||
let parent = dest
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
rustix::fs::renameat_with(
|
||||
rustix::fs::CWD,
|
||||
stage.path(),
|
||||
rustix::fs::CWD,
|
||||
dest,
|
||||
rustix::fs::RenameFlags::NOREPLACE,
|
||||
)
|
||||
.context("publish: atomic no-overwrite rename")?;
|
||||
File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn report(root: &Path, value: &super::Report) -> Result<()> {
|
||||
// Imported worlds may already carry a report; never replace source files.
|
||||
for i in 0..10000 {
|
||||
let name = if i == 0 {
|
||||
"conversion-report.json".to_owned()
|
||||
} else {
|
||||
format!("conversion-report-{i}.json")
|
||||
};
|
||||
let path = root.join(name);
|
||||
if !path.exists() {
|
||||
return json(&path, value);
|
||||
}
|
||||
}
|
||||
bail!("report_limit: too many existing reports")
|
||||
}
|
||||
fn sync_tree(p: &Path, depth: usize) -> Result<()> {
|
||||
ensure!(depth <= 32, "input_limit: path depth");
|
||||
for e in fs::read_dir(p)? {
|
||||
let e = e?;
|
||||
let t = e.file_type()?;
|
||||
if t.is_dir() {
|
||||
sync_tree(&e.path(), depth + 1)?
|
||||
} else if t.is_file() {
|
||||
File::open(e.path())?.sync_all()?
|
||||
} else {
|
||||
bail!("unsafe_path: special file")
|
||||
}
|
||||
}
|
||||
File::open(p)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct FileHash {
|
||||
path: String,
|
||||
size: u64,
|
||||
sha256: String,
|
||||
}
|
||||
pub(crate) fn safe_path(root: &Path, s: &str) -> Result<PathBuf> {
|
||||
let p = Path::new(s);
|
||||
ensure!(
|
||||
p.components().all(|c| matches!(c, Component::Normal(_))),
|
||||
"unsafe_path: nonrelative path"
|
||||
);
|
||||
let mut result = root.to_path_buf();
|
||||
ensure!(
|
||||
!fs::symlink_metadata(root)?.file_type().is_symlink(),
|
||||
"unsafe_path: symlink root"
|
||||
);
|
||||
for part in p.components() {
|
||||
result.push(part);
|
||||
if let Ok(metadata) = fs::symlink_metadata(&result) {
|
||||
ensure!(
|
||||
!metadata.file_type().is_symlink(),
|
||||
"unsafe_path: symlink component"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub(crate) fn copy_source(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
index: &Path,
|
||||
report: &mut super::Report,
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
!fs::symlink_metadata(source)?.file_type().is_symlink(),
|
||||
"unsafe_path: symlink root"
|
||||
);
|
||||
fs::create_dir_all(dest)?;
|
||||
let mut log = File::create(index)?;
|
||||
copy_dir(source, dest, source, &mut log, report, 0)?;
|
||||
log.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
fn copy_dir(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
root: &Path,
|
||||
index: &mut File,
|
||||
report: &mut super::Report,
|
||||
depth: usize,
|
||||
) -> Result<()> {
|
||||
ensure!(depth <= 24, "input_limit: directory depth");
|
||||
for entry in fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
let kind = entry.file_type()?;
|
||||
let to = dest.join(entry.file_name());
|
||||
if kind.is_dir() {
|
||||
fs::create_dir(&to)?;
|
||||
copy_dir(&entry.path(), &to, root, index, report, depth + 1)?
|
||||
} else {
|
||||
ensure!(
|
||||
kind.is_file(),
|
||||
"unsafe_path: symlink/special file {}",
|
||||
entry.path().display()
|
||||
);
|
||||
report.source_files += 1;
|
||||
ensure!(
|
||||
report.source_files <= 1_000_000,
|
||||
"input_limit: source file count"
|
||||
);
|
||||
let mut from = File::open(entry.path())?;
|
||||
let before = from.metadata()?;
|
||||
ensure!(
|
||||
before.len() <= 8 * 1024 * 1024 * 1024,
|
||||
"input_limit: individual source file exceeds8GiB"
|
||||
);
|
||||
let mut out = OpenOptions::new().write(true).create_new(true).open(&to)?;
|
||||
let (size, hash) = copy_hash(&mut from, &mut out)?;
|
||||
let after = from.metadata()?;
|
||||
ensure!(
|
||||
size == before.len()
|
||||
&& after.len() == before.len()
|
||||
&& after.modified()? == before.modified()?,
|
||||
"source_changed: {}",
|
||||
entry.path().display()
|
||||
);
|
||||
report.source_bytes = report
|
||||
.source_bytes
|
||||
.checked_add(size)
|
||||
.context("input_limit: bytes overflow")?;
|
||||
ensure!(
|
||||
report.source_bytes <= 1024 * 1024 * 1024 * 1024,
|
||||
"input_limit: source exceeds1TiB"
|
||||
);
|
||||
let path = entry
|
||||
.path()
|
||||
.strip_prefix(root)?
|
||||
.to_str()
|
||||
.context("unsafe_path: non-UTF8 filename")?
|
||||
.to_owned();
|
||||
serde_json::to_writer(
|
||||
&mut *index,
|
||||
&FileHash {
|
||||
path,
|
||||
size,
|
||||
sha256: hash,
|
||||
},
|
||||
)?;
|
||||
index.write_all(b"\n")?;
|
||||
out.sync_all()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn copy_hash(r: &mut impl Read, w: &mut impl Write) -> Result<(u64, String)> {
|
||||
let mut sha = Sha256::new();
|
||||
let mut buf = [0; 65536];
|
||||
let mut size = 0;
|
||||
loop {
|
||||
let n = r.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
sha.update(&buf[..n]);
|
||||
w.write_all(&buf[..n])?;
|
||||
size += n as u64;
|
||||
}
|
||||
Ok((size, format!("{:x}", sha.finalize())))
|
||||
}
|
||||
pub(crate) fn copy_verified(root: &Path, dest: &Path, index: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dest)?;
|
||||
let mut reader = BufReader::new(File::open(index)?);
|
||||
let mut count = 0;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = (&mut reader).take(16385).read_line(&mut line)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
ensure!(
|
||||
n <= 16384 && line.ends_with('\n'),
|
||||
"provenance_corrupt: checksum record"
|
||||
);
|
||||
count += 1;
|
||||
ensure!(count <= 1_000_000, "input_limit: checksum records");
|
||||
let item: FileHash = serde_json::from_str(&line)?;
|
||||
let from = safe_path(root, &item.path)?;
|
||||
ensure!(
|
||||
fs::symlink_metadata(&from)?.file_type().is_file(),
|
||||
"provenance_corrupt: not regular file"
|
||||
);
|
||||
let to = safe_path(dest, &item.path)?;
|
||||
fs::create_dir_all(to.parent().context("path")?)?;
|
||||
let mut w = OpenOptions::new().write(true).create_new(true).open(to)?;
|
||||
ensure!(
|
||||
item.size <= 8 * 1024 * 1024 * 1024,
|
||||
"input_limit: source file size"
|
||||
);
|
||||
let mut input = File::open(from)?.take(item.size + 1);
|
||||
let (size, hash) = copy_hash(&mut input, &mut w)?;
|
||||
ensure!(
|
||||
size == item.size && hash == item.sha256,
|
||||
"provenance_corrupt: source hash {}",
|
||||
item.path
|
||||
);
|
||||
w.sync_all()?
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn visit_files(
|
||||
root: &Path,
|
||||
callback: &mut impl FnMut(&Path) -> Result<()>,
|
||||
depth: usize,
|
||||
) -> Result<()> {
|
||||
ensure!(depth <= 24, "input_limit: directory depth");
|
||||
for e in fs::read_dir(root)? {
|
||||
let e = e?;
|
||||
let t = e.file_type()?;
|
||||
if t.is_dir() {
|
||||
visit_files(&e.path(), callback, depth + 1)?
|
||||
} else {
|
||||
ensure!(t.is_file(), "unsafe_path: symlink/special file");
|
||||
callback(&e.path())?
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Offline world interchange. Imports publish a new native store atomically;
|
||||
//! exports hold its single-writer lock and never overwrite a destination.
|
||||
mod anvil;
|
||||
mod entities;
|
||||
mod io;
|
||||
pub mod nbt;
|
||||
mod schem;
|
||||
pub use anvil::{export_anvil, import_anvil};
|
||||
use anyhow::{Result, ensure};
|
||||
pub use schem::{export_schem, import_schem};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shacraft_core::WorldStore;
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
pub const DATA_VERSION: i32 = 4903;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Mode {
|
||||
Exact,
|
||||
BestEffort,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Issue {
|
||||
pub code: String,
|
||||
pub severity: String,
|
||||
pub action: String,
|
||||
pub location: String,
|
||||
pub detail: String,
|
||||
pub count: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Report {
|
||||
pub schema_version: u32,
|
||||
pub format: String,
|
||||
pub mode: Mode,
|
||||
pub data_version: i32,
|
||||
pub worlds: BTreeMap<String, u64>,
|
||||
pub chunks: u64,
|
||||
pub sections: u64,
|
||||
pub nonair_blocks: u64,
|
||||
pub issues: Vec<Issue>,
|
||||
pub issue_limit: usize,
|
||||
pub omitted_issues: u64,
|
||||
pub source_files: u64,
|
||||
pub source_bytes: u64,
|
||||
pub native_entity_bytes: u64,
|
||||
pub profile: String,
|
||||
}
|
||||
impl Report {
|
||||
fn new(format: &str, mode: Mode) -> Self {
|
||||
Self{schema_version:1,format:format.into(),mode,data_version:DATA_VERSION,worlds:BTreeMap::new(),chunks:0,sections:0,nonair_blocks:0,issues:vec![],issue_limit:1000,omitted_issues:0,source_files:0,source_bytes:0,native_entity_bytes:0,profile:"Java 26.2 (DataVersion 4903); blocks editable, other typed NBT preserved on disk; offline writer lock".into()}
|
||||
}
|
||||
fn issue(
|
||||
&mut self,
|
||||
code: &str,
|
||||
action: &str,
|
||||
location: impl Into<String>,
|
||||
detail: impl Into<String>,
|
||||
) {
|
||||
if self.issues.len() < self.issue_limit {
|
||||
self.issues.push(Issue {
|
||||
code: code.into(),
|
||||
severity: if action == "preserved" {
|
||||
"info"
|
||||
} else {
|
||||
"warning"
|
||||
}
|
||||
.into(),
|
||||
action: action.into(),
|
||||
location: location.into(),
|
||||
detail: detail.into(),
|
||||
count: 1,
|
||||
})
|
||||
} else {
|
||||
self.omitted_issues += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Provenance {
|
||||
schema_version: u32,
|
||||
format: String,
|
||||
data_version: i32,
|
||||
worlds: BTreeMap<String, Dimension>,
|
||||
schem_min: Option<[i32; 3]>,
|
||||
schem_max: Option<[i32; 3]>,
|
||||
#[serde(default)]
|
||||
entity_fingerprint: Option<String>,
|
||||
#[serde(default)]
|
||||
entity_blob_hashes: BTreeMap<String, String>,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Dimension {
|
||||
path: String,
|
||||
revision: u64,
|
||||
}
|
||||
fn provenance(root: &Path) -> Result<Option<Provenance>> {
|
||||
let p = root.join("compat/provenance.json");
|
||||
if !p.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let b = io::read_limited(&p, 4 * 1024 * 1024)?;
|
||||
let p: Provenance = serde_json::from_slice(&b)?;
|
||||
ensure!(
|
||||
p.schema_version == 1,
|
||||
"compat_version: unsupported provenance"
|
||||
);
|
||||
Ok(Some(p))
|
||||
}
|
||||
fn section_cells(store: &mut WorldStore, world: &str, pos: [i32; 3]) -> Result<[u32; 4096]> {
|
||||
let min = pos.map(|v| v * 16);
|
||||
let max = min.map(|v| v + 15);
|
||||
let mut cells = [0; 4096];
|
||||
for c in store.read_region(world, min, max)? {
|
||||
let [x, y, z] = c.pos.map(|v| v.rem_euclid(16) as usize);
|
||||
cells[x + z * 16 + y * 256] = c.block
|
||||
}
|
||||
Ok(cells)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use shacraft_compat::{Mode, export_anvil, export_schem, import_anvil, import_schem};
|
||||
use std::path::PathBuf;
|
||||
#[derive(Parser)]
|
||||
#[command(about = "Offline bounded Java26.2 world converter; every destination must be new")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
ImportAnvil {
|
||||
source: PathBuf,
|
||||
destination: PathBuf,
|
||||
#[arg(long, value_enum, default_value = "exact")]
|
||||
mode: Mode,
|
||||
},
|
||||
ExportAnvil {
|
||||
store: PathBuf,
|
||||
destination: PathBuf,
|
||||
#[arg(long)]
|
||||
world: Option<String>,
|
||||
#[arg(long, value_enum, default_value = "exact")]
|
||||
mode: Mode,
|
||||
},
|
||||
ImportSchem {
|
||||
source: PathBuf,
|
||||
destination: PathBuf,
|
||||
#[arg(long, default_value = "main")]
|
||||
world: String,
|
||||
#[arg(long, value_enum, default_value = "exact")]
|
||||
mode: Mode,
|
||||
},
|
||||
ExportSchem {
|
||||
store: PathBuf,
|
||||
destination: PathBuf,
|
||||
#[arg(long, default_value = "main")]
|
||||
world: String,
|
||||
#[arg(long,value_parser=parse_pos,allow_hyphen_values=true)]
|
||||
min: Option<[i32; 3]>,
|
||||
#[arg(long,value_parser=parse_pos,allow_hyphen_values=true)]
|
||||
max: Option<[i32; 3]>,
|
||||
#[arg(long, value_enum, default_value = "exact")]
|
||||
mode: Mode,
|
||||
},
|
||||
}
|
||||
fn parse_pos(s: &str) -> Result<[i32; 3], String> {
|
||||
let parts = s
|
||||
.split(',')
|
||||
.map(str::parse::<i32>)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
parts
|
||||
.try_into()
|
||||
.map_err(|_| "coordinates must be x,y,z".into())
|
||||
}
|
||||
fn run() -> Result<()> {
|
||||
let report = match Cli::parse().command {
|
||||
Command::ImportAnvil {
|
||||
source,
|
||||
destination,
|
||||
mode,
|
||||
} => import_anvil(&source, &destination, mode)?,
|
||||
Command::ExportAnvil {
|
||||
store,
|
||||
destination,
|
||||
world,
|
||||
mode,
|
||||
} => export_anvil(&store, world.as_deref(), &destination, mode)?,
|
||||
Command::ImportSchem {
|
||||
source,
|
||||
destination,
|
||||
world,
|
||||
mode,
|
||||
} => import_schem(&source, &destination, &world, mode)?,
|
||||
Command::ExportSchem {
|
||||
store,
|
||||
destination,
|
||||
world,
|
||||
min,
|
||||
max,
|
||||
mode,
|
||||
} => export_schem(&store, &world, &destination, min, max, mode)?,
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
Ok(())
|
||||
}
|
||||
fn main() {
|
||||
if let Err(e) = run() {
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({"error":format!("{e:#}"),"success":false})
|
||||
);
|
||||
std::process::exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Bounded, big-endian Java NBT. Numeric types, float bit patterns and list
|
||||
//! element types (including empty lists) survive decoding and encoding.
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
pub type Compound = BTreeMap<String, Tag>;
|
||||
pub const MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAX_NODES: usize = 1_000_000;
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Tag {
|
||||
Byte(i8),
|
||||
Short(i16),
|
||||
Int(i32),
|
||||
Long(i64),
|
||||
Float(u32),
|
||||
Double(u64),
|
||||
ByteArray(Vec<u8>),
|
||||
String(String),
|
||||
List(u8, Vec<Tag>),
|
||||
Compound(Compound),
|
||||
IntArray(Vec<i32>),
|
||||
LongArray(Vec<i64>),
|
||||
}
|
||||
impl Tag {
|
||||
pub fn id(&self) -> u8 {
|
||||
match self {
|
||||
Self::Byte(_) => 1,
|
||||
Self::Short(_) => 2,
|
||||
Self::Int(_) => 3,
|
||||
Self::Long(_) => 4,
|
||||
Self::Float(_) => 5,
|
||||
Self::Double(_) => 6,
|
||||
Self::ByteArray(_) => 7,
|
||||
Self::String(_) => 8,
|
||||
Self::List(..) => 9,
|
||||
Self::Compound(_) => 10,
|
||||
Self::IntArray(_) => 11,
|
||||
Self::LongArray(_) => 12,
|
||||
}
|
||||
}
|
||||
pub fn compound(&self) -> Result<&Compound> {
|
||||
if let Self::Compound(v) = self {
|
||||
Ok(v)
|
||||
} else {
|
||||
bail!("nbt_type: expected compound")
|
||||
}
|
||||
}
|
||||
pub fn compound_mut(&mut self) -> Result<&mut Compound> {
|
||||
if let Self::Compound(v) = self {
|
||||
Ok(v)
|
||||
} else {
|
||||
bail!("nbt_type: expected compound")
|
||||
}
|
||||
}
|
||||
pub fn list(&self, id: u8) -> Result<&[Tag]> {
|
||||
if let Self::List(kind, v) = self {
|
||||
ensure!(
|
||||
*kind == id || v.is_empty(),
|
||||
"nbt_type: wrong list element type"
|
||||
);
|
||||
Ok(v)
|
||||
} else {
|
||||
bail!("nbt_type: expected list")
|
||||
}
|
||||
}
|
||||
pub fn int(&self) -> Result<i32> {
|
||||
if let Self::Int(v) = self {
|
||||
Ok(*v)
|
||||
} else {
|
||||
bail!("nbt_type: expected int")
|
||||
}
|
||||
}
|
||||
pub fn numeric_i32(&self) -> Result<i32> {
|
||||
match self {
|
||||
Self::Byte(v) => Ok(*v as i32),
|
||||
Self::Short(v) => Ok(*v as i32),
|
||||
Self::Int(v) => Ok(*v),
|
||||
_ => bail!("nbt_type: expected integer"),
|
||||
}
|
||||
}
|
||||
pub fn string(&self) -> Result<&str> {
|
||||
if let Self::String(v) = self {
|
||||
Ok(v)
|
||||
} else {
|
||||
bail!("nbt_type: expected string")
|
||||
}
|
||||
}
|
||||
pub fn bytes(&self) -> Result<&[u8]> {
|
||||
if let Self::ByteArray(v) = self {
|
||||
Ok(v)
|
||||
} else {
|
||||
bail!("nbt_type: expected byte array")
|
||||
}
|
||||
}
|
||||
pub fn longs(&self) -> Result<&[i64]> {
|
||||
if let Self::LongArray(v) = self {
|
||||
Ok(v)
|
||||
} else {
|
||||
bail!("nbt_type: expected long array")
|
||||
}
|
||||
}
|
||||
pub fn ints(&self) -> Result<&[i32]> {
|
||||
if let Self::IntArray(v) = self {
|
||||
Ok(v)
|
||||
} else {
|
||||
bail!("nbt_type: expected int array")
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn get<'a>(c: &'a Compound, k: &str) -> Result<&'a Tag> {
|
||||
c.get(k).with_context(|| format!("nbt_missing: {k}"))
|
||||
}
|
||||
pub fn compound(entries: impl IntoIterator<Item = (impl Into<String>, Tag)>) -> Tag {
|
||||
Tag::Compound(entries.into_iter().map(|(k, v)| (k.into(), v)).collect())
|
||||
}
|
||||
|
||||
pub fn decode(bytes: &[u8]) -> Result<(String, Tag)> {
|
||||
ensure!(bytes.len() <= MAX_BYTES, "nbt_limit: decompressed bytes");
|
||||
let mut r = Decoder {
|
||||
r: Cursor::new(bytes),
|
||||
nodes: 0,
|
||||
};
|
||||
let id = r.byte()?;
|
||||
ensure!(id == 10, "nbt_root: root must be compound");
|
||||
let name = r.string()?;
|
||||
let tag = r.tag(id, 0)?;
|
||||
ensure!(
|
||||
r.r.position() as usize == bytes.len(),
|
||||
"nbt_trailing: extra bytes"
|
||||
);
|
||||
Ok((name, tag))
|
||||
}
|
||||
struct Decoder<'a> {
|
||||
r: Cursor<&'a [u8]>,
|
||||
nodes: usize,
|
||||
}
|
||||
impl Decoder<'_> {
|
||||
fn bytes<const N: usize>(&mut self) -> Result<[u8; N]> {
|
||||
let mut b = [0; N];
|
||||
self.r.read_exact(&mut b)?;
|
||||
Ok(b)
|
||||
}
|
||||
fn byte(&mut self) -> Result<u8> {
|
||||
Ok(self.bytes::<1>()?[0])
|
||||
}
|
||||
fn len(&mut self, width: usize) -> Result<usize> {
|
||||
let n = i32::from_be_bytes(self.bytes()?);
|
||||
ensure!(n >= 0, "nbt_length: negative array/list length");
|
||||
let n = n as usize;
|
||||
ensure!(
|
||||
n <= MAX_BYTES / width
|
||||
&& n * width <= self.r.get_ref().len() - self.r.position() as usize,
|
||||
"nbt_limit: impossible array/list length"
|
||||
);
|
||||
Ok(n)
|
||||
}
|
||||
fn string(&mut self) -> Result<String> {
|
||||
let n = u16::from_be_bytes(self.bytes()?) as usize;
|
||||
let mut b = vec![0; n];
|
||||
self.r.read_exact(&mut b)?;
|
||||
Ok(cesu8::from_java_cesu8(&b)
|
||||
.context("nbt_string: invalid modified UTF-8")?
|
||||
.into_owned())
|
||||
}
|
||||
fn tag(&mut self, id: u8, depth: usize) -> Result<Tag> {
|
||||
self.nodes += 1;
|
||||
ensure!(
|
||||
depth <= 64 && self.nodes <= MAX_NODES,
|
||||
"nbt_limit: depth/node budget"
|
||||
);
|
||||
Ok(match id {
|
||||
1 => Tag::Byte(self.byte()? as i8),
|
||||
2 => Tag::Short(i16::from_be_bytes(self.bytes()?)),
|
||||
3 => Tag::Int(i32::from_be_bytes(self.bytes()?)),
|
||||
4 => Tag::Long(i64::from_be_bytes(self.bytes()?)),
|
||||
5 => Tag::Float(u32::from_be_bytes(self.bytes()?)),
|
||||
6 => Tag::Double(u64::from_be_bytes(self.bytes()?)),
|
||||
7 => {
|
||||
let n = self.len(1)?;
|
||||
let mut v = vec![0; n];
|
||||
self.r.read_exact(&mut v)?;
|
||||
Tag::ByteArray(v)
|
||||
}
|
||||
8 => Tag::String(self.string()?),
|
||||
9 => {
|
||||
let kind = self.byte()?;
|
||||
let n = self.len(1)?;
|
||||
ensure!(
|
||||
kind <= 12 && (kind != 0 || n == 0),
|
||||
"nbt_type: invalid list element"
|
||||
);
|
||||
ensure!(n <= MAX_NODES - self.nodes, "nbt_limit: list nodes");
|
||||
let mut v = Vec::new();
|
||||
for _ in 0..n {
|
||||
v.push(self.tag(kind, depth + 1)?)
|
||||
}
|
||||
Tag::List(kind, v)
|
||||
}
|
||||
10 => {
|
||||
let mut c = BTreeMap::new();
|
||||
loop {
|
||||
let kind = self.byte()?;
|
||||
if kind == 0 {
|
||||
break;
|
||||
}
|
||||
let name = self.string()?;
|
||||
ensure!(
|
||||
!c.contains_key(&name),
|
||||
"nbt_duplicate: duplicate compound name"
|
||||
);
|
||||
c.insert(name, self.tag(kind, depth + 1)?);
|
||||
}
|
||||
Tag::Compound(c)
|
||||
}
|
||||
11 => {
|
||||
let n = self.len(4)?;
|
||||
let mut v = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
v.push(i32::from_be_bytes(self.bytes()?))
|
||||
}
|
||||
Tag::IntArray(v)
|
||||
}
|
||||
12 => {
|
||||
let n = self.len(8)?;
|
||||
let mut v = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
v.push(i64::from_be_bytes(self.bytes()?))
|
||||
}
|
||||
Tag::LongArray(v)
|
||||
}
|
||||
_ => bail!("nbt_type: unknown tag {id}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
pub fn encode(name: &str, tag: &Tag) -> Result<Vec<u8>> {
|
||||
ensure!(tag.id() == 10, "nbt_root: expected compound");
|
||||
let mut b = vec![10];
|
||||
write_string(&mut b, name)?;
|
||||
write_tag(&mut b, tag, 0)?;
|
||||
ensure!(b.len() <= MAX_BYTES, "nbt_limit: encoded bytes");
|
||||
Ok(b)
|
||||
}
|
||||
fn write_string(w: &mut Vec<u8>, s: &str) -> Result<()> {
|
||||
let b = cesu8::to_java_cesu8(s);
|
||||
ensure!(b.len() <= u16::MAX as usize, "nbt_limit: string bytes");
|
||||
w.write_all(&(b.len() as u16).to_be_bytes())?;
|
||||
w.write_all(&b)?;
|
||||
Ok(())
|
||||
}
|
||||
fn write_len(w: &mut Vec<u8>, n: usize) -> Result<()> {
|
||||
ensure!(n <= MAX_BYTES, "nbt_limit: array count");
|
||||
w.extend((n as i32).to_be_bytes());
|
||||
Ok(())
|
||||
}
|
||||
fn write_tag(w: &mut Vec<u8>, t: &Tag, depth: usize) -> Result<()> {
|
||||
ensure!(
|
||||
depth <= 64 && w.len() <= MAX_BYTES,
|
||||
"nbt_limit: encode depth/bytes"
|
||||
);
|
||||
match t {
|
||||
Tag::Byte(v) => w.push(*v as u8),
|
||||
Tag::Short(v) => w.extend(v.to_be_bytes()),
|
||||
Tag::Int(v) => w.extend(v.to_be_bytes()),
|
||||
Tag::Long(v) => w.extend(v.to_be_bytes()),
|
||||
Tag::Float(v) => w.extend(v.to_be_bytes()),
|
||||
Tag::Double(v) => w.extend(v.to_be_bytes()),
|
||||
Tag::String(s) => write_string(w, s)?,
|
||||
Tag::ByteArray(v) => {
|
||||
write_len(w, v.len())?;
|
||||
w.extend(v)
|
||||
}
|
||||
Tag::IntArray(v) => {
|
||||
write_len(w, v.len())?;
|
||||
for n in v {
|
||||
w.extend(n.to_be_bytes())
|
||||
}
|
||||
}
|
||||
Tag::LongArray(v) => {
|
||||
write_len(w, v.len())?;
|
||||
for n in v {
|
||||
w.extend(n.to_be_bytes())
|
||||
}
|
||||
}
|
||||
Tag::List(id, v) => {
|
||||
ensure!(*id <= 12 && (*id != 0 || v.is_empty()), "nbt_type: list");
|
||||
w.push(*id);
|
||||
write_len(w, v.len())?;
|
||||
for t in v {
|
||||
ensure!(t.id() == *id, "nbt_type: heterogeneous list");
|
||||
write_tag(w, t, depth + 1)?
|
||||
}
|
||||
}
|
||||
Tag::Compound(c) => {
|
||||
for (k, t) in c {
|
||||
w.push(t.id());
|
||||
write_string(w, k)?;
|
||||
write_tag(w, t, depth + 1)?
|
||||
}
|
||||
w.push(0)
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn all_types_roundtrip() {
|
||||
let t = compound([
|
||||
("byte", Tag::Byte(-128)),
|
||||
("short", Tag::Short(-32768)),
|
||||
("int", Tag::Int(i32::MAX)),
|
||||
("long", Tag::Long(i64::MIN)),
|
||||
("float", Tag::Float(0x7fc12345)),
|
||||
("double", Tag::Double(0xfff0000000000000)),
|
||||
("bytes", Tag::ByteArray(vec![0, 128, 255])),
|
||||
("ints", Tag::IntArray(vec![-1, 0, 2])),
|
||||
("longs", Tag::LongArray(vec![i64::MIN, i64::MAX])),
|
||||
("string", Tag::String("\0Привет🌍".into())),
|
||||
("empty", Tag::List(4, vec![])),
|
||||
("list", Tag::List(10, vec![compound([("x", Tag::Int(2))])])),
|
||||
]);
|
||||
assert_eq!(
|
||||
decode(&encode("root", &t).unwrap()).unwrap(),
|
||||
("root".into(), t)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn rejects_bombs() {
|
||||
assert!(decode(&[10, 0, 0, 7, 0, 1, b'a', 127, 255, 255, 255, 0]).is_err());
|
||||
assert!(decode(&[10, 0, 0, 0, 0]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
use crate::{
|
||||
DATA_VERSION, Dimension, Mode, Provenance, Report, io,
|
||||
nbt::{self, Compound, Tag, compound, get},
|
||||
provenance,
|
||||
};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use shacraft_core::{BlockChange, WorldStore};
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
const MAX_VOLUME: usize = 262_144;
|
||||
|
||||
fn container(root: &Tag) -> Result<&Compound> {
|
||||
get(root.compound()?, "Schematic")?.compound()
|
||||
}
|
||||
fn shape(c: &Compound) -> Result<([i32; 3], [i32; 3], usize)> {
|
||||
let dim = |key| -> Result<i32> {
|
||||
if let Tag::Short(v) = get(c, key)? {
|
||||
Ok(*v as u16 as i32)
|
||||
} else {
|
||||
anyhow::bail!("schem_type: {key} must be unsigned short")
|
||||
}
|
||||
};
|
||||
let size = [dim("Width")?, dim("Height")?, dim("Length")?];
|
||||
ensure!(
|
||||
size.iter().all(|v| *v > 0),
|
||||
"schem_dimensions: empty dimension"
|
||||
);
|
||||
let volume = size
|
||||
.iter()
|
||||
.try_fold(1usize, |n, v| n.checked_mul(*v as usize))
|
||||
.context("schem_limit: volume overflow")?;
|
||||
ensure!(volume <= MAX_VOLUME, "schem_limit: maximum262144 cells");
|
||||
let min = if let Some(t) = c.get("Offset") {
|
||||
let v = t.ints()?;
|
||||
ensure!(v.len() == 3, "schem_offset: expected3 values");
|
||||
[v[0], v[1], v[2]]
|
||||
} else {
|
||||
[0; 3]
|
||||
};
|
||||
let mut max = min;
|
||||
for i in 0..3 {
|
||||
max[i] = min[i]
|
||||
.checked_add(size[i] - 1)
|
||||
.context("schem_limit: offset overflow")?;
|
||||
ensure!(
|
||||
min[i] >= -30_000_000 && max[i] <= 30_000_000,
|
||||
"schem_limit: coordinates"
|
||||
)
|
||||
}
|
||||
Ok((min, max, volume))
|
||||
}
|
||||
fn varints(bytes: &[u8], count: usize) -> Result<Vec<u32>> {
|
||||
let mut out = Vec::with_capacity(count);
|
||||
let mut p = 0;
|
||||
for _ in 0..count {
|
||||
let mut n = 0;
|
||||
for shift in (0..35).step_by(7) {
|
||||
let b = *bytes.get(p).context("schem_data: truncated varint")?;
|
||||
p += 1;
|
||||
ensure!(shift != 28 || b <= 15, "schem_data: varint overflow");
|
||||
n |= u32::from(b & 127) << shift;
|
||||
if b & 128 == 0 {
|
||||
out.push(n);
|
||||
break;
|
||||
}
|
||||
ensure!(shift < 28, "schem_data: overlong varint")
|
||||
}
|
||||
}
|
||||
ensure!(p == bytes.len(), "schem_data: trailing indices");
|
||||
Ok(out)
|
||||
}
|
||||
fn write_varint(out: &mut Vec<u8>, mut n: u32) {
|
||||
while n >= 128 {
|
||||
out.push((n as u8 & 127) | 128);
|
||||
n >>= 7
|
||||
}
|
||||
out.push(n as u8)
|
||||
}
|
||||
fn palette_data(c: &Compound, count: usize) -> Result<(BTreeMap<u32, String>, Vec<u32>)> {
|
||||
let mut palette = BTreeMap::new();
|
||||
for (name, id) in get(c, "Palette")?.compound()? {
|
||||
let id = id.int()?;
|
||||
ensure!(id >= 0, "schem_palette: negative index");
|
||||
ensure!(
|
||||
palette.insert(id as u32, name.clone()).is_none(),
|
||||
"schem_palette: duplicate index"
|
||||
)
|
||||
}
|
||||
ensure!(
|
||||
!palette.is_empty() && palette.len() <= MAX_VOLUME,
|
||||
"schem_palette: size"
|
||||
);
|
||||
let data = varints(get(c, "Data")?.bytes()?, count)?;
|
||||
ensure!(
|
||||
data.iter().all(|id| palette.contains_key(id)),
|
||||
"schem_palette: index not in palette"
|
||||
);
|
||||
Ok((palette, data))
|
||||
}
|
||||
fn local_pos(index: usize, min: [i32; 3], max: [i32; 3]) -> [i32; 3] {
|
||||
let w = (max[0] - min[0] + 1) as usize;
|
||||
let d = (max[2] - min[2] + 1) as usize;
|
||||
[
|
||||
min[0] + (index % w) as i32,
|
||||
min[1] + (index / (w * d)) as i32,
|
||||
min[2] + ((index / w) % d) as i32,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn import_schem(source: &Path, destination: &Path, world: &str, mode: Mode) -> Result<Report> {
|
||||
let stage = io::stage(destination)?;
|
||||
let compat = stage.path().join("compat");
|
||||
fs::create_dir(&compat)?;
|
||||
let bytes = io::gzip_read(source)?;
|
||||
let (_, root) = nbt::decode(&bytes)?;
|
||||
let c = container(&root)?;
|
||||
ensure!(
|
||||
get(c, "Version")?.int()? == 3,
|
||||
"schem_version: only Sponge v3 supported"
|
||||
);
|
||||
let version = get(c, "DataVersion")?.int()?;
|
||||
ensure!(
|
||||
mode != Mode::Exact || version == DATA_VERSION,
|
||||
"unsupported_data_version: exact requires4903"
|
||||
);
|
||||
let (min, max, volume) = shape(c)?;
|
||||
let mut report = Report::new("sponge-schem-v3", mode);
|
||||
report.data_version = version;
|
||||
if version != DATA_VERSION {
|
||||
report.issue(
|
||||
"unverified_data_version",
|
||||
"unsupported",
|
||||
"Schematic.DataVersion",
|
||||
format!("Preserved {version}; no DataFixer migration"),
|
||||
)
|
||||
}
|
||||
let blocks = get(c, "Blocks")?.compound()?;
|
||||
let (palette, indices) = palette_data(blocks, volume)?;
|
||||
if let Some(b) = c.get("Biomes") {
|
||||
palette_data(b.compound()?, volume)?;
|
||||
report.issue("biomes_preserved","preserved","Schematic.Biomes","Full3D biome container retained in typed NBT source; native simulation does not consume it")
|
||||
}
|
||||
for key in ["Entities", "Metadata"] {
|
||||
if c.contains_key(key) {
|
||||
report.issue(
|
||||
"opaque_nbt_preserved",
|
||||
"preserved",
|
||||
format!("Schematic.{key}"),
|
||||
"Retained in original typed NBT; not editable in this adapter",
|
||||
)
|
||||
}
|
||||
}
|
||||
if blocks.contains_key("BlockEntities") {
|
||||
for entity in get(blocks, "BlockEntities")?.list(10)? {
|
||||
let entity = entity.compound()?;
|
||||
let pos = get(entity, "Pos")?.ints()?;
|
||||
ensure!(
|
||||
pos.len() == 3 && (0..3).all(|i| pos[i] >= 0 && pos[i] <= max[i] - min[i]),
|
||||
"schem_block_entity_position: outside volume"
|
||||
);
|
||||
get(entity, "Id")?.string()?;
|
||||
if let Some(data) = entity.get("Data") {
|
||||
data.compound()?;
|
||||
}
|
||||
}
|
||||
report.issue(
|
||||
"block_entities_preserved",
|
||||
"preserved",
|
||||
"Schematic.Blocks.BlockEntities",
|
||||
"Inventories and unknown tags retained on disk",
|
||||
)
|
||||
}
|
||||
let mut store = WorldStore::open(stage.path(), 32)?;
|
||||
store.create_world(world, None)?;
|
||||
let mut registry = BTreeMap::new();
|
||||
for (id, state) in palette {
|
||||
let name = if state.contains(':') {
|
||||
state
|
||||
} else {
|
||||
format!("minecraft:{state}")
|
||||
};
|
||||
registry.insert(id, store.register_block(&name)?);
|
||||
}
|
||||
let mut batch = Vec::new();
|
||||
let mut op = 0;
|
||||
for (index, id) in indices.into_iter().enumerate() {
|
||||
let block = registry[&id];
|
||||
if block != 0 {
|
||||
batch.push(BlockChange {
|
||||
pos: local_pos(index, min, max),
|
||||
block,
|
||||
});
|
||||
report.nonair_blocks += 1
|
||||
}
|
||||
if batch.len() == 32768 {
|
||||
store.edit(
|
||||
world,
|
||||
store.revision(world)?,
|
||||
&format!("compat-import-{op}"),
|
||||
std::mem::take(&mut batch),
|
||||
)?;
|
||||
op += 1
|
||||
}
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
store.edit(
|
||||
world,
|
||||
store.revision(world)?,
|
||||
&format!("compat-import-{op}"),
|
||||
batch,
|
||||
)?;
|
||||
}
|
||||
let revision = store.revision(world)?;
|
||||
store.flush()?;
|
||||
drop(store);
|
||||
let original = compat.join("original");
|
||||
fs::create_dir(&original)?;
|
||||
fs::copy(source, original.join("source.schem"))?;
|
||||
// The source is copied and hashed after parsing, then checked against parsed bytes.
|
||||
ensure!(
|
||||
io::gzip_read(&original.join("source.schem"))? == bytes,
|
||||
"source_changed: schematic changed during import"
|
||||
);
|
||||
let mut provenance_report = Report::new("source", mode);
|
||||
let indexed = compat.join("indexed");
|
||||
io::copy_source(
|
||||
&original,
|
||||
&indexed,
|
||||
&compat.join("files.jsonl"),
|
||||
&mut provenance_report,
|
||||
)?;
|
||||
fs::remove_dir_all(&original)?;
|
||||
fs::rename(indexed, &original)?;
|
||||
report.source_files = provenance_report.source_files;
|
||||
report.source_bytes = provenance_report.source_bytes;
|
||||
report.worlds.insert(world.into(), revision);
|
||||
let mut provenance = Provenance {
|
||||
schema_version: 1,
|
||||
format: "sponge-schem-v3".into(),
|
||||
data_version: version,
|
||||
worlds: BTreeMap::from([(
|
||||
world.into(),
|
||||
Dimension {
|
||||
path: String::new(),
|
||||
revision,
|
||||
},
|
||||
)]),
|
||||
schem_min: Some(min),
|
||||
schem_max: Some(max),
|
||||
entity_fingerprint: None,
|
||||
entity_blob_hashes: BTreeMap::new(),
|
||||
};
|
||||
crate::entities::import_schem(stage.path(), c, &mut provenance, &mut report)?;
|
||||
io::json(&compat.join("provenance.json"), &provenance)?;
|
||||
io::json(&compat.join("import-report.json"), &report)?;
|
||||
io::publish(stage, destination)?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
pub fn export_schem(
|
||||
store_path: &Path,
|
||||
world: &str,
|
||||
destination: &Path,
|
||||
min: Option<[i32; 3]>,
|
||||
max: Option<[i32; 3]>,
|
||||
mode: Mode,
|
||||
) -> Result<Report> {
|
||||
let mut store = WorldStore::open(store_path, 32)?;
|
||||
let revision = store.revision(world)?;
|
||||
let p = provenance(store_path)?;
|
||||
let original = p
|
||||
.as_ref()
|
||||
.filter(|p| p.format == "sponge-schem-v3" && p.worlds.contains_key(world));
|
||||
let bounds = match (min, max) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
(None, None) => {
|
||||
let p = original.context("schem_bounds: supply --min and --max for a native world")?;
|
||||
(
|
||||
p.schem_min.context("provenance bounds")?,
|
||||
p.schem_max.context("provenance bounds")?,
|
||||
)
|
||||
}
|
||||
_ => anyhow::bail!("schem_bounds: both min/max required"),
|
||||
};
|
||||
let stage = io::stage(destination)?;
|
||||
let mut report = Report::new("sponge-schem-v3", mode);
|
||||
if original.is_none() && p.is_some() {
|
||||
ensure!(
|
||||
mode != Mode::Exact,
|
||||
"exact_cross_format: source biome/block-entity metadata requires format-specific mapping; use best-effort"
|
||||
);
|
||||
io::copy_verified(
|
||||
&store_path.join("compat/original"),
|
||||
&stage.path().join("source-sidecar"),
|
||||
&store_path.join("compat/files.jsonl"),
|
||||
)?;
|
||||
report.issue("cross_format_opaque_data","unsupported","source-sidecar","Source world metadata, block entities and biome layout are archived without cross-format translation; blocks and entity bridge are exported");
|
||||
}
|
||||
report.worlds.insert(world.into(), revision);
|
||||
let (mut name, mut root) = if let Some(p) = original {
|
||||
ensure!(
|
||||
Some(bounds.0) == p.schem_min && Some(bounds.1) == p.schem_max,
|
||||
"schem_bounds: cropping imported entities/biomes unsupported"
|
||||
);
|
||||
ensure!(
|
||||
mode != Mode::Exact || p.data_version == DATA_VERSION,
|
||||
"unsupported_data_version: exact requires4903"
|
||||
);
|
||||
let checked = stage.path().join(".checked");
|
||||
io::copy_verified(
|
||||
&store_path.join("compat/original"),
|
||||
&checked,
|
||||
&store_path.join("compat/files.jsonl"),
|
||||
)?;
|
||||
let entity_changes = crate::entities::changed(store_path, p)?;
|
||||
if p.worlds[world].revision == revision && !entity_changes {
|
||||
fs::rename(
|
||||
checked.join("source.schem"),
|
||||
stage.path().join("world.schem"),
|
||||
)?;
|
||||
fs::remove_dir(&checked)?;
|
||||
report.data_version = p.data_version;
|
||||
report.issue(
|
||||
"unchanged_original",
|
||||
"preserved",
|
||||
"world.schem",
|
||||
"Byte-identical verified source",
|
||||
);
|
||||
io::report(stage.path(), &report)?;
|
||||
io::publish(stage, destination)?;
|
||||
return Ok(report);
|
||||
}
|
||||
ensure!(
|
||||
mode != Mode::Exact,
|
||||
"exact_provenance_invalidated: world revision changed; derived/opaque metadata cannot be proven current; use best-effort"
|
||||
);
|
||||
let decoded = nbt::decode(&io::gzip_read(&checked.join("source.schem"))?)?;
|
||||
fs::rename(&checked, stage.path().join("source-sidecar"))?;
|
||||
report.data_version = p.data_version;
|
||||
report.issue("modified_source_metadata","approximated","Schematic","Original entities,3D biomes,metadata and unknown tags preserved; behavior and placement dependencies are not simulated");
|
||||
decoded
|
||||
} else {
|
||||
(
|
||||
"".into(),
|
||||
compound([(
|
||||
"Schematic",
|
||||
compound([
|
||||
("Version", Tag::Int(3)),
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
]),
|
||||
)]),
|
||||
)
|
||||
};
|
||||
let c = get_mut(root.compound_mut()?, "Schematic")?.compound_mut()?;
|
||||
let mut volume = 1usize;
|
||||
for (i, key) in ["Width", "Height", "Length"].into_iter().enumerate() {
|
||||
let d = i64::from(bounds.1[i]) - i64::from(bounds.0[i]) + 1;
|
||||
ensure!((1..=65535).contains(&d), "schem_dimensions: invalid extent");
|
||||
volume = volume
|
||||
.checked_mul(d as usize)
|
||||
.context("schem_limit: volume overflow")?;
|
||||
ensure!(volume <= MAX_VOLUME, "schem_limit: maximum262144 cells");
|
||||
c.insert(key.into(), Tag::Short(d as u16 as i16));
|
||||
}
|
||||
c.insert("Offset".into(), Tag::IntArray(bounds.0.to_vec()));
|
||||
let old_blocks = c.get("Blocks").cloned();
|
||||
let mut blocks = old_blocks
|
||||
.clone()
|
||||
.unwrap_or_else(|| compound([] as [(&str, Tag); 0]));
|
||||
let old_data = if let Some(old) = &old_blocks {
|
||||
Some(palette_data(old.compound()?, volume)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut states = BTreeMap::<String, u32>::new();
|
||||
let mut indices = vec![0u32; volume];
|
||||
let air = store.registry()[0].clone();
|
||||
states.insert(air, 0);
|
||||
let width = (bounds.1[0] - bounds.0[0] + 1) as usize;
|
||||
let depth = (bounds.1[2] - bounds.0[2] + 1) as usize;
|
||||
for change in store.read_region(world, bounds.0, bounds.1)? {
|
||||
let state = store.registry()[change.block as usize].clone();
|
||||
let next = states.len() as u32;
|
||||
let id = *states.entry(state).or_insert(next);
|
||||
let [x, y, z] = [
|
||||
change.pos[0] - bounds.0[0],
|
||||
change.pos[1] - bounds.0[1],
|
||||
change.pos[2] - bounds.0[2],
|
||||
];
|
||||
indices[x as usize + z as usize * width + y as usize * width * depth] = id;
|
||||
report.nonair_blocks += 1
|
||||
}
|
||||
for state in states.keys().filter(|s| !s.starts_with("minecraft:")) {
|
||||
report.issue(
|
||||
"nonvanilla_block_state",
|
||||
"unsupported",
|
||||
"Schematic.Blocks.Palette",
|
||||
format!(
|
||||
"{state} is preserved as a Sponge resource id; pasting requires matching target mod"
|
||||
),
|
||||
);
|
||||
}
|
||||
let reverse: BTreeMap<u32, &str> = states.iter().map(|(s, id)| (*id, s.as_str())).collect();
|
||||
let bc = blocks.compound_mut()?;
|
||||
if let Some(Tag::List(_, entities)) = bc.get_mut("BlockEntities") {
|
||||
entities.retain(|e|{
|
||||
let Ok(e)=e.compound()else{return true};let Ok(pos)=get(e,"Pos").and_then(Tag::ints)else{return true};if pos.len()!=3{return true}let i=pos[0]as i64+pos[2]as i64*width as i64+pos[1]as i64*(width*depth)as i64;
|
||||
let changed=if let Some((pal,data))=&old_data{if i>=0&&(i as usize)<volume{pal.get(&data[i as usize]).map(String::as_str)!=reverse.get(&indices[i as usize]).copied()}else{true}}else{false};
|
||||
if changed{report.issue("changed_block_entity","dropped",format!("{pos:?}"),"Block state changed; stale block entity removed from export and retained in source-sidecar")}!changed
|
||||
});
|
||||
}
|
||||
bc.insert(
|
||||
"Palette".into(),
|
||||
Tag::Compound(
|
||||
states
|
||||
.into_iter()
|
||||
.map(|(s, id)| (s, Tag::Int(id as i32)))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
let mut data = Vec::with_capacity(volume);
|
||||
for id in indices {
|
||||
write_varint(&mut data, id)
|
||||
}
|
||||
bc.insert("Data".into(), Tag::ByteArray(data));
|
||||
c.insert("Blocks".into(), blocks);
|
||||
if original.is_none() {
|
||||
c.insert(
|
||||
"Metadata".into(),
|
||||
compound([
|
||||
("Name", Tag::String(world.into())),
|
||||
("Author", Tag::String("Shacraft Core".into())),
|
||||
]),
|
||||
);
|
||||
report.issue("native_biomes_absent","preserved","Schematic","Native bounds exported; no biome NBT exists for native worlds. Native entities use the explicit bridge.")
|
||||
}
|
||||
crate::entities::export_schem(store_path, world, c, bounds.0, p.as_ref(), &mut report)?;
|
||||
io::json(
|
||||
&stage.path().join("shacraft-native-entities.json"),
|
||||
&crate::entities::read_native(store_path)?,
|
||||
)?;
|
||||
if name == "Schematic" {
|
||||
name.clear()
|
||||
}
|
||||
io::gzip_write(
|
||||
&stage.path().join("world.schem"),
|
||||
&nbt::encode(&name, &root)?,
|
||||
)?;
|
||||
io::report(stage.path(), &report)?;
|
||||
io::publish(stage, destination)?;
|
||||
Ok(report)
|
||||
}
|
||||
fn get_mut<'a>(c: &'a mut Compound, k: &str) -> Result<&'a mut Tag> {
|
||||
c.get_mut(k).with_context(|| format!("nbt_missing: {k}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn varint_validation() {
|
||||
assert_eq!(
|
||||
varints(&[0, 127, 128, 1, 255, 255, 255, 255, 15], 4).unwrap(),
|
||||
vec![0, 127, 128, u32::MAX]
|
||||
);
|
||||
assert!(varints(&[128], 1).is_err());
|
||||
assert!(varints(&[255, 255, 255, 255, 16], 1).is_err());
|
||||
assert!(varints(&[0, 0], 1).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
use shacraft_compat::{
|
||||
Mode, export_anvil, export_schem, import_anvil, import_schem,
|
||||
nbt::{self, Tag},
|
||||
};
|
||||
use shacraft_core::{BlockChange, WorldStore};
|
||||
use std::{
|
||||
fs,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("fixtures")
|
||||
.join(name)
|
||||
}
|
||||
fn read_gzip(p: &Path) -> Tag {
|
||||
let mut b = Vec::new();
|
||||
flate2::read::GzDecoder::new(fs::File::open(p).unwrap())
|
||||
.read_to_end(&mut b)
|
||||
.unwrap();
|
||||
nbt::decode(&b).unwrap().1
|
||||
}
|
||||
fn assert_tree_equal(source: &Path, out: &Path) {
|
||||
for e in fs::read_dir(source).unwrap() {
|
||||
let e = e.unwrap();
|
||||
let to = out.join(e.file_name());
|
||||
if e.file_type().unwrap().is_dir() {
|
||||
assert_tree_equal(&e.path(), &to)
|
||||
} else {
|
||||
assert_eq!(
|
||||
fs::read(e.path()).unwrap(),
|
||||
fs::read(to).unwrap(),
|
||||
"{}",
|
||||
e.path().display()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sponge_import_edits_unknown_states_and_preserves_nbt() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let store_path = t.path().join("store");
|
||||
let source = fixture("sponge-v3.schem");
|
||||
let imported = import_schem(&source, &store_path, "main", Mode::Exact).unwrap();
|
||||
assert_eq!(imported.nonair_blocks, 4);
|
||||
let exact = t.path().join("exact");
|
||||
export_schem(&store_path, "main", &exact, None, None, Mode::Exact).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(&source).unwrap(),
|
||||
fs::read(exact.join("world.schem")).unwrap()
|
||||
);
|
||||
let mut store = WorldStore::open(&store_path, 2).unwrap();
|
||||
let unknown = store.get_block("main", [-15, -1, 31]).unwrap();
|
||||
assert_eq!(
|
||||
store.registry()[unknown as usize],
|
||||
"example:unknown[facing=west]"
|
||||
);
|
||||
let revision = store.revision("main").unwrap();
|
||||
store
|
||||
.edit(
|
||||
"main",
|
||||
revision,
|
||||
"break-container",
|
||||
vec![BlockChange {
|
||||
pos: [-17, -1, 31],
|
||||
block: 0,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let rejected = t.path().join("rejected");
|
||||
assert!(
|
||||
export_schem(&store_path, "main", &rejected, None, None, Mode::Exact)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("exact_provenance_invalidated")
|
||||
);
|
||||
assert!(!rejected.exists());
|
||||
let edited = t.path().join("edited");
|
||||
let r = export_schem(&store_path, "main", &edited, None, None, Mode::BestEffort).unwrap();
|
||||
assert!(r.issues.iter().any(|i| i.code == "changed_block_entity"));
|
||||
let a = read_gzip(&source);
|
||||
let b = read_gzip(&edited.join("world.schem"));
|
||||
let a = a.compound().unwrap()["Schematic"].compound().unwrap();
|
||||
let b = b.compound().unwrap()["Schematic"].compound().unwrap();
|
||||
for k in ["Metadata", "Entities", "Biomes", "UnknownRoot", "Offset"] {
|
||||
assert_eq!(a[k], b[k], "{k}")
|
||||
}
|
||||
let reimport = t.path().join("reimport");
|
||||
import_schem(&edited.join("world.schem"), &reimport, "main", Mode::Exact).unwrap();
|
||||
let mut s = WorldStore::open(&reimport, 2).unwrap();
|
||||
assert_eq!(s.get_block("main", [-17, -1, 31]).unwrap(), 0);
|
||||
assert_ne!(s.get_block("main", [-16, -1, 31]).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genuine_java_world_import_exact_roundtrip_edit_and_new_chunk() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let store_path = t.path().join("store");
|
||||
let source = fixture("java26_2-world");
|
||||
let imported = import_anvil(&source, &store_path, Mode::Exact).unwrap();
|
||||
assert_eq!(imported.nonair_blocks, 3);
|
||||
assert_eq!(imported.chunks, 1);
|
||||
let exact = t.path().join("exact");
|
||||
export_anvil(&store_path, None, &exact, Mode::Exact).unwrap();
|
||||
assert_tree_equal(&source, &exact);
|
||||
let mut store = WorldStore::open(&store_path, 2).unwrap();
|
||||
let stone = store.register_block("minecraft:stone").unwrap();
|
||||
assert_ne!(store.get_block("main", [-1, -1, 31]).unwrap(), 0);
|
||||
let revision = store.revision("main").unwrap();
|
||||
store
|
||||
.edit(
|
||||
"main",
|
||||
revision,
|
||||
"edit",
|
||||
vec![
|
||||
BlockChange {
|
||||
pos: [-1, -1, 31],
|
||||
block: 0,
|
||||
},
|
||||
BlockChange {
|
||||
pos: [512, 64, -513],
|
||||
block: stone,
|
||||
},
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let rejected = t.path().join("rejected");
|
||||
assert!(export_anvil(&store_path, None, &rejected, Mode::Exact).is_err());
|
||||
assert!(!rejected.exists());
|
||||
let edited = t.path().join("edited");
|
||||
let report = export_anvil(&store_path, None, &edited, Mode::BestEffort).unwrap();
|
||||
assert_eq!(report.chunks, 2);
|
||||
assert!(
|
||||
report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|i| i.code == "dependent_records_invalidated")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(source.join("entities/r.-1.0.mca")).unwrap(),
|
||||
fs::read(edited.join("entities/r.-1.0.mca")).unwrap()
|
||||
);
|
||||
let reimport = t.path().join("reimport");
|
||||
import_anvil(&edited, &reimport, Mode::Exact).unwrap();
|
||||
let mut store = WorldStore::open(&reimport, 2).unwrap();
|
||||
assert_eq!(store.get_block("main", [-1, -1, 31]).unwrap(), 0);
|
||||
let id = store.get_block("main", [512, 64, -513]).unwrap();
|
||||
assert_eq!(store.registry()[id as usize], "minecraft:stone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_new_world_export_and_schematic() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let store_path = t.path().join("store");
|
||||
let mut store = WorldStore::open(&store_path, 2).unwrap();
|
||||
store.create_world("native", None).unwrap();
|
||||
let stone = store.register_block("minecraft:stone").unwrap();
|
||||
let stairs = store
|
||||
.register_block(
|
||||
"minecraft:oak_stairs[facing=east,half=top,shape=straight,waterlogged=false]",
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.edit(
|
||||
"native",
|
||||
0,
|
||||
"build",
|
||||
vec![
|
||||
BlockChange {
|
||||
pos: [-1, 63, 0],
|
||||
block: stone,
|
||||
},
|
||||
BlockChange {
|
||||
pos: [0, 64, 0],
|
||||
block: stairs,
|
||||
},
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let world = t.path().join("world");
|
||||
export_anvil(&store_path, Some("native"), &world, Mode::BestEffort).unwrap();
|
||||
assert!(
|
||||
world
|
||||
.join("data/minecraft/world_gen_settings.dat")
|
||||
.is_file()
|
||||
);
|
||||
let reimport = t.path().join("reimport");
|
||||
assert_eq!(
|
||||
import_anvil(&world, &reimport, Mode::Exact)
|
||||
.unwrap()
|
||||
.nonair_blocks,
|
||||
2
|
||||
);
|
||||
let schem = t.path().join("schem");
|
||||
export_schem(
|
||||
&store_path,
|
||||
"native",
|
||||
&schem,
|
||||
Some([-1, 63, 0]),
|
||||
Some([0, 64, 0]),
|
||||
Mode::Exact,
|
||||
)
|
||||
.unwrap();
|
||||
let s = t.path().join("schem-reimport");
|
||||
assert_eq!(
|
||||
import_schem(&schem.join("world.schem"), &s, "main", Mode::Exact)
|
||||
.unwrap()
|
||||
.nonair_blocks,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_overwrite_and_provenance_hash_guard() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let path = t.path().join("store");
|
||||
let source = fixture("sponge-v3.schem");
|
||||
import_schem(&source, &path, "main", Mode::Exact).unwrap();
|
||||
let before = fs::read(path.join("worlds.sqlite3")).unwrap();
|
||||
assert!(import_schem(&source, &path, "other", Mode::Exact).is_err());
|
||||
assert_eq!(before, fs::read(path.join("worlds.sqlite3")).unwrap());
|
||||
fs::write(path.join("compat/original/source.schem"), b"tampered").unwrap();
|
||||
let out = t.path().join("out");
|
||||
assert!(export_schem(&path, "main", &out, None, None, Mode::Exact).is_err());
|
||||
assert!(!out.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_input_is_not_published() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let bad = t.path().join("bad.schem");
|
||||
fs::write(&bad, b"not gzip").unwrap();
|
||||
let out = t.path().join("out");
|
||||
assert!(import_schem(&bad, &out, "main", Mode::Exact).is_err());
|
||||
assert!(!out.exists());
|
||||
}
|
||||
|
||||
fn metadata(path: &Path) -> serde_json::Value {
|
||||
let db = rusqlite::Connection::open(path.join("server.sqlite3")).unwrap();
|
||||
let text: String = db
|
||||
.query_row("SELECT json FROM metadata WHERE id=1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
serde_json::from_str(&text).unwrap()
|
||||
}
|
||||
fn save_metadata(path: &Path, v: &serde_json::Value) {
|
||||
let db = rusqlite::Connection::open(path.join("server.sqlite3")).unwrap();
|
||||
db.execute(
|
||||
"UPDATE metadata SET json=?1 WHERE id=1",
|
||||
[serde_json::to_string(v).unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn native_entity_edit_move_create_remove_roundtrip() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let path = t.path().join("store");
|
||||
import_anvil(&fixture("java26_2-world"), &path, Mode::Exact).unwrap();
|
||||
let mut meta = metadata(&path);
|
||||
let id = meta["entities"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.next()
|
||||
.unwrap()
|
||||
.clone();
|
||||
meta["entities"][&id]["position"] = serde_json::json!([513.5, 65., -513.5]);
|
||||
meta["entities"][&id]["yaw"] = serde_json::json!(1.25);
|
||||
meta["entities"][&id]["properties"]["Invisible"] = serde_json::json!(true);
|
||||
let new_id = "12345678-1234-4234-8234-123456789abc";
|
||||
meta["entities"][new_id] = serde_json::json!({"id":new_id,"world":"main","kind":"minecraft:cow","type":"minecraft:cow","position":[0.5,70.,0.5],"yaw":0.,"properties":{"Health":17.0,"CustomName":"moo","custom_unmapped":{"a":1}}});
|
||||
meta["revision"] = serde_json::json!(1);
|
||||
save_metadata(&path, &meta);
|
||||
let rejected = t.path().join("exact");
|
||||
assert!(
|
||||
export_anvil(&path, None, &rejected, Mode::Exact)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("exact_entity_revision")
|
||||
);
|
||||
assert!(!rejected.exists());
|
||||
let out = t.path().join("out");
|
||||
let r = export_anvil(&path, None, &out, Mode::BestEffort).unwrap();
|
||||
assert!(
|
||||
r.issues
|
||||
.iter()
|
||||
.any(|i| i.code == "entity_property_unmapped")
|
||||
);
|
||||
let again = t.path().join("again");
|
||||
import_anvil(&out, &again, Mode::Exact).unwrap();
|
||||
let got = metadata(&again);
|
||||
assert_eq!(got["entities"].as_object().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
got["entities"][&id]["position"],
|
||||
meta["entities"][&id]["position"]
|
||||
);
|
||||
assert!(
|
||||
got["entities"][&id]["properties"]["Invisible"]
|
||||
.as_bool()
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
got["entities"][new_id]["properties"]["Health"],
|
||||
serde_json::json!(17.0)
|
||||
);
|
||||
meta["entities"] = serde_json::json!({});
|
||||
save_metadata(&path, &meta);
|
||||
let removed = t.path().join("removed");
|
||||
let r = export_anvil(&path, None, &removed, Mode::BestEffort).unwrap();
|
||||
assert!(r.issues.iter().any(|i| i.code == "entity_removed"));
|
||||
let final_store = t.path().join("final");
|
||||
import_anvil(&removed, &final_store, Mode::Exact).unwrap();
|
||||
assert!(
|
||||
metadata(&final_store)["entities"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn sponge_entity_metadata_guard_and_position_roundtrip() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let path = t.path().join("store");
|
||||
import_schem(&fixture("sponge-v3.schem"), &path, "main", Mode::Exact).unwrap();
|
||||
let mut meta = metadata(&path);
|
||||
let id = meta["entities"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.next()
|
||||
.unwrap()
|
||||
.clone();
|
||||
meta["entities"][&id]["position"] = serde_json::json!([-15., 1., 31.]);
|
||||
save_metadata(&path, &meta);
|
||||
assert!(
|
||||
export_schem(
|
||||
&path,
|
||||
"main",
|
||||
&t.path().join("reject"),
|
||||
None,
|
||||
None,
|
||||
Mode::Exact
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let out = t.path().join("out");
|
||||
export_schem(&path, "main", &out, None, None, Mode::BestEffort).unwrap();
|
||||
let again = t.path().join("again");
|
||||
import_schem(&out.join("world.schem"), &again, "main", Mode::Exact).unwrap();
|
||||
assert_eq!(
|
||||
metadata(&again)["entities"][&id]["position"],
|
||||
meta["entities"][&id]["position"]
|
||||
);
|
||||
}
|
||||
fn write_gzip(path: &Path, tag: &Tag) {
|
||||
use std::io::Write;
|
||||
let mut out = flate2::write::GzEncoder::new(
|
||||
fs::File::create(path).unwrap(),
|
||||
flate2::Compression::default(),
|
||||
);
|
||||
out.write_all(&nbt::encode("", tag).unwrap()).unwrap();
|
||||
out.finish().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn duplicate_entity_uuid_stays_outside_editable_subset() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut tag = read_gzip(&fixture("sponge-v3.schem"));
|
||||
let c = tag
|
||||
.compound_mut()
|
||||
.unwrap()
|
||||
.get_mut("Schematic")
|
||||
.unwrap()
|
||||
.compound_mut()
|
||||
.unwrap();
|
||||
if let Tag::List(_, entities) = c.get_mut("Entities").unwrap() {
|
||||
entities.push(entities[0].clone());
|
||||
} else {
|
||||
panic!("fixture entities")
|
||||
}
|
||||
let source = t.path().join("duplicate.schem");
|
||||
write_gzip(&source, &tag);
|
||||
let store = t.path().join("store");
|
||||
let report = import_schem(&source, &store, "main", Mode::Exact).unwrap();
|
||||
assert!(
|
||||
report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|i| i.code == "entity_duplicate_uuid")
|
||||
);
|
||||
assert!(metadata(&store)["entities"].as_object().unwrap().is_empty());
|
||||
let out = t.path().join("out");
|
||||
export_schem(&store, "main", &out, None, None, Mode::Exact).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(source).unwrap(),
|
||||
fs::read(out.join("world.schem")).unwrap()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn unsupported_data_version_rejects_exact_before_publication() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut tag = read_gzip(&fixture("sponge-v3.schem"));
|
||||
let c = tag
|
||||
.compound_mut()
|
||||
.unwrap()
|
||||
.get_mut("Schematic")
|
||||
.unwrap()
|
||||
.compound_mut()
|
||||
.unwrap();
|
||||
c.insert("DataVersion".into(), Tag::Int(100));
|
||||
let source = t.path().join("old.schem");
|
||||
write_gzip(&source, &tag);
|
||||
let dest = t.path().join("store");
|
||||
assert!(import_schem(&source, &dest, "main", Mode::Exact).is_err());
|
||||
assert!(!dest.exists());
|
||||
let report = import_schem(&source, &dest, "main", Mode::BestEffort).unwrap();
|
||||
assert!(
|
||||
report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|i| i.code == "unverified_data_version")
|
||||
);
|
||||
assert!(
|
||||
export_schem(
|
||||
&dest,
|
||||
"main",
|
||||
&t.path().join("reject"),
|
||||
None,
|
||||
None,
|
||||
Mode::Exact
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "shacraft-content"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
flate2 = "1"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
Binary file not shown.
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"source": {
|
||||
"url": "https://piston-data.mojang.com/v1/objects/823e2250d24b3ddac457a60c92a6a941943fcd6a/server.jar",
|
||||
"size": 60894273,
|
||||
"sha1": "823e2250d24b3ddac457a60c92a6a941943fcd6a",
|
||||
"sha256": "cdacdfb25898de5e4b4b0e5ddcc2722f77067e46605709c2d886c000ebb63ec5"
|
||||
},
|
||||
"version": {
|
||||
"id": "26.2",
|
||||
"name": "26.2",
|
||||
"world_version": 4903,
|
||||
"series_id": "main",
|
||||
"protocol_version": 776,
|
||||
"pack_version": {
|
||||
"resource_major": 88,
|
||||
"resource_minor": 0,
|
||||
"data_major": 107,
|
||||
"data_minor": 1
|
||||
},
|
||||
"build_time": "2026-06-16T12:01:27+00:00",
|
||||
"java_component": "java-runtime-epsilon",
|
||||
"java_version": 25,
|
||||
"stable": true,
|
||||
"use_editor": false
|
||||
},
|
||||
"java": "openjdk version \"25.0.1\" 2025-10-21 LTS\nOpenJDK Runtime Environment Microsoft-12574222 (build 25.0.1+8-LTS)\nOpenJDK 64-Bit Server VM Microsoft-12574222 (build 25.0.1+8-LTS, mixed mode)",
|
||||
"extracted_utc": "2026-09-14T13:56:31.823007+00:00",
|
||||
"command": "python3 scripts/catalog_generate.py --java /path/to/java25/bin/java",
|
||||
"data_command": "java -Xmx1G -DbundlerMainClass=net.minecraft.data.Main -jar server.jar --reports --output generated",
|
||||
"collision_context": "All states at BlockPos.ZERO, with air neighbors and CollisionContext.empty(); compare solid neighbors and positionContext(+10/-10). Entity-dependent/block-entity contexts remain approximate.",
|
||||
"counts": {
|
||||
"blocks": 1196,
|
||||
"states": 32366,
|
||||
"entities": 158,
|
||||
"distinct_collision_shapes": 326,
|
||||
"collision_errors": 0,
|
||||
"context_sample_differences": 32
|
||||
},
|
||||
"inputs": {
|
||||
"blocks.json": "dec22eb9547b0bbea396cbf35569f7ec81b7be662775ce03dc2236227473af7b",
|
||||
"registries.json": "9f34d4deb0b494627f6a1a1590e7fb21c11a3949d84bacb59a304e6315287b24",
|
||||
"measurements.json": "256a1852db69f9e05394c44a76cbc9d22e9b02a85b16a95fb4ee52c05e966045",
|
||||
"catalog_extract.java": "6a80d75c3a63da382dcedd702ba2218a1f8339df77a30829d33df6e3dd599164"
|
||||
},
|
||||
"outputs": {
|
||||
"catalog.json.gz": "df889f6ce074b96130011d00b2f3b661cf910aba44064a0b5f361d7c064ce8ed",
|
||||
"report-identities.json.gz": "938232b10d17071d3eefa54ce9a1c3026e17f39b5a2c0f4d6ede2644824d9135"
|
||||
},
|
||||
"license_note": "Factual interoperability data derived through public APIs. No Minecraft visual/audio assets, server binaries or decompiled code are redistributed. Authored runtime and render templates: MIT OR Apache-2.0."
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
use shacraft_content::Catalog;
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let c = Catalog::load_builtin()?;
|
||||
println!("{}", serde_json::to_string_pretty(&c.summary())?);
|
||||
let mut approximate = std::collections::BTreeMap::<String, usize>::new();
|
||||
for state in &c.states {
|
||||
if state.coverage.render == "authored-approximate" {
|
||||
*approximate.entry(state.family.clone()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"Approximate render families: {}",
|
||||
serde_json::to_string(&approximate)?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
//! Independent factual content catalog and authored render templates.
|
||||
//!
|
||||
//! Minecraft IDs are provenance, never Shacraft runtime BlockIds. Collision
|
||||
//! measurements use an explicitly documented world/entity context; they do not
|
||||
//! claim to reproduce entity-dependent or block-entity-dependent behavior.
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use flate2::read::GzDecoder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::io::Read;
|
||||
|
||||
mod packages;
|
||||
mod shapes;
|
||||
pub use packages::*;
|
||||
|
||||
pub const MINECRAFT_VERSION: &str = "26.2";
|
||||
pub const DATA_VERSION: i32 = 4903;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Aabb {
|
||||
pub min: [f32; 3],
|
||||
pub max: [f32; 3],
|
||||
}
|
||||
impl Aabb {
|
||||
pub const fn new(min: [f32; 3], max: [f32; 3]) -> Self {
|
||||
Self { min, max }
|
||||
}
|
||||
pub fn volume(&self) -> f32 {
|
||||
(0..3).map(|i| self.max[i] - self.min[i]).product()
|
||||
}
|
||||
pub fn valid(&self) -> bool {
|
||||
(0..3).all(|i| {
|
||||
self.min[i].is_finite() && self.max[i].is_finite() && self.max[i] > self.min[i]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Coverage {
|
||||
pub identity: String,
|
||||
pub render: String,
|
||||
pub collision: String,
|
||||
pub behavior: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateDefinition {
|
||||
pub state: String,
|
||||
/// Target Java numerical state ID, unrelated to the persistent runtime registry.
|
||||
pub minecraft_id: u32,
|
||||
pub color: [u8; 3],
|
||||
pub opacity: f32,
|
||||
pub collision: Vec<Aabb>,
|
||||
pub render: Vec<Aabb>,
|
||||
pub coverage: Coverage,
|
||||
pub family: String,
|
||||
pub light: u8,
|
||||
pub hardness: f32,
|
||||
}
|
||||
impl StateDefinition {
|
||||
pub fn solid(&self) -> bool {
|
||||
!self.collision.is_empty()
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlockDefinition {
|
||||
pub name: String,
|
||||
pub default_state: String,
|
||||
pub properties: BTreeMap<String, Vec<String>>,
|
||||
pub family: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlockSample {
|
||||
pub state: String,
|
||||
pub position: [i32; 3],
|
||||
pub family: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EntityDefinition {
|
||||
pub name: String,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub color: [u8; 3],
|
||||
/// Authored boxes in entity-local coordinates, relative to the feet.
|
||||
pub render: Vec<Aabb>,
|
||||
/// Default physical dimensions, registry flags, attributes, and model family.
|
||||
/// Instance properties and unknown NBT belong to the entity persistence layer.
|
||||
pub properties: BTreeMap<String, Value>,
|
||||
pub coverage: Coverage,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Catalog {
|
||||
pub version: String,
|
||||
pub data_version: i32,
|
||||
pub blocks: Vec<BlockDefinition>,
|
||||
pub states: Vec<StateDefinition>,
|
||||
pub entities: Vec<EntityDefinition>,
|
||||
#[serde(skip)]
|
||||
state_index: HashMap<String, usize>,
|
||||
#[serde(skip)]
|
||||
entity_index: HashMap<String, usize>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct RawCatalog {
|
||||
version: String,
|
||||
data_version: i32,
|
||||
blocks: Vec<BlockDefinition>,
|
||||
states: Vec<RawState>,
|
||||
shapes: Vec<Vec<[f32; 6]>>,
|
||||
entities: BTreeMap<String, BTreeMap<String, Value>>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct RawState {
|
||||
state: String,
|
||||
minecraft_id: u32,
|
||||
block: usize,
|
||||
shape: usize,
|
||||
context: bool,
|
||||
light: u8,
|
||||
hardness: f32,
|
||||
}
|
||||
|
||||
fn decompress(bytes: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
GzDecoder::new(bytes)
|
||||
.take(32 * 1024 * 1024)
|
||||
.read_to_end(&mut out)?;
|
||||
ensure!(
|
||||
out.len() < 32 * 1024 * 1024,
|
||||
"catalog exceeds maximum decompressed size"
|
||||
);
|
||||
Ok(out)
|
||||
}
|
||||
impl Catalog {
|
||||
pub fn load_builtin() -> Result<Self> {
|
||||
let raw: RawCatalog =
|
||||
serde_json::from_slice(&decompress(include_bytes!("../data/catalog.json.gz"))?)?;
|
||||
ensure!(
|
||||
raw.version == MINECRAFT_VERSION && raw.data_version == DATA_VERSION,
|
||||
"unexpected built-in catalog version"
|
||||
);
|
||||
let mut states = Vec::with_capacity(raw.states.len() + 1);
|
||||
for s in raw.states {
|
||||
let block = raw.blocks.get(s.block).context("invalid block index")?;
|
||||
let collision = raw
|
||||
.shapes
|
||||
.get(s.shape)
|
||||
.context("invalid shape index")?
|
||||
.iter()
|
||||
.map(|b| Aabb::new([b[0], b[1], b[2]], [b[3], b[4], b[5]]))
|
||||
.collect::<Vec<_>>();
|
||||
let (render, render_coverage) =
|
||||
shapes::block_render(&s.state, &block.family, &collision);
|
||||
let contextual = s.context
|
||||
|| matches!(
|
||||
block.family.as_str(),
|
||||
"powder_snow" | "shulker_box" | "moving_piston" | "big_dripleaf"
|
||||
);
|
||||
states.push(StateDefinition {
|
||||
color: shapes::color(&block.name),
|
||||
opacity: shapes::opacity(&block.name),
|
||||
state: s.state,
|
||||
minecraft_id: s.minecraft_id,
|
||||
collision,
|
||||
render,
|
||||
coverage: Coverage {
|
||||
identity: "official-report-exact".into(),
|
||||
render: render_coverage.into(),
|
||||
collision: if contextual {
|
||||
"approximate-context"
|
||||
} else {
|
||||
"measured-empty-context"
|
||||
}
|
||||
.into(),
|
||||
behavior: "storage-and-physics".into(),
|
||||
},
|
||||
family: block.family.clone(),
|
||||
light: s.light,
|
||||
hardness: s.hardness,
|
||||
});
|
||||
}
|
||||
let mut entities = Vec::new();
|
||||
for (name, mut properties) in raw.entities {
|
||||
let width = properties
|
||||
.get("width")
|
||||
.and_then(Value::as_f64)
|
||||
.context("entity width missing")? as f32;
|
||||
let height = properties
|
||||
.get("height")
|
||||
.and_then(Value::as_f64)
|
||||
.context("entity height missing")? as f32;
|
||||
let (render, family) = shapes::entity_render(&name, width, height);
|
||||
properties.insert("model_family".into(), json!(family));
|
||||
entities.push(EntityDefinition {
|
||||
name: name.clone(),
|
||||
width,
|
||||
height,
|
||||
color: shapes::entity_color(&name),
|
||||
render,
|
||||
properties,
|
||||
coverage: Coverage {
|
||||
identity: "official-report-exact".into(),
|
||||
render: if family == "invisible" {
|
||||
"intentionally-invisible"
|
||||
} else {
|
||||
"authored-procedural"
|
||||
}
|
||||
.into(),
|
||||
collision: "measured-default-dimensions".into(),
|
||||
behavior: "persistent-instance-no-vanilla-ai".into(),
|
||||
},
|
||||
});
|
||||
}
|
||||
let mut result = Self {
|
||||
version: raw.version,
|
||||
data_version: raw.data_version,
|
||||
blocks: raw.blocks,
|
||||
states,
|
||||
entities,
|
||||
state_index: HashMap::new(),
|
||||
entity_index: HashMap::new(),
|
||||
};
|
||||
result.add_trampoline();
|
||||
result.rebuild_indexes()?;
|
||||
Ok(result)
|
||||
}
|
||||
pub fn rebuild_indexes(&mut self) -> Result<()> {
|
||||
self.state_index.clear();
|
||||
self.entity_index.clear();
|
||||
for (i, s) in self.states.iter().enumerate() {
|
||||
ensure!(
|
||||
self.state_index.insert(s.state.clone(), i).is_none(),
|
||||
"duplicate canonical state {}",
|
||||
s.state
|
||||
);
|
||||
ensure!(
|
||||
s.collision.iter().chain(s.render.iter()).all(Aabb::valid),
|
||||
"invalid geometry for {}",
|
||||
s.state
|
||||
);
|
||||
}
|
||||
for (i, e) in self.entities.iter().enumerate() {
|
||||
ensure!(
|
||||
self.entity_index.insert(e.name.clone(), i).is_none(),
|
||||
"duplicate entity {}",
|
||||
e.name
|
||||
);
|
||||
ensure!(
|
||||
e.render.iter().all(Aabb::valid),
|
||||
"invalid entity model {}",
|
||||
e.name
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn state(&self, canonical: &str) -> Option<&StateDefinition> {
|
||||
self.state_index
|
||||
.get(canonical)
|
||||
.map(|i| &self.states[*i])
|
||||
.or_else(|| {
|
||||
self.blocks
|
||||
.iter()
|
||||
.find(|b| b.name == canonical)
|
||||
.and_then(|b| self.state_index.get(&b.default_state))
|
||||
.map(|i| &self.states[*i])
|
||||
})
|
||||
}
|
||||
pub fn entity(&self, name: &str) -> Option<&EntityDefinition> {
|
||||
self.entity_index.get(name).map(|i| &self.entities[*i])
|
||||
}
|
||||
pub fn default_state(&self, name: &str) -> Option<&str> {
|
||||
self.blocks
|
||||
.iter()
|
||||
.find(|b| b.name == name)
|
||||
.map(|b| b.default_state.as_str())
|
||||
}
|
||||
pub fn palette(&self) -> BTreeMap<String, String> {
|
||||
[
|
||||
("grass", "minecraft:grass_block"),
|
||||
("stone", "minecraft:stone"),
|
||||
("snow", "minecraft:snow_block"),
|
||||
("glass", "minecraft:glass"),
|
||||
("wood", "minecraft:oak_planks"),
|
||||
("stairs", "minecraft:oak_stairs"),
|
||||
("slab", "minecraft:oak_slab"),
|
||||
("fence", "minecraft:oak_fence"),
|
||||
("water", "minecraft:water"),
|
||||
("sand", "minecraft:sand"),
|
||||
("light", "minecraft:sea_lantern"),
|
||||
("trampoline", "shacraft:trampoline"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(key, name)| {
|
||||
self.default_state(name)
|
||||
.map(|s| (key.to_owned(), s.to_owned()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// One default state per block, in an original 32-column inspection garden.
|
||||
/// The floor is caller-owned; every sample is one cell above it.
|
||||
pub fn sampler(&self) -> Vec<BlockSample> {
|
||||
self.blocks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| BlockSample {
|
||||
state: b.default_state.clone(),
|
||||
family: b.family.clone(),
|
||||
position: [-48 + (i % 32) as i32 * 3, 1, -57 + (i / 32) as i32 * 3],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn summary(&self) -> Value {
|
||||
let mut render = BTreeMap::<&str, usize>::new();
|
||||
let mut collision = BTreeMap::<&str, usize>::new();
|
||||
for s in &self.states {
|
||||
*render.entry(&s.coverage.render).or_default() += 1;
|
||||
*collision.entry(&s.coverage.collision).or_default() += 1;
|
||||
}
|
||||
json!({"minecraft_version":self.version,"data_version":self.data_version,
|
||||
"minecraft_blocks":self.blocks.iter().filter(|b|b.name.starts_with("minecraft:")).count(),
|
||||
"minecraft_states":self.states.iter().filter(|s|s.state.starts_with("minecraft:")).count(),
|
||||
"entities":self.entities.len(),"total_states":self.states.len(),"render":render,"collision":collision,
|
||||
"palette":self.palette(),"collision_context":"BlockPos.ZERO, air neighbors, empty entity context; contextual classes flagged separately",
|
||||
"simulation":"server movement, block editing, authored modules; vanilla AI/redstone/fluid simulation are not implemented"})
|
||||
}
|
||||
fn add_trampoline(&mut self) {
|
||||
self.blocks.push(BlockDefinition {
|
||||
name: "shacraft:trampoline".into(),
|
||||
default_state: "shacraft:trampoline".into(),
|
||||
properties: BTreeMap::new(),
|
||||
family: "trampoline".into(),
|
||||
});
|
||||
self.states.push(StateDefinition {
|
||||
state: "shacraft:trampoline".into(),
|
||||
minecraft_id: u32::MAX,
|
||||
color: [97, 205, 151],
|
||||
opacity: 1.,
|
||||
collision: vec![Aabb::new([0., 0., 0.], [1., 1., 1.])],
|
||||
render: vec![
|
||||
Aabb::new([0., 0., 0.], [1., 0.18, 1.]),
|
||||
Aabb::new([0.05, 0.18, 0.05], [0.95, 0.86, 0.95]),
|
||||
Aabb::new([0., 0.86, 0.], [1., 1., 1.]),
|
||||
],
|
||||
coverage: Coverage {
|
||||
identity: "authored-package".into(),
|
||||
render: "authored-procedural".into(),
|
||||
collision: "authored-exact".into(),
|
||||
behavior: "wasm-on-jump".into(),
|
||||
},
|
||||
family: "trampoline".into(),
|
||||
light: 0,
|
||||
hardness: 1.,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonicalize registry state keys without discarding unknown properties.
|
||||
pub fn canonical_state(name: &str, properties: &BTreeMap<String, String>) -> Result<String> {
|
||||
ensure!(
|
||||
!name.is_empty()
|
||||
&& name.is_ascii()
|
||||
&& name.contains(':')
|
||||
&& !name
|
||||
.bytes()
|
||||
.any(|b| b.is_ascii_whitespace() || b"[]=,".contains(&b)),
|
||||
"invalid resource name"
|
||||
);
|
||||
for (k, v) in properties {
|
||||
if k.is_empty()
|
||||
|| v.is_empty()
|
||||
|| !k.is_ascii()
|
||||
|| !v.is_ascii()
|
||||
|| k.bytes()
|
||||
.chain(v.bytes())
|
||||
.any(|b| b.is_ascii_whitespace() || b"[]=,:".contains(&b))
|
||||
{
|
||||
bail!("invalid property");
|
||||
}
|
||||
}
|
||||
let result = if properties.is_empty() {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"{}[{}]",
|
||||
name,
|
||||
properties
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)
|
||||
};
|
||||
ensure!(result.len() <= 512, "canonical state too long");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
fn catalog() -> &'static Catalog {
|
||||
static C: OnceLock<Catalog> = OnceLock::new();
|
||||
C.get_or_init(|| Catalog::load_builtin().unwrap())
|
||||
}
|
||||
#[test]
|
||||
fn complete_against_independent_official_report_projection() {
|
||||
let c = catalog();
|
||||
let report: Value = serde_json::from_slice(
|
||||
&decompress(include_bytes!("../data/report-identities.json.gz")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let blocks = report["blocks"].as_object().unwrap();
|
||||
assert_eq!(blocks.len(), 1196);
|
||||
assert_eq!(c.data_version, 4903);
|
||||
let mut actual = HashMap::new();
|
||||
for s in &c.states {
|
||||
if s.state.starts_with("minecraft:") {
|
||||
actual.insert(s.state.clone(), s.minecraft_id);
|
||||
}
|
||||
}
|
||||
let mut expected = HashMap::new();
|
||||
for (name, b) in blocks {
|
||||
let def = c.blocks.iter().find(|b| &b.name == name).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&def.properties).unwrap(),
|
||||
b["properties"]
|
||||
);
|
||||
for state in b["states"].as_array().unwrap() {
|
||||
let props =
|
||||
serde_json::from_value(state.get("properties").cloned().unwrap_or(json!({})))
|
||||
.unwrap();
|
||||
let name = canonical_state(name, &props).unwrap();
|
||||
expected.insert(name.clone(), state["id"].as_u64().unwrap() as u32);
|
||||
if state.get("default") == Some(&json!(true)) {
|
||||
assert_eq!(def.default_state, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(actual.len(), 32366);
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(
|
||||
c.entities
|
||||
.iter()
|
||||
.map(|e| e.name.clone())
|
||||
.collect::<HashSet<_>>(),
|
||||
report["entities"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s.as_str().unwrap().to_owned())
|
||||
.collect()
|
||||
);
|
||||
assert!(c.state("minecraft:potent_sulfur").is_some());
|
||||
assert!(c.entity("minecraft:sulfur_cube").is_some());
|
||||
}
|
||||
#[test]
|
||||
fn every_state_has_valid_shapes_and_explicit_coverage() {
|
||||
for s in &catalog().states {
|
||||
assert!(!s.coverage.render.is_empty());
|
||||
assert!(!s.coverage.collision.is_empty());
|
||||
for b in s.collision.iter().chain(&s.render) {
|
||||
assert!(b.valid(), "{} {b:?}", s.state);
|
||||
assert!(
|
||||
b.min.iter().chain(&b.max).all(|v| (-2.0..=3.0).contains(v)),
|
||||
"{} {b:?}",
|
||||
s.state
|
||||
);
|
||||
}
|
||||
if s.family != "air"
|
||||
&& !matches!(
|
||||
s.family.as_str(),
|
||||
"light" | "structure_void" | "barrier" | "moving_piston"
|
||||
)
|
||||
{
|
||||
assert!(!s.render.is_empty(), "missing {}", s.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn official_entity_dimensions_and_authored_models_present() {
|
||||
assert_eq!(catalog().entities.len(), 158);
|
||||
for e in &catalog().entities {
|
||||
assert!(e.width >= 0. && e.height >= 0.);
|
||||
assert!(e.properties.contains_key("attributes"));
|
||||
if e.properties["model_family"] != "invisible" {
|
||||
assert!(!e.render.is_empty());
|
||||
}
|
||||
}
|
||||
assert_eq!(catalog().entity("minecraft:player").unwrap().width, 0.6);
|
||||
assert_eq!(
|
||||
catalog().entity("minecraft:cow").unwrap().properties["model_family"],
|
||||
"quadruped"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn sampler_defaults_and_palette_are_registered() {
|
||||
let c = catalog();
|
||||
let samples = c.sampler();
|
||||
assert_eq!(samples.len(), 1197);
|
||||
let positions = samples.iter().map(|s| s.position).collect::<HashSet<_>>();
|
||||
assert_eq!(positions.len(), samples.len());
|
||||
for sample in samples {
|
||||
assert!(c.state(&sample.state).is_some());
|
||||
assert!(sample.position[0].abs() <= 64 && sample.position[2].abs() <= 64);
|
||||
}
|
||||
for state in c.palette().values() {
|
||||
assert!(c.state(state).is_some());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn key_collision_states_and_orientation() {
|
||||
let c = catalog();
|
||||
let bottom = c
|
||||
.state("minecraft:oak_slab[type=bottom,waterlogged=false]")
|
||||
.unwrap();
|
||||
let top = c
|
||||
.state("minecraft:oak_slab[type=top,waterlogged=false]")
|
||||
.unwrap();
|
||||
assert_eq!(bottom.collision[0], Aabb::new([0., 0., 0.], [1., 0.5, 1.]));
|
||||
assert_eq!(top.collision[0].min[1], 0.5);
|
||||
for half in ["bottom", "top"] {
|
||||
for facing in ["north", "south", "east", "west"] {
|
||||
let s=c.state(&format!("minecraft:oak_stairs[facing={facing},half={half},shape=straight,waterlogged=false]")).unwrap();
|
||||
assert!((s.collision.iter().map(Aabb::volume).sum::<f32>() - 0.75).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
let door = c
|
||||
.state(
|
||||
"minecraft:oak_door[facing=north,half=lower,hinge=left,open=false,powered=false]",
|
||||
)
|
||||
.unwrap();
|
||||
let open = c
|
||||
.state("minecraft:oak_door[facing=north,half=lower,hinge=left,open=true,powered=false]")
|
||||
.unwrap();
|
||||
assert_ne!(door.collision, open.collision);
|
||||
assert!(
|
||||
c.state("minecraft:water[level=0]")
|
||||
.unwrap()
|
||||
.collision
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
c.state("minecraft:oak_fence")
|
||||
.unwrap()
|
||||
.collision
|
||||
.iter()
|
||||
.any(|b| b.max[1] == 1.5)
|
||||
);
|
||||
assert!(
|
||||
c.state("minecraft:scaffolding")
|
||||
.unwrap()
|
||||
.coverage
|
||||
.collision
|
||||
.contains("approximate")
|
||||
);
|
||||
assert!(
|
||||
c.state("minecraft:powder_snow")
|
||||
.unwrap()
|
||||
.coverage
|
||||
.collision
|
||||
.contains("approximate")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Unified packages: verified resources with an explicit public/server boundary.
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MAX_RESOURCE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
pub const MAX_PACKAGE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PackageDependency {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PackageResource {
|
||||
pub path: String,
|
||||
pub scope: String,
|
||||
pub role: String,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PackageManifest {
|
||||
pub schema: u32,
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub license: String,
|
||||
pub dependencies: Vec<PackageDependency>,
|
||||
pub capabilities: Vec<String>,
|
||||
pub resources: Vec<PackageResource>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Package {
|
||||
pub directory: PathBuf,
|
||||
pub manifest: PackageManifest,
|
||||
}
|
||||
fn token(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 128
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
|
||||
}
|
||||
fn version(s: &str) -> bool {
|
||||
let p = s.split('.').collect::<Vec<_>>();
|
||||
p.len() == 3
|
||||
&& p.iter().all(|s| {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 5
|
||||
&& (s.len() == 1 || !s.starts_with('0'))
|
||||
&& s.bytes().all(|b| b.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
fn safe_path(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 256
|
||||
&& s.is_ascii()
|
||||
&& !s.contains('\\')
|
||||
&& !s.contains(':')
|
||||
&& !s.contains('%')
|
||||
&& !s.bytes().any(|b| b.is_ascii_control())
|
||||
&& !Path::new(s).is_absolute()
|
||||
&& Path::new(s)
|
||||
.components()
|
||||
.all(|c| matches!(c, Component::Normal(_)))
|
||||
&& s.split('/').all(|s| !s.is_empty() && s != "." && s != "..")
|
||||
}
|
||||
impl PackageManifest {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
ensure!(self.schema == 1, "unsupported package schema");
|
||||
ensure!(token(&self.id), "invalid package id");
|
||||
ensure!(version(&self.version), "invalid exact semantic version");
|
||||
ensure!(
|
||||
!self.license.is_empty()
|
||||
&& self.license.len() <= 256
|
||||
&& !self.license.chars().any(char::is_control),
|
||||
"license required"
|
||||
);
|
||||
ensure!(
|
||||
self.resources.len() <= 128 && !self.resources.is_empty(),
|
||||
"invalid package resource count"
|
||||
);
|
||||
ensure!(
|
||||
self.dependencies.len() <= 32 && self.capabilities.len() <= 32,
|
||||
"too many dependencies or capabilities"
|
||||
);
|
||||
let mut paths = HashSet::new();
|
||||
let mut total = 0u64;
|
||||
for r in &self.resources {
|
||||
ensure!(safe_path(&r.path), "unsafe resource path {}", r.path);
|
||||
ensure!(paths.insert(&r.path), "duplicate resource path");
|
||||
ensure!(
|
||||
matches!(r.scope.as_str(), "client" | "common" | "server"),
|
||||
"invalid resource scope"
|
||||
);
|
||||
ensure!(token(&r.role), "invalid resource role");
|
||||
ensure!(
|
||||
r.sha256.len() == 64
|
||||
&& r.sha256
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()),
|
||||
"invalid SHA-256"
|
||||
);
|
||||
ensure!(r.size <= MAX_RESOURCE_BYTES, "resource too large");
|
||||
total = total.checked_add(r.size).context("package size overflow")?;
|
||||
if r.role == "wasm-on-jump" {
|
||||
ensure!(
|
||||
r.scope == "server" && r.path.ends_with(".wasm"),
|
||||
"executable jump hook must be server-only WASM"
|
||||
);
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
total <= MAX_PACKAGE_BYTES,
|
||||
"package exceeds total size limit"
|
||||
);
|
||||
let mut deps = HashSet::new();
|
||||
for d in &self.dependencies {
|
||||
ensure!(
|
||||
token(&d.id) && version(&d.version) && d.id != self.id && deps.insert(&d.id),
|
||||
"invalid or duplicate dependency"
|
||||
);
|
||||
}
|
||||
for c in &self.capabilities {
|
||||
ensure!(
|
||||
matches!(
|
||||
c.as_str(),
|
||||
"blocks.define"
|
||||
| "entities.define"
|
||||
| "client.texture"
|
||||
| "client.shader"
|
||||
| "client.sound"
|
||||
| "client.style"
|
||||
| "server.on_jump"
|
||||
),
|
||||
"unsupported capability {c}"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
self.resources.iter().all(|r| r.role != "wasm-on-jump"
|
||||
|| self.capabilities.iter().any(|c| c == "server.on_jump")),
|
||||
"WASM hook capability missing"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
/// Clients never receive server resource names, contents, sizes, or hashes.
|
||||
pub fn client_manifest(&self) -> Value {
|
||||
json!({"schema":self.schema,"id":self.id,"version":self.version,"license":self.license,
|
||||
"dependencies":self.dependencies,"capabilities":self.capabilities.iter().filter(|s|!s.starts_with("server.")).collect::<Vec<_>>(),
|
||||
"resources":self.resources.iter().filter(|r|r.scope!="server").collect::<Vec<_>>()})
|
||||
}
|
||||
}
|
||||
impl Package {
|
||||
pub fn load(directory: impl AsRef<Path>) -> Result<Self> {
|
||||
let dir = directory.as_ref();
|
||||
ensure!(
|
||||
!dir.symlink_metadata()?.file_type().is_symlink(),
|
||||
"package directory is a symlink"
|
||||
);
|
||||
let directory = dir.canonicalize()?;
|
||||
let path = directory.join("manifest.json");
|
||||
ensure!(
|
||||
!path.symlink_metadata()?.file_type().is_symlink(),
|
||||
"package manifest is a symlink"
|
||||
);
|
||||
ensure!(path.metadata()?.len() <= 256 * 1024, "manifest too large");
|
||||
let manifest: PackageManifest = serde_json::from_slice(&std::fs::read(path)?)?;
|
||||
manifest.validate()?;
|
||||
let package = Self {
|
||||
directory,
|
||||
manifest,
|
||||
};
|
||||
for r in &package.manifest.resources {
|
||||
package.read_resource(&r.path)?;
|
||||
}
|
||||
Ok(package)
|
||||
}
|
||||
/// Re-verifies the resource at read time to reject replacement after loading.
|
||||
pub fn read_resource(&self, path: &str) -> Result<Vec<u8>> {
|
||||
let resource = self
|
||||
.manifest
|
||||
.resources
|
||||
.iter()
|
||||
.find(|r| r.path == path)
|
||||
.context("resource is not declared")?;
|
||||
ensure!(safe_path(path), "unsafe resource path");
|
||||
let mut real = self.directory.clone();
|
||||
for part in Path::new(path).components() {
|
||||
real.push(part);
|
||||
ensure!(
|
||||
!real.symlink_metadata()?.file_type().is_symlink(),
|
||||
"resource paths may not contain symlinks"
|
||||
);
|
||||
}
|
||||
let file = std::fs::File::open(&real)?;
|
||||
ensure!(
|
||||
file.metadata()?.is_file()
|
||||
&& file.metadata()?.len() == resource.size
|
||||
&& resource.size <= MAX_RESOURCE_BYTES,
|
||||
"resource size mismatch"
|
||||
);
|
||||
use std::io::Read;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_RESOURCE_BYTES + 1).read_to_end(&mut bytes)?;
|
||||
ensure!(
|
||||
bytes.len() as u64 == resource.size,
|
||||
"resource changed while reading"
|
||||
);
|
||||
ensure!(
|
||||
format!("{:x}", Sha256::digest(&bytes)) == resource.sha256,
|
||||
"resource hash mismatch: {path}"
|
||||
);
|
||||
Ok(bytes)
|
||||
}
|
||||
pub fn public_resource(&self, path: &str) -> Result<Vec<u8>> {
|
||||
ensure!(
|
||||
self.manifest
|
||||
.resources
|
||||
.iter()
|
||||
.any(|r| r.path == path && r.scope != "server"),
|
||||
"resource is not public"
|
||||
);
|
||||
self.read_resource(path)
|
||||
}
|
||||
}
|
||||
/// Load and verify the entire dependency graph. Missing or incompatible required
|
||||
/// packages and cycles fail before the server exposes any package to clients.
|
||||
pub fn load_packages(root: impl AsRef<Path>) -> Result<Vec<Package>> {
|
||||
let mut dirs = std::fs::read_dir(root)?
|
||||
.map(|e| e.map(|e| e.path()))
|
||||
.collect::<std::io::Result<Vec<_>>>()?;
|
||||
dirs.sort();
|
||||
ensure!(dirs.len() <= 128, "too many package entries");
|
||||
let mut out = Vec::new();
|
||||
let mut ids = BTreeMap::new();
|
||||
for path in dirs {
|
||||
if path.is_dir() && path.join("manifest.json").exists() {
|
||||
let p = Package::load(path)?;
|
||||
ensure!(
|
||||
ids.insert(p.manifest.id.clone(), out.len()).is_none(),
|
||||
"duplicate package id"
|
||||
);
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
for p in &out {
|
||||
for d in &p.manifest.dependencies {
|
||||
let i = *ids
|
||||
.get(&d.id)
|
||||
.with_context(|| format!("missing required package {}", d.id))?;
|
||||
ensure!(
|
||||
out[i].manifest.version == d.version,
|
||||
"dependency version mismatch for {}",
|
||||
d.id
|
||||
);
|
||||
}
|
||||
}
|
||||
fn visit(
|
||||
i: usize,
|
||||
p: &[Package],
|
||||
ids: &BTreeMap<String, usize>,
|
||||
visiting: &mut HashSet<usize>,
|
||||
done: &mut HashSet<usize>,
|
||||
) -> Result<()> {
|
||||
if done.contains(&i) {
|
||||
return Ok(());
|
||||
}
|
||||
if !visiting.insert(i) {
|
||||
bail!("package dependency cycle")
|
||||
}
|
||||
for d in &p[i].manifest.dependencies {
|
||||
visit(ids[&d.id], p, ids, visiting, done)?;
|
||||
}
|
||||
visiting.remove(&i);
|
||||
done.insert(i);
|
||||
Ok(())
|
||||
}
|
||||
let mut done = HashSet::new();
|
||||
for i in 0..out.len() {
|
||||
visit(i, &out, &ids, &mut HashSet::new(), &mut done)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn package_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packages")
|
||||
}
|
||||
#[test]
|
||||
fn bundled_packages_hashes_dependencies_and_privacy() {
|
||||
let p = load_packages(package_root()).unwrap();
|
||||
assert_eq!(p.len(), 2);
|
||||
let t = p
|
||||
.iter()
|
||||
.find(|p| p.manifest.id == "shacraft.trampoline")
|
||||
.unwrap();
|
||||
assert!(
|
||||
t.read_resource("server/trampoline.wasm")
|
||||
.unwrap()
|
||||
.starts_with(b"\0asm")
|
||||
);
|
||||
assert!(t.public_resource("server/trampoline.wasm").is_err());
|
||||
let public = t.manifest.client_manifest().to_string();
|
||||
assert!(!public.contains("server/"));
|
||||
assert!(!public.contains("server.on_jump"));
|
||||
}
|
||||
#[test]
|
||||
fn paths_sizes_and_versions_are_validated() {
|
||||
let p = load_packages(package_root()).unwrap();
|
||||
for path in [
|
||||
"../secret",
|
||||
"/etc/passwd",
|
||||
"client/../../secret",
|
||||
"client\\secret",
|
||||
"client//file",
|
||||
"client/./file",
|
||||
"client/%2e%2e/key",
|
||||
] {
|
||||
let mut m = p[0].manifest.clone();
|
||||
m.resources[0].path = path.into();
|
||||
assert!(m.validate().is_err(), "{path}");
|
||||
}
|
||||
let mut m = p[0].manifest.clone();
|
||||
m.resources[0].size = MAX_RESOURCE_BYTES + 1;
|
||||
assert!(m.validate().is_err());
|
||||
m = p[0].manifest.clone();
|
||||
m.version = "latest".into();
|
||||
assert!(m.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn changed_resource_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let bytes = b"own content";
|
||||
std::fs::write(dir.path().join("asset"), bytes).unwrap();
|
||||
let m = PackageManifest {
|
||||
schema: 1,
|
||||
id: "test.sample".into(),
|
||||
version: "1.0.0".into(),
|
||||
license: "MIT".into(),
|
||||
dependencies: vec![],
|
||||
capabilities: vec![],
|
||||
resources: vec![PackageResource {
|
||||
path: "asset".into(),
|
||||
scope: "client".into(),
|
||||
role: "texture".into(),
|
||||
sha256: format!("{:x}", Sha256::digest(bytes)),
|
||||
size: bytes.len() as u64,
|
||||
}],
|
||||
};
|
||||
std::fs::write(
|
||||
dir.path().join("manifest.json"),
|
||||
serde_json::to_vec(&m).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = Package::load(dir.path()).unwrap();
|
||||
std::fs::write(dir.path().join("asset"), b"bad content").unwrap();
|
||||
assert!(p.read_resource("asset").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
//! Shacraft's original low-poly templates; no Minecraft visual models/assets.
|
||||
use crate::Aabb;
|
||||
use std::collections::BTreeMap;
|
||||
fn bx(a: [f32; 3], b: [f32; 3]) -> Aabb {
|
||||
Aabb::new(a, b)
|
||||
}
|
||||
fn props(state: &str) -> BTreeMap<&str, &str> {
|
||||
state
|
||||
.split_once('[')
|
||||
.map(|(_, tail)| {
|
||||
tail.trim_end_matches(']')
|
||||
.split(',')
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn get<'a>(p: &BTreeMap<&'a str, &'a str>, key: &str, default: &'a str) -> &'a str {
|
||||
p.get(key).copied().unwrap_or(default)
|
||||
}
|
||||
fn num(p: &BTreeMap<&str, &str>, key: &str, default: f32) -> f32 {
|
||||
p.get(key).and_then(|s| s.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
fn rotate(boxes: Vec<Aabb>, facing: &str) -> Vec<Aabb> {
|
||||
let steps = match facing {
|
||||
"east" => 1,
|
||||
"south" => 2,
|
||||
"west" => 3,
|
||||
_ => 0,
|
||||
};
|
||||
boxes
|
||||
.into_iter()
|
||||
.map(|mut b| {
|
||||
for _ in 0..steps {
|
||||
b = bx(
|
||||
[1. - b.max[2], b.min[1], b.min[0]],
|
||||
[1. - b.min[2], b.max[1], b.max[0]],
|
||||
)
|
||||
}
|
||||
b
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
const FULL: Aabb = Aabb::new([0., 0., 0.], [1., 1., 1.]);
|
||||
pub(crate) fn block_render(
|
||||
state: &str,
|
||||
family: &str,
|
||||
collision: &[Aabb],
|
||||
) -> (Vec<Aabb>, &'static str) {
|
||||
let p = props(state);
|
||||
let name = state.split('[').next().unwrap_or(state);
|
||||
let facing = get(&p, "facing", "north");
|
||||
let b = match family {
|
||||
"air" | "barrier" | "light" | "structure_void" | "moving_piston" => {
|
||||
return (vec![], "intentionally-invisible");
|
||||
}
|
||||
"liquid" | "bubble_column" => vec![bx(
|
||||
[0., 0., 0.],
|
||||
[
|
||||
1.,
|
||||
if num(&p, "level", 0.) >= 8. {
|
||||
1.
|
||||
} else {
|
||||
(8. - num(&p, "level", 0.)).max(1.) / 9.
|
||||
},
|
||||
1.,
|
||||
],
|
||||
)],
|
||||
"button" | "lever" => {
|
||||
let pressed = get(&p, "powered", "false") == "true";
|
||||
let depth = if pressed { 0.0625 } else { 0.125 };
|
||||
let face = get(&p, "face", "wall");
|
||||
let mut b = vec![match face {
|
||||
"floor" => bx([0.3125, 0., 0.375], [0.6875, depth, 0.625]),
|
||||
"ceiling" => bx([0.3125, 1. - depth, 0.375], [0.6875, 1., 0.625]),
|
||||
_ => bx([0.3125, 0.375, 1. - depth], [0.6875, 0.625, 1.]),
|
||||
}];
|
||||
if family == "lever" {
|
||||
b.push(match face {
|
||||
"floor" => bx(
|
||||
[0.46875, 0.0625, if pressed { 0.55 } else { 0.25 }],
|
||||
[0.53125, 0.375, if pressed { 0.75 } else { 0.45 }],
|
||||
),
|
||||
"ceiling" => bx(
|
||||
[0.46875, 0.625, if pressed { 0.55 } else { 0.25 }],
|
||||
[0.53125, 0.9375, if pressed { 0.75 } else { 0.45 }],
|
||||
),
|
||||
_ => bx(
|
||||
[0.46875, if pressed { 0.25 } else { 0.55 }, 0.625],
|
||||
[0.53125, if pressed { 0.45 } else { 0.75 }, 0.9375],
|
||||
),
|
||||
});
|
||||
}
|
||||
rotate(b, facing)
|
||||
}
|
||||
"pressure_plate" | "weighted_pressure_plate" => vec![bx(
|
||||
[0.0625, 0., 0.0625],
|
||||
[
|
||||
0.9375,
|
||||
if get(&p, "powered", "false") == "true" || num(&p, "power", 0.) > 0. {
|
||||
0.03125
|
||||
} else {
|
||||
0.0625
|
||||
},
|
||||
0.9375,
|
||||
],
|
||||
)],
|
||||
"trip_wire_hook" => rotate(
|
||||
vec![
|
||||
bx([0.375, 0.25, 0.875], [0.625, 0.75, 1.]),
|
||||
bx([0.46875, 0.4375, 0.5], [0.53125, 0.5, 0.9375]),
|
||||
],
|
||||
facing,
|
||||
),
|
||||
"snow_layer" => vec![bx([0., 0., 0.], [1., num(&p, "layers", 1.) / 8., 1.])],
|
||||
"mossy_carpet" => {
|
||||
let mut b = vec![bx([0., 0., 0.], [1., 0.0625, 1.])];
|
||||
for (dir, a, z) in [
|
||||
("north", [0., 0., 0.], [1., 0., 0.0625]),
|
||||
("south", [0., 0., 0.9375], [1., 0., 1.]),
|
||||
("west", [0., 0., 0.], [0.0625, 0., 1.]),
|
||||
("east", [0.9375, 0., 0.], [1., 0., 1.]),
|
||||
] {
|
||||
let v = get(&p, dir, "none");
|
||||
if v != "none" {
|
||||
b.push(bx(a, [z[0], if v == "tall" { 1. } else { 0.5 }, z[2]]));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
"big_dripleaf" | "big_dripleaf_stem" => {
|
||||
let mut b = vec![bx([0.4375, 0., 0.4375], [0.5625, 0.875, 0.5625])];
|
||||
if family == "big_dripleaf" {
|
||||
let tilt = get(&p, "tilt", "none");
|
||||
b.push(if tilt == "full" {
|
||||
bx([0.0625, 0.375, 0.75], [0.9375, 0.875, 0.8125])
|
||||
} else {
|
||||
bx(
|
||||
[
|
||||
0.0625,
|
||||
if tilt == "partial" { 0.625 } else { 0.8125 },
|
||||
0.0625,
|
||||
],
|
||||
[0.9375, 0.875, 0.9375],
|
||||
)
|
||||
});
|
||||
}
|
||||
rotate(b, facing)
|
||||
}
|
||||
"fence" => {
|
||||
let mut b = vec![bx([0.375, 0., 0.375], [0.625, 1., 0.625])];
|
||||
for (dir, a, z) in [
|
||||
("north", [0.4375, 0., 0.], [0.5625, 0., 0.5]),
|
||||
("south", [0.4375, 0., 0.5], [0.5625, 0., 1.]),
|
||||
("west", [0., 0., 0.4375], [0.5, 0., 0.5625]),
|
||||
("east", [0.5, 0., 0.4375], [1., 0., 0.5625]),
|
||||
] {
|
||||
if get(&p, dir, "false") == "true" {
|
||||
for y in [0.375, 0.75] {
|
||||
b.push(bx([a[0], y, a[2]], [z[0], y + 0.1875, z[2]]));
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
"wall" => {
|
||||
let mut b = vec![];
|
||||
if get(&p, "up", "true") == "true" {
|
||||
b.push(bx([0.25, 0., 0.25], [0.75, 1., 0.75]));
|
||||
}
|
||||
for (dir, a, z) in [
|
||||
("north", [0.3125, 0., 0.], [0.6875, 0., 0.5]),
|
||||
("south", [0.3125, 0., 0.5], [0.6875, 0., 1.]),
|
||||
("west", [0., 0., 0.3125], [0.5, 0., 0.6875]),
|
||||
("east", [0.5, 0., 0.3125], [1., 0., 0.6875]),
|
||||
] {
|
||||
let value = get(&p, dir, "none");
|
||||
if value != "none" {
|
||||
b.push(bx(
|
||||
a,
|
||||
[z[0], if value == "tall" { 1. } else { 0.8125 }, z[2]],
|
||||
));
|
||||
}
|
||||
}
|
||||
if b.is_empty() {
|
||||
b.push(bx([0.25, 0., 0.25], [0.75, 1., 0.75]));
|
||||
}
|
||||
b
|
||||
}
|
||||
"fence_gate" => {
|
||||
let mut b = vec![
|
||||
bx([0., 0.3125, 0.375], [0.125, 1., 0.625]),
|
||||
bx([0.875, 0.3125, 0.375], [1., 1., 0.625]),
|
||||
];
|
||||
if get(&p, "open", "false") == "true" {
|
||||
b.push(bx([0., 0.375, 0.5], [0.1875, 0.875, 1.]));
|
||||
b.push(bx([0.8125, 0.375, 0.5], [1., 0.875, 1.]));
|
||||
} else {
|
||||
for y in [0.375, 0.75] {
|
||||
b.push(bx([0.125, y, 0.4375], [0.875, y + 0.125, 0.5625]));
|
||||
}
|
||||
}
|
||||
rotate(b, facing)
|
||||
}
|
||||
"rail" | "powered_rail" | "detector_rail" => {
|
||||
let s = get(&p, "shape", "north_south");
|
||||
let mut b = vec![];
|
||||
if s.starts_with("ascending_") {
|
||||
for i in 0..8 {
|
||||
let z = i as f32 / 8.;
|
||||
for x in [0.1875, 0.75] {
|
||||
b.push(bx([x, 0.875 - z, z], [x + 0.0625, 0.9375 - z, z + 0.125]));
|
||||
}
|
||||
}
|
||||
rotate(b, s.trim_start_matches("ascending_"))
|
||||
} else {
|
||||
for x in [0.1875, 0.75] {
|
||||
b.push(bx([x, 0.0625, 0.], [x + 0.0625, 0.125, 1.]));
|
||||
}
|
||||
for z in [0.125, 0.5, 0.875] {
|
||||
b.push(bx([0.125, 0., z], [0.875, 0.0625, (z + 0.0625).min(1.)]));
|
||||
}
|
||||
// Curved tracks use a visible orthogonal corner in this box renderer.
|
||||
if s == "east_west" {
|
||||
rotate(b, "east")
|
||||
} else if s.contains('_') && s != "north_south" {
|
||||
let mut c = vec![
|
||||
bx([0.1875, 0.06, 0.5], [1., 0.125, 0.5625]),
|
||||
bx([0.1875, 0.06, 0.], [0.25, 0.125, 0.56]),
|
||||
bx([0.75, 0.06, 0.], [0.8125, 0.125, 0.94]),
|
||||
bx([0.75, 0.06, 0.875], [1., 0.125, 0.9375]),
|
||||
];
|
||||
c = rotate(
|
||||
c,
|
||||
match s {
|
||||
"south_east" => "east",
|
||||
"south_west" => "south",
|
||||
"north_west" => "west",
|
||||
_ => "north",
|
||||
},
|
||||
);
|
||||
c
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
}
|
||||
"ladder" => rotate(
|
||||
vec![
|
||||
bx([0.125, 0., 0.9375], [0.25, 1., 1.]),
|
||||
bx([0.75, 0., 0.9375], [0.875, 1., 1.]),
|
||||
bx([0.125, 0.1875, 0.875], [0.875, 0.25, 1.]),
|
||||
bx([0.125, 0.4375, 0.875], [0.875, 0.5, 1.]),
|
||||
bx([0.125, 0.6875, 0.875], [0.875, 0.75, 1.]),
|
||||
bx([0.125, 0.9375, 0.875], [0.875, 1., 1.]),
|
||||
],
|
||||
facing,
|
||||
),
|
||||
"standing_sign" | "banner" => rotate(
|
||||
vec![
|
||||
bx([0.46875, 0., 0.46875], [0.53125, 0.5, 0.53125]),
|
||||
bx([0.0625, 0.5, 0.46875], [0.9375, 0.9375, 0.53125]),
|
||||
],
|
||||
rotation_facing(num(&p, "rotation", 0.)),
|
||||
),
|
||||
"wall_sign" | "wall_banner" => rotate(
|
||||
vec![bx([0.0625, 0.25, 0.9375], [0.9375, 0.8125, 1.])],
|
||||
facing,
|
||||
),
|
||||
"ceiling_hanging_sign" | "wall_hanging_sign" => rotate(
|
||||
vec![
|
||||
bx([0.0625, 0.125, 0.4375], [0.9375, 0.6875, 0.5625]),
|
||||
bx([0.1875, 0.6875, 0.46875], [0.25, 1., 0.53125]),
|
||||
bx([0.75, 0.6875, 0.46875], [0.8125, 1., 0.53125]),
|
||||
],
|
||||
facing,
|
||||
),
|
||||
"torch" | "redstone_torch" => vec![
|
||||
bx([0.4375, 0., 0.4375], [0.5625, 0.625, 0.5625]),
|
||||
bx([0.375, 0.5, 0.375], [0.625, 0.75, 0.625]),
|
||||
],
|
||||
"wall_torch" | "redstone_wall_torch" => rotate(
|
||||
vec![
|
||||
bx([0.4375, 0.125, 0.6875], [0.5625, 0.5, 0.875]),
|
||||
bx([0.375, 0.4375, 0.625], [0.625, 0.6875, 0.875]),
|
||||
],
|
||||
facing,
|
||||
),
|
||||
"redstone_wire" | "tripwire" => {
|
||||
let mut b = vec![bx([0.3125, 0.01, 0.3125], [0.6875, 0.04, 0.6875])];
|
||||
for (dir, a, z) in [
|
||||
("north", [0.4375, 0.01, 0.], [0.5625, 0.04, 0.5]),
|
||||
("south", [0.4375, 0.01, 0.5], [0.5625, 0.04, 1.]),
|
||||
("west", [0., 0.01, 0.4375], [0.5, 0.04, 0.5625]),
|
||||
("east", [0.5, 0.01, 0.4375], [1., 0.04, 0.5625]),
|
||||
] {
|
||||
let v = get(&p, dir, "none");
|
||||
if v != "none" && v != "false" {
|
||||
b.push(bx(a, z));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
"nether_portal" => {
|
||||
if get(&p, "axis", "x") == "x" {
|
||||
vec![bx([0., 0., 0.4375], [1., 1., 0.5625])]
|
||||
} else {
|
||||
vec![bx([0.4375, 0., 0.], [0.5625, 1., 1.])]
|
||||
}
|
||||
}
|
||||
"end_portal" | "end_gateway" => vec![bx([0., 0.125, 0.], [1., 0.1875, 1.])],
|
||||
"fire" | "soul_fire" => vec![
|
||||
bx([0.125, 0., 0.46875], [0.875, 0.875, 0.53125]),
|
||||
bx([0.46875, 0., 0.125], [0.53125, 0.75, 0.875]),
|
||||
],
|
||||
"vine" | "glow_lichen" | "sculk_vein" | "multiface" => {
|
||||
let mut b = vec![];
|
||||
for (dir, a, z) in [
|
||||
("north", [0., 0., 0.], [1., 1., 0.03125]),
|
||||
("south", [0., 0., 0.96875], [1., 1., 1.]),
|
||||
("west", [0., 0., 0.], [0.03125, 1., 1.]),
|
||||
("east", [0.96875, 0., 0.], [1., 1., 1.]),
|
||||
("up", [0., 0.96875, 0.], [1., 1., 1.]),
|
||||
("down", [0., 0., 0.], [1., 0.03125, 1.]),
|
||||
] {
|
||||
if get(&p, dir, "false") == "true" {
|
||||
b.push(bx(a, z));
|
||||
}
|
||||
}
|
||||
if b.is_empty() {
|
||||
b.push(bx([0., 0., 0.], [1., 1., 0.03125]));
|
||||
}
|
||||
b
|
||||
}
|
||||
"flower_pot" => {
|
||||
let mut b = vec![bx([0.3125, 0., 0.3125], [0.6875, 0.375, 0.6875])];
|
||||
if name != "minecraft:flower_pot" {
|
||||
b.extend(plant(0.8));
|
||||
}
|
||||
b
|
||||
}
|
||||
"crop" | "beetroot" | "potato" | "carrot" | "torchflower_crop" | "pitcher_crop"
|
||||
| "nether_wart" => plant(0.2 + num(&p, "age", 0.).min(7.) / 10.),
|
||||
"flower"
|
||||
| "wither_rose"
|
||||
| "eyeblossom"
|
||||
| "tall_flower"
|
||||
| "double_plant"
|
||||
| "sapling"
|
||||
| "bush"
|
||||
| "tall_grass"
|
||||
| "short_dry_grass"
|
||||
| "tall_dry_grass"
|
||||
| "dry_vegetation"
|
||||
| "sugar_cane"
|
||||
| "mushroom"
|
||||
| "nether_fungus"
|
||||
| "nether_roots"
|
||||
| "nether_sprouts"
|
||||
| "hanging_roots"
|
||||
| "hanging_moss"
|
||||
| "firefly_bush"
|
||||
| "sweet_berry_bush"
|
||||
| "mangrove_propagule"
|
||||
| "bamboo_sapling"
|
||||
| "stem"
|
||||
| "attached_stem"
|
||||
| "seagrass"
|
||||
| "tall_seagrass"
|
||||
| "kelp"
|
||||
| "kelp_plant"
|
||||
| "twisting_vines"
|
||||
| "twisting_vines_plant"
|
||||
| "weeping_vines"
|
||||
| "weeping_vines_plant"
|
||||
| "cave_vines"
|
||||
| "cave_vines_plant"
|
||||
| "small_dripleaf"
|
||||
| "spore_blossom"
|
||||
| "cactus_flower" => plant(if family == "mushroom" { 0.45 } else { 0.85 }),
|
||||
"leaf_litter" | "flower_bed" | "frogspawn" => {
|
||||
vec![bx([0.0625, 0.015, 0.0625], [0.9375, 0.0625, 0.9375])]
|
||||
}
|
||||
"coral"
|
||||
| "coral_plant"
|
||||
| "base_coral_plant"
|
||||
| "coral_fan"
|
||||
| "base_coral_fan"
|
||||
| "coral_wall_fan"
|
||||
| "base_coral_wall_fan" => plant(0.625),
|
||||
"web" => vec![
|
||||
bx([0., 0., 0.46875], [1., 1., 0.53125]),
|
||||
bx([0.46875, 0., 0.], [0.53125, 1., 1.]),
|
||||
],
|
||||
"powder_snow" => vec![FULL],
|
||||
_ => {
|
||||
if !collision.is_empty() {
|
||||
collision.to_vec()
|
||||
} else {
|
||||
// Noncollidable interaction geometry authored from the family, no
|
||||
// silent air substitution. Rare unsupported visual details remain
|
||||
// explicitly approximate in the coverage field.
|
||||
return (
|
||||
vec![bx([0.125, 0., 0.125], [0.875, 0.5, 0.875])],
|
||||
"authored-approximate",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
(b, "authored-procedural")
|
||||
}
|
||||
fn rotation_facing(v: f32) -> &'static str {
|
||||
match ((v + 2.) as i32 / 4).rem_euclid(4) {
|
||||
0 => "south",
|
||||
1 => "west",
|
||||
2 => "north",
|
||||
_ => "east",
|
||||
}
|
||||
}
|
||||
fn plant(height: f32) -> Vec<Aabb> {
|
||||
vec![
|
||||
bx([0.46875, 0., 0.46875], [0.53125, height, 0.53125]),
|
||||
bx(
|
||||
[0.1875, height * 0.35, 0.46875],
|
||||
[0.8125, height * 0.75, 0.53125],
|
||||
),
|
||||
bx(
|
||||
[0.46875, height * 0.25, 0.1875],
|
||||
[0.53125, height * 0.65, 0.8125],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn color(name: &str) -> [u8; 3] {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
let colors = [
|
||||
("light_blue", [108, 169, 193]),
|
||||
("light_gray", [170, 173, 163]),
|
||||
("magenta", [175, 97, 153]),
|
||||
("orange", [207, 136, 66]),
|
||||
("yellow", [220, 191, 80]),
|
||||
("purple", [130, 96, 164]),
|
||||
("cyan", [61, 155, 156]),
|
||||
("blue", [74, 111, 181]),
|
||||
("brown", [119, 88, 65]),
|
||||
("green", [91, 132, 75]),
|
||||
("lime", [148, 185, 87]),
|
||||
("pink", [216, 153, 165]),
|
||||
("red", [179, 78, 64]),
|
||||
("black", [54, 59, 62]),
|
||||
("gray", [104, 113, 112]),
|
||||
("white", [215, 219, 207]),
|
||||
];
|
||||
for (prefix, c) in colors {
|
||||
if name.starts_with(&format!("{prefix}_")) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
if name.contains("sulfur") {
|
||||
return [220, 210, 78];
|
||||
}
|
||||
if name.contains("cinnabar") {
|
||||
return [185, 78, 63];
|
||||
}
|
||||
if name.contains("snow") || name.contains("quartz") || name.contains("calcite") {
|
||||
return [217, 229, 228];
|
||||
}
|
||||
if name.contains("water") {
|
||||
return [69, 133, 182];
|
||||
}
|
||||
if name.contains("lava") {
|
||||
return [226, 123, 49];
|
||||
}
|
||||
if name.contains("glass") || name.contains("ice") {
|
||||
return [139, 190, 203];
|
||||
}
|
||||
if name.contains("grass") || name.contains("leaves") || name.contains("moss") {
|
||||
return [109, 148, 82];
|
||||
}
|
||||
if name.contains("cherry") {
|
||||
return [191, 141, 142];
|
||||
}
|
||||
if name.contains("pale_oak") {
|
||||
return [194, 192, 169];
|
||||
}
|
||||
if name.contains("crimson") {
|
||||
return [132, 63, 82];
|
||||
}
|
||||
if name.contains("warped") {
|
||||
return [58, 139, 135];
|
||||
}
|
||||
if name.contains("dark_oak") || name.contains("spruce") {
|
||||
return [106, 85, 60];
|
||||
}
|
||||
if name.contains("birch") || name.contains("bamboo") {
|
||||
return [190, 179, 121];
|
||||
}
|
||||
if name.contains("acacia") || name.contains("mangrove") {
|
||||
return [159, 102, 71];
|
||||
}
|
||||
if name.contains("oak")
|
||||
|| name.contains("jungle")
|
||||
|| name.contains("wood")
|
||||
|| name.contains("chest")
|
||||
{
|
||||
return [163, 134, 91];
|
||||
}
|
||||
if name.contains("copper") {
|
||||
return if name.contains("oxidized") {
|
||||
[79, 155, 131]
|
||||
} else {
|
||||
[179, 115, 83]
|
||||
};
|
||||
}
|
||||
if name.contains("gold") {
|
||||
return [210, 181, 81];
|
||||
}
|
||||
if name.contains("diamond") {
|
||||
return [91, 189, 191];
|
||||
}
|
||||
if name.contains("emerald") {
|
||||
return [67, 161, 112];
|
||||
}
|
||||
if name.contains("redstone") {
|
||||
return [162, 70, 57];
|
||||
}
|
||||
if name.contains("sand") || name.contains("end_stone") {
|
||||
return [195, 184, 144];
|
||||
}
|
||||
if name.contains("dirt") || name.contains("mud") {
|
||||
return [131, 108, 83];
|
||||
}
|
||||
if name.contains("deepslate") || name.contains("blackstone") || name.contains("obsidian") {
|
||||
return [69, 72, 80];
|
||||
}
|
||||
if name.contains("stone") || name.contains("andesite") || name.contains("cobble") {
|
||||
return [141, 150, 146];
|
||||
}
|
||||
let hash = name
|
||||
.bytes()
|
||||
.fold(2166136261u32, |h, b| (h ^ b as u32).wrapping_mul(16777619));
|
||||
[
|
||||
100 + (hash % 82) as u8,
|
||||
100 + ((hash >> 8) % 82) as u8,
|
||||
100 + ((hash >> 16) % 82) as u8,
|
||||
]
|
||||
}
|
||||
pub(crate) fn opacity(name: &str) -> f32 {
|
||||
if name.contains("glass") {
|
||||
0.42
|
||||
} else if name.contains("water") || name.contains("ice") {
|
||||
0.68
|
||||
} else {
|
||||
1.
|
||||
}
|
||||
}
|
||||
pub(crate) fn entity_color(name: &str) -> [u8; 3] {
|
||||
match name.strip_prefix("minecraft:").unwrap_or(name) {
|
||||
"player" | "mannequin" => [78, 154, 169],
|
||||
"cow" | "mooshroom" => [132, 108, 90],
|
||||
"pig" => [209, 150, 155],
|
||||
"sheep" => [219, 216, 196],
|
||||
"creeper" | "zombie" | "zombie_villager" => [110, 152, 98],
|
||||
"skeleton" | "stray" | "parched" => [201, 202, 179],
|
||||
"enderman" | "ender_dragon" => [66, 60, 83],
|
||||
"sulfur_cube" => [213, 209, 75],
|
||||
"slime" => [129, 188, 103],
|
||||
"magma_cube" => [149, 76, 44],
|
||||
"allay" => [96, 199, 212],
|
||||
"bee" => [213, 182, 72],
|
||||
"fox" => [191, 123, 71],
|
||||
"wolf" => [169, 177, 166],
|
||||
_ => color(name),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn entity_render(name: &str, width: f32, height: f32) -> (Vec<Aabb>, &'static str) {
|
||||
let n = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
let family = if n.ends_with("_boat") || n.ends_with("_raft") {
|
||||
"boat"
|
||||
} else if n.ends_with("minecart") {
|
||||
"cart"
|
||||
} else {
|
||||
match n {
|
||||
"marker" | "interaction" | "area_effect_cloud" | "lightning_bolt" => "invisible",
|
||||
"cow" | "mooshroom" | "pig" | "sheep" | "wolf" | "cat" | "ocelot" | "fox" | "panda"
|
||||
| "polar_bear" | "goat" | "horse" | "skeleton_horse" | "zombie_horse" | "donkey"
|
||||
| "mule" | "llama" | "trader_llama" | "camel" | "camel_husk" | "hoglin" | "zoglin"
|
||||
| "ravager" | "sniffer" | "armadillo" => "quadruped",
|
||||
"creeper" => "creeper",
|
||||
"spider" | "cave_spider" | "silverfish" | "endermite" => "arthropod",
|
||||
"cod" | "salmon" | "tropical_fish" | "pufferfish" | "dolphin" | "tadpole"
|
||||
| "axolotl" | "guardian" | "elder_guardian" | "nautilus" | "zombie_nautilus" => {
|
||||
"aquatic"
|
||||
}
|
||||
"squid" | "glow_squid" => "squid",
|
||||
"ghast" | "happy_ghast" => "ghast",
|
||||
"slime" | "magma_cube" | "sulfur_cube" | "shulker" => "cube-creature",
|
||||
"bat" | "bee" | "allay" | "vex" | "parrot" | "phantom" => "winged",
|
||||
"chicken" => "bird",
|
||||
"rabbit" => "rabbit",
|
||||
"frog" | "turtle" => "low-quadruped",
|
||||
"ender_dragon" => "dragon",
|
||||
"wither" => "wither",
|
||||
"snow_golem" => "snow-golem",
|
||||
"blaze" | "breeze" => "elemental",
|
||||
"player" | "mannequin" | "zombie" | "zombie_villager" | "drowned" | "husk"
|
||||
| "skeleton" | "stray" | "parched" | "bogged" | "wither_skeleton"
|
||||
| "zombified_piglin" | "piglin" | "piglin_brute" | "villager" | "wandering_trader"
|
||||
| "pillager" | "vindicator" | "evoker" | "illusioner" | "witch" | "enderman"
|
||||
| "iron_golem" | "copper_golem" | "creaking" | "warden" | "giant" | "armor_stand"
|
||||
| "strider" => "biped",
|
||||
"painting" | "item_frame" | "glow_item_frame" => "frame",
|
||||
"block_display" | "item_display" | "text_display" => "property-dependent-display",
|
||||
"arrow" | "spectral_arrow" | "trident" | "firework_rocket" => "bolt",
|
||||
"falling_block" | "tnt" => "block",
|
||||
"end_crystal" | "ominous_item_spawner" => "crystal",
|
||||
"evoker_fangs" => "fangs",
|
||||
"item" | "leash_knot" | "fishing_bobber" | "egg" | "ender_pearl"
|
||||
| "experience_bottle" | "experience_orb" | "eye_of_ender" | "fireball"
|
||||
| "small_fireball" | "dragon_fireball" | "snowball" | "splash_potion"
|
||||
| "lingering_potion" | "llama_spit" | "shulker_bullet" | "wind_charge"
|
||||
| "breeze_wind_charge" | "wither_skull" => "small-object",
|
||||
_ => "small-object",
|
||||
}
|
||||
};
|
||||
if family == "invisible" {
|
||||
return (vec![], family);
|
||||
}
|
||||
let mut b = match family {
|
||||
"biped" => vec![
|
||||
bx([-0.3, 0.38, -0.18], [0.3, 0.77, 0.18]),
|
||||
bx([-0.23, 0.77, -0.23], [0.23, 1., 0.23]),
|
||||
bx([-0.27, 0., -0.16], [-0.04, 0.4, 0.16]),
|
||||
bx([0.04, 0., -0.16], [0.27, 0.4, 0.16]),
|
||||
bx([-0.5, 0.4, -0.14], [-0.31, 0.75, 0.14]),
|
||||
bx([0.31, 0.4, -0.14], [0.5, 0.75, 0.14]),
|
||||
],
|
||||
"quadruped" | "low-quadruped" => {
|
||||
let mut b = vec![
|
||||
bx([-0.4, 0.3, -0.35], [0.4, 0.8, 0.35]),
|
||||
bx([-0.3, 0.58, -0.5], [0.3, 0.98, -0.2]),
|
||||
];
|
||||
for x in [-0.35, 0.15] {
|
||||
for z in [-0.3, 0.18] {
|
||||
b.push(bx([x, 0., z], [x + 0.2, 0.4, z + 0.15]));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
"creeper" => vec![
|
||||
bx([-0.27, 0.22, -0.2], [0.27, 0.73, 0.2]),
|
||||
bx([-0.38, 0.7, -0.38], [0.38, 1., 0.38]),
|
||||
bx([-0.38, 0., -0.38], [-0.02, 0.25, -0.06]),
|
||||
bx([0.02, 0., -0.38], [0.38, 0.25, -0.06]),
|
||||
bx([-0.38, 0., 0.06], [-0.02, 0.25, 0.38]),
|
||||
bx([0.02, 0., 0.06], [0.38, 0.25, 0.38]),
|
||||
],
|
||||
"arthropod" => {
|
||||
let mut b = vec![
|
||||
bx([-0.27, 0.3, -0.35], [0.27, 0.7, 0.35]),
|
||||
bx([-0.2, 0.32, -0.5], [0.2, 0.62, -0.25]),
|
||||
];
|
||||
for z in [-0.32, -0.12, 0.12, 0.32] {
|
||||
b.push(bx([-0.5, 0.1, z], [-0.2, 0.35, z + 0.07]));
|
||||
b.push(bx([0.2, 0.1, z], [0.5, 0.35, z + 0.07]));
|
||||
}
|
||||
b
|
||||
}
|
||||
"aquatic" => vec![
|
||||
bx([-0.24, 0.25, -0.45], [0.24, 0.7, 0.25]),
|
||||
bx([-0.4, 0.3, 0.2], [0.4, 0.62, 0.5]),
|
||||
bx([-0.03, 0.7, -0.15], [0.03, 1., 0.1]),
|
||||
bx([-0.45, 0.25, -0.2], [0.45, 0.35, 0.1]),
|
||||
],
|
||||
"squid" | "ghast" => {
|
||||
let mut b = vec![bx([-0.4, 0.4, -0.4], [0.4, 1., 0.4])];
|
||||
for x in [-0.3, 0., 0.3] {
|
||||
for z in [-0.3, 0., 0.3] {
|
||||
b.push(bx([x - 0.04, 0., z - 0.04], [x + 0.04, 0.45, z + 0.04]));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
"cube-creature" => vec![
|
||||
bx([-0.5, 0., -0.5], [0.5, 1., 0.5]),
|
||||
bx([-0.3, 0.55, -0.52], [-0.12, 0.72, -0.49]),
|
||||
bx([0.12, 0.55, -0.52], [0.3, 0.72, -0.49]),
|
||||
],
|
||||
"winged" => vec![
|
||||
bx([-0.19, 0.2, -0.3], [0.19, 0.8, 0.3]),
|
||||
bx([-0.23, 0.65, -0.4], [0.23, 1., -0.05]),
|
||||
bx([-0.5, 0.45, -0.1], [-0.15, 0.6, 0.3]),
|
||||
bx([0.15, 0.45, -0.1], [0.5, 0.6, 0.3]),
|
||||
],
|
||||
"bird" => vec![
|
||||
bx([-0.34, 0.25, -0.25], [0.34, 0.7, 0.25]),
|
||||
bx([-0.23, 0.65, -0.35], [0.23, 1., -0.02]),
|
||||
bx([-0.12, 0.73, -0.5], [0.12, 0.82, -0.3]),
|
||||
bx([-0.22, 0., -0.03], [-0.08, 0.3, 0.06]),
|
||||
bx([0.08, 0., -0.03], [0.22, 0.3, 0.06]),
|
||||
],
|
||||
"rabbit" => vec![
|
||||
bx([-0.3, 0.12, -0.25], [0.3, 0.6, 0.3]),
|
||||
bx([-0.22, 0.5, -0.4], [0.22, 0.78, -0.05]),
|
||||
bx([-0.2, 0.7, -0.25], [-0.08, 1., -0.1]),
|
||||
bx([0.08, 0.7, -0.25], [0.2, 1., -0.1]),
|
||||
bx([-0.4, 0., 0.05], [-0.1, 0.2, 0.4]),
|
||||
bx([0.1, 0., 0.05], [0.4, 0.2, 0.4]),
|
||||
],
|
||||
"dragon" => vec![
|
||||
bx([-0.15, 0.3, -0.3], [0.15, 0.7, 0.3]),
|
||||
bx([-0.1, 0.4, -0.5], [0.1, 0.65, -0.2]),
|
||||
bx([-0.5, 0.48, -0.1], [-0.12, 0.56, 0.3]),
|
||||
bx([0.12, 0.48, -0.1], [0.5, 0.56, 0.3]),
|
||||
bx([-0.055, 0.35, 0.25], [0.055, 0.45, 0.5]),
|
||||
],
|
||||
"wither" => vec![
|
||||
bx([-0.1, 0.05, -0.15], [0.1, 0.75, 0.15]),
|
||||
bx([-0.4, 0.65, -0.12], [0.4, 0.75, 0.12]),
|
||||
bx([-0.17, 0.75, -0.2], [0.17, 1., 0.2]),
|
||||
bx([-0.5, 0.65, -0.18], [-0.25, 0.9, 0.18]),
|
||||
bx([0.25, 0.65, -0.18], [0.5, 0.9, 0.18]),
|
||||
],
|
||||
"snow-golem" => vec![
|
||||
bx([-0.45, 0., -0.45], [0.45, 0.4, 0.45]),
|
||||
bx([-0.35, 0.4, -0.35], [0.35, 0.75, 0.35]),
|
||||
bx([-0.25, 0.75, -0.25], [0.25, 1., 0.25]),
|
||||
bx([-0.5, 0.5, -0.04], [0.5, 0.6, 0.04]),
|
||||
],
|
||||
"elemental" => {
|
||||
let mut b = vec![bx([-0.3, 0.7, -0.3], [0.3, 1., 0.3])];
|
||||
for (x, z) in [(-0.4, -0.3), (0.3, -0.3), (-0.4, 0.3), (0.3, 0.3)] {
|
||||
b.push(bx([x, 0.15, z], [x + 0.1, 0.65, z + 0.1]));
|
||||
}
|
||||
b
|
||||
}
|
||||
"boat" => vec![
|
||||
bx([-0.5, 0., -0.5], [0.5, 0.2, 0.5]),
|
||||
bx([-0.5, 0.2, -0.5], [-0.36, 0.7, 0.5]),
|
||||
bx([0.36, 0.2, -0.5], [0.5, 0.7, 0.5]),
|
||||
bx([-0.36, 0.2, -0.5], [0.36, 0.7, -0.38]),
|
||||
bx([-0.36, 0.2, 0.38], [0.36, 0.7, 0.5]),
|
||||
bx([-0.36, 0.32, -0.08], [0.36, 0.42, 0.08]),
|
||||
],
|
||||
"cart" => vec![
|
||||
bx([-0.5, 0.1, -0.5], [0.5, 0.3, 0.5]),
|
||||
bx([-0.5, 0.3, -0.5], [-0.38, 0.85, 0.5]),
|
||||
bx([0.38, 0.3, -0.5], [0.5, 0.85, 0.5]),
|
||||
bx([-0.38, 0.3, -0.5], [0.38, 0.85, -0.38]),
|
||||
bx([-0.38, 0.3, 0.38], [0.38, 0.85, 0.5]),
|
||||
],
|
||||
"frame" | "property-dependent-display" => vec![bx([-0.5, 0., -0.05], [0.5, 1., 0.05])],
|
||||
"bolt" => vec![
|
||||
bx([-0.04, 0.45, -0.5], [0.04, 0.55, 0.5]),
|
||||
bx([-0.18, 0.43, -0.5], [0.18, 0.57, -0.3]),
|
||||
],
|
||||
"block" => vec![bx([-0.5, 0., -0.5], [0.5, 1., 0.5])],
|
||||
"crystal" => vec![
|
||||
bx([-0.3, 0.25, -0.3], [0.3, 0.75, 0.3]),
|
||||
bx([-0.15, 0.1, -0.15], [0.15, 0.9, 0.15]),
|
||||
bx([-0.45, 0., -0.45], [0.45, 0.08, 0.45]),
|
||||
],
|
||||
"fangs" => vec![
|
||||
bx([-0.35, 0., -0.3], [-0.1, 1., 0.3]),
|
||||
bx([0.1, 0., -0.3], [0.35, 1., 0.3]),
|
||||
],
|
||||
_ => vec![
|
||||
bx([-0.3, 0.2, -0.4], [0.3, 0.8, 0.4]),
|
||||
bx([-0.4, 0.3, -0.25], [0.4, 0.7, 0.25]),
|
||||
],
|
||||
};
|
||||
if family == "boat" && n.contains("chest") {
|
||||
b.push(bx([-0.25, 0.4, 0.08], [0.25, 0.95, 0.42]));
|
||||
}
|
||||
if family == "cart" && n != "minecart" {
|
||||
b.push(bx([-0.3, 0.3, -0.3], [0.3, 1., 0.3]));
|
||||
}
|
||||
let w = width.max(0.25);
|
||||
let h = height.max(0.25);
|
||||
for v in &mut b {
|
||||
for i in 0..3 {
|
||||
v.min[i] *= if i == 1 { h } else { w };
|
||||
v.max[i] *= if i == 1 { h } else { w };
|
||||
}
|
||||
}
|
||||
(b, family)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn four_quarter_rotations_restore_box() {
|
||||
let initial = vec![bx([0.125, 0.25, 0.375], [0.25, 0.5, 0.75])];
|
||||
let mut b = initial.clone();
|
||||
for _ in 0..4 {
|
||||
b = rotate(b, "east");
|
||||
}
|
||||
assert_eq!(b, initial);
|
||||
}
|
||||
#[test]
|
||||
fn connected_fence_has_two_added_rails() {
|
||||
let (a, _) = block_render(
|
||||
"minecraft:oak_fence[east=false,north=false,south=false,waterlogged=false,west=false]",
|
||||
"fence",
|
||||
&[],
|
||||
);
|
||||
let (b, _) = block_render(
|
||||
"minecraft:oak_fence[east=true,north=false,south=false,waterlogged=false,west=false]",
|
||||
"fence",
|
||||
&[],
|
||||
);
|
||||
assert_eq!(b.len(), a.len() + 2);
|
||||
assert!(b.iter().all(|b| b.max[1] <= 1.));
|
||||
}
|
||||
#[test]
|
||||
fn rail_ascent_reaches_next_block() {
|
||||
let (b, _) = block_render(
|
||||
"minecraft:rail[shape=ascending_north,waterlogged=false]",
|
||||
"rail",
|
||||
&[],
|
||||
);
|
||||
assert_eq!(b.len(), 16);
|
||||
assert!(b.iter().any(|b| b.max[1] > 0.9));
|
||||
assert!(b.iter().all(Aabb::valid));
|
||||
}
|
||||
}
|
||||
@@ -509,6 +509,45 @@ impl WorldStore {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Page effective stored section coordinates in lexicographic x/y/z order.
|
||||
/// Coordinates here identify 16³ sections, not individual blocks. Air
|
||||
/// overrides are included, which matters when exporting deletions. The
|
||||
/// caller holding this single writer must not mutate between pages when
|
||||
/// it needs one consistent snapshot.
|
||||
pub fn section_positions(
|
||||
&self,
|
||||
name: &str,
|
||||
after: Option<Pos>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Pos>> {
|
||||
validate_world_name(name)?;
|
||||
ensure!(
|
||||
(1..=4096).contains(&limit),
|
||||
"input_limit: section page must be 1..4096"
|
||||
);
|
||||
let world = lookup_world(&self.conn, name)?;
|
||||
let mut query = self.conn.prepare(
|
||||
"WITH section_keys AS (
|
||||
SELECT x,y,z FROM world_sections WHERE world_id=?1
|
||||
UNION SELECT x,y,z FROM snapshot_sections WHERE snapshot_id=?2
|
||||
) SELECT x,y,z FROM section_keys
|
||||
WHERE (?3 IS NULL OR (x,y,z) > (?3,?4,?5))
|
||||
ORDER BY x,y,z LIMIT ?6",
|
||||
)?;
|
||||
let rows = query.query_map(
|
||||
params![
|
||||
world.id,
|
||||
world.base,
|
||||
after.map(|p| p[0]),
|
||||
after.map(|p| p[1]),
|
||||
after.map(|p| p[2]),
|
||||
limit as i64
|
||||
],
|
||||
|row| Ok([row.get(0)?, row.get(1)?, row.get(2)?]),
|
||||
)?;
|
||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// Durably committed operations need no flush to survive process restart.
|
||||
/// This also checkpoints WAL, reporting busy/error instead of hiding it.
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
use shacraft_core::{BlockChange, WorldStore};
|
||||
|
||||
#[test]
|
||||
fn section_pages_are_sorted_bounded_and_include_inherited_air_overrides() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = WorldStore::open(dir.path(), 2).unwrap();
|
||||
let stone = store.register_block("minecraft:stone").unwrap();
|
||||
store.create_world("source", None).unwrap();
|
||||
store
|
||||
.edit(
|
||||
"source",
|
||||
0,
|
||||
"seed",
|
||||
vec![
|
||||
BlockChange {
|
||||
pos: [-17, 0, 0],
|
||||
block: stone,
|
||||
},
|
||||
BlockChange {
|
||||
pos: [-1, 0, 0],
|
||||
block: stone,
|
||||
},
|
||||
BlockChange {
|
||||
pos: [0, 16, 0],
|
||||
block: stone,
|
||||
},
|
||||
BlockChange {
|
||||
pos: [32, 0, 0],
|
||||
block: stone,
|
||||
},
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
store.create_world("child", Some("source")).unwrap();
|
||||
store
|
||||
.edit(
|
||||
"child",
|
||||
0,
|
||||
"override",
|
||||
vec![
|
||||
BlockChange {
|
||||
pos: [-1, 0, 0],
|
||||
block: 0,
|
||||
},
|
||||
BlockChange {
|
||||
pos: [16, 0, 0],
|
||||
block: stone,
|
||||
},
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let expected = vec![[-2, 0, 0], [-1, 0, 0], [0, 1, 0], [1, 0, 0], [2, 0, 0]];
|
||||
let mut all = Vec::new();
|
||||
loop {
|
||||
let page = store
|
||||
.section_positions("child", all.last().copied(), 2)
|
||||
.unwrap();
|
||||
if page.is_empty() {
|
||||
break;
|
||||
}
|
||||
assert!(page.len() <= 2);
|
||||
all.extend(page);
|
||||
}
|
||||
assert_eq!(all, expected);
|
||||
assert_eq!(store.get_block("child", [-1, 0, 0]).unwrap(), 0);
|
||||
assert!(store.section_positions("child", None, 0).is_err());
|
||||
assert!(store.section_positions("child", None, 4097).is_err());
|
||||
drop(store);
|
||||
let store = WorldStore::open(dir.path(), 2).unwrap();
|
||||
assert_eq!(
|
||||
store.section_positions("child", None, 100).unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "shacraft-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde_json.workspace = true
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
@@ -0,0 +1,749 @@
|
||||
//! Bounded newline-delimited JSON-RPC MCP transport, independent of world storage.
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
io::{self, BufRead, Read, Write},
|
||||
path::PathBuf,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const MAX_FRAME: usize = 8 * 1024 * 1024;
|
||||
const VERSIONS: &[&str] = &["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(about = "Shacraft MCP server over stdio (JSON-RPC; diagnostics only on stderr)")]
|
||||
struct Args {
|
||||
#[arg(long, default_value = "http://127.0.0.1:4000")]
|
||||
server: String,
|
||||
#[arg(long, default_value = "data")]
|
||||
data: PathBuf,
|
||||
#[arg(long)]
|
||||
token_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
struct Backend {
|
||||
url: String,
|
||||
token_file: PathBuf,
|
||||
client: reqwest::blocking::Client,
|
||||
}
|
||||
impl Backend {
|
||||
fn new(args: Args) -> Result<Self> {
|
||||
let parsed = reqwest::Url::parse(&args.server).context("Invalid --server URL")?;
|
||||
if !matches!(parsed.scheme(), "http" | "https")
|
||||
|| parsed.host_str().is_none()
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
{
|
||||
bail!("--server must be an HTTP(S) URL without credentials, query, or fragment");
|
||||
}
|
||||
Ok(Self {
|
||||
url: args.server.trim_end_matches('/').to_owned(),
|
||||
token_file: args
|
||||
.token_file
|
||||
.unwrap_or_else(|| args.data.join("control.token")),
|
||||
client: reqwest::blocking::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(3))
|
||||
.timeout(Duration::from_secs(20))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?,
|
||||
})
|
||||
}
|
||||
fn read_response(&self, mut response: reqwest::blocking::Response) -> Result<Value> {
|
||||
let status = response.status();
|
||||
let mut bytes = Vec::new();
|
||||
Read::by_ref(&mut response)
|
||||
.take((MAX_FRAME + 1) as u64)
|
||||
.read_to_end(&mut bytes)?;
|
||||
if bytes.len() > MAX_FRAME {
|
||||
bail!("Server response exceeds 8 MiB; request a smaller region");
|
||||
}
|
||||
let body: Value = serde_json::from_slice(&bytes).context("Server returned invalid JSON")?;
|
||||
if let Some(error) = body.get("error") {
|
||||
bail!("{}", error.as_str().unwrap_or("Control operation failed"));
|
||||
}
|
||||
if !status.is_success() {
|
||||
bail!("Server returned HTTP {}", status.as_u16());
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
fn control(&self, method: &str, params: &Value) -> Result<Value> {
|
||||
let file = std::fs::File::open(&self.token_file)
|
||||
.context("Cannot read control token; start shacraft-server or set --token-file")?;
|
||||
let mut token = String::new();
|
||||
file.take(4097).read_to_string(&mut token)?;
|
||||
if token.len() > 4096 {
|
||||
bail!("Control token file is too large");
|
||||
}
|
||||
let token = token.trim();
|
||||
if token.is_empty() || token.chars().any(char::is_control) {
|
||||
bail!("Control token is empty or invalid");
|
||||
}
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("{}/api/control", self.url))
|
||||
.bearer_auth(token)
|
||||
.json(&json!({"method":method,"params":params}))
|
||||
.send()
|
||||
.context("Shacraft server is unavailable")?;
|
||||
self.read_response(response)?
|
||||
.get("result")
|
||||
.cloned()
|
||||
.context("Control response is missing result")
|
||||
}
|
||||
fn public(&self, path: &str) -> Result<Value> {
|
||||
self.read_response(
|
||||
self.client
|
||||
.get(format!("{}{}", self.url, path))
|
||||
.send()
|
||||
.context("Shacraft server is unavailable")?,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn object(properties: Value, required: &[&str]) -> Value {
|
||||
json!({"type":"object","properties":properties,"required":required,"additionalProperties":false})
|
||||
}
|
||||
fn pos(integer: bool) -> Value {
|
||||
json!({"type":"array","items":{"type":if integer{"integer"}else{"number"}},"minItems":3,"maxItems":3})
|
||||
}
|
||||
fn world() -> Value {
|
||||
json!({"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"})
|
||||
}
|
||||
fn operation() -> Value {
|
||||
json!({"type":"string","minLength":1,"maxLength":128,"description":"Caller-generated idempotency key, retained per world. Reuse only for an identical retry."})
|
||||
}
|
||||
fn revision() -> Value {
|
||||
json!({"type":"integer","minimum":0,"description":"Observed world revision; a mismatch refuses the write without mutation."})
|
||||
}
|
||||
fn metadata_revision() -> Value {
|
||||
json!({"type":"integer","minimum":0,"description":"Optional global metadata revision from entity_list or metrics; detects concurrent entity/rule changes."})
|
||||
}
|
||||
fn arena_position() -> Value {
|
||||
json!({"type":"array","items":{"type":"number","minimum":-32700,"maximum":32700},"minItems":3,"maxItems":3,"description":"Feet position. Y must be greater than elimination_y + 0.5."})
|
||||
}
|
||||
fn block() -> Value {
|
||||
json!({"oneOf":[{"type":"integer","minimum":0},{"type":"string","minLength":1,"maxLength":512}],"description":"Runtime BlockId or canonical state, e.g. minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]. Use catalog_search to discover states."})
|
||||
}
|
||||
fn tool(name: &str, description: &str, input: Value, read_only: bool, idempotent: bool) -> Value {
|
||||
json!({"name":name,"description":description,"inputSchema":input,"annotations":{"readOnlyHint":read_only,"destructiveHint":!read_only,"idempotentHint":idempotent,"openWorldHint":false}})
|
||||
}
|
||||
fn tools() -> Vec<Value> {
|
||||
vec![
|
||||
tool(
|
||||
"world_list",
|
||||
"List all persistent worlds, their revisions and pinned source templates. Start here before reading or editing a world.",
|
||||
object(json!({}), &[]),
|
||||
true,
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"world_create",
|
||||
"Create an empty independent world or a copy-on-write snapshot of an existing template. Later source edits never change the copy.",
|
||||
object(json!({"world":world(),"template":world()}), &["world"]),
|
||||
false,
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"world_read",
|
||||
"Read a bounded inclusive region. Returns non-air cells plus world revision and registry information. Maximum region volume 262144 cells.",
|
||||
object(
|
||||
json!({"world":world(),"min":pos(true),"max":pos(true)}),
|
||||
&["world", "min", "max"],
|
||||
),
|
||||
true,
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"world_edit",
|
||||
"Atomically edit at most 32768 unique cells. Requires current revision and idempotency key. Use air (BlockId 0) to remove a block. The server commits durably before replying.",
|
||||
object(
|
||||
json!({"world":world(),"expected_revision":revision(),"operation_id":operation(),"changes":{"type":"array","minItems":1,"maxItems":32768,"items":object(json!({"pos":pos(true),"block":{"type":"integer","minimum":0,"description":"Runtime BlockId from catalog_search"}}),&["pos","block"])}}),
|
||||
&["world", "expected_revision", "operation_id", "changes"],
|
||||
),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"world_undo",
|
||||
"Undo the latest edit atomically. Refuses when any later revision or reset could be overwritten. target_operation is the original edit's operation_id.",
|
||||
object(
|
||||
json!({"world":world(),"expected_revision":revision(),"operation_id":operation(),"target_operation":operation()}),
|
||||
&[
|
||||
"world",
|
||||
"expected_revision",
|
||||
"operation_id",
|
||||
"target_operation",
|
||||
],
|
||||
),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"world_reset",
|
||||
"Reset a world to its pinned base snapshot, or air if created without a template. All instance edits are removed; revision increases. Reset is an undo barrier.",
|
||||
object(
|
||||
json!({"world":world(),"expected_revision":revision(),"operation_id":operation()}),
|
||||
&["world", "expected_revision", "operation_id"],
|
||||
),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"catalog_search",
|
||||
"Search the Minecraft Java 26.2 and installed-package state catalog. Reports exact identifiers, runtime IDs, shapes, and coverage; registered content does not imply all vanilla behaviors are simulated.",
|
||||
object(
|
||||
json!({"query":{"type":"string","maxLength":256},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":256}}),
|
||||
&[],
|
||||
),
|
||||
true,
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"entity_list",
|
||||
"List persisted entities of one world, including IDs, positions, and properties. Entity kinds are defined in the public entity catalog.",
|
||||
object(json!({"world":world()}), &["world"]),
|
||||
true,
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"entity_spawn",
|
||||
"Spawn one entity from a registered kind at a feet position. Its persisted properties are distinct from implemented behavior. Discover kinds in the entity catalog resource.",
|
||||
object(
|
||||
json!({"world":world(),"kind":{"type":"string","maxLength":128},"position":pos(false),"properties":{"type":"object"},"yaw":{"type":"number"}}),
|
||||
&["world", "kind", "position"],
|
||||
),
|
||||
false,
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"entity_update",
|
||||
"Update a persisted entity's position, yaw, or properties. Fields omitted from the request are preserved.",
|
||||
object(
|
||||
json!({"id":{"type":"string"},"expected_revision":metadata_revision(),"position":pos(false),"properties":{"type":"object"},"yaw":{"type":"number"}}),
|
||||
&["id"],
|
||||
),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"entity_delete",
|
||||
"Remove a single persisted entity by ID from its world.",
|
||||
object(
|
||||
json!({"id":{"type":"string"},"expected_revision":metadata_revision()}),
|
||||
&["id"],
|
||||
),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"arena_start",
|
||||
"Start the configured Spleef match in the specified arena. Requires enough joined players and resets the arena floor according to its rules.",
|
||||
object(json!({"world":world()}), &["world"]),
|
||||
false,
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"arena_configure",
|
||||
"Persist creative/Spleef mode, spawn positions and match rules. Configuration is refused during a started round. Nonempty spawn_points must cover min_players and all joining participants; points must be at least 0.8 apart. floor_block defines the breakable material and does not rebuild the map.",
|
||||
object(
|
||||
json!({"world":world(),"expected_revision":metadata_revision(),"mode":{"type":"string","enum":["creative","spleef"]},"spawn":arena_position(),"countdown_seconds":{"type":"integer","minimum":1,"maximum":30},"round_seconds":{"type":"integer","minimum":1,"maximum":3600},"min_players":{"type":"integer","minimum":2,"maximum":32},"elimination_y":{"type":"number","minimum":-32700,"maximum":32700},"spawn_points":{"type":"array","maxItems":32,"items":arena_position(),"description":"Empty uses a radius-7 circle. Otherwise positions must cover all participants."},"spectator_spawn":arena_position(),"floor_block":{"type":"string","minLength":1,"maxLength":512,"description":"Known solid canonical state that players may break during Spleef."}}),
|
||||
&["world"],
|
||||
),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"metrics",
|
||||
"Read live process RSS, storage/cache estimates, connected players, tick and queue diagnostics. Payload estimates must not be reported as total process RAM.",
|
||||
object(json!({}), &[]),
|
||||
true,
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"build_plan",
|
||||
"Plan bounded construction without changing world blocks. Expands box, line and cylinder operations, checks limits and returns a plan_id, summary and expected revision. Review the plan before build_commit.",
|
||||
object(
|
||||
json!({"world":world(),"expected_revision":revision(),"operations":{"type":"array","minItems":1,"maxItems":128,"items":{"oneOf":[object(json!({"type":{"const":"box"},"min":pos(true),"max":pos(true),"block":block(),"hollow":{"type":"boolean"}}),&["type","min","max","block"]),object(json!({"type":{"const":"line"},"from":pos(true),"to":pos(true),"block":block()}),&["type","from","to","block"]),object(json!({"type":{"const":"cylinder"},"center":pos(true),"radius":{"type":"integer","minimum":1,"maximum":64},"height":{"type":"integer","minimum":1,"maximum":128},"block":block(),"hollow":{"type":"boolean"}}),&["type","center","radius","height","block"])]}}}),
|
||||
&["world", "expected_revision", "operations"],
|
||||
),
|
||||
false,
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"build_commit",
|
||||
"Commit a previously reviewed build_plan atomically using plan_id and an idempotency key. A stale revision is refused; create a new plan after inspecting intervening changes.",
|
||||
object(
|
||||
json!({"plan_id":{"type":"string","minLength":1,"maxLength":128},"operation_id":operation()}),
|
||||
&["plan_id", "operation_id"],
|
||||
),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"camera_capture",
|
||||
"Capture a server-rendered isometric PNG of a bounded world region. Returns an MCP image with world/revision metadata; this is an inspection projection, not the browser's first-person camera.",
|
||||
object(
|
||||
json!({"world":world(),"min":pos(true),"max":pos(true)}),
|
||||
&["world"],
|
||||
),
|
||||
true,
|
||||
true,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn validate(schema: &Value, value: &Value, path: &str) -> std::result::Result<(), String> {
|
||||
if let Some(variants) = schema.get("oneOf").and_then(Value::as_array)
|
||||
&& variants
|
||||
.iter()
|
||||
.filter(|s| validate(s, value, path).is_ok())
|
||||
.count()
|
||||
!= 1
|
||||
{
|
||||
return Err(format!("{path}: does not match exactly one accepted form"));
|
||||
}
|
||||
if let Some(choices) = schema.get("enum").and_then(Value::as_array)
|
||||
&& !choices.contains(value)
|
||||
{
|
||||
return Err(format!("{path}: value is not in allowed enum"));
|
||||
}
|
||||
if let Some(constant) = schema.get("const")
|
||||
&& value != constant
|
||||
{
|
||||
return Err(format!("{path}: expected {constant}"));
|
||||
}
|
||||
if let Some(t) = schema.get("type").and_then(Value::as_str) {
|
||||
let matches = match t {
|
||||
"object" => value.is_object(),
|
||||
"array" => value.is_array(),
|
||||
"string" => value.is_string(),
|
||||
"integer" => value.is_i64() || value.is_u64(),
|
||||
"number" => value.is_number(),
|
||||
"boolean" => value.is_boolean(),
|
||||
_ => true,
|
||||
};
|
||||
if !matches {
|
||||
return Err(format!("{path}: expected {t}"));
|
||||
}
|
||||
}
|
||||
if let Some(obj) = value.as_object() {
|
||||
if let Some(required) = schema.get("required").and_then(Value::as_array) {
|
||||
for key in required {
|
||||
let key = key.as_str().unwrap_or("");
|
||||
if !obj.contains_key(key) {
|
||||
return Err(format!("{path}.{key}: required"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
|
||||
for (key, value) in obj {
|
||||
if let Some(subschema) = properties.get(key) {
|
||||
validate(subschema, value, &format!("{path}.{key}"))?;
|
||||
} else if schema.get("additionalProperties") == Some(&json!(false)) {
|
||||
return Err(format!("{path}.{key}: unknown field"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(values) = value.as_array() {
|
||||
if let Some(n) = schema.get("minItems").and_then(Value::as_u64)
|
||||
&& values.len() < (n as usize)
|
||||
{
|
||||
return Err(format!("{path}: too few items"));
|
||||
}
|
||||
if let Some(n) = schema.get("maxItems").and_then(Value::as_u64)
|
||||
&& values.len() > (n as usize)
|
||||
{
|
||||
return Err(format!("{path}: too many items"));
|
||||
}
|
||||
if let Some(item) = schema.get("items") {
|
||||
for (i, value) in values.iter().enumerate() {
|
||||
validate(item, value, &format!("{path}[{i}]"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = value.as_str() {
|
||||
let len = s.chars().count();
|
||||
if schema
|
||||
.get("minLength")
|
||||
.and_then(Value::as_u64)
|
||||
.is_some_and(|n| len < n as usize)
|
||||
|| schema
|
||||
.get("maxLength")
|
||||
.and_then(Value::as_u64)
|
||||
.is_some_and(|n| len > n as usize)
|
||||
{
|
||||
return Err(format!("{path}: string length out of range"));
|
||||
}
|
||||
if schema.get("pattern") == Some(&json!("^[A-Za-z0-9_-]{1,64}$"))
|
||||
&& (s.is_empty()
|
||||
|| s.len() > 64
|
||||
|| !s
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-'))
|
||||
{
|
||||
return Err(format!("{path}: invalid world name"));
|
||||
}
|
||||
}
|
||||
if let Some(n) = value.as_f64()
|
||||
&& (schema
|
||||
.get("minimum")
|
||||
.and_then(Value::as_f64)
|
||||
.is_some_and(|min| n < min)
|
||||
|| schema
|
||||
.get("maximum")
|
||||
.and_then(Value::as_f64)
|
||||
.is_some_and(|max| n > max))
|
||||
{
|
||||
return Err(format!("{path}: number out of range"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resources() -> Value {
|
||||
json!({"resources":[
|
||||
{"uri":"shacraft://server/status","name":"Server status","description":"Live server health and storage metrics","mimeType":"application/json"},
|
||||
{"uri":"shacraft://catalog/blocks","name":"Block catalog","description":"Initial page and search endpoint for registered block states","mimeType":"application/json"},
|
||||
{"uri":"shacraft://catalog/entities","name":"Entity catalog","description":"All registered entity kinds, shapes and coverage","mimeType":"application/json"},
|
||||
{"uri":"shacraft://packages","name":"Required packages","description":"Client-visible manifest and resource integrity hashes; contains no admin credentials","mimeType":"application/json"}
|
||||
]})
|
||||
}
|
||||
fn rpc_error(id: Value, code: i32, message: impl Into<String>) -> Value {
|
||||
json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message.into()}})
|
||||
}
|
||||
fn rpc_result(id: Value, result: Value) -> Value {
|
||||
json!({"jsonrpc":"2.0","id":id,"result":result})
|
||||
}
|
||||
fn tool_error(message: impl Into<String>) -> Value {
|
||||
json!({"isError":true,"content":[{"type":"text","text":message.into()}]})
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Session {
|
||||
initialized: bool,
|
||||
ready: bool,
|
||||
}
|
||||
impl Session {
|
||||
fn dispatch(&mut self, request: Value, backend: &Backend) -> Option<Value> {
|
||||
let id = request.get("id").cloned().unwrap_or(Value::Null);
|
||||
if !request.is_object()
|
||||
|| request.get("jsonrpc") != Some(&json!("2.0"))
|
||||
|| request.get("method").and_then(Value::as_str).is_none()
|
||||
|| request
|
||||
.get("id")
|
||||
.is_some_and(|id| !id.is_string() && !id.is_i64() && !id.is_u64())
|
||||
{
|
||||
return Some(rpc_error(id, -32600, "Invalid JSON-RPC request"));
|
||||
}
|
||||
let method = request["method"].as_str().unwrap();
|
||||
if request.get("id").is_none() {
|
||||
if method == "notifications/initialized" && self.initialized {
|
||||
self.ready = true;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
if !params.is_object() {
|
||||
return Some(rpc_error(id, -32602, "params must be an object"));
|
||||
}
|
||||
if method == "initialize" {
|
||||
if self.initialized {
|
||||
return Some(rpc_error(id, -32600, "Session already initialized"));
|
||||
}
|
||||
let Some(version) = params.get("protocolVersion").and_then(Value::as_str) else {
|
||||
return Some(rpc_error(id, -32602, "protocolVersion is required"));
|
||||
};
|
||||
if !params.get("capabilities").is_some_and(Value::is_object)
|
||||
|| !params
|
||||
.get("clientInfo")
|
||||
.is_some_and(|v| v["name"].is_string() && v["version"].is_string())
|
||||
{
|
||||
return Some(rpc_error(
|
||||
id,
|
||||
-32602,
|
||||
"capabilities and clientInfo.name/version are required",
|
||||
));
|
||||
}
|
||||
self.initialized = true;
|
||||
return Some(rpc_result(
|
||||
id,
|
||||
json!({"protocolVersion":if VERSIONS.contains(&version){version}else{VERSIONS[0]},"capabilities":{"tools":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"prompts":{"listChanged":false}},"serverInfo":{"name":"shacraft-mcp","version":env!("CARGO_PKG_VERSION")},"instructions":"Inspect world revisions and catalog before editing. Plan bounded construction with build_plan; commit with a unique operation_id. Preserve expected_revision on retries. camera_capture is an isometric inspection image. This MCP controls only the configured Shacraft server."}),
|
||||
));
|
||||
}
|
||||
if method == "ping" {
|
||||
return Some(rpc_result(id, json!({})));
|
||||
}
|
||||
if !self.ready {
|
||||
return Some(rpc_error(
|
||||
id,
|
||||
-32002,
|
||||
"Initialize and send notifications/initialized first",
|
||||
));
|
||||
}
|
||||
let result = match method {
|
||||
"tools/list" => {
|
||||
if params.get("cursor").is_some() {
|
||||
return Some(rpc_error(id, -32602, "This tool list has no further pages"));
|
||||
}
|
||||
json!({"tools":tools()})
|
||||
}
|
||||
"tools/call" => {
|
||||
let Some(name) = params.get("name").and_then(Value::as_str) else {
|
||||
return Some(rpc_error(id, -32602, "Tool name is required"));
|
||||
};
|
||||
let list = tools();
|
||||
let Some(tool) = list.iter().find(|t| t["name"] == name) else {
|
||||
return Some(rpc_error(id, -32602, "Unknown tool"));
|
||||
};
|
||||
let args = params
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}));
|
||||
if let Err(error) = validate(&tool["inputSchema"], &args, "arguments") {
|
||||
tool_error(error)
|
||||
} else {
|
||||
let control = name.replacen('_', ".", 1);
|
||||
match backend.control(&control, &args) {
|
||||
Ok(mut output) => {
|
||||
let mut content = Vec::new();
|
||||
if name == "camera_capture"
|
||||
&& let Some(png) = output.get("png_base64").and_then(Value::as_str)
|
||||
{
|
||||
content.push(
|
||||
json!({"type":"image","data":png,"mimeType":"image/png"}),
|
||||
);
|
||||
if let Some(obj) = output.as_object_mut() {
|
||||
obj.remove("png_base64");
|
||||
}
|
||||
}
|
||||
content.push(json!({"type":"text","text":serde_json::to_string(&output).unwrap_or_default()}));
|
||||
let structured = if output.is_object() {
|
||||
output
|
||||
} else {
|
||||
json!({"result":output})
|
||||
};
|
||||
json!({"content":content,"structuredContent":structured,"isError":false})
|
||||
}
|
||||
Err(error) => tool_error(format!("{error:#}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
"resources/list" => resources(),
|
||||
"resources/templates/list" => json!({"resourceTemplates":[]}),
|
||||
"resources/read" => {
|
||||
let Some(uri) = params.get("uri").and_then(Value::as_str) else {
|
||||
return Some(rpc_error(id, -32602, "Resource URI is required"));
|
||||
};
|
||||
let path = match uri {
|
||||
"shacraft://server/status" => "/api/metrics",
|
||||
"shacraft://catalog/blocks" => "/api/catalog?offset=0&limit=64",
|
||||
"shacraft://catalog/entities" => "/api/entities",
|
||||
"shacraft://packages" => "/api/manifest",
|
||||
_ => return Some(rpc_error(id, -32002, "Unknown resource URI")),
|
||||
};
|
||||
match backend.public(path) {
|
||||
Ok(data) => {
|
||||
json!({"contents":[{"uri":uri,"mimeType":"application/json","text":serde_json::to_string(&data).unwrap_or_default()}]})
|
||||
}
|
||||
Err(error) => return Some(rpc_error(id, -32000, format!("{error:#}"))),
|
||||
}
|
||||
}
|
||||
"prompts/list" => {
|
||||
json!({"prompts":[{"name":"construct","description":"Inspect, plan, commit and visually verify a bounded Shacraft build","arguments":[{"name":"world","description":"Target world name","required":true},{"name":"request","description":"What to build and any constraints","required":true}]}]})
|
||||
}
|
||||
"prompts/get" => {
|
||||
if params["name"] != "construct" {
|
||||
return Some(rpc_error(id, -32602, "Unknown prompt"));
|
||||
}
|
||||
let a = ¶ms["arguments"];
|
||||
let (Some(world), Some(request)) = (a["world"].as_str(), a["request"].as_str())
|
||||
else {
|
||||
return Some(rpc_error(
|
||||
id,
|
||||
-32602,
|
||||
"world and request string arguments are required",
|
||||
));
|
||||
};
|
||||
json!({"description":"Revision-safe construction workflow","messages":[{"role":"user","content":{"type":"text","text":format!("Build in world {world}: {request}\nUse world_list and world_read to inspect the site and current revision, then catalog_search for exact block states. Use build_plan for bounded box/line/cylinder operations. Review the resulting bounds and change count against the requested construction; then call build_commit with a unique operation_id if the request authorizes the build. Capture camera_capture afterward and inspect the result. If the revision conflicts, read the world and make a new plan rather than forcing a stale write. Undo uses the original operation_id and is safe only before later edits. Explain content coverage and any limitations relevant to the build.")}}]})
|
||||
}
|
||||
_ => return Some(rpc_error(id, -32601, "Method not found")),
|
||||
};
|
||||
Some(rpc_result(id, result))
|
||||
}
|
||||
}
|
||||
|
||||
/// Discards an oversized frame through its newline without accumulating its tail.
|
||||
fn read_frame(reader: &mut impl BufRead) -> io::Result<Option<std::result::Result<Vec<u8>, ()>>> {
|
||||
let mut frame = Vec::new();
|
||||
let mut oversized = false;
|
||||
loop {
|
||||
let buffer = reader.fill_buf()?;
|
||||
if buffer.is_empty() {
|
||||
return Ok(if frame.is_empty() && !oversized {
|
||||
None
|
||||
} else {
|
||||
Some(if oversized { Err(()) } else { Ok(frame) })
|
||||
});
|
||||
}
|
||||
let end = buffer.iter().position(|&b| b == b'\n').map(|i| i + 1);
|
||||
let take = end.unwrap_or(buffer.len());
|
||||
if !oversized {
|
||||
if frame.len() + take > MAX_FRAME {
|
||||
oversized = true;
|
||||
frame.clear();
|
||||
} else {
|
||||
frame.extend_from_slice(&buffer[..take]);
|
||||
}
|
||||
}
|
||||
reader.consume(take);
|
||||
if end.is_some() {
|
||||
return Ok(Some(if oversized { Err(()) } else { Ok(frame) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let backend = Backend::new(Args::parse())?;
|
||||
let mut session = Session::default();
|
||||
let stdin = io::stdin();
|
||||
let mut input = stdin.lock();
|
||||
let stdout = io::stdout();
|
||||
let mut output = stdout.lock();
|
||||
while let Some(frame) = read_frame(&mut input)? {
|
||||
let reply = match frame {
|
||||
Err(()) => Some(rpc_error(Value::Null, -32600, "Request exceeds 8 MiB")),
|
||||
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
|
||||
Ok(request) => session.dispatch(request, &backend),
|
||||
Err(_) => Some(rpc_error(Value::Null, -32700, "Parse error")),
|
||||
},
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
serde_json::to_writer(&mut output, &reply)?;
|
||||
output.write_all(b"\n")?;
|
||||
output.flush()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn backend() -> Backend {
|
||||
Backend::new(Args {
|
||||
server: "http://127.0.0.1:1".into(),
|
||||
data: PathBuf::from("/nonexistent-shacraft-test-data"),
|
||||
token_file: None,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
fn request(id: i32, method: &str, params: Value) -> Value {
|
||||
json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})
|
||||
}
|
||||
fn initialize(s: &mut Session, b: &Backend, version: &str) -> Value {
|
||||
let result=s.dispatch(request(1,"initialize",json!({"protocolVersion":version,"capabilities":{},"clientInfo":{"name":"test","version":"1"}})),b).unwrap();
|
||||
assert!(
|
||||
s.dispatch(
|
||||
json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
|
||||
b
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
result
|
||||
}
|
||||
#[test]
|
||||
fn lifecycle_negotiates_and_rejects_before_initialization() {
|
||||
let b = backend();
|
||||
let mut s = Session::default();
|
||||
assert_eq!(
|
||||
s.dispatch(request(1, "tools/list", json!({})), &b).unwrap()["error"]["code"],
|
||||
-32002
|
||||
);
|
||||
assert_eq!(
|
||||
initialize(&mut s, &b, "unknown")["result"]["protocolVersion"],
|
||||
VERSIONS[0]
|
||||
);
|
||||
let list = s.dispatch(request(2, "tools/list", json!({})), &b).unwrap();
|
||||
assert_eq!(list["result"]["tools"].as_array().unwrap().len(), 17);
|
||||
assert_eq!(
|
||||
s.dispatch(request(3, "initialize", json!({})), &b).unwrap()["error"]["code"],
|
||||
-32600
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn supported_version_is_preserved() {
|
||||
let b = backend();
|
||||
let mut s = Session::default();
|
||||
assert_eq!(
|
||||
initialize(&mut s, &b, "2024-11-05")["result"]["protocolVersion"],
|
||||
"2024-11-05"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn notifications_never_generate_responses() {
|
||||
let b = backend();
|
||||
let mut s = Session::default();
|
||||
assert!(
|
||||
s.dispatch(json!({"jsonrpc":"2.0","method":"unknown/notification"}), &b)
|
||||
.is_none()
|
||||
);
|
||||
assert!(s.dispatch(json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":3}}),&b).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn invalid_tool_arguments_are_mcp_content_errors() {
|
||||
let b = backend();
|
||||
let mut s = Session::default();
|
||||
initialize(&mut s, &b, VERSIONS[0]);
|
||||
let reply=s.dispatch(request(2,"tools/call",json!({"name":"world_edit","arguments":{"world":"lobby","expected_revision":0,"operation_id":"x","changes":[{"pos":[0,0],"block":1}]}})),&b).unwrap();
|
||||
assert_eq!(reply["result"]["isError"], true);
|
||||
assert!(
|
||||
reply["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("too few items")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn unavailable_token_is_a_tool_error_not_transport_error() {
|
||||
let b = backend();
|
||||
let mut s = Session::default();
|
||||
initialize(&mut s, &b, VERSIONS[0]);
|
||||
let reply = s
|
||||
.dispatch(request(2, "tools/call", json!({"name":"world_list"})), &b)
|
||||
.unwrap();
|
||||
assert_eq!(reply["result"]["isError"], true);
|
||||
assert!(reply.get("error").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn malformed_ids_and_requests_do_not_execute() {
|
||||
let b = backend();
|
||||
let mut s = Session::default();
|
||||
for req in [
|
||||
json!([]),
|
||||
json!({"jsonrpc":"2.0","id":null,"method":"ping"}),
|
||||
json!({"id":1,"method":"ping"}),
|
||||
] {
|
||||
assert_eq!(s.dispatch(req, &b).unwrap()["error"]["code"], -32600);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn bounded_reader_recovers_after_oversized_line() {
|
||||
let mut input = vec![b'x'; MAX_FRAME + 100];
|
||||
input.extend_from_slice(b"\n{\"ok\":true}\n");
|
||||
let mut input = io::BufReader::new(io::Cursor::new(input));
|
||||
assert!(read_frame(&mut input).unwrap().unwrap().is_err());
|
||||
assert_eq!(
|
||||
read_frame(&mut input).unwrap().unwrap().unwrap(),
|
||||
b"{\"ok\":true}\n"
|
||||
);
|
||||
assert!(read_frame(&mut input).unwrap().is_none());
|
||||
}
|
||||
#[test]
|
||||
fn build_shapes_validate_without_allowing_invented_parameters() {
|
||||
let list = tools();
|
||||
let schema = &list.iter().find(|t| t["name"] == "build_plan").unwrap()["inputSchema"];
|
||||
assert!(validate(schema,&json!({"world":"lobby","expected_revision":0,"operations":[{"type":"box","min":[0,0,0],"max":[3,3,3],"block":"minecraft:stone"}]}),"args").is_ok());
|
||||
assert!(validate(schema,&json!({"world":"lobby","expected_revision":0,"operations":[{"type":"box","min":[0,0,0],"max":[3,3,3],"block":1,"erase_everything":true}]}),"args").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Real MCP SDK end-to-end smoke. Requires a running disposable Shacraft server.
|
||||
// node sdk-smoke.mjs --binary /abs/shacraft-mcp --data /abs/test-data [--server URL]
|
||||
// SHACRAFT_MCP_SDK may point at an installed @modelcontextprotocol/sdk directory.
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL, fileURLToPath } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
const args = {};
|
||||
for (let i = 2; i < process.argv.length; i += 2)
|
||||
args[process.argv[i].replace(/^--/, "")] = process.argv[i + 1];
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../..",
|
||||
);
|
||||
const sdk =
|
||||
process.env.SHACRAFT_MCP_SDK ||
|
||||
args.sdk ||
|
||||
path.resolve(
|
||||
root,
|
||||
"../minecraft-builder-mcp/bridge/node_modules/@modelcontextprotocol/sdk",
|
||||
);
|
||||
if (!existsSync(sdk))
|
||||
throw Error(
|
||||
"Set SHACRAFT_MCP_SDK to an installed @modelcontextprotocol/sdk directory.",
|
||||
);
|
||||
const { Client } = await import(
|
||||
pathToFileURL(path.join(sdk, "dist/esm/client/index.js"))
|
||||
);
|
||||
const { StdioClientTransport } = await import(
|
||||
pathToFileURL(path.join(sdk, "dist/esm/client/stdio.js"))
|
||||
);
|
||||
const binary = args.binary || path.join(root, "target/debug/shacraft-mcp");
|
||||
assert.ok(args.data, "--data is required: use disposable server data");
|
||||
const client = new Client(
|
||||
{ name: "shacraft-sdk-smoke", version: "1.0.0" },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
const transport = new StdioClientTransport({
|
||||
command: binary,
|
||||
args: [
|
||||
"--server",
|
||||
args.server || "http://127.0.0.1:4000",
|
||||
"--data",
|
||||
path.resolve(args.data),
|
||||
],
|
||||
stderr: "pipe",
|
||||
});
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => (stderr += chunk));
|
||||
async function call(name, arguments_ = {}) {
|
||||
const r = await client.callTool({ name, arguments: arguments_ });
|
||||
assert.notEqual(r.isError, true, `${name}: ${JSON.stringify(r.content)}`);
|
||||
return (
|
||||
r.structuredContent ||
|
||||
JSON.parse(r.content.find((c) => c.type === "text").text)
|
||||
);
|
||||
}
|
||||
const report = { sdk: true, checks: [], world: `mcp_sdk_${Date.now()}` };
|
||||
try {
|
||||
await client.connect(transport);
|
||||
assert.equal(client.getServerVersion()?.name, "shacraft-mcp");
|
||||
await client.ping();
|
||||
report.checks.push(
|
||||
"SDK initialize, version negotiation, notification and ping",
|
||||
);
|
||||
const listed = await client.listTools();
|
||||
assert.equal(listed.tools.length, 17);
|
||||
report.checks.push("17 discoverable tool schemas accepted by SDK");
|
||||
const resources = await client.listResources();
|
||||
assert.equal(resources.resources.length, 4);
|
||||
for (const resource of resources.resources) {
|
||||
const r = await client.readResource({ uri: resource.uri });
|
||||
assert.equal(r.contents[0].mimeType, "application/json");
|
||||
JSON.parse(r.contents[0].text);
|
||||
}
|
||||
report.checks.push("4 resources read through SDK");
|
||||
const prompts = await client.listPrompts();
|
||||
assert.equal(prompts.prompts[0].name, "construct");
|
||||
const prompt = await client.getPrompt({
|
||||
name: "construct",
|
||||
arguments: {
|
||||
world: report.world,
|
||||
request: "Create a small test structure",
|
||||
},
|
||||
});
|
||||
assert.ok(prompt.messages.length);
|
||||
report.checks.push("Construction prompt and arguments");
|
||||
const found = await call("catalog_search", {
|
||||
query: "minecraft:stone",
|
||||
limit: 32,
|
||||
});
|
||||
const stone = found.items.find((x) => x.state === "minecraft:stone");
|
||||
assert.ok(stone);
|
||||
await call("world_create", { world: report.world });
|
||||
const edit = {
|
||||
world: report.world,
|
||||
expected_revision: 0,
|
||||
operation_id: "sdk-edit",
|
||||
changes: [{ pos: [0, 0, 0], block: stone.id }],
|
||||
};
|
||||
const first = await call("world_edit", edit);
|
||||
assert.equal(first.revision, 1);
|
||||
assert.equal((await call("world_edit", edit)).replayed, true);
|
||||
const read = await call("world_read", {
|
||||
world: report.world,
|
||||
min: [0, 0, 0],
|
||||
max: [1, 1, 1],
|
||||
});
|
||||
assert.equal(read.blocks.length, 1);
|
||||
const conflict = await client.callTool({
|
||||
name: "world_edit",
|
||||
arguments: { ...edit, operation_id: "sdk-conflict" },
|
||||
});
|
||||
assert.equal(conflict.isError, true);
|
||||
await call("world_undo", {
|
||||
world: report.world,
|
||||
expected_revision: 1,
|
||||
operation_id: "sdk-undo",
|
||||
target_operation: "sdk-edit",
|
||||
});
|
||||
report.checks.push(
|
||||
"Durable write, read, idempotent retry, conflict content and undo",
|
||||
);
|
||||
const plan = await call("build_plan", {
|
||||
world: report.world,
|
||||
expected_revision: 2,
|
||||
operations: [
|
||||
{ type: "box", min: [0, 0, 0], max: [2, 1, 2], block: stone.id },
|
||||
],
|
||||
});
|
||||
assert.equal(plan.cells, 18);
|
||||
const committed = await call("build_commit", {
|
||||
plan_id: plan.plan_id,
|
||||
operation_id: "sdk-build",
|
||||
});
|
||||
assert.equal(committed.changed, 18);
|
||||
assert.equal(
|
||||
(
|
||||
await call("build_commit", {
|
||||
plan_id: plan.plan_id,
|
||||
operation_id: "sdk-build",
|
||||
})
|
||||
).replayed,
|
||||
true,
|
||||
);
|
||||
report.checks.push("Plan and atomic idempotent commit");
|
||||
const image = await client.callTool({
|
||||
name: "camera_capture",
|
||||
arguments: { world: report.world, min: [-2, -2, -2], max: [4, 4, 4] },
|
||||
});
|
||||
assert.notEqual(image.isError, true);
|
||||
const png = image.content.find((c) => c.type === "image");
|
||||
assert.ok(png);
|
||||
assert.equal(png.mimeType, "image/png");
|
||||
assert.deepEqual(
|
||||
[...Buffer.from(png.data, "base64").subarray(0, 8)],
|
||||
[137, 80, 78, 71, 13, 10, 26, 10],
|
||||
);
|
||||
report.checks.push("MCP PNG image content");
|
||||
const entity = await call("entity_spawn", {
|
||||
world: report.world,
|
||||
kind: "minecraft:pig",
|
||||
position: [1, 2, 1],
|
||||
properties: { name: "SDK test" },
|
||||
});
|
||||
const id = entity.entity.id;
|
||||
assert.ok(id);
|
||||
const updated = await call("entity_update", {
|
||||
id,
|
||||
position: [2, 2, 1],
|
||||
properties: { name: "SDK updated" },
|
||||
});
|
||||
assert.deepEqual(updated.entity.position, [2, 2, 1]);
|
||||
const entities = await call("entity_list", { world: report.world });
|
||||
assert.ok(entities.items.some((e) => e.id === id));
|
||||
await call("entity_delete", { id });
|
||||
assert.equal(
|
||||
(await call("entity_list", { world: report.world })).items.length,
|
||||
0,
|
||||
);
|
||||
report.checks.push("Entity spawn/update/list/delete");
|
||||
const rules = await call("arena_configure", {
|
||||
world: report.world,
|
||||
mode: "creative",
|
||||
spawn: [0, 3, 0],
|
||||
countdown_seconds: 2,
|
||||
round_seconds: 120,
|
||||
min_players: 2,
|
||||
elimination_y: -5,
|
||||
spawn_points: [],
|
||||
spectator_spawn: [13, 3, 0],
|
||||
floor_block: "minecraft:snow_block",
|
||||
});
|
||||
assert.equal(rules.countdown_seconds ?? rules.rules?.countdown_seconds, 2);
|
||||
const metrics = await call("metrics");
|
||||
assert.ok(metrics.storage);
|
||||
report.checks.push("Persisted world configuration and measured metrics");
|
||||
await call("world_reset", {
|
||||
world: report.world,
|
||||
expected_revision: committed.revision,
|
||||
operation_id: "sdk-reset",
|
||||
});
|
||||
const empty = await call("world_read", {
|
||||
world: report.world,
|
||||
min: [0, 0, 0],
|
||||
max: [2, 1, 2],
|
||||
});
|
||||
assert.equal(empty.blocks.length, 0);
|
||||
report.checks.push("World reset");
|
||||
assert.equal(stderr, "", "MCP server should not emit routine diagnostics");
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "shacraft-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
shacraft-core.workspace = true
|
||||
shacraft-content = { path = "../shacraft-content" }
|
||||
axum = { version = "0.8.9", features = ["ws"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "limit", "trace"] }
|
||||
tower = { version = "0.5", features = ["limit", "load-shed"] }
|
||||
futures-util = "0.3"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
png = "0.17"
|
||||
wasmi = "2"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
rusqlite = { version = "=0.40.2", features = ["bundled"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,499 @@
|
||||
impl Game {
|
||||
pub fn query(&mut self, method: &str, p: Value) -> Result<Value> {
|
||||
match method {
|
||||
"health" => Ok(json!({"status":"ok","protocol":1,"minecraft":"26.2","tick":self.tick})),
|
||||
"manifest" => Ok(self.manifest.clone()),
|
||||
"world.list" => Ok(json!(
|
||||
self.store
|
||||
.list_worlds()?
|
||||
.into_iter()
|
||||
.filter(|w| self.config(&w.name).mode != "template")
|
||||
.map(|w| {
|
||||
let mut v = json!(w);
|
||||
v["mode"] = json!(self.config(&w.name).mode);
|
||||
v
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
)),
|
||||
"catalog.search" => {
|
||||
let query = p["query"].as_str().unwrap_or("").to_lowercase();
|
||||
ensure!(query.len() <= 256, "query too long");
|
||||
let offset = p["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let limit = p["limit"].as_u64().unwrap_or(64).clamp(1, 256) as usize;
|
||||
let selected = p["ids"]
|
||||
.as_str()
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.take(128)
|
||||
.filter_map(|s| s.parse::<u32>().ok())
|
||||
.collect::<HashSet<_>>()
|
||||
})
|
||||
.or_else(|| {
|
||||
p["states"].as_str().map(|s| {
|
||||
split_states(s)
|
||||
.into_iter()
|
||||
.take(128)
|
||||
.filter_map(|s| self.id(s).ok())
|
||||
.collect::<HashSet<_>>()
|
||||
})
|
||||
});
|
||||
let ids: Vec<_> = self
|
||||
.store
|
||||
.registry()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(id, s)| {
|
||||
if let Some(ref selected) = selected {
|
||||
selected.contains(&(*id as u32))
|
||||
} else {
|
||||
s.to_lowercase().contains(&query)
|
||||
}
|
||||
})
|
||||
.map(|(i, _)| i as u32)
|
||||
.collect();
|
||||
Ok(
|
||||
json!({"items":ids.iter().skip(offset).take(limit).filter_map(|id|self.material(*id)).collect::<Vec<_>>(),"total":ids.len(),"summary":self.catalog.summary()}),
|
||||
)
|
||||
}
|
||||
"entity.catalog" => Ok(json!({"items":self.catalog.entities})),
|
||||
"metrics" => {
|
||||
let rss = std::fs::read_to_string("/proc/self/status")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines()
|
||||
.find(|l| l.starts_with("VmRSS:"))
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(|n| n * 1024)
|
||||
});
|
||||
Ok(
|
||||
json!({"tick":self.tick,"tick_ms":self.last_tick_ms,"max_tick_ms":self.max_tick_ms,"uptime_seconds":self.started.elapsed().as_secs(),"players":self.players.len(),"active_worlds":self.players.values().map(|p|&p.world).collect::<HashSet<_>>().len(),"world_count":self.store.list_worlds()?.len(),"plans":self.plans.len(),"entities":self.meta.entities.len(),"entity_revision":self.meta.revision,"rss_bytes":rss,"slow_clients_disconnected":self.slow_clients,"queued_client_bytes":self.players.values().map(|p|p.out.bytes.load(Ordering::Relaxed)).sum::<usize>(),"limits":{"players":32,"command_queue":64,"messages_per_client":16,"bytes_per_client":8388608,"build_plans":16,"plan_cells":32768},"storage":self.store.stats(),"catalog":self.catalog.summary(),"wasm":self.jump.metrics()}),
|
||||
)
|
||||
}
|
||||
"world.create" => {
|
||||
let world = string(&p, "world")?;
|
||||
self.store.create_world(world, p["template"].as_str())?;
|
||||
let conf = if let Some(template) = p["template"].as_str() {
|
||||
let mut c = self.config(template);
|
||||
if c.mode == "template" {
|
||||
c.mode = "spleef".into();
|
||||
}
|
||||
c
|
||||
} else {
|
||||
WorldConfig::default()
|
||||
};
|
||||
self.meta.worlds.insert(world.into(), conf);
|
||||
self.save_meta()?;
|
||||
Ok(json!({"world":world,"revision":0}))
|
||||
}
|
||||
"world.read" => {
|
||||
let world = string(&p, "world")?;
|
||||
let min: Pos = serde_json::from_value(p["min"].clone())?;
|
||||
let max: Pos = serde_json::from_value(p["max"].clone())?;
|
||||
let blocks = self.store.read_region(world, min, max)?;
|
||||
let response = json!({"world":world,"revision":self.store.revision(world)?,"blocks":blocks,"materials":self.materials(&blocks)});
|
||||
ensure!(
|
||||
response.to_string().len() <= 6 * 1024 * 1024,
|
||||
"read response exceeds 6 MiB; request a smaller region"
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
"world.edit" => {
|
||||
let world = string(&p, "world")?;
|
||||
let rev = p["expected_revision"]
|
||||
.as_u64()
|
||||
.context("expected_revision required")?;
|
||||
let op = string(&p, "operation_id")?;
|
||||
let changes: Vec<BlockChange> = serde_json::from_value(p["changes"].clone())?;
|
||||
let result = self.store.edit(world, rev, op, changes.clone())?;
|
||||
if !result.replayed {
|
||||
self.broadcast(world,json!({"type":"blocks","revision":result.revision,"changes":changes,"materials":self.materials(&changes)}));
|
||||
}
|
||||
Ok(json!(result))
|
||||
}
|
||||
"world.undo" | "world.reset" => {
|
||||
let world = string(&p, "world")?;
|
||||
let rev = p["expected_revision"]
|
||||
.as_u64()
|
||||
.context("expected_revision required")?;
|
||||
let op = string(&p, "operation_id")?;
|
||||
let result = if method == "world.undo" {
|
||||
self.store
|
||||
.undo(world, rev, op, string(&p, "target_operation")?)?
|
||||
} else {
|
||||
self.store.reset_world(world, rev, op)?
|
||||
};
|
||||
if !result.replayed {
|
||||
self.resnapshot(world);
|
||||
}
|
||||
Ok(json!(result))
|
||||
}
|
||||
"entity.list" => {
|
||||
let world = string(&p, "world")?;
|
||||
self.store.revision(world)?;
|
||||
Ok(
|
||||
json!({"items":self.meta.entities.values().filter(|e|e["world"]==world).skip(p["offset"].as_u64().unwrap_or(0)as usize).take(p["limit"].as_u64().unwrap_or(64).clamp(1,128)as usize).cloned().collect::<Vec<_>>(),"total":self.meta.entities.values().filter(|e|e["world"]==world).count(),"revision":self.meta.revision}),
|
||||
)
|
||||
}
|
||||
"entity.spawn" => {
|
||||
ensure!(self.meta.entities.len() < 4096, "entity limit reached");
|
||||
let world = string(&p, "world")?;
|
||||
self.store.revision(world)?;
|
||||
let kind = p["kind"]
|
||||
.as_str()
|
||||
.or_else(|| p["entity_type"].as_str())
|
||||
.context("kind required")?;
|
||||
ensure!(self.catalog.entity(kind).is_some(), "unknown entity type");
|
||||
let pos = entity_position(&p["position"])?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let properties = p.get("properties").cloned().unwrap_or(json!({}));
|
||||
ensure!(
|
||||
properties.to_string().len() <= 16384,
|
||||
"entity properties too large"
|
||||
);
|
||||
let entity = json!({"id":id,"world":world,"kind":kind,"type":kind,"position":pos,"yaw":p["yaw"].as_f64().unwrap_or(0.),"properties":properties});
|
||||
self.meta.entities.insert(id, entity.clone());
|
||||
self.save_meta()?;
|
||||
self.broadcast(
|
||||
world,
|
||||
json!({"type":"entities","entities":self.entity_list(world)}),
|
||||
);
|
||||
Ok(json!({"entity":entity,"revision":self.meta.revision}))
|
||||
}
|
||||
"entity.update" | "entity.delete" => {
|
||||
let id = string(&p, "id")?;
|
||||
let mut entity = self
|
||||
.meta
|
||||
.entities
|
||||
.get(id)
|
||||
.cloned()
|
||||
.context("unknown entity")?;
|
||||
let world = entity["world"].as_str().unwrap().to_owned();
|
||||
if let Some(r) = p["expected_revision"].as_u64() {
|
||||
ensure!(r == self.meta.revision, "entity revision conflict");
|
||||
}
|
||||
if method == "entity.delete" {
|
||||
self.meta.entities.remove(id);
|
||||
} else {
|
||||
if p.get("position").is_some() {
|
||||
entity["position"] = json!(entity_position(&p["position"])?);
|
||||
}
|
||||
if let Some(prop) = p.get("properties") {
|
||||
ensure!(
|
||||
prop.to_string().len() <= 16384,
|
||||
"entity properties too large"
|
||||
);
|
||||
entity["properties"] = prop.clone();
|
||||
}
|
||||
if p.get("yaw").is_some() {
|
||||
entity["yaw"] = json!(number(&p, "yaw")?);
|
||||
}
|
||||
self.meta.entities.insert(id.into(), entity.clone());
|
||||
}
|
||||
self.save_meta()?;
|
||||
self.broadcast(
|
||||
&world,
|
||||
json!({"type":"entities","entities":self.entity_list(&world)}),
|
||||
);
|
||||
Ok(json!({"entity":entity,"revision":self.meta.revision}))
|
||||
}
|
||||
"arena.configure" => {
|
||||
let world = string(&p, "world")?;
|
||||
self.store.revision(world)?;
|
||||
ensure!(
|
||||
!self.matches.contains_key(world),
|
||||
"cannot change rules during a match"
|
||||
);
|
||||
if let Some(revision) = p["expected_revision"].as_u64() {
|
||||
ensure!(
|
||||
revision == self.meta.revision,
|
||||
"arena configuration revision conflict"
|
||||
);
|
||||
}
|
||||
let mut conf = self.config(world);
|
||||
if let Some(mode) = p.get("mode") {
|
||||
let mode = mode.as_str().context("mode must be a string")?;
|
||||
ensure!(
|
||||
["creative", "spleef"].contains(&mode),
|
||||
"unsupported world mode"
|
||||
);
|
||||
conf.mode = mode.into();
|
||||
}
|
||||
if let Some(value) = p.get("spawn") {
|
||||
conf.spawn = entity_position(value)?;
|
||||
}
|
||||
if let Some(value) = p.get("spectator_spawn") {
|
||||
conf.spectator_spawn = entity_position(value)?;
|
||||
}
|
||||
if let Some(value) = p.get("spawn_points") {
|
||||
conf.spawn_points = serde_json::from_value(value.clone())
|
||||
.context("spawn_points must be arrays of three coordinates")?;
|
||||
}
|
||||
if let Some(value) = p.get("countdown_seconds") {
|
||||
conf.countdown_seconds = u32::try_from(
|
||||
value
|
||||
.as_u64()
|
||||
.context("countdown_seconds must be an integer")?,
|
||||
)
|
||||
.context("countdown_seconds too large")?;
|
||||
}
|
||||
if let Some(value) = p.get("round_seconds") {
|
||||
conf.round_seconds =
|
||||
u32::try_from(value.as_u64().context("round_seconds must be an integer")?)
|
||||
.context("round_seconds too large")?;
|
||||
}
|
||||
if let Some(value) = p.get("min_players") {
|
||||
conf.min_players =
|
||||
usize::try_from(value.as_u64().context("min_players must be an integer")?)
|
||||
.context("min_players too large")?;
|
||||
}
|
||||
if p.get("elimination_y").is_some() {
|
||||
conf.elimination_y = number(&p, "elimination_y")?;
|
||||
}
|
||||
if let Some(value) = p.get("floor_block") {
|
||||
let name = value
|
||||
.as_str()
|
||||
.context("floor_block must be a canonical state")?;
|
||||
conf.floor_block = self
|
||||
.catalog
|
||||
.state(name)
|
||||
.context("unknown floor_block")?
|
||||
.state
|
||||
.clone();
|
||||
}
|
||||
conf.validate(&self.catalog)?;
|
||||
self.meta.worlds.insert(world.into(), conf.clone());
|
||||
self.save_meta()?;
|
||||
Ok(json!(conf))
|
||||
}
|
||||
"arena.start" => self.start_match(string(&p, "world")?),
|
||||
"build.plan" => self.build_plan(p),
|
||||
"build.commit" => {
|
||||
let id = string(&p, "plan_id")?;
|
||||
let plan = self.plans.get(id).context("plan missing or expired")?;
|
||||
ensure!(plan.expires > Instant::now(), "plan expired");
|
||||
let world = plan.world.clone();
|
||||
let changes = plan.changes.clone();
|
||||
let result = self.store.edit(
|
||||
&world,
|
||||
plan.revision,
|
||||
string(&p, "operation_id")?,
|
||||
changes.clone(),
|
||||
)?;
|
||||
if !result.replayed {
|
||||
self.broadcast(&world,json!({"type":"blocks","revision":result.revision,"changes":changes,"materials":self.materials(&changes)}));
|
||||
}
|
||||
Ok(json!(result))
|
||||
}
|
||||
"camera.capture" => {
|
||||
let world = string(&p, "world")?;
|
||||
let min: Pos =
|
||||
serde_json::from_value(p.get("min").cloned().unwrap_or(json!([-24, -4, -24])))?;
|
||||
let max: Pos =
|
||||
serde_json::from_value(p.get("max").cloned().unwrap_or(json!([24, 24, 24])))?;
|
||||
let blocks = self.store.read_region(world, min, max)?;
|
||||
let colors = blocks
|
||||
.iter()
|
||||
.map(|b| {
|
||||
(
|
||||
b.pos,
|
||||
self.definition(b.block)
|
||||
.map(|s| s.color)
|
||||
.unwrap_or([190, 60, 185]),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let png = crate::render::capture(min, max, colors)?;
|
||||
use base64::Engine;
|
||||
Ok(
|
||||
json!({"png_base64":base64::engine::general_purpose::STANDARD.encode(png),"mimeType":"image/png","world":world,"revision":self.store.revision(world)?,"kind":"server-software-isometric","min":min,"max":max}),
|
||||
)
|
||||
}
|
||||
_ => bail!("unknown control method {method}"),
|
||||
}
|
||||
}
|
||||
fn build_plan(&mut self, p: Value) -> Result<Value> {
|
||||
self.plans.retain(|_, p| p.expires > Instant::now());
|
||||
ensure!(self.plans.len() < 16, "build plan limit reached");
|
||||
let world = string(&p, "world")?.to_owned();
|
||||
let revision = p["expected_revision"]
|
||||
.as_u64()
|
||||
.context("expected_revision required")?;
|
||||
ensure!(
|
||||
self.store.revision(&world)? == revision,
|
||||
"revision conflict"
|
||||
);
|
||||
let ops = p["operations"].as_array().context("operations required")?;
|
||||
ensure!(
|
||||
!ops.is_empty() && ops.len() <= 128,
|
||||
"expected 1..128 operations"
|
||||
);
|
||||
let mut cells = BTreeMap::new();
|
||||
for op in ops {
|
||||
let state = op["state"].as_str().or_else(|| op["block"].as_str());
|
||||
let block = if let Some(s) = state {
|
||||
self.id(s)?
|
||||
} else {
|
||||
op["block"]
|
||||
.as_u64()
|
||||
.filter(|n| *n < self.store.registry().len() as u64)
|
||||
.context("block ID or state required")? as u32
|
||||
};
|
||||
let kind = op["type"]
|
||||
.as_str()
|
||||
.or_else(|| op["kind"].as_str())
|
||||
.context("operation type required")?;
|
||||
let mut put = |pos: Pos| -> Result<()> {
|
||||
ensure!(
|
||||
pos.iter().all(|n| n.unsigned_abs() <= 30_000_000),
|
||||
"position outside world limits"
|
||||
);
|
||||
cells.insert(pos, block);
|
||||
ensure!(cells.len() <= 32768, "plan exceeds 32768 cells");
|
||||
Ok(())
|
||||
};
|
||||
match kind {
|
||||
"box" | "replace" => {
|
||||
let min: Pos = serde_json::from_value(op["min"].clone())?;
|
||||
let max: Pos = serde_json::from_value(op["max"].clone())?;
|
||||
let mut volume = 1i64;
|
||||
for i in 0..3 {
|
||||
ensure!(max[i] >= min[i], "invalid box bounds");
|
||||
volume = volume
|
||||
.checked_mul(i64::from(max[i]) - i64::from(min[i]) + 1)
|
||||
.context("box too large")?;
|
||||
}
|
||||
ensure!(volume <= 32768, "box exceeds 32768 cells");
|
||||
let replace = if kind == "replace" {
|
||||
Some(if let Some(s) = op["from"].as_str() {
|
||||
self.id(s)?
|
||||
} else {
|
||||
op["from"].as_u64().context("replace from required")? as u32
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for x in min[0]..=max[0] {
|
||||
for y in min[1]..=max[1] {
|
||||
for z in min[2]..=max[2] {
|
||||
let pos = [x, y, z];
|
||||
if op["hollow"] == true
|
||||
&& x != min[0]
|
||||
&& x != max[0]
|
||||
&& y != min[1]
|
||||
&& y != max[1]
|
||||
&& z != min[2]
|
||||
&& z != max[2]
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if replace.is_none()
|
||||
|| Some(self.store.get_block(&world, pos)?) == replace
|
||||
{
|
||||
put(pos)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"line" => {
|
||||
let from: Pos = serde_json::from_value(op["from"].clone())?;
|
||||
let to: Pos = serde_json::from_value(op["to"].clone())?;
|
||||
let steps = (0..3)
|
||||
.map(|i| (i64::from(to[i]) - i64::from(from[i])).abs())
|
||||
.max()
|
||||
.unwrap();
|
||||
ensure!(steps <= 32767, "line too long");
|
||||
for n in 0..=steps {
|
||||
put(std::array::from_fn(|i| {
|
||||
(f64::from(from[i])
|
||||
+ (f64::from(to[i]) - f64::from(from[i])) * n as f64
|
||||
/ steps.max(1) as f64)
|
||||
.round() as i32
|
||||
}))?;
|
||||
}
|
||||
}
|
||||
"cylinder" => {
|
||||
let center: Pos = serde_json::from_value(op["center"].clone())?;
|
||||
let radius = op["radius"].as_i64().context("integer radius required")?;
|
||||
let height = op["height"].as_i64().context("integer height required")?;
|
||||
ensure!(
|
||||
(1..=64).contains(&radius) && (1..=128).contains(&height),
|
||||
"cylinder dimensions out of range"
|
||||
);
|
||||
for x in -radius..=radius {
|
||||
for z in -radius..=radius {
|
||||
if x * x + z * z > radius * radius {
|
||||
continue;
|
||||
}
|
||||
if op["hollow"] == true && x * x + z * z < (radius - 1) * (radius - 1) {
|
||||
continue;
|
||||
}
|
||||
for y in 0..height {
|
||||
put([
|
||||
center[0]
|
||||
.checked_add(x as i32)
|
||||
.context("coordinate overflow")?,
|
||||
center[1]
|
||||
.checked_add(y as i32)
|
||||
.context("coordinate overflow")?,
|
||||
center[2]
|
||||
.checked_add(z as i32)
|
||||
.context("coordinate overflow")?,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => bail!("unsupported build primitive {kind}"),
|
||||
}
|
||||
}
|
||||
let changes: Vec<_> = cells
|
||||
.into_iter()
|
||||
.map(|(pos, block)| BlockChange { pos, block })
|
||||
.collect();
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let count = changes.len();
|
||||
let min: Pos = std::array::from_fn(|i| changes.iter().map(|c| c.pos[i]).min().unwrap_or(0));
|
||||
let max: Pos = std::array::from_fn(|i| changes.iter().map(|c| c.pos[i]).max().unwrap_or(0));
|
||||
self.plans.insert(
|
||||
id.clone(),
|
||||
Plan {
|
||||
world: world.clone(),
|
||||
revision,
|
||||
changes,
|
||||
expires: Instant::now() + Duration::from_secs(300),
|
||||
},
|
||||
);
|
||||
Ok(
|
||||
json!({"plan_id":id,"world":world,"expected_revision":revision,"cells":count,"min":min,"max":max,"expires_in_seconds":300}),
|
||||
)
|
||||
}
|
||||
}
|
||||
fn entity_position(v: &Value) -> Result<[f32; 3]> {
|
||||
let p: [f32; 3] = serde_json::from_value(v.clone())?;
|
||||
ensure!(
|
||||
p.iter().all(|n| n.is_finite() && n.abs() <= 32700.),
|
||||
"position outside playable limits +/-32700"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn split_states(s: &str) -> Vec<&str> {
|
||||
let mut depth = 0;
|
||||
let mut start = 0;
|
||||
let mut out = Vec::new();
|
||||
for (i, c) in s.char_indices() {
|
||||
match c {
|
||||
'[' => depth += 1,
|
||||
']' => depth -= 1,
|
||||
',' if depth == 0 => {
|
||||
out.push(&s[start..i]);
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(&s[start..]);
|
||||
out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
||||
mod game;
|
||||
mod packages;
|
||||
mod render;
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{
|
||||
DefaultBodyLimit, Path as AxPath, Query, State, WebSocketUpgrade,
|
||||
ws::{Message, WebSocket},
|
||||
},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use clap::Parser;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::sync::{mpsc as tmpsc, oneshot};
|
||||
use tower_http::services::ServeDir;
|
||||
#[derive(Parser)]
|
||||
#[command(about = "Shacraft authoritative voxel server + browser client")]
|
||||
struct Args {
|
||||
#[arg(long, default_value = "127.0.0.1:4000")]
|
||||
listen: String,
|
||||
#[arg(long, default_value = "data")]
|
||||
data: PathBuf,
|
||||
#[arg(long, default_value_t = 64)]
|
||||
cache_sections: usize,
|
||||
#[arg(long,default_value=concat!(env!("CARGO_MANIFEST_DIR"),"/../../client"))]
|
||||
client: PathBuf,
|
||||
#[arg(long,default_value=concat!(env!("CARGO_MANIFEST_DIR"),"/../../packages"))]
|
||||
packages: PathBuf,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct App {
|
||||
tx: mpsc::SyncSender<game::Command>,
|
||||
token: Arc<String>,
|
||||
packages: Arc<Vec<shacraft_content::Package>>,
|
||||
sockets: Arc<tokio::sync::Semaphore>,
|
||||
}
|
||||
impl App {
|
||||
async fn query(&self, method: &str, p: Value) -> Result<Value> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.try_send(game::Command::Query(method.into(), p, tx))
|
||||
.map_err(|_| anyhow::anyhow!("server command queue busy"))?;
|
||||
tokio::time::timeout(Duration::from_secs(25), rx)
|
||||
.await
|
||||
.context("server query timeout")?
|
||||
.context("server stopped")?
|
||||
}
|
||||
}
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
anyhow::ensure!(
|
||||
(1..=4096).contains(&args.cache_sections),
|
||||
"cache_sections must be 1..4096"
|
||||
);
|
||||
std::fs::create_dir_all(&args.data)?;
|
||||
let token_path = args.data.join("control.token");
|
||||
let token = if token_path.exists() {
|
||||
std::fs::read_to_string(&token_path)?.trim().to_owned()
|
||||
} else {
|
||||
let token = format!(
|
||||
"{}{}",
|
||||
uuid::Uuid::new_v4().simple(),
|
||||
uuid::Uuid::new_v4().simple()
|
||||
);
|
||||
use std::io::Write;
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
options.open(&token_path)?.write_all(token.as_bytes())?;
|
||||
token
|
||||
};
|
||||
anyhow::ensure!(
|
||||
token.len() >= 32,
|
||||
"control token must have at least 32 characters"
|
||||
);
|
||||
let loaded = shacraft_content::load_packages(&args.packages)?;
|
||||
let package_manifest = packages::manifest(&loaded);
|
||||
let jump = packages::JumpModule::load(&loaded)?;
|
||||
eprintln!("Loading durable worlds and complete Java 26.2 catalog…");
|
||||
let game = game::Game::open(&args.data, args.cache_sections, package_manifest, jump)?;
|
||||
let (tx, rx) = mpsc::sync_channel(64);
|
||||
std::thread::Builder::new()
|
||||
.name("shacraft-world".into())
|
||||
.spawn(move || game.run(rx))?;
|
||||
let app = App {
|
||||
tx,
|
||||
token: Arc::new(token),
|
||||
packages: Arc::new(loaded),
|
||||
sockets: Arc::new(tokio::sync::Semaphore::new(32)),
|
||||
};
|
||||
let router = Router::new()
|
||||
.route("/api/health", get(health))
|
||||
.route("/api/manifest", get(manifest))
|
||||
.route("/api/worlds", get(worlds))
|
||||
.route("/api/catalog", get(catalog))
|
||||
.route("/api/entities", get(entities))
|
||||
.route("/api/metrics", get(metrics))
|
||||
.route("/api/control", post(control))
|
||||
.route("/packages/{id}/{version}/{*path}", get(resource))
|
||||
.route("/ws", get(websocket))
|
||||
.fallback_service(ServeDir::new(&args.client))
|
||||
.layer(DefaultBodyLimit::max(2 * 1024 * 1024))
|
||||
.layer(tower::limit::ConcurrencyLimitLayer::new(16))
|
||||
.with_state(app);
|
||||
let listener = tokio::net::TcpListener::bind(&args.listen).await?;
|
||||
eprintln!(
|
||||
"Shacraft ready: http://{} · data {} · 20 ticks/s",
|
||||
listener.local_addr()?,
|
||||
args.data.display()
|
||||
);
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
fn response(result: Result<Value>) -> Response {
|
||||
match result {
|
||||
Ok(v) => Json(v).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error":e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
async fn health(State(app): State<App>) -> Response {
|
||||
response(app.query("health", json!({})).await)
|
||||
}
|
||||
async fn manifest(State(app): State<App>) -> Response {
|
||||
response(app.query("manifest", json!({})).await)
|
||||
}
|
||||
async fn worlds(State(app): State<App>) -> Response {
|
||||
response(app.query("world.list", json!({})).await)
|
||||
}
|
||||
async fn metrics(State(app): State<App>) -> Response {
|
||||
response(app.query("metrics", json!({})).await)
|
||||
}
|
||||
async fn entities(State(app): State<App>) -> Response {
|
||||
response(app.query("entity.catalog", json!({})).await)
|
||||
}
|
||||
async fn catalog(
|
||||
State(app): State<App>,
|
||||
Query(q): Query<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let p = json!({"query":q.get("query"),"ids":q.get("ids"),"states":q.get("states"),"offset":q.get("offset").and_then(|s|s.parse::<u64>().ok()).unwrap_or(0),"limit":q.get("limit").and_then(|s|s.parse::<u64>().ok()).unwrap_or(64)});
|
||||
response(app.query("catalog.search", p).await)
|
||||
}
|
||||
async fn control(State(app): State<App>, headers: HeaderMap, Json(body): Json<Value>) -> Response {
|
||||
if headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
!= Some(format!("Bearer {}", app.token).as_str())
|
||||
{
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error":"control token required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok())
|
||||
&& !same_origin(&headers, origin)
|
||||
{
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let Some(method) = body["method"].as_str() else {
|
||||
return response(Err(anyhow::anyhow!("method required")));
|
||||
};
|
||||
match app
|
||||
.query(method, body.get("params").cloned().unwrap_or(json!({})))
|
||||
.await
|
||||
{
|
||||
Ok(v) => Json(json!({"result":v})).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error":e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
async fn resource(
|
||||
State(app): State<App>,
|
||||
AxPath((id, version, path)): AxPath<(String, String, String)>,
|
||||
) -> Response {
|
||||
let Some(p) = app
|
||||
.packages
|
||||
.iter()
|
||||
.find(|p| p.manifest.id == id && p.manifest.version == version)
|
||||
else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
match p.public_resource(&path) {
|
||||
Ok(bytes) => {
|
||||
let mime = if path.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if path.ends_with(".wav") {
|
||||
"audio/wav"
|
||||
} else if path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else {
|
||||
"text/plain; charset=utf-8"
|
||||
};
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, mime),
|
||||
(header::CACHE_CONTROL, "public, max-age=0, must-revalidate"),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
fn same_origin(headers: &HeaderMap, origin: &str) -> bool {
|
||||
headers
|
||||
.get(header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|host| {
|
||||
origin == format!("http://{host}") || origin == format!("https://{host}")
|
||||
})
|
||||
}
|
||||
async fn websocket(State(app): State<App>, headers: HeaderMap, ws: WebSocketUpgrade) -> Response {
|
||||
if headers
|
||||
.get(header::ORIGIN)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|o| !same_origin(&headers, o))
|
||||
{
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let Ok(permit) = app.sockets.clone().try_acquire_owned() else {
|
||||
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
};
|
||||
ws.max_message_size(65536)
|
||||
.max_frame_size(65536)
|
||||
.on_upgrade(move |socket| session(socket, app, permit))
|
||||
}
|
||||
async fn session(mut socket: WebSocket, app: App, _permit: tokio::sync::OwnedSemaphorePermit) {
|
||||
let join = match tokio::time::timeout(Duration::from_secs(10), socket.recv()).await {
|
||||
Ok(Some(Ok(Message::Text(text)))) => serde_json::from_str::<Value>(&text).ok(),
|
||||
_ => None,
|
||||
};
|
||||
let Some(join) = join else { return };
|
||||
if join["type"] != "join" {
|
||||
return;
|
||||
}
|
||||
let (tx, mut rx) = tmpsc::channel(16);
|
||||
let bytes = Arc::new(AtomicUsize::new(0));
|
||||
let out = game::Outbox {
|
||||
tx,
|
||||
bytes: bytes.clone(),
|
||||
};
|
||||
let (reply, answer) = oneshot::channel();
|
||||
if app
|
||||
.tx
|
||||
.try_send(game::Command::Join(join, out, reply))
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let id = match tokio::time::timeout(Duration::from_secs(25), answer).await {
|
||||
Ok(Ok(Ok(id))) => id,
|
||||
Ok(Ok(Err(e))) => {
|
||||
let _ = socket
|
||||
.send(Message::Text(
|
||||
json!({"type":"error","message":e.to_string()})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
let (mut writer, mut reader) = socket.split();
|
||||
let sending = async {
|
||||
while let Some(text) = rx.recv().await {
|
||||
let n = text.len();
|
||||
let sent = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
writer.send(Message::Text((*text).clone().into())),
|
||||
)
|
||||
.await;
|
||||
bytes.fetch_sub(n, Ordering::Relaxed);
|
||||
if !matches!(sent, Ok(Ok(()))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
let receiving = async {
|
||||
let mut start = Instant::now();
|
||||
let mut count = 0;
|
||||
while let Some(Ok(message)) = reader.next().await {
|
||||
if start.elapsed() >= Duration::from_secs(1) {
|
||||
start = Instant::now();
|
||||
count = 0;
|
||||
}
|
||||
count += 1;
|
||||
if count > 80 {
|
||||
break;
|
||||
}
|
||||
match message {
|
||||
Message::Text(text) => {
|
||||
let Ok(value) = serde_json::from_str(&text) else {
|
||||
break;
|
||||
};
|
||||
if app
|
||||
.tx
|
||||
.try_send(game::Command::Client(id.clone(), value))
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::select! {_=sending=>{},_=receiving=>{}}
|
||||
let _ = app.tx.try_send(game::Command::Leave(id));
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use shacraft_content::Package;
|
||||
use wasmi::{Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc};
|
||||
pub fn manifest(packages: &[Package]) -> Value {
|
||||
let public: Vec<_> = packages
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut m = p.manifest.client_manifest();
|
||||
let files: Vec<_> = p
|
||||
.manifest
|
||||
.resources
|
||||
.iter()
|
||||
.filter(|r| r.scope != "server")
|
||||
.map(|r| {
|
||||
let mut v = json!(r);
|
||||
v["url"] = json!(format!(
|
||||
"/packages/{}/{}/{}",
|
||||
p.manifest.id, p.manifest.version, r.path
|
||||
));
|
||||
v["kind"] = json!(r.role);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
m["files"] = json!(files);
|
||||
m.as_object_mut().unwrap().remove("resources");
|
||||
m
|
||||
})
|
||||
.collect();
|
||||
let hash = format!("{:x}", Sha256::digest(serde_json::to_vec(&public).unwrap()));
|
||||
json!({"protocol":1,"hash":hash,"packages":public,"minecraft_version":"26.2","client_integrity":"manifest compatibility only; executable integrity is not proven"})
|
||||
}
|
||||
pub struct JumpModule {
|
||||
store: Store<StoreLimits>,
|
||||
function: TypedFunc<(), f32>,
|
||||
calls: u64,
|
||||
traps: u64,
|
||||
definitions: Vec<Value>,
|
||||
hook_states: std::collections::HashSet<String>,
|
||||
}
|
||||
impl JumpModule {
|
||||
pub fn load(packages: &[Package]) -> Result<Self> {
|
||||
let mut source = None;
|
||||
for p in packages {
|
||||
for r in &p.manifest.resources {
|
||||
if r.role == "wasm-on-jump" {
|
||||
ensure!(source.is_none(), "only one on_jump provider may be active");
|
||||
source = Some(p.read_resource(&r.path)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut module = Self::from_bytes(&source.context("on_jump module missing")?)?;
|
||||
module.hook_states.clear();
|
||||
for package in packages {
|
||||
for resource in &package.manifest.resources {
|
||||
if resource.role == "definitions" {
|
||||
let data: Value =
|
||||
serde_json::from_slice(&package.read_resource(&resource.path)?)?;
|
||||
ensure!(
|
||||
data["schema"] == 1,
|
||||
"unsupported content definitions schema"
|
||||
);
|
||||
if let Some(value) = data.get("blocks") {
|
||||
let blocks = value
|
||||
.as_array()
|
||||
.context("content blocks must be an array")?;
|
||||
ensure!(
|
||||
package
|
||||
.manifest
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|c| c == "blocks.define"),
|
||||
"blocks.define capability required"
|
||||
);
|
||||
ensure!(
|
||||
module.definitions.len() + blocks.len() <= 4096,
|
||||
"package block limit exceeded"
|
||||
);
|
||||
for block in blocks {
|
||||
if block["behavior"]["server_hook"] == "on_jump" {
|
||||
module.hook_states.insert(
|
||||
block["state"].as_str().context("state required")?.into(),
|
||||
);
|
||||
} else {
|
||||
ensure!(
|
||||
block.get("behavior").is_none(),
|
||||
"unsupported block behavior"
|
||||
);
|
||||
}
|
||||
module.definitions.push(block.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(module)
|
||||
}
|
||||
fn from_bytes(bytes: &[u8]) -> Result<Self> {
|
||||
ensure!(bytes.len() <= 65536, "WASM module exceeds 64 KiB");
|
||||
let mut config = Config::default();
|
||||
config.consume_fuel(true);
|
||||
let engine = Engine::new(&config);
|
||||
let module = Module::new(&engine, bytes)?;
|
||||
ensure!(
|
||||
module.imports().next().is_none(),
|
||||
"WASM imports are not permitted"
|
||||
);
|
||||
let limits = StoreLimitsBuilder::new()
|
||||
.memory_size(65536)
|
||||
.table_elements(128)
|
||||
.instances(1)
|
||||
.tables(1)
|
||||
.memories(1)
|
||||
.trap_on_grow_failure(true)
|
||||
.build();
|
||||
let mut store = Store::new(&engine, limits);
|
||||
store.limiter(|s| s);
|
||||
store.set_fuel(10000)?;
|
||||
let linker = Linker::new(&engine);
|
||||
let instance = linker.instantiate_and_start(&mut store, &module)?;
|
||||
let function = instance.get_typed_func::<(), f32>(&store, "on_jump")?;
|
||||
Ok(Self {
|
||||
store,
|
||||
function,
|
||||
calls: 0,
|
||||
traps: 0,
|
||||
definitions: Vec::new(),
|
||||
hook_states: ["shacraft:trampoline".into()].into_iter().collect(),
|
||||
})
|
||||
}
|
||||
pub fn handles(&self, state: &str) -> bool {
|
||||
self.hook_states.contains(state)
|
||||
}
|
||||
pub fn extend_catalog(&self, catalog: &mut shacraft_content::Catalog) -> Result<()> {
|
||||
use shacraft_content::{Aabb, BlockDefinition, Coverage, StateDefinition};
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for definition in &self.definitions {
|
||||
let state = definition["state"]
|
||||
.as_str()
|
||||
.context("package state required")?;
|
||||
let (namespace, path) = state
|
||||
.split_once(':')
|
||||
.context("package namespace required")?;
|
||||
let valid_part = |part: &str, slash: bool| {
|
||||
!part.is_empty()
|
||||
&& part.bytes().all(|b| {
|
||||
b.is_ascii_lowercase()
|
||||
|| b.is_ascii_digit()
|
||||
|| b"_.-".contains(&b)
|
||||
|| (slash && b == b'/')
|
||||
})
|
||||
};
|
||||
ensure!(
|
||||
state.len() <= 512
|
||||
&& namespace != "minecraft"
|
||||
&& valid_part(namespace, false)
|
||||
&& valid_part(path, true),
|
||||
"package state must be a new lowercase namespaced identifier"
|
||||
);
|
||||
ensure!(seen.insert(state), "duplicate package block definition");
|
||||
let color: [u8; 3] = serde_json::from_value(definition["color"].clone())?;
|
||||
let collision: Vec<Aabb> = serde_json::from_value(definition["collision"].clone())?;
|
||||
let render: Vec<Aabb> = if let Some(value) = definition.get("render") {
|
||||
serde_json::from_value(value.clone())?
|
||||
} else {
|
||||
catalog
|
||||
.state(state)
|
||||
.map(|s| s.render.clone())
|
||||
.unwrap_or_else(|| collision.clone())
|
||||
};
|
||||
ensure!(
|
||||
collision.len() <= 64
|
||||
&& render.len() <= 64
|
||||
&& collision.iter().chain(&render).all(|b| b.valid()
|
||||
&& b.min.iter().chain(&b.max).all(|v| (-2.0..=3.0).contains(v))),
|
||||
"invalid package geometry"
|
||||
);
|
||||
let opacity = if let Some(value) = definition.get("opacity") {
|
||||
value.as_f64().context("opacity must be a number")?
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
ensure!(
|
||||
opacity.is_finite() && (0.0..=1.0).contains(&opacity),
|
||||
"opacity must be in 0..1"
|
||||
);
|
||||
let item = StateDefinition {
|
||||
state: state.into(),
|
||||
minecraft_id: u32::MAX,
|
||||
color,
|
||||
opacity: opacity as f32,
|
||||
collision,
|
||||
render,
|
||||
coverage: Coverage {
|
||||
identity: "authored-package".into(),
|
||||
render: "authored-package".into(),
|
||||
collision: "authored-exact".into(),
|
||||
behavior: if self.handles(state) {
|
||||
"wasm-on-jump"
|
||||
} else {
|
||||
"storage-and-physics"
|
||||
}
|
||||
.into(),
|
||||
},
|
||||
family: state.split(':').nth(1).unwrap().into(),
|
||||
light: 0,
|
||||
hardness: 1.,
|
||||
};
|
||||
if let Some(existing) = catalog.states.iter_mut().find(|s| s.state == state) {
|
||||
*existing = item;
|
||||
} else {
|
||||
catalog.states.push(item);
|
||||
catalog.blocks.push(BlockDefinition {
|
||||
name: state.into(),
|
||||
default_state: state.into(),
|
||||
properties: Default::default(),
|
||||
family: "package".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
catalog.rebuild_indexes()?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn call(&mut self) -> Result<f32> {
|
||||
self.calls += 1;
|
||||
self.store.set_fuel(10000)?;
|
||||
match self.function.call(&mut self.store, ()) {
|
||||
Ok(v) if v.is_finite() && (0.0..=20.0).contains(&v) => Ok(v),
|
||||
_ => {
|
||||
self.traps += 1;
|
||||
anyhow::bail!("on_jump trapped or returned an invalid impulse")
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn metrics(&self) -> Value {
|
||||
json!({"runtime":"wasmi","calls":self.calls,"traps":self.traps,"fuel_per_call":10000,"memory_limit":65536,"imports":0})
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn sandbox_executes_and_bounds_fuel_memory_and_results() {
|
||||
let mut m = JumpModule::from_bytes(
|
||||
b"(module (func (export \"on_jump\") (result f32) f32.const 10))",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(m.call().unwrap(), 10.);
|
||||
let mut looped = JumpModule::from_bytes(
|
||||
b"(module (func (export \"on_jump\") (result f32) (loop br 0) f32.const 10))",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(looped.call().is_err());
|
||||
assert_eq!(looped.metrics()["traps"], 1);
|
||||
assert!(
|
||||
JumpModule::from_bytes(
|
||||
b"(module (memory 2) (func (export \"on_jump\") (result f32) f32.const 10))"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(JumpModule::from_bytes(b"(module (import \"host\" \"secret\" (func)) (func (export \"on_jump\") (result f32) f32.const 10))").is_err());
|
||||
let mut invalid = JumpModule::from_bytes(
|
||||
b"(module (func (export \"on_jump\") (result f32) f32.const nan))",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(invalid.call().is_err());
|
||||
}
|
||||
fn custom_block(state: &str) -> Value {
|
||||
json!({"state":state,"color":[81,153,187],"collision":[{"min":[0,0,0],"max":[1,0.75,1]}],"render":[{"min":[0.1,0,0.1],"max":[0.9,0.75,0.9]}],"opacity":0.9,"behavior":{"server_hook":"on_jump"}})
|
||||
}
|
||||
fn package_fixture(blocks: Vec<Value>) -> (tempfile::TempDir, Vec<Package>) {
|
||||
use shacraft_content::{PackageManifest, PackageResource};
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = root.path().join("custom");
|
||||
std::fs::create_dir_all(dir.join("common")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("server")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("common/definitions.json"),
|
||||
serde_json::to_vec(&json!({"schema":1,"blocks":blocks})).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut wasm =
|
||||
include_bytes!("../../../packages/trampoline/server/trampoline.wasm").to_vec();
|
||||
let end = wasm.len();
|
||||
wasm[end - 5..end - 1].copy_from_slice(&12f32.to_le_bytes());
|
||||
std::fs::write(dir.join("server/spring.wasm"), &wasm).unwrap();
|
||||
let resources = [
|
||||
("common/definitions.json", "common", "definitions"),
|
||||
("server/spring.wasm", "server", "wasm-on-jump"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(path, scope, role)| {
|
||||
let data = std::fs::read(dir.join(path)).unwrap();
|
||||
PackageResource {
|
||||
path: path.into(),
|
||||
scope: scope.into(),
|
||||
role: role.into(),
|
||||
size: data.len() as u64,
|
||||
sha256: format!("{:x}", Sha256::digest(&data)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let manifest = PackageManifest {
|
||||
schema: 1,
|
||||
id: "example.custom".into(),
|
||||
version: "1.0.0".into(),
|
||||
license: "MIT".into(),
|
||||
dependencies: vec![],
|
||||
capabilities: vec!["blocks.define".into(), "server.on_jump".into()],
|
||||
resources,
|
||||
};
|
||||
std::fs::write(
|
||||
dir.join("manifest.json"),
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let packages = shacraft_content::load_packages(root.path()).unwrap();
|
||||
(root, packages)
|
||||
}
|
||||
#[test]
|
||||
fn verified_custom_package_registers_content_and_binds_real_wasm() {
|
||||
use shacraft_core::{BlockChange, WorldStore};
|
||||
let mut ordinary = custom_block("example:plain_tile");
|
||||
ordinary.as_object_mut().unwrap().remove("behavior");
|
||||
let (root, packages) = package_fixture(vec![custom_block("example:spring"), ordinary]);
|
||||
let mut module = JumpModule::load(&packages).unwrap();
|
||||
let mut catalog = shacraft_content::Catalog::load_builtin().unwrap();
|
||||
module.extend_catalog(&mut catalog).unwrap();
|
||||
let spring = catalog.state("example:spring").unwrap();
|
||||
assert_eq!(spring.color, [81, 153, 187]);
|
||||
assert_eq!(spring.collision[0].max[1], 0.75);
|
||||
assert_eq!(spring.render[0].min[0], 0.1);
|
||||
assert_eq!(spring.coverage.behavior, "wasm-on-jump");
|
||||
assert!(module.handles("example:spring"));
|
||||
assert!(!module.handles("example:plain_tile"));
|
||||
assert!(!module.handles("shacraft:trampoline"));
|
||||
let world_path = root.path().join("world");
|
||||
let mut store = WorldStore::open(&world_path, 2).unwrap();
|
||||
store.register_block("minecraft:dirt").unwrap();
|
||||
let id = store.register_block(&spring.state).unwrap();
|
||||
assert_ne!(id, spring.minecraft_id);
|
||||
store.create_world("module", None).unwrap();
|
||||
store
|
||||
.edit(
|
||||
"module",
|
||||
0,
|
||||
"package-placement",
|
||||
vec![BlockChange {
|
||||
pos: [0, 0, 0],
|
||||
block: id,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let under = store.get_block("module", [0, 0, 0]).unwrap();
|
||||
assert!(module.handles(&store.registry()[under as usize]));
|
||||
assert_eq!(module.call().unwrap(), 12.);
|
||||
assert_eq!(module.metrics()["calls"], 1);
|
||||
drop(store);
|
||||
let mut reopened = WorldStore::open(&world_path, 2).unwrap();
|
||||
assert_eq!(reopened.get_block("module", [0, 0, 0]).unwrap(), id);
|
||||
assert_eq!(reopened.registry()[id as usize], "example:spring");
|
||||
let public = manifest(&packages).to_string();
|
||||
assert!(public.contains("common/definitions.json"));
|
||||
assert!(!public.contains("server/spring.wasm"));
|
||||
}
|
||||
#[test]
|
||||
fn duplicate_vanilla_and_invalid_geometry_definitions_are_rejected() {
|
||||
let mut bad_box = custom_block("example:bad_box");
|
||||
bad_box["collision"][0]["max"] = json!([4, 1, 1]);
|
||||
let mut reversed = custom_block("example:reversed");
|
||||
reversed["render"][0]["max"] = json!([0, 0, 0]);
|
||||
let mut oversized = custom_block("example:many_boxes");
|
||||
oversized["render"] = json!(vec![json!({"min":[0,0,0],"max":[1,1,1]}); 65]);
|
||||
let mut opacity = custom_block("example:bad_opacity");
|
||||
opacity["opacity"] = json!(2);
|
||||
let invalid_sets = vec![
|
||||
vec![
|
||||
custom_block("example:duplicate"),
|
||||
custom_block("example:duplicate"),
|
||||
],
|
||||
vec![custom_block("minecraft:stone")],
|
||||
vec![bad_box],
|
||||
vec![reversed],
|
||||
vec![oversized],
|
||||
vec![opacity],
|
||||
vec![custom_block("example:bad,state")],
|
||||
vec![custom_block(":empty")],
|
||||
];
|
||||
let catalog = shacraft_content::Catalog::load_builtin().unwrap();
|
||||
for blocks in invalid_sets {
|
||||
let (_root, packages) = package_fixture(blocks);
|
||||
let module = JumpModule::load(&packages).unwrap();
|
||||
assert!(module.extend_catalog(&mut catalog.clone()).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn changed_definition_or_module_fails_hash_verification_before_loading() {
|
||||
for path in ["common/definitions.json", "server/spring.wasm"] {
|
||||
let (root, packages) = package_fixture(vec![custom_block("example:spring")]);
|
||||
let file = root.path().join("custom").join(path);
|
||||
let mut bytes = std::fs::read(&file).unwrap();
|
||||
let last = bytes.len() - 1;
|
||||
bytes[last] ^= 1;
|
||||
std::fs::write(&file, bytes).unwrap();
|
||||
assert!(
|
||||
JumpModule::load(&packages).is_err(),
|
||||
"changed {path} accepted after verified package load"
|
||||
);
|
||||
assert!(shacraft_content::load_packages(root.path()).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use anyhow::{Result, ensure};
|
||||
use shacraft_core::Pos;
|
||||
pub fn capture(min: Pos, max: Pos, mut cells: Vec<(Pos, [u8; 3])>) -> Result<Vec<u8>> {
|
||||
ensure!((0..3).all(|i| max[i] >= min[i]), "invalid capture bounds");
|
||||
let size = 512usize;
|
||||
let mut image = vec![0u8; size * size * 3];
|
||||
for pixel in image.chunks_exact_mut(3) {
|
||||
pixel.copy_from_slice(&[24, 36, 48]);
|
||||
}
|
||||
let span = (max[0] - min[0] + max[2] - min[2] + 2) as f32;
|
||||
let vertical = ((max[1] - min[1] + 1) as f32 + span * 0.25).max(1.);
|
||||
let scale = (460. / span.max(1.)).min(440. / vertical).clamp(0.1, 24.);
|
||||
cells.sort_by_key(|(p, _)| (p[0] + p[2], p[1]));
|
||||
for (p, c) in cells {
|
||||
let x = (p[0] - min[0]) as f32;
|
||||
let y = (p[1] - min[1]) as f32;
|
||||
let z = (p[2] - min[2]) as f32;
|
||||
let cx = 256. + (x - z - (max[0] - min[0] - max[2] + min[2]) as f32 * 0.5) * scale;
|
||||
let cy = 470. - (y + 1.) * scale - (span * 0.5 - x - z) * scale * 0.5;
|
||||
let top = [
|
||||
[cx, cy - scale * 0.5],
|
||||
[cx + scale, cy],
|
||||
[cx, cy + scale * 0.5],
|
||||
[cx - scale, cy],
|
||||
];
|
||||
polygon(&mut image, size, top, c);
|
||||
polygon(
|
||||
&mut image,
|
||||
size,
|
||||
[
|
||||
[cx - scale, cy],
|
||||
[cx, cy + scale * 0.5],
|
||||
[cx, cy + scale * 1.5],
|
||||
[cx - scale, cy + scale],
|
||||
],
|
||||
c.map(|v| (v as f32 * 0.64) as u8),
|
||||
);
|
||||
polygon(
|
||||
&mut image,
|
||||
size,
|
||||
[
|
||||
[cx, cy + scale * 0.5],
|
||||
[cx + scale, cy],
|
||||
[cx + scale, cy + scale],
|
||||
[cx, cy + scale * 1.5],
|
||||
],
|
||||
c.map(|v| (v as f32 * 0.82) as u8),
|
||||
);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut bytes, size as u32, size as u32);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header()?.write_image_data(&image)?;
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
fn polygon(img: &mut [u8], size: usize, p: [[f32; 2]; 4], color: [u8; 3]) {
|
||||
let minx = p
|
||||
.iter()
|
||||
.map(|p| p[0])
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
.floor()
|
||||
.max(0.) as usize;
|
||||
let maxx = p
|
||||
.iter()
|
||||
.map(|p| p[0])
|
||||
.fold(f32::NEG_INFINITY, f32::max)
|
||||
.ceil()
|
||||
.clamp(0., size as f32 - 1.) as usize;
|
||||
let miny = p
|
||||
.iter()
|
||||
.map(|p| p[1])
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
.floor()
|
||||
.max(0.) as usize;
|
||||
let maxy = p
|
||||
.iter()
|
||||
.map(|p| p[1])
|
||||
.fold(f32::NEG_INFINITY, f32::max)
|
||||
.ceil()
|
||||
.clamp(0., size as f32 - 1.) as usize;
|
||||
for y in miny..=maxy {
|
||||
for x in minx..=maxx {
|
||||
let mut signs = [false; 4];
|
||||
for i in 0..4 {
|
||||
let a = p[i];
|
||||
let b = p[(i + 1) % 4];
|
||||
signs[i] = (b[0] - a[0]) * (y as f32 + 0.5 - a[1])
|
||||
- (b[1] - a[1]) * (x as f32 + 0.5 - a[0])
|
||||
>= 0.;
|
||||
}
|
||||
if signs.iter().all(|s| *s) || signs.iter().all(|s| !*s) {
|
||||
let i = (y * size + x) * 3;
|
||||
img[i..i + 3].copy_from_slice(&color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
//! Stateful acceptance tests for authority, durability, and bounded client queues.
|
||||
//! Included as a child module of game.rs so tests exercise the real game actor.
|
||||
use super::*;
|
||||
use std::sync::OnceLock;
|
||||
use tempfile::TempDir;
|
||||
|
||||
type Inbox = tmpsc::Receiver<Arc<String>>;
|
||||
struct TestClient {
|
||||
id: String,
|
||||
inbox: Inbox,
|
||||
bytes: Arc<AtomicUsize>,
|
||||
}
|
||||
impl TestClient {
|
||||
fn drain(&mut self) -> Vec<Value> {
|
||||
let mut messages = Vec::new();
|
||||
while let Ok(message) = self.inbox.try_recv() {
|
||||
self.bytes.fetch_sub(message.len(), Ordering::Relaxed);
|
||||
messages.push(serde_json::from_str(&message).unwrap());
|
||||
}
|
||||
messages
|
||||
}
|
||||
}
|
||||
fn open_game(path: &Path) -> Game {
|
||||
let packages = shacraft_content::load_packages(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packages"),
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = crate::packages::manifest(&packages);
|
||||
let jump = crate::packages::JumpModule::load(&packages).unwrap();
|
||||
Game::open(path, 16, manifest, jump).unwrap()
|
||||
}
|
||||
fn fixture() -> (TempDir, Game) {
|
||||
// Do expensive full-catalog initial registration once. Copy closed durable
|
||||
// databases into isolated directories; do not share a live WorldStore writer.
|
||||
static SEED: OnceLock<TempDir> = OnceLock::new();
|
||||
let seed = SEED.get_or_init(|| {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut game = open_game(dir.path());
|
||||
game.store.flush().unwrap();
|
||||
drop(game);
|
||||
dir
|
||||
});
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for entry in std::fs::read_dir(seed.path()).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_file() && path.extension().is_some_and(|s| s == "sqlite3") {
|
||||
std::fs::copy(&path, dir.path().join(path.file_name().unwrap())).unwrap();
|
||||
}
|
||||
}
|
||||
let game = open_game(dir.path());
|
||||
(dir, game)
|
||||
}
|
||||
fn connect(game: &mut Game, world: &str, name: &str) -> TestClient {
|
||||
let (tx, inbox) = tmpsc::channel(16);
|
||||
let bytes = Arc::new(AtomicUsize::new(0));
|
||||
let p = json!({"type":"join","protocol":1,"name":name,"world":world,"manifest_hash":game.manifest["hash"]});
|
||||
let id = game
|
||||
.join(
|
||||
p,
|
||||
Outbox {
|
||||
tx,
|
||||
bytes: bytes.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
TestClient { id, inbox, bytes }
|
||||
}
|
||||
fn ticks(game: &mut Game, clients: &mut [&mut TestClient], count: usize) {
|
||||
for _ in 0..count {
|
||||
game.step();
|
||||
for client in clients.iter_mut() {
|
||||
client.drain();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn edit(game: &mut Game, world: &str, changes: Vec<BlockChange>) {
|
||||
let revision = game.store.revision(world).unwrap();
|
||||
game.store
|
||||
.edit(world, revision, &Uuid::new_v4().to_string(), changes)
|
||||
.unwrap();
|
||||
}
|
||||
fn entity_spawn(game: &mut Game, kind: &str) -> Result<Value> {
|
||||
game.query("entity.spawn",json!({"world":"lobby","kind":kind,"position":[2.,1.,2.],"properties":{"custom_name":"Saved animal","nbt":{"test:unrecognized":{"type":"long","value":"9223372036854775807"}}},"expected_revision":game.meta.revision,"operation_id":Uuid::new_v4().to_string()}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_collision_shapes_drive_world_physics() {
|
||||
let (_dir, mut game) = fixture();
|
||||
game.store.create_world("collision", None).unwrap();
|
||||
let states = [
|
||||
"minecraft:stone",
|
||||
"minecraft:oak_slab[type=bottom,waterlogged=false]",
|
||||
"minecraft:oak_fence",
|
||||
"minecraft:water",
|
||||
];
|
||||
let changes = states
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| BlockChange {
|
||||
pos: [i as i32 * 3, 0, 0],
|
||||
block: game.id(s).unwrap(),
|
||||
})
|
||||
.collect();
|
||||
edit(&mut game, "collision", changes);
|
||||
assert!(!game.collision("collision", [0.5, 1., 0.5]));
|
||||
assert!(game.collision("collision", [0.5, 0.9, 0.5]));
|
||||
assert!(!game.collision("collision", [3.5, 0.5, 0.5]));
|
||||
assert!(game.collision("collision", [3.5, 0.49, 0.5]));
|
||||
assert!(game.collision("collision", [6.5, 1.2, 0.5]));
|
||||
assert!(!game.collision("collision", [6.5, 1.5, 0.5]));
|
||||
assert!(!game.collision("collision", [9.5, 0., 0.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_actions_reject_forged_targets_and_sync_real_edits() {
|
||||
let (_dir, mut game) = fixture();
|
||||
let mut a = connect(&mut game, "lobby", "Builder");
|
||||
let mut b = connect(&mut game, "lobby", "Observer");
|
||||
a.drain();
|
||||
b.drain();
|
||||
let player = game.players.get_mut(&a.id).unwrap();
|
||||
player.position = [0.5, 1., 8.5];
|
||||
player.input.pitch = -1.2;
|
||||
let before = game.store.revision("lobby").unwrap();
|
||||
assert!(
|
||||
game.client(&a.id, json!({"type":"break","pos":[20,0,20]}))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(game.store.revision("lobby").unwrap(), before);
|
||||
let (hit, normal) = game.raycast(&a.id).unwrap().expect("ground in reach");
|
||||
let place: Pos = std::array::from_fn(|i| hit[i] + normal[i]);
|
||||
assert!(
|
||||
game.client(&a.id, json!({"type":"place","pos":place,"block":u64::MAX}))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(game.store.revision("lobby").unwrap(), before);
|
||||
game.client(&a.id, json!({"type":"break","pos":hit}))
|
||||
.unwrap();
|
||||
assert_eq!(game.store.get_block("lobby", hit).unwrap(), 0);
|
||||
let messages = b.drain();
|
||||
let delta = messages
|
||||
.iter()
|
||||
.find(|m| m["type"] == "blocks")
|
||||
.expect("observer must receive edit");
|
||||
assert_eq!(delta["revision"], before + 1);
|
||||
assert_eq!(delta["changes"][0]["pos"], json!(hit));
|
||||
assert!(
|
||||
game.client(
|
||||
&a.id,
|
||||
json!({"type":"input","seq":1,"yaw":0,"pitch":0,"forward":1000,"strafe":0,"jump":false})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trampoline_executes_wasm_despite_small_contact_gap() {
|
||||
let (_dir, mut game) = fixture();
|
||||
game.store.create_world("spring", None).unwrap();
|
||||
let spring = game.id("shacraft:trampoline").unwrap();
|
||||
edit(
|
||||
&mut game,
|
||||
"spring",
|
||||
vec![BlockChange {
|
||||
pos: [0, 0, 0],
|
||||
block: spring,
|
||||
}],
|
||||
);
|
||||
let mut client = connect(&mut game, "spring", "Jumper");
|
||||
client.drain();
|
||||
let p = game.players.get_mut(&client.id).unwrap();
|
||||
p.position = [0.5, 1.075, 0.5];
|
||||
p.grounded = true;
|
||||
p.velocity = 0.;
|
||||
p.input.jump = true;
|
||||
p.last_input = Instant::now();
|
||||
let before = game.jump.metrics()["calls"].as_u64().unwrap();
|
||||
game.step();
|
||||
client.drain();
|
||||
assert_eq!(
|
||||
game.jump.metrics()["calls"].as_u64().unwrap(),
|
||||
before + 1,
|
||||
"grounded spring contact must invoke module"
|
||||
);
|
||||
assert!(
|
||||
game.players[&client.id].position[1] > 1.45,
|
||||
"WASM impulse must affect authoritative position"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eliminated_spleef_players_cannot_reenter_and_late_joins_spectate() {
|
||||
let (_dir, mut game) = fixture();
|
||||
let mut a = connect(&mut game, "arena_1", "A");
|
||||
let mut b = connect(&mut game, "arena_1", "B");
|
||||
let mut c = connect(&mut game, "arena_1", "C");
|
||||
a.drain();
|
||||
b.drain();
|
||||
c.drain();
|
||||
game.start_match("arena_1").unwrap();
|
||||
ticks(&mut game, &mut [&mut a, &mut b, &mut c], 60);
|
||||
assert_eq!(game.match_json("arena_1")["phase"], "active");
|
||||
game.players.get_mut(&a.id).unwrap().position[1] = -7.;
|
||||
ticks(&mut game, &mut [&mut a, &mut b, &mut c], 1);
|
||||
assert!(!game.players[&a.id].alive);
|
||||
assert_eq!(game.match_json("arena_1")["phase"], "active");
|
||||
game.players.get_mut(&a.id).unwrap().last_snapshot = Instant::now() - Duration::from_secs(5);
|
||||
game.client(&a.id, json!({"type":"switch_world","world":"lobby"}))
|
||||
.unwrap();
|
||||
a.drain();
|
||||
game.players.get_mut(&a.id).unwrap().last_snapshot = Instant::now() - Duration::from_secs(5);
|
||||
let result = game.client(&a.id, json!({"type":"switch_world","world":"arena_1"}));
|
||||
if result.is_ok() {
|
||||
assert!(
|
||||
!game.players[&a.id].alive,
|
||||
"eliminated participant became alive by switching worlds"
|
||||
);
|
||||
}
|
||||
let mut late = connect(&mut game, "arena_1", "Late");
|
||||
let welcome = late.drain();
|
||||
assert_eq!(
|
||||
welcome[0]["revision"],
|
||||
game.store.revision("arena_1").unwrap()
|
||||
);
|
||||
assert!(
|
||||
!game.players[&late.id].alive,
|
||||
"mid-round joins must be spectators"
|
||||
);
|
||||
assert!(
|
||||
game.client(&late.id, json!({"type":"break","pos":[0,0,0]}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entities_are_durable_and_failed_commits_do_not_publish_changes() {
|
||||
let (dir, mut game) = fixture();
|
||||
let spawned = entity_spawn(&mut game, "minecraft:cow").unwrap();
|
||||
let entity = spawned["entity"].clone();
|
||||
let id = entity["id"].as_str().unwrap().to_owned();
|
||||
let revision = game.meta.revision;
|
||||
drop(game);
|
||||
let mut game = open_game(dir.path());
|
||||
assert_eq!(game.meta.entities.get(&id), Some(&entity));
|
||||
assert!(game.meta.revision >= revision);
|
||||
let before = game.meta.entities.clone();
|
||||
let revision = game.meta.revision;
|
||||
game.db.execute_batch("PRAGMA query_only=ON;").unwrap();
|
||||
assert!(entity_spawn(&mut game, "minecraft:pig").is_err());
|
||||
assert_eq!(
|
||||
game.meta.entities, before,
|
||||
"failed persistent mutation leaked into visible RAM state"
|
||||
);
|
||||
assert_eq!(
|
||||
game.meta.revision, revision,
|
||||
"failed persistent mutation advanced public revision"
|
||||
);
|
||||
game.db.execute_batch("PRAGMA query_only=OFF;").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_recovers_arena_configuration_and_cleans_interrupted_round() {
|
||||
let (dir, mut game) = fixture();
|
||||
let mut a = connect(&mut game, "arena_1", "A");
|
||||
let mut b = connect(&mut game, "arena_1", "B");
|
||||
a.drain();
|
||||
b.drain();
|
||||
game.query("arena.configure",json!({"world":"arena_1","spawn":[3.,2.,5.],"expected_revision":game.meta.revision,"operation_id":Uuid::new_v4().to_string()})).unwrap();
|
||||
game.start_match("arena_1").unwrap();
|
||||
edit(
|
||||
&mut game,
|
||||
"arena_1",
|
||||
vec![BlockChange {
|
||||
pos: [0, 0, 0],
|
||||
block: 0,
|
||||
}],
|
||||
);
|
||||
let dirty_revision = game.store.revision("arena_1").unwrap();
|
||||
drop(game);
|
||||
let mut reopened = open_game(dir.path());
|
||||
assert_eq!(reopened.config("arena_1").spawn, [3., 2., 5.]);
|
||||
assert_eq!(reopened.match_json("arena_1")["phase"], "waiting");
|
||||
assert_eq!(
|
||||
reopened.store.get_block("arena_1", [0, 0, 0]).unwrap(),
|
||||
reopened.id("minecraft:snow_block").unwrap(),
|
||||
"interrupted round left a damaged waiting arena"
|
||||
);
|
||||
assert!(reopened.store.revision("arena_1").unwrap() > dirty_revision);
|
||||
assert_eq!(
|
||||
reopened.store.get_block("arena_2", [0, 0, 0]).unwrap(),
|
||||
reopened.id("minecraft:snow_block").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_carry_revision_and_slow_consumers_are_disconnected() {
|
||||
let (_dir, mut game) = fixture();
|
||||
let mut client = connect(&mut game, "lobby", "Slow");
|
||||
let welcome = client.drain();
|
||||
assert_eq!(welcome[0]["type"], "welcome");
|
||||
assert_eq!(
|
||||
welcome[0]["revision"],
|
||||
game.store.revision("lobby").unwrap()
|
||||
);
|
||||
assert!(
|
||||
welcome[0]["materials"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|m| m["id"].is_u64())
|
||||
);
|
||||
for _ in 0..20 {
|
||||
game.step();
|
||||
}
|
||||
assert!(
|
||||
!game.players.contains_key(&client.id),
|
||||
"full bounded outbox must evict slow player"
|
||||
);
|
||||
assert_eq!(game.slow_clients, 1);
|
||||
assert!(client.bytes.load(Ordering::Relaxed) <= 8 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbox_has_byte_and_message_limits() {
|
||||
let (tx, _rx) = tmpsc::channel(16);
|
||||
let bytes = Arc::new(AtomicUsize::new(0));
|
||||
let out = Outbox {
|
||||
tx,
|
||||
bytes: bytes.clone(),
|
||||
};
|
||||
assert!(!out.send_shared(Arc::new("x".repeat(8 * 1024 * 1024 + 1))));
|
||||
assert_eq!(bytes.load(Ordering::Relaxed), 0);
|
||||
for _ in 0..16 {
|
||||
assert!(out.send(json!({"a":1})));
|
||||
}
|
||||
let before = bytes.load(Ordering::Relaxed);
|
||||
assert!(!out.send(json!({"a":2})));
|
||||
assert_eq!(bytes.load(Ordering::Relaxed), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_world_configuration_gets_safe_spleef_defaults() {
|
||||
let conf: WorldConfig =
|
||||
serde_json::from_value(json!({"spawn":[1,2,3],"mode":"spleef"})).unwrap();
|
||||
assert_eq!(conf.spawn, [1., 2., 3.]);
|
||||
assert_eq!(conf.countdown_seconds, 3);
|
||||
assert_eq!(conf.round_seconds, 180);
|
||||
assert_eq!(conf.min_players, 2);
|
||||
assert_eq!(conf.elimination_y, -6.);
|
||||
assert!(conf.spawn_points.is_empty());
|
||||
assert_eq!(conf.spectator_spawn, [13., 2., 0.]);
|
||||
assert_eq!(conf.floor_block, "minecraft:snow_block");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arena_configuration_validates_before_mutation() {
|
||||
let (_dir, mut game) = fixture();
|
||||
let before = serde_json::to_value(&game.meta).unwrap();
|
||||
for (key, value) in [
|
||||
("countdown_seconds", json!(0)),
|
||||
("countdown_seconds", json!(31)),
|
||||
("round_seconds", json!(0)),
|
||||
("round_seconds", json!(3601)),
|
||||
("min_players", json!(1)),
|
||||
("min_players", json!(33)),
|
||||
("spawn", json!([32701, 2, 0])),
|
||||
("spectator_spawn", json!([0, -7, 0])),
|
||||
("elimination_y", json!(5)),
|
||||
("floor_block", json!("minecraft:water")),
|
||||
("floor_block", json!("unknown:missing")),
|
||||
("spawn_points", json!([[0, 2, 0]])),
|
||||
("spawn_points", json!([[0, 2, 0], [0, 2, 0]])),
|
||||
("spawn_points", json!([[0, 2, 0], [1, 2, 0], [32701, 2, 0]])),
|
||||
("spawn_points", json!(vec![[0, 2, 0]; 33])),
|
||||
("min_players", Value::Null),
|
||||
] {
|
||||
let mut params = json!({"world":"arena_1"});
|
||||
params[key] = value;
|
||||
assert!(
|
||||
game.query("arena.configure", params).is_err(),
|
||||
"accepted invalid {key}"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&game.meta).unwrap(),
|
||||
before,
|
||||
"failed {key} changed persistent metadata"
|
||||
);
|
||||
}
|
||||
assert!(entity_position(&json!([32701, 1, 0])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_spleef_rules_control_full_round_and_survive_restart() {
|
||||
let (dir, mut game) = fixture();
|
||||
game.store.create_world("stone_template", None).unwrap();
|
||||
let stone = game.id("minecraft:stone").unwrap();
|
||||
let mut cells = Vec::new();
|
||||
for x in -4..=4 {
|
||||
for z in -4..=4 {
|
||||
cells.push(BlockChange {
|
||||
pos: [x, 0, z],
|
||||
block: stone,
|
||||
});
|
||||
}
|
||||
}
|
||||
edit(&mut game, "stone_template", cells);
|
||||
game.query(
|
||||
"world.create",
|
||||
json!({"world":"stone_arena","template":"stone_template"}),
|
||||
)
|
||||
.unwrap();
|
||||
let configured=game.query("arena.configure",json!({"world":"stone_arena","mode":"spleef","spawn":[0.5,2.,3.5],"countdown_seconds":1,"round_seconds":2,"min_players":3,"elimination_y":-2,"spawn_points":[[0.5,1.,2.5],[2.5,1.,2.5],[-2.5,1.,2.5]],"spectator_spawn":[13.,2.,4.],"floor_block":"minecraft:stone"})).unwrap();
|
||||
let mut a = connect(&mut game, "stone_arena", "A");
|
||||
let mut b = connect(&mut game, "stone_arena", "B");
|
||||
a.drain();
|
||||
b.drain();
|
||||
assert!(game.start_match("stone_arena").is_err());
|
||||
let mut c = connect(&mut game, "stone_arena", "C");
|
||||
c.drain();
|
||||
game.start_match("stone_arena").unwrap();
|
||||
let spawns: Vec<_> = game
|
||||
.players
|
||||
.values()
|
||||
.filter(|p| p.world == "stone_arena")
|
||||
.map(|p| p.position)
|
||||
.collect();
|
||||
for point in [[0.5, 1., 2.5], [2.5, 1., 2.5], [-2.5, 1., 2.5]] {
|
||||
assert!(spawns.contains(&point));
|
||||
}
|
||||
assert_eq!(game.match_json("stone_arena")["remaining"], 1.);
|
||||
assert!(
|
||||
game.query(
|
||||
"arena.configure",
|
||||
json!({"world":"stone_arena","round_seconds":60})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
ticks(&mut game, &mut [&mut a, &mut b, &mut c], 20);
|
||||
assert_eq!(game.match_json("stone_arena")["phase"], "active");
|
||||
assert_eq!(game.match_json("stone_arena")["remaining"], 2.);
|
||||
let player = game.players.get_mut(&a.id).unwrap();
|
||||
player.position = [0.5, 1., 2.5];
|
||||
player.input.pitch = -1.2;
|
||||
player.last_input = Instant::now();
|
||||
let (hit, _) = game.raycast(&a.id).unwrap().unwrap();
|
||||
assert_eq!(game.store.get_block("stone_arena", hit).unwrap(), stone);
|
||||
game.client(&a.id, json!({"type":"break","pos":hit}))
|
||||
.unwrap();
|
||||
assert_eq!(game.store.get_block("stone_arena", hit).unwrap(), 0);
|
||||
a.drain();
|
||||
b.drain();
|
||||
c.drain();
|
||||
game.players.get_mut(&a.id).unwrap().position[1] = -3.;
|
||||
ticks(&mut game, &mut [&mut a, &mut b, &mut c], 1);
|
||||
assert!(!game.players[&a.id].alive);
|
||||
assert_eq!(game.players[&a.id].position, [13., 2., 4.]);
|
||||
let mut late = connect(&mut game, "stone_arena", "Late");
|
||||
late.drain();
|
||||
assert_eq!(game.players[&late.id].position, [13., 2., 4.]);
|
||||
assert!(!game.players[&late.id].alive);
|
||||
game.tick = game.matches["stone_arena"].deadline - 1;
|
||||
ticks(&mut game, &mut [&mut a, &mut b, &mut c, &mut late], 1);
|
||||
assert_eq!(game.match_json("stone_arena")["phase"], "finished");
|
||||
assert_eq!(game.match_json("stone_arena")["winner"], Value::Null);
|
||||
game.tick = game.matches["stone_arena"].deadline - 1;
|
||||
ticks(&mut game, &mut [&mut a, &mut b, &mut c, &mut late], 1);
|
||||
assert_eq!(game.match_json("stone_arena")["phase"], "waiting");
|
||||
assert_eq!(game.store.get_block("stone_arena", hit).unwrap(), stone);
|
||||
assert_eq!(game.players[&a.id].position, [0.5, 2., 3.5]);
|
||||
assert!(!game.meta.active_matches.contains("stone_arena"));
|
||||
drop(game);
|
||||
let mut reopened = open_game(dir.path());
|
||||
assert_eq!(
|
||||
serde_json::to_value(reopened.config("stone_arena")).unwrap(),
|
||||
configured
|
||||
);
|
||||
assert_eq!(
|
||||
reopened.store.get_block("stone_template", hit).unwrap(),
|
||||
stone
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gallery_contains_every_default_state_over_a_bounded_floor() {
|
||||
let (_dir, mut game) = fixture();
|
||||
assert_eq!(game.config("gallery").mode, "creative");
|
||||
let samples = game.catalog.sampler();
|
||||
assert!(samples.len() >= 1197);
|
||||
for sample in samples {
|
||||
assert_eq!(
|
||||
game.store.get_block("gallery", sample.position).unwrap(),
|
||||
game.id(&sample.state).unwrap(),
|
||||
"{} sample missing",
|
||||
sample.state
|
||||
);
|
||||
assert_ne!(
|
||||
game.store
|
||||
.get_block("gallery", [sample.position[0], 0, sample.position[2]])
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
assert_eq!(game.store.get_block("gallery", [64, 0, 0]).unwrap(), 0);
|
||||
assert_ne!(game.store.get_block("gallery", [-64, 0, -64]).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_payloads_are_bounded_and_runtime_id_lookup_works_without_registry_dump() {
|
||||
let (_dir, mut game) = fixture();
|
||||
let cells = game
|
||||
.store
|
||||
.registry()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, _)| BlockChange {
|
||||
pos: [0, 0, 0],
|
||||
block: id as u32,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let materials = game.materials(&cells);
|
||||
assert!(materials.len() <= 256);
|
||||
assert!(serde_json::to_vec(&materials).unwrap().len() <= 129 * 1024);
|
||||
let stone = game.id("minecraft:stone").unwrap();
|
||||
let result = game
|
||||
.query(
|
||||
"catalog.search",
|
||||
json!({"ids":stone.to_string(),"limit":128}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(result["items"][0]["state"], "minecraft:stone");
|
||||
}
|
||||
#[test]
|
||||
fn interrupted_creation_of_managed_world_can_finish_its_seed() {
|
||||
let (_dir, mut game) = fixture();
|
||||
// Simulate an interrupted bootstrap after the core committed create_world,
|
||||
// before its first seed edit. Only reserved demonstration worlds are rebuilt.
|
||||
let scratch = tempfile::tempdir().unwrap();
|
||||
game.store = WorldStore::open(scratch.path(), 8).unwrap();
|
||||
for state in &game.catalog.states {
|
||||
game.store.register_block(&state.state).unwrap();
|
||||
}
|
||||
game.store.create_world("lobby", None).unwrap();
|
||||
game.ids = game
|
||||
.store
|
||||
.registry()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, s)| (s.clone(), id as u32))
|
||||
.collect();
|
||||
game.seed().unwrap();
|
||||
assert_eq!(game.store.revision("lobby").unwrap(), 1);
|
||||
assert_ne!(game.store.get_block("lobby", [0, 0, 0]).unwrap(), 0);
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Приёмка Shacraft Core
|
||||
|
||||
Статус: план проверок; фактические результаты первого этапа находятся в `STATUS.md` и `VERIFICATION.md`. Рубежи A и B целиком ещё не пройдены. Основа — исходный запрос пользователя и текущий `docs/CONTRACT.md`. Последний описывает первый рабочий протокол, но пока не покрывает весь согласованный MVP.
|
||||
Статус: критерии для локального MVP. Реализованные сценарии и доказательства перечислены в STATUS и VERIFICATION. Этот документ сохраняет исходный план приёмки; ограничения более широких стресс-тестов и совместимости не скрываются общим флагом готовности.
|
||||
|
||||
Shacraft Core — самостоятельный открытый движок на Rust. Shacraft-сервер и Spleef проверяют его пригодность на практике. Экономия серверной RAM — основное архитектурное требование. Полный базовый каталог Minecraft 26.2 означает контент, состояния и формы, а не требование воспроизвести архитектуру Minecraft или весь ванильный игровой процесс.
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Браузерный клиент MVP
|
||||
|
||||
Клиент находится в `client/` и раздаётся `shacraft-server` по `/`. Сборщик, npm-зависимости и готовый игровой движок не нужны: это ES-модули и собственный WebGL2-рендерер. Откройте `http://127.0.0.1:4000` после запуска сервера. Поддерживаются современные настольные браузеры с WebGL2; `localhost` или HTTPS необходим для `crypto.subtle` и проверки пакетов.
|
||||
|
||||
## Управление
|
||||
|
||||
- Нажмите на мир, чтобы захватить мышь. Если браузер запрещает захват, удерживайте кнопку и перетаскивайте для обзора.
|
||||
- WASD или стрелки — движение; пробел — прыжок. Направление определяется камерой, +Y вверх, yaw 0 смотрит вдоль −Z. Позиция игрока задаёт ступни.
|
||||
- ЛКМ — убрать выбранный блок, ПКМ — поставить блок на выбранной грани, средняя кнопка — взять выбранный материал в текущую ячейку.
|
||||
- 1–9 или колесо мыши — ячейка панели. E — библиотека всех состояний; поиск использует английские Minecraft-идентификаторы. Выбор заменяет текущую ячейку.
|
||||
- T или Enter — чат, Enter отправляет, Esc закрывает.
|
||||
- Кнопка имени мира открывает выбор экземпляра. F3 открывает диагностику. Меню содержит имя игрока, возвращение к точке появления и запуск Spleef.
|
||||
- Esc освобождает мышь; отпускание фокуса и открытие панели отправляют нулевой ввод, чтобы игрок не продолжал движение.
|
||||
|
||||
## Рендер и синхронизация
|
||||
|
||||
Геометрия строится по локальным box-формам материалов. Сетки разбиты на секции 16³; общие грани полных непрозрачных кубов отбрасываются. Изменение блока пересобирает его секцию и смежные секции. Шейдер использует направленный свет, туман, пиксельную фактуру и авторскую текстуру из проверенного пакета. Небо и солнце также рисуются WebGL. Прозрачные материалы рисуются отдельным проходом с сортировкой секций по расстоянию (внутри секции порядок прозрачных поверхностей упрощён). Пакет с декларативным стилем `effect: bounce` (включая trampoline и пользовательские блоки) подключает текстуру, проверенную GLSL-функцию подсветки и авторский звук; отклик привязан к полученному от сервера подъёму игрока. Формы сущностей берутся из каталога; игроки имеют отдельные составные аватары. Это собственное упрощённое визуальное представление, а не точное воспроизведение Minecraft.
|
||||
|
||||
Клиент отправляет ввод максимум 20 раз в секунду, не объявляет собственную позицию и не изменяет блоки оптимистически. Сервер рассчитывает движение, столкновения и правки, а клиент сглаживает полученные позиции между кадрами. Это добавляет небольшую задержку движения, зато визуальное положение следует авторитетной симуляции. Камера реагирует на мышь локально.
|
||||
|
||||
`welcome` и `snapshot` полностью заменяют видимую геометрию. Полный `registry` необязателен: снимок содержит не больше 256 описаний используемых материалов, остальные формы клиент постепенно запрашивает через `/api/catalog?ids=...&limit=128`. Каждый ответ достраивает только затронутые секции; до получения формы отображается нейтральный куб. События `blocks` применяются только по порядку ревизий; данные за пределами последнего окна 64×40×64 отбрасываются, сохраняя продвижение ревизии. Пропуск запрашивает `resync`; старые ревизии игнорируются. При потере соединения клиент переподключается с задержкой 1, 2, 4, 8, 16, затем 20 секунд. Ошибки подключения, несовместимого протокола, ресурсов и отказанные сервером действия выводятся пользователю.
|
||||
|
||||
## Пакеты
|
||||
|
||||
До WebSocket-входа клиент получает `/api/manifest`, загружает объявленные клиентские файлы с того же сервера, проверяет их размер и SHA-256 и сохраняет содержимое в CacheStorage по хешу. Кэшированные файлы повторно проверяются. Отказ кэша не отменяет проверку целостности. При несовпадении хеша файл удаляется из кэша, вход не выполняется. Максимум одного файла — 64 МиБ, набора за загрузку — 128 МиБ, кэш — 512 последних записей. Это ограничения проверяемого содержимого; браузерные сетевые буферы и декодирование изображений также используют память.
|
||||
|
||||
Токен Control API клиенту не передаётся. В браузере не выполняется произвольный привилегированный JavaScript из пакетов. Проверка клиентом хешей означает совместимость ресурсов и не доказывает неизменность самого клиента на устройстве игрока.
|
||||
|
||||
## Отладка и проверки
|
||||
|
||||
F3 показывает настоящие значения: мир, ревизию, число видимых блоков и сущностей, игроков, WebGL, FPS, треугольники, тик, подтверждённый ввод, координаты, пакеты и доступные серверные метрики. На `#world-canvas` есть `data-world`, `data-revision`, `data-blocks`, `data-entities`, `data-webgl`, `data-connected`; они обновляются раз в секунду для автоматизированной браузерной проверки. Секреты и управляющие команды через DOM не выставляются.
|
||||
|
||||
Проверки чистой математики: `cd client && npm test`. Проверяются оси камеры и проекция, отрицательные координаты, ближайшая грань, ограничения дальности и точное попадание в неполные формы. Визуальная и сквозная проверка выполняется с настоящим сервером отдельно от этих unit-тестов.
|
||||
+16
-90
@@ -1,104 +1,30 @@
|
||||
# Совместимость с Minecraft Java 26.2
|
||||
|
||||
Дата проверки источников: 14 сентября 2026 года. Это план и критерии приёмки; наличие этого документа не означает, что конвертер или полный каталог уже реализованы.
|
||||
Проверено 14 сентября 2026 года. Реализованы полный каталог целевой версии и двусторонний конвертер с явно определённым профилем точности. Подробные контракты и команды: [CONTENT](CONTENT.md), [interop](interop.md), [SERVER](SERVER.md).
|
||||
|
||||
## Цель и границы
|
||||
## Источник
|
||||
|
||||
Shacraft остаётся самостоятельным движком на Rust. Совместимость означает полный каталог идентификаторов блоков, их допустимых состояний и типов сущностей целевой версии, отдельное покрытие форм, а также обмен пользовательскими мирами и постройками с сохранением исходных данных. Совпадение всей симуляции Minecraft — редстоуна, генератора мира, поведения мобов, жидкостей и команд — требует собственных модулей и не следует автоматически из наличия каталога.
|
||||
Mojang выпустила [Java 26.2](https://www.minecraft.net/en-us/article/minecraft-java-edition-26-2) 16 июня 2026 года. [Официальный launcher manifest](https://piston-meta.mojang.com/mc/game/version_manifest_v2.json) указал точные [метаданные версии](https://piston-meta.mojang.com/v1/packages/bc42e43dfe43d65a2f6c2c1dbb322c75134e51fe/26.2.json):
|
||||
|
||||
Целевая версия действительно существует: Mojang выпустила Java 26.2 16 июня 2026 года. В ней добавлены, среди прочего, sulfur/cinnabar и sulfur cube; поэтому каталог более ранней версии нельзя выдавать за 26.2. [Официальный релиз](https://www.minecraft.net/en-us/article/minecraft-java-edition-26-2).
|
||||
- Server JAR: `823e2250d24b3ddac457a60c92a6a941943fcd6a`, 60 894 273 байта; Java major 25.
|
||||
- DataVersion, извлечённый из целевой версии: **4903**.
|
||||
- Отчёты и публичный API подтвердили **1 196 блоков**, **32 366 состояний**, **158 сущностей**.
|
||||
- Все состояния сопоставляются по канонической строке и множеству свойств, не только по общему количеству.
|
||||
|
||||
Первый рабочий срез может иметь небольшой демонстрационный каталог, однако в интерфейсе, README и отчётах он должен называться именно демонстрационным. Требование полного каталога остаётся незакрытым до проверки против зафиксированного источника.
|
||||
`scripts/catalog_generate.py` проверяет артефакт, извлекает отчёты и измеряет коллизии. Повторное извлечение дало побайтно одинаковые сжатые каталоги. JAR остаётся в игнорируемом кэше; исходный код, текстуры, модели и звуки Minecraft в дистрибутив не включаются.
|
||||
|
||||
## Зафиксированный источник данных
|
||||
## Что означает поддержка
|
||||
|
||||
При проверке [официального launcher manifest](https://piston-meta.mojang.com/mc/game/version_manifest_v2.json) получена запись `id=26.2`, `type=release`, `releaseTime=2026-06-16T12:03:33+00:00`. Она ссылается на [метаданные версии](https://piston-meta.mojang.com/v1/packages/bc42e43dfe43d65a2f6c2c1dbb322c75134e51fe/26.2.json):
|
||||
`identity`, `render`, `collision` и `behavior` — отдельные поля. Все ванильные состояния известны; 179 контекстных состояний явно помечены `approximate-context`. Остальные коллизии измерены при воздушных соседях и пустом контексте сущности. Это не обещает контекстную эквивалентность во всех ситуациях. Визуальные модели авторские процедурные, а не ванильные. Невидимые служебные блоки намеренно не получают видимую геометрию.
|
||||
|
||||
- SHA-1 JSON метаданных: `bc42e43dfe43d65a2f6c2c1dbb322c75134e51fe`.
|
||||
- Java runtime: major version `25`.
|
||||
- [Официальный server.jar](https://piston-data.mojang.com/v1/objects/823e2250d24b3ddac457a60c92a6a941943fcd6a/server.jar): размер `60894273` байта, SHA-1 `823e2250d24b3ddac457a60c92a6a941943fcd6a`.
|
||||
158 типов сущностей имеют измеренные размеры, сохраняемые экземпляры и собственные формы. Полноценная AI, redstone, инвентари, генератор и симуляция жидкостей не реализованы. Неизвестные импортированные состояния сохраняются в реестре и видимы как отмеченная заглушка; они не подменяются воздухом в данных.
|
||||
|
||||
SHA-1 здесь фиксирует артефакт по метаданным Mojang. Наш файл происхождения дополнительно должен содержать вычисленный SHA-256, дату, команду извлечения, Java version и хеш каждого итогового отчёта.
|
||||
Числовой runtime BlockId Shacraft не равен Minecraft state ID. Конвертер сопоставляет канонические строки; одинаковые названия в независимых WorldStore могут иметь разные внутренние номера.
|
||||
|
||||
План получения каталога:
|
||||
## Проверенный обмен
|
||||
|
||||
1. Скачать этот JAR в локальный игнорируемый кэш, проверить размер и хеш. Не включать его в репозиторий и дистрибутив Shacraft.
|
||||
2. Проверить доступную точку входа и `--help` генератора данных именно этого артефакта, затем получить отчёты блоков и реестров. Команда для старых версий не считается проверенной командой для 26.2. Сам факт существования `blocks.json` в генераторе подтверждён Mojang; его структура меняется, что видно уже в [изменениях 26.3 Snapshot 2](https://www.minecraft.net/en-us/article/minecraft-26-3-snapshot-2).
|
||||
3. Из отчёта блоков извлечь resource location, свойства, допустимые значения, все перечисленные состояния и состояние по умолчанию. Из реестров — типы сущностей, block entities, биомы и прочие необходимые для конвертации ключи. Если нужного поля нет в отчёте, пометить пробел и сделать отдельное воспроизводимое извлечение из исполняемого API целевой версии.
|
||||
4. Получить `DataVersion` из целевого артефакта/мира, сохранить в манифесте. Номер ещё не извлечён в рамках этого исследования; не подменять его номером datapack/resource pack.
|
||||
5. Хранить минимальный производный каталог и собственные правила адаптации. Текстуры, звуки, модели, шейдеры и декомпилированный код Minecraft в него не входят. Визуальные ресурсы Shacraft создаются отдельно либо подключаются из локальных ресурсов пользователя отдельным адаптером.
|
||||
Anvil импортируется по одному chunk с типизированным NBT, включая измерения и непрозрачные дополнительные данные. Неизменённый `exact` возвращает сохранённый оригинал после проверки хешей. После правок `exact` консервативно отказывает. `best-effort` переносит изменения блоков и поддержанных свойств сущностей, инвалидирует зависимые данные и сохраняет неподдержанные данные с отчётом и исходником.
|
||||
|
||||
Полнота проверяется равенством множеств канонических состояний и типов сущностей с отчётами, а не ожидаемым числом из статьи или ручного списка. Количества блоков/состояний/сущностей появятся только после фактического извлечения.
|
||||
Sponge `.schem` v3 поддерживается в обе стороны. Обмен между разными форматами не обещает преобразования всей семантики биомов/block entities: неперенесённые данные архивируются и отмечаются в отчёте. Старые версии, legacy `.schematic`, structure `.nbt` и полная миграция произвольных DataVersion не объявляются поддержанными.
|
||||
|
||||
## Контракт каталога и форм
|
||||
|
||||
В `WorldStore` используется каноническая строка `namespace:block[key=value,...]`, где ключи свойств отсортированы. Числовой `BlockId` — внутренний идентификатор Shacraft; он не равен числовому идентификатору Minecraft и не переносится между независимыми реестрами без явной таблицы соответствия. `air` остаётся ID 0 по `CONTRACT.md`.
|
||||
|
||||
Для совместимости необходимы разные признаки покрытия:
|
||||
|
||||
- `identity`: идентификатор и все состояния известны и проверены.
|
||||
- `render`: собственная геометрия отображает состояние; заглушка обозначена явно.
|
||||
- `collision`: проверена форма столкновений для этого состояния и контекста.
|
||||
- `behavior`: установлен модуль поведения, если он реализован.
|
||||
- `roundtrip`: данные можно вернуть в конкретную версию и формат.
|
||||
|
||||
Один флаг `supported` скрывал бы различия. Нельзя получать коллизию автоматически из текстур или из визуальной модели. Формы могут зависеть от соседей, сущности и состояния мира; лестницы, плиты, двери, заборы, стены, растения, жидкости и динамические блоки требуют раздельных тестов. Начать с собственных параметрических форм, затем сравнивать их с выборкой поведения целевой версии. Формы со сложным контекстом сохраняют статус `approximate` до проверки.
|
||||
|
||||
Сущность может быть известна по идентификатору и полностью сохраняться для экспорта, даже если в Shacraft она пока отображается маркером и не имеет AI. В интерфейсе это должно быть видно. Неизвестный блок сохраняет исходную строку и NBT; отображение заглушкой не должно заменять его на `air` в хранилище.
|
||||
|
||||
## Форматы и порядок поддержки
|
||||
|
||||
**NBT — слой сериализации, а не единый формат мира.** Для Java нужен типизированный big-endian NBT. Типы чисел, массивов и списков необходимо сохранять: преобразование к обычному JSON теряет информацию для обратной записи. Это различие прямо показано в [API Prismarine NBT](https://github.com/PrismarineJS/prismarine-nbt), который можно использовать как независимый тестовый декодер, не как зависимость Rust runtime.
|
||||
|
||||
**Sponge `.schem` v3 — первый полноценный адаптер построек.** Поддержать блоки и состояния, block entities, entities, 3D-биомы, размеры, offset, metadata и DataVersion. Спецификация предусматривает эти контейнеры и палитры; точный порядок байтов и кодирование брать из [первичной спецификации Sponge v3](https://github.com/SpongePowered/Schematic-Specification/blob/master/versions/schematic-3.md). Затем добавить v2 как отдельную ветку декодера. Старый MCEdit `.schematic` не считать тем же форматом и не распознавать по одному похожему расширению.
|
||||
|
||||
**Vanilla structure `.nbt` — отдельный адаптер.** Нужно зафиксировать схему 26.2 на файлах, сохранённых structure block: размеры, палитра/альтернативные палитры, блоки, относительные координаты, block entity NBT и сущности. Сырой NBT-декодер не равен поддержке этой схемы. Неподдерживаемые альтернативные палитры нельзя молча свести к первой.
|
||||
|
||||
**Anvil world directory — потоковый адаптер миров.** Обрабатывать `level.dat`, измерения, region-файлы, секции/палитры, биомы, block entities, отдельные файлы сущностей и POI, а также остальные файлы сохранения. Извлекать разделы по мере необходимости, с ограничением кэша, сохраняя оригинал на диске. Проверять chunk `DataVersion` наряду с версией мира.
|
||||
|
||||
Существующая реализация [WorldEdit AnvilChunk18](https://github.com/EngineHub/WorldEdit/blob/dca10737fe81bd81bb6a096e1801cc2d94f1793b/worldedit-core/src/main/java/com/sk89q/worldedit/world/chunk/AnvilChunk18.java) подтверждает практическую работу с секционными палитрами `Name`/`Properties`; [McRegionReader](https://github.com/EngineHub/WorldEdit/blob/dca10737fe81bd81bb6a096e1801cc2d94f1793b/worldedit-core/src/main/java/com/sk89q/worldedit/world/storage/McRegionReader.java) полезен для проверки контейнера. Эти реализации — независимые ориентиры, не доказательство полного соответствия 26.2. Код не копировать в ядро без отдельного решения о лицензии.
|
||||
|
||||
Нужно явно проверить варианты сжатия, внешние chunk payload и крупные chunks на целевых fixtures. Поддержкой одного zlib нельзя объявлять поддержку всех Anvil-файлов: возможность LZ4-сжатия регионов описана в [официальном релизе Java 1.20.5](https://www.minecraft.net/es-es/article/minecraft-java-edition-1-20-5). Неизвестное сжатие даёт диагностируемый отказ, а не пустой chunk.
|
||||
|
||||
## Режимы экспорта и сохранение оригинала
|
||||
|
||||
**`exact` — проверенная точность данных.** По умолчанию используется та же версия и тот же формат, что у источника. Исходные файлы помещаются в неизменяемое дисковое хранилище с хешами; изменения Shacraft хранятся отдельно. Нетронутые файлы копируются побайтно. В изменённых файлах переписываются только поддерживаемые данные; остальные типизированные NBT-поля сохраняются. Контракт точности — семантическое равенство NBT за исключением явных правок и заранее объявленных производных полей, а не одинаковое сжатие и размещение секторов.
|
||||
|
||||
При неизвестной версии, некорректной палитре, неподдерживаемой операции, несовместимой сущности, неразрешённом преобразовании metadata или отсутствии необходимых исходных данных `exact` прекращает экспорт до публикации результата. Особенно проверять замену блока с block entity, инвентари, пассажиров и UUID, а также зависимости от света, heightmaps, POI и scheduled ticks. Нельзя оставить заведомо неверные производные данные и назвать экспорт точным. Правило пересчёта/инвалидации каждого такого поля должно быть проверено целевым Minecraft.
|
||||
|
||||
**`best-effort` — преобразование с отчётом.** Разрешает только явно заданные правила замены. Формирует `conversion-report.json`: версия источника/назначения, формат, координата/UUID, код причины, исходное значение, действие, количество и тяжесть. Неизвестные данные остаются в сопровождающем архиве, однако это не означает, что целевой Minecraft сможет их использовать. Отчёт различает `preserved`, `approximated`, `dropped`, `unsupported`.
|
||||
|
||||
Для мира, созданного в Shacraft без Minecraft-оригинала, экспорт в `.schem` доступен после проверки всех данных на представимость. Создание полноценного Anvil-мира потребует отдельного генератора корректного окружения сохранения; одной записи массива блоков в `.mca` недостаточно.
|
||||
|
||||
Исходный мир никогда не редактируется конвертером на месте. Вывод готовится во временном каталоге и переименовывается после успешной проверки. Прерванный импорт можно возобновить по журналу chunks и хешам. Экспорт получает согласованный снимок мира; параллельные игровые правки не должны давать смесь ревизий.
|
||||
|
||||
## Необходимые дополнения к текущему API
|
||||
|
||||
`CONTRACT.md` покрывает блоки и ревизии. В нём пока нет durable API произвольного NBT, биомов, сущностей и provenance. До заявления о lossless-конвертации добавить версионируемый compatibility sidecar со ссылками на неизменяемые blobs, типизированными NBT и изменениями по chunk/UUID. Он должен участвовать в атомарной фиксации ревизии вместе с блоками.
|
||||
|
||||
Не перегружать строковый реестр блоков всем NBT мира и не держать sidecar целиком в RAM. Экспортёр/импортёр — отдельная библиотека/CLI над ядром; Java используется для получения эталонных данных и интеграционной проверки, но не для работы Shacraft-сервера.
|
||||
|
||||
## Приёмочные проверки
|
||||
|
||||
1. **Каталог:** равенство множеств всех состояний/типов целевому отчёту, один default на блок, канонизация независимо от порядка properties, сохранение неизвестного namespace, стабильность таблицы ID после перезапуска.
|
||||
2. **NBT:** все типы, пустые списки, 64-битные значения, массивы, Unicode, неизвестные поля. Проверка encode/decode независимым декодером; обычный JSON не выступает эталоном.
|
||||
3. **Палитры:** один блок на всю секцию, границы размера палитры и битовой упаковки, секции из воздуха, отрицательные X/Z/Y, границы chunks/regions, отсутствующие данные и повреждённые индексы.
|
||||
4. **Без правок:** Minecraft/WorldEdit fixture → Shacraft → исходный формат. Нетронутые файлы побайтно идентичны; пересобранные структуры семантически равны по типизированному NBT. Третий декодер подтверждает результат.
|
||||
5. **С правками:** поменять одно состояние, сломать контейнер, поставить лестницу/дверь, переместить поддерживаемую сущность. После экспорта меняются только предусмотренные данные; metadata, прочие inventories/entities/biomes и неизвестные теги сохраняются либо дают точный отказ.
|
||||
6. **Открытие:** отдельная копия экспортированного мира открывается целевым Java 26.2 без ошибок декодирования/утраты chunks; фиксируются хеш JAR, логи и проверяемые координаты. Одного собственного roundtrip недостаточно — одинаковая ошибка в reader/writer может остаться незамеченной.
|
||||
7. **Версии/потери:** unsupported DataVersion, модифицированный namespace, отсутствующая модель, неподдерживаемое NBT и cross-format export проверяют отказ `exact` и полный отчёт `best-effort`.
|
||||
8. **Ресурсы:** большие sparse-миры, высокоэнтропийные секции, большие payload, повреждённые длины и сильное сжатие. Ограничить распакованный размер, глубину NBT, очередь и кэш; измерить пик RAM. Прерывание/повтор импорта и сбой финальной записи не повреждают исходник и сохранённые ревизии.
|
||||
|
||||
Fixtures должны быть собственными маленькими мирами/постройками без сторонних карт и ассетов. Для версии и сжатия, которых нет в fixtures и внешней проверке, не ставить статус `verified`.
|
||||
|
||||
## Этапы и условия завершения
|
||||
|
||||
1. **Происхождение:** зафиксированный источник 26.2, воспроизводимый генератор, DataVersion, хеши и точные количества; отсутствие этих результатов оставляет полный каталог незавершённым.
|
||||
2. **Реестр:** все состояния и entity IDs загружаются в Shacraft, сохраняются и ищутся; формы и behavior имеют отдельную карту покрытия.
|
||||
3. **NBT + `.schem` v3:** типизированные данные и архив оригинала, оба направления, отчёт потерь, независимые roundtrip-тесты.
|
||||
4. **Structure NBT + `.schem` v2:** схемы разделены и проверены на эталонах, включая offset/повороты только там, где они реализованы.
|
||||
5. **Anvil 26.2:** потоковый импорт, возврат исходного мира, затем проверенные правки и создание новых экспортируемых миров; тесты с целевым Minecraft.
|
||||
6. **Полное покрытие форм:** все состояния имеют проверенную визуальную форму и отдельно коллизию либо явный незакрытый дефект. Завершение списка ID само по себе не закрывает этот этап.
|
||||
7. **Расширение версий:** добавлять другие DataVersion только через отдельные адаптеры/миграции и fixtures. Не обещать автоматический downgrade и произвольные модифицированные миры.
|
||||
|
||||
Главные технические риски: дрейф схем между версиями, скрытые связи block entities/POI/ticks, формы с зависимостью от контекста, полная пересборка мира из частично поддерживаемых данных и непредсказуемые пики RAM при распаковке. Каждый риск выше привязан к конкретному отказу или приёмочному тесту, чтобы он не превратился в молчаливую потерю пользовательского мира.
|
||||
Новый, неизменённый и отредактированный экспорт прочитаны официальными Java 26.2 `NbtIo`, `RegionFile`, `SerializableChunkData`, `PrimaryLevelData`, `WorldGenSettings` и читателем сущностей. Независимый nbtlib проверил Sponge и типы NBT. Это проверка официальных codecs без запуска игрового сервера и принятия EULA; игровой плейтест в Minecraft не заявляется.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Каталог, формы и единые пакеты MVP
|
||||
|
||||
Встроенный каталог фактически извлечён из **Minecraft Java 26.2**, `DataVersion = 4903`: **1 196 блоков, 32 366 состояний, 158 типов сущностей**. Проверка сравнивает полные множества состояний, числовые ID источника, состояния по умолчанию, свойства и типы сущностей с отдельной проекцией официальных отчётов. Дополнительно зарегистрирован собственный `shacraft:trampoline`: всего 1 197 блоков и 32 367 состояний.
|
||||
|
||||
Числовой `minecraft_id` — только идентификатор источника. Сервер отдельно регистрирует канонические строки в `WorldStore`; внутренний `BlockId` зависит от истории конкретного хранилища. Нельзя использовать `minecraft_id` в операциях редактирования мира. Для собственного состояния значение `minecraft_id = u32::MAX` означает отсутствие ID Minecraft.
|
||||
|
||||
## Происхождение и воспроизведение
|
||||
|
||||
Источник: [официальный server.jar Java 26.2](https://piston-data.mojang.com/v1/objects/823e2250d24b3ddac457a60c92a6a941943fcd6a/server.jar). SHA-1 `823e2250d24b3ddac457a60c92a6a941943fcd6a`, размер 60 894 273 байта. Полные SHA-256, версия Java, команды и контрольные суммы отчётов находятся в `crates/shacraft-content/data/provenance.json`.
|
||||
|
||||
Генератор проверяет закреплённые размер и SHA-1 перед использованием JAR:
|
||||
|
||||
```sh
|
||||
python3 scripts/catalog_generate.py --java /path/to/java25/bin/java
|
||||
python3 scripts/catalog_assets.py
|
||||
cargo test -p shacraft-content
|
||||
cargo run -p shacraft-content --example catalog_report
|
||||
```
|
||||
|
||||
Нужен установленный JDK 25 или новее, включая `javac`. Проверенный локальный runtime: Microsoft OpenJDK 25.0.1+8-LTS. Скрипт не меняет системную Java. `--reuse-reports` позволяет повторно упаковать уже полученные локальные отчёты; полное воспроизведение выполняют без него. Gzip имеет фиксированный `mtime=0`; содержимое каталога детерминировано. В provenance меняется время запуска.
|
||||
|
||||
Фактически проверена точка входа:
|
||||
|
||||
```sh
|
||||
java -Xmx1G -DbundlerMainClass=net.minecraft.data.Main -jar server.jar --reports --output generated
|
||||
```
|
||||
|
||||
`catalog_extract.java` — собственная небольшая программа, вызывающая публичные API реестров и коллизий 26.2. Она не содержит декомпилированного кода. Генератор данных и измерения не запускают игровой сервер, не создают `eula=true`, не принимают соглашений. JAR, распакованные библиотеки, отчёты и Java-классы остаются в игнорируемом `artifacts/catalog-cache`; в дистрибутив входят только минимальные фактические данные совместимости и авторский код.
|
||||
|
||||
## Что проверено у форм
|
||||
|
||||
Для **каждого из 32 366 состояний** получен `getCollisionShape` официального исполняемого API. Измерение не использует текстуры или визуальные модели. Все измерения завершились без ошибок; после дедупликации осталось 326 различных наборов прямоугольных объёмов.
|
||||
|
||||
Базовый контекст измерения — `BlockPos.ZERO`, соседи `air`, отсутствие сущности (`CollisionContext.empty()`). Дополнительно сравнены каменные соседи и `CollisionContext.positionContext(+10/-10)`: различия обнаружены у 32 состояний scaffolding. Помимо них явно помечены классы с зависимостью от сущности/блочной сущности/динамического состояния: powder snow, shulker box, moving piston и big dripleaf. Поэтому покрытие коллизий разделено:
|
||||
|
||||
- 32 187 состояний: `measured-empty-context` — измерена форма в описанном контексте.
|
||||
- 179 состояний: `approximate-context` — геометрия базового контекста доступна, полной эквивалентности динамических условий нет.
|
||||
- 1 собственное состояние: `authored-exact`.
|
||||
|
||||
Первое значение **не утверждает эквивалентность во всех произвольных контекстах**. Положение игрока, специальные экипировки, поршневые блочные сущности, открывание shulker box, обновление связей соседей, AI, редстоун и течения жидкостей не следуют из каталога. Сервер реализует собственную симуляцию и явно ограниченные модули.
|
||||
|
||||
Отдельные регрессионные проверки подтверждают верхнюю/нижнюю плиту, объём и направления лестниц, открывание двери, пустую коллизию воды, коллизию забора высотой 1.5, контекстную пометку scaffolding/powder snow, валидность границ всех форм. Геометрия коллизий может выходить за клетку: нельзя безусловно ограничивать Y диапазоном `[0,1]`.
|
||||
|
||||
## Авторское отображение
|
||||
|
||||
`render` и `collision` — разные наборы `Aabb { min, max }`, координаты относительно блока. Рендерер использует собственные процедурные формы и палитру Shacraft. Встроенные PNG, звуковой WAV и GLSL написаны/сгенерированы в этом репозитории; оригинальных ресурсов Minecraft нет.
|
||||
|
||||
Семейства плит, лестниц, дверей, люков, панелей, сундуков и многих сложных твёрдых блоков используют измеренные объёмы как основу самостоятельной геометрии. Для заборов, стен, открытых калиток, знаков, рельсов, жидкостей, растений, кнопок, рычагов, ковров и других нетвёрдых форм предусмотрены отдельные параметрические шаблоны. Забор рисуется до Y=1, при этом коллизия достигает Y=1.5. У наклонного рельса ступенчатая геометрия; изогнутый рельс — угловая аппроксимация из прямоугольников. Это читаемый авторский стиль, а не копия визуальной точности Minecraft.
|
||||
|
||||
Покрытие: 32 317 состояний `authored-procedural` и 50 `intentionally-invisible` (включая воздух, технический light/barrier/structure void и moving piston без исходной динамической блочной сущности). Универсальных визуальных кубов-заглушек для неизвестных семейств встроенного каталога сейчас нет. При этом каждая декоративная деталь, текст надписей, анимация, waterlogged-жидкость внутри модели и световая симуляция не воспроизводятся автоматически по факту наличия состояния.
|
||||
|
||||
Для 158 типов сущностей извлечены начальные width/height/eye height, fixed dimensions, категория, summonable/serializable/fire immune, tracking/update interval и все доступные базовые числовые attributes. Эти свойства находятся в `EntityDefinition.properties`. Модели относительно ног — авторские семейства biped, quadruped, aquatic, winged, boat, cart, dragon, arthropod и другие. Служебные marker/interaction/area effect cloud/lightning bolt намеренно невидимы. Display-сущности имеют явно указанную зависимость от свойств экземпляра. Размеры возраста/позы/масштаба и произвольный NBT сохраняются слоем экземпляров; каталог хранит размеры по умолчанию. Наличие модели не означает наличие ванильного AI.
|
||||
|
||||
## Rust API
|
||||
|
||||
- `Catalog::load_builtin() -> Result<Catalog>`: полный встроенный каталог и trampoline.
|
||||
- `catalog.state(canonical_or_block_name)`, `catalog.entity(name)`, `catalog.default_state(name)`.
|
||||
- `catalog.palette()`: 12 удобных канонических состояний для тестового клиента.
|
||||
- `catalog.sampler()`: 1 197 образцов по умолчанию в сетке 32 столбца с шагом 3; отдельные клетки относительно пола Y=0, X/Z в пределах ±64. Создание пола и запись в мир — ответственность сервера.
|
||||
- `catalog.summary()`: количества, раздельное покрытие, версия и ограничения.
|
||||
- `canonical_state(name, properties)`: сортировка ключей и ограниченная валидация без потери неизвестных свойств.
|
||||
- Все публичные определения реализуют `Clone`, `Serialize`, `Deserialize`. После десериализации целого `Catalog` следует вызвать `rebuild_indexes()` перед поиском.
|
||||
|
||||
Хранение бинарного встроенного каталога занимает около 246 KiB gzip; при загрузке он распаковывается и создаёт строки/геометрию и индексы. Размер файла не равен RAM каталога. Полные строки 32 тысяч состояний и реестр `WorldStore` нужно учитывать отдельно в RSS измерениях процесса.
|
||||
|
||||
## Единый формат пакета v1
|
||||
|
||||
Каждый подкаталог `packages/` содержит `manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"id": "shacraft.trampoline",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": [{"id": "shacraft.base", "version": "1.0.0"}],
|
||||
"capabilities": ["blocks.define", "client.texture", "client.style", "server.on_jump"],
|
||||
"resources": [{
|
||||
"path": "server/trampoline.wasm", "scope": "server", "role": "wasm-on-jump",
|
||||
"sha256": "<64 lowercase hexadecimal characters>", "size": 40
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Пример иллюстрирует схему; действительные размеры и хеши берутся из сгенерированных манифестов. Версии зависимостей точные. `load_packages(root)` проверяет весь граф до выдачи ресурсов: схему, уникальность, зависимости, совпадение версий, циклы, допустимые capabilities, пути, размеры и SHA-256 всех объявленных ресурсов. Один ресурс ограничен 16 MiB, весь пакет — 64 MiB; symlink и traversal в ресурсных путях отвергаются. `read_resource` повторно проверяет хеш и размер при чтении.
|
||||
|
||||
Scopes: `common` — общие определения; `client` — ресурсы клиента; `server` — внутренние данные и код сервера. `PackageManifest::client_manifest()` исключает серверные ресурсы и capabilities. `Package::public_resource(path)` отказывает для server scope; HTTP-маршрут обязан использовать именно этот метод. Статический сервер не должен раздавать весь каталог `packages/` напрямую.
|
||||
|
||||
`shacraft.base` включает описание каталога, собственный 64×64 текстурный шум и клиентский стиль. `shacraft.trampoline` включает общее определение блока, 32×32 текстуру сетки, клиентский визуальный эффект, короткий авторский звук, необязательную функцию GLSL и **исполняемый серверный WebAssembly-модуль**. Его экспорт `on_jump() -> f32` возвращает скорость 10 блоков/с; импортов, памяти и внешних системных вызовов нет. WAT-исходник находится рядом. Сервер исполняет бинарный модуль в ограниченной среде; контракт предусматривает лимит топлива 10 000, памяти 1 страницу, проверку диапазона результата. Клиент использует встроенный эффект `bounce` по данным пакета, не произвольный JavaScript из сети. Произвольная среда клиентского кода и полноценная модовая экосистема не объявляются реализованными.
|
||||
|
||||
`python3 scripts/catalog_assets.py` детерминированно пересоздаёт все авторские ресурсы и хеши. Ресурсы пакетов доступны на условиях `MIT OR Apache-2.0`, как собственный код проекта. Генерация ничего не скачивает.
|
||||
@@ -44,3 +44,12 @@ Client fetch GET /api/manifest -> {protocol:1,hash,packages:[],...}; GET /api/wo
|
||||
Control HTTP: GET /api/health, /api/metrics, /api/worlds, /api/catalog public; POST /api/control with Authorization: Bearer token and {method,params}, JSON return {result:...} or {error:'...'}.
|
||||
Control methods: world.list, world.create {name,template?}, world.reset {world,expected_revision,operation_id}, world.read {world,min,max}, world.edit {world,expected_revision,operation_id,changes}, world.undo {world,expected_revision,operation_id,target_operation}, catalog.search {query,limit}, metrics, arena.start {world}, camera.capture {world,min?,max?} (top-down PNG), entity.spawn {world,kind,position}, entity.list {world}.
|
||||
Default server host 127.0.0.1 port 4000, token file data/control.token (0600). No publishing or production access.
|
||||
|
||||
|
||||
## Интегрированные компоненты MVP
|
||||
|
||||
Игровой протокол и границы Control API описаны в SERVER.md, настоящий stdio MCP — в MCP.md, пакеты и WASM — в PACKAGES.md, полный каталог — в CONTENT.md, Anvil/Sponge и типизированные sidecar — в interop.md. Данные документы дополняют контракт ядра выше, не изменяя его durable-правил.
|
||||
|
||||
`WorldStore::section_positions(world, after: Option<Pos>, limit)` постранично возвращает эффективные координаты секций 16³, включая унаследованные секции и воздушные overrides, в порядке x/y/z. Лимит 1–4096. Экспорт удерживает единственного писателя и не меняет мир между страницами.
|
||||
|
||||
История ядра находится на диске. Метаданные сущностей/правил имеют отдельную транзакционную область и ревизию. Планы preview живут 5 минут и не переживают рестарт; принятая правка живёт по контракту WorldStore. Неуспешный metadata commit не публикует изменённое состояние. При прерывании активного матча арена сбрасывается к закреплённой основе на следующем запуске.
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
# Shacraft MCP
|
||||
|
||||
`shacraft-mcp` — отдельный процесс с настоящим MCP поверх stdio и JSON-RPC 2.0. Он обращается к авторизованному Control API работающего сервера и не открывает файлы миров напрямую. Сервер хранит и проверяет ревизии, лимиты и долговечность операций.
|
||||
|
||||
```sh
|
||||
cargo run -p shacraft-mcp -- --server http://127.0.0.1:4000 --data data
|
||||
```
|
||||
|
||||
По умолчанию токен читается из `data/control.token`. Параметр `--token-file /absolute/path/control.token` переопределяет путь. Токен читается при управляющем вызове, поэтому MCP можно запустить раньше сервера. В стандартный вывод пишутся только JSON-RPC-сообщения, одна строка на сообщение; диагностика и CLI-ошибки идут в stderr. Не запускайте через командную обёртку, которая печатает посторонний текст в stdout.
|
||||
|
||||
Пример записи в конфигурации MCP-клиента (пути заменяются своими абсолютными):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"shacraft": {
|
||||
"command": "/absolute/path/shacraft-core/target/release/shacraft-mcp",
|
||||
"args": ["--server", "http://127.0.0.1:4000", "--data", "/absolute/path/shacraft-data"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Протокол
|
||||
|
||||
Поддерживаются версии `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`. Сервер возвращает запрошенную поддерживаемую версию или новейшую поддерживаемую. Клиент начинает с `initialize`, затем посылает `notifications/initialized`. До этого доступны только `initialize` и `ping`. Уведомления не вызывают ответов; неизвестные методы запросов получают JSON-RPC-ошибку. Ошибки аргументов и выполнения известных инструментов возвращаются в `result.content` с `isError: true`.
|
||||
|
||||
Реализованы `tools/list`, `tools/call`, `resources/list`, `resources/read`, `resources/templates/list`, `prompts/list`, `prompts/get` и `ping`. Списки небольшие и возвращаются целиком. Подписки, асинхронные задачи и уведомления о смене списков не объявляются. Выполнение последовательное; уведомление об отмене не прерывает уже выполняющийся HTTP-запрос. Сетевой тайм-аут ограничен 20 секундами (установление соединения — 3 секунды).
|
||||
|
||||
Строка запроса и HTTP-ответ ограничены 8 МиБ. Слишком длинная входная строка отбрасывается до перевода строки, после чего поток продолжает разбираться. HTTP-редиректы отключены: bearer-токен не передаётся перенаправленному адресу.
|
||||
|
||||
## Инструменты и рабочий процесс
|
||||
|
||||
- `world_list`, `world_create`, `world_read`: осмотр миров и создание пустого мира или экземпляра закреплённого шаблона.
|
||||
- `catalog_search`: точные состояния, runtime ID, формы и сведения о покрытии реализации.
|
||||
- `world_edit`, `world_undo`, `world_reset`: атомарные правки, консервативная отмена последней операции и возврат к базе.
|
||||
- `build_plan`, `build_commit`: предварительный расчёт ограниченной постройки из box/line/cylinder и последующая фиксация плана.
|
||||
- `entity_list`, `entity_spawn`, `entity_update`, `entity_delete`: сохранённые сущности мира и их свойства.
|
||||
- `arena_configure`, `arena_start`: сохраняемые правила Spleef (время, минимум игроков, высота выбывания, точки появления, материал пола) и запуск матча. Правила нельзя менять во время начавшегося раунда; материал пола задаёт разрешённые для разрушения блоки, но не перестраивает карту.
|
||||
- `metrics`: измеренный RSS и отдельные оценки полезных данных кэша.
|
||||
- `camera_capture`: изометрическое изображение мира в формате MCP image plus metadata. Это диагностическая проекция сервера, не снимок WebGL-камеры игрока.
|
||||
|
||||
Полные JSON Schema доступны через `tools/list`. Имена инструментов соответствуют Control API с заменой первого `_` на точку, например `world_edit` → `world.edit`.
|
||||
|
||||
Для строительства сначала прочитайте ревизию и площадку через `world_list`/`world_read`, найдите точные материалы через `catalog_search`, сформируйте `build_plan`, оцените границы и объём, затем выполните `build_commit` с уникальным `operation_id` в пределах авторизованного запроса. После фиксации проверьте `camera_capture`. При конфликте перечитайте мир и сформируйте новый план. Тот же ключ операции используйте только при повторе идентичного запроса.
|
||||
|
||||
Отмена требует текущую ревизию и `target_operation` исходной правки. Последующие изменения и reset могут сделать отмену недоступной; инструмент не должен перезаписывать их молча.
|
||||
|
||||
## Ресурсы и prompt
|
||||
|
||||
Ресурсы `shacraft://server/status`, `shacraft://catalog/blocks`, `shacraft://catalog/entities`, `shacraft://packages` читают публичные сведения сервера. Ресурс блоков даёт начальную ограниченную страницу; полный каталог доступен поиском и пагинацией инструмента.
|
||||
|
||||
Prompt `construct` принимает строки `world` и `request` и описывает последовательность осмотра, планирования, правки и визуальной проверки. Он не расширяет пользовательское разрешение на действие.
|
||||
|
||||
Проверки: `cargo test -p shacraft-mcp`. Unit-тесты проверяют переходы жизненного цикла, согласование версии, отсутствие ответов на уведомления, ошибки аргументов и токена, недопустимые JSON-RPC ID, восстановление после слишком большой строки и схемы строительных операций. Сквозные тесты с настоящим сервером выполняются дополнительно.
|
||||
|
||||
Основание протокола: официальные [lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle), [stdio transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) и [tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools).
|
||||
|
||||
## Проверка настоящим MCP SDK
|
||||
|
||||
После запуска отдельного тестового сервера:
|
||||
|
||||
```sh
|
||||
node crates/shacraft-mcp/tests/sdk-smoke.mjs --data /absolute/path/test-server-data
|
||||
```
|
||||
|
||||
Скрипт использует `@modelcontextprotocol/sdk` (проверен с 1.30.0), выполняет согласование протокола, читает все ресурсы, вызывает prompt, делает правку/повтор/конфликт/отмену, план/фиксацию, получает PNG, проверяет CRUD сущностей и reset. Он создаёт отдельный мир с префиксом `mcp_sdk_`. Путь к установленному SDK задаётся через `SHACRAFT_MCP_SDK`; в локальной среде поддерживается соседний `minecraft-builder-mcp/bridge/node_modules`. Это независимая проверка протокола, не зависимость исполняемого MCP от Minecraft Builder.
|
||||
|
||||
Для воспроизводимой проверки с автоматически запущенным отдельным сервером:
|
||||
|
||||
```sh
|
||||
node scripts/check_mcp.mjs --binary target/release/shacraft-server --mcp-binary target/release/shacraft-mcp --port 4002 --output artifacts/mcp-sdk.json
|
||||
```
|
||||
|
||||
Обёртка создаёт временный каталог данных, использует настоящий SDK и закрывает тестовый сервер. Рабочий сервер на порту 4000 не затрагивается. `--sdk` или `SHACRAFT_MCP_SDK` задают путь к уже установленному SDK; результат записывается JSON-файлом, включая ошибку при неуспешной проверке.
|
||||
|
||||
Связанный сквозной тест `scripts/check_server.mjs` проверяет HTTP/WebSocket-клиенты, действия, экземпляры миров, полный матч и восстановление после реального `SIGKILL`: подтверждённые блоки, сущности и правила арены должны пережить перезапуск того же каталога данных. Временные тестовые данные сохраняются для разбора.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Единые пакеты расширений
|
||||
|
||||
`packages/<directory>/manifest.json` объявляет точные версии, лицензии, зависимости, возможности и ресурсы. При запуске сервер проверяет весь граф зависимостей, размеры, SHA-256, пути и отсутствие symlink. Отсутствующая версия, цикл, неизвестная возможность или изменённый ресурс останавливают запуск. Публикуются только `client` и `common`; файлы `server` не имеют публичного маршрута. Ресурс повторно проверяется при HTTP-чтении.
|
||||
|
||||
Примеры — `packages/base` и `packages/trampoline`. Все изображения, звук и модели авторские. Данные каталога происходят из официальных отчётов и измерения публичного API Java 26.2; ванильные текстуры и модели в пакеты не входят.
|
||||
|
||||
Манифест schema 1 содержит:
|
||||
|
||||
- `id`, `version` из трёх числовых частей, `license`;
|
||||
- `dependencies:[{id,version}]` с точными версиями;
|
||||
- `capabilities`, например `blocks.define`, `client.texture`, `server.on_jump`;
|
||||
- `resources:[{path,scope,role,sha256,size}]`.
|
||||
|
||||
Один ресурс не больше 16 МиБ; пакет не больше 64 МиБ, до 128 ресурсов, до 128 записей в каталоге пакетов. Это ограничения сервера; браузер дополнительно ограничивает суммарно проверяемые публичные ресурсы 128 МиБ на подключение. Определения блоков имеют роль `definitions`, ресурс с этой ролью требует schema 1. Сервер допускает до 4096 дополнительных определений. Блок задаёт `state` как новый идентификатор `namespace:path` без свойств (строчные ASCII-буквы, цифры, `_`, `.`, `-`, дополнительно `/` в path), `color:[r,g,b]`, `collision:[{min,max}]` и необязательный `render` аналогичной формы. Необязательный `opacity` — число 0…1. Если render не указан, используется существующая авторская форма того же состояния или коллизия нового блока. Максимум 64 бокса на каждый набор; допустимый локальный диапазон координат −2…3. Дубликаты определений и переопределение `minecraft:*` отвергаются. Runtime ID назначает сохраняемый реестр; `Game::open` расширяет каталог проверенными определениями до регистрации в WorldStore. Обычный пакетный блок без `behavior` хранится и участвует в физике; `behavior.server_hook: on_jump` связывает его с активным WASM-провайдером.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"blocks": [{
|
||||
"state": "example:spring",
|
||||
"color": [97,205,151],
|
||||
"collision": [{"min":[0,0,0],"max":[1,1,1]}],
|
||||
"render": [{"min":[0,0,0],"max":[1,1,1]}],
|
||||
"behavior": {"server_hook":"on_jump"}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Исполнение модуля
|
||||
|
||||
В MVP нужен ровно один активный провайдер серверного hook `on_jump`; отсутствие провайдера или два модуля с этой ролью останавливают запуск. Серверный `.wasm` объявляется ресурсом `role: "wasm-on-jump"`, `scope: "server"`; необходима capability `server.on_jump`. Экспорт `on_jump: () -> f32` возвращает импульс прыжка в блоках/с. Сервер вызывает его только при прыжке игрока с блока, который связан с hook через проверенное определение пакета.
|
||||
|
||||
Исполнение идёт в Wasmi: без импортов, файлов, сети и системных вызовов; до 10 000 единиц fuel на вызов, одна память до 64 КиБ, одна таблица до 128 элементов, один экземпляр. Бинарный модуль не больше 64 КиБ. Результат должен быть конечным числом 0…20; trap или недопустимое значение фиксируется метрикой и даёт обычный прыжок. Выполняемый пример возвращает 10. Тесты проверяют реальный вызов, бесконечный цикл, превышение памяти, попытку импорта и NaN. Отдельный тест создаёт пакет `example:spring`, проверяет его SHA-256, добавляет новый блок в каталог, регистрирует и сохраняет runtime ID, связывает блок с модулем и получает импульс 12 от собственного бинарного WASM. Дубликаты, Minecraft override, недопустимая геометрия, opacity/идентификатор и изменение ресурса после загрузки отвергаются.
|
||||
|
||||
Это ограниченный действующий API расширения, а не обещание произвольной совместимости с Forge/Fabric или доступа WASM к полному состоянию мира. Для нового hook необходима явная версия контракта. Spleef в этом MVP реализован серверным режимом Rust с сохраняемыми правилами.
|
||||
|
||||
## Клиентские ресурсы
|
||||
|
||||
`/api/manifest` возвращает публичный список с URL, точной версией, размерами и SHA-256. Браузер скачивает файлы, проверяет размер и хеш **до входа**, затем кладёт в CacheStorage по хешу. При повторном подключении содержимое кэша тоже проверяется. Ошибка хеша удаляет повреждённую запись и запрещает вход. Неподходящий `manifest_hash` сервер отвергает. Публичный хеш не подтверждает, что клиентское приложение не модифицировано.
|
||||
|
||||
Базовый пакет даёт пиксельную текстуру; trampoline — собственную текстуру, GLSL-функцию окраски, настройку стиля и короткий синтезированный звук. Они применяются собственным WebGL2-рендерером. В ядре и сервере отсутствует графический движок. Пакет не загружает произвольный привилегированный JavaScript.
|
||||
|
||||
Для изменения пакета пересчитайте размер и SHA-256 каждого изменённого ресурса, увеличьте версию и обновите точные зависимости. `scripts/catalog_assets.py` воспроизводимо генерирует встроенные авторские ресурсы и манифесты. Формат ориентирован на будущий лаунчер: лаунчер сможет получить тот же manifest и кэшировать по `(id,version,sha256)`; интеграция конкретного лаунчера в этот репозиторий не входит.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
План составлен до реализации. Каждый этап заканчивается воспроизводимым результатом в локальном репозитории. `docs/STATUS.md` обновляется после проверок. Пункт не считается выполненным по наличию интерфейса, заглушки, каталога имён или успешной компиляции.
|
||||
|
||||
Текущий прогресс: этап 0 и первая реализация этапа 1 завершены. Проверены библиотека хранения, CLI и аварийное восстановление. Этапы 2–6 остаются впереди; подробности в STATUS и VERIFICATION.
|
||||
Текущий прогресс: этапы 0–6 реализованы для локального профиля MVP. Компоненты интегрированы; фактические проверки, пределы нагрузки и ограничения совместимости описаны в STATUS и VERIFICATION. Исходный порядок этапов ниже сохранён.
|
||||
|
||||
## Этап 0. Сохранить контекст и проверить проектирование
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Сервер, протокол и эксплуатация MVP
|
||||
|
||||
## Запуск
|
||||
|
||||
Нужны Rust 1.96+ и современный браузер с WebGL2. Java не нужна для запуска Shacraft: проверенный каталог включён в исходники. Node 22+ используется для проверок клиента и сети.
|
||||
|
||||
```sh
|
||||
cargo build --release --locked --workspace
|
||||
bash scripts/run.sh --data data --listen 127.0.0.1:4000
|
||||
```
|
||||
|
||||
Открыть http://127.0.0.1:4000. Для друзей в локальной сети можно задать `--listen 0.0.0.0:4000`; браузерная проверка SHA-256 требует HTTPS для адресов вне localhost. Для внешней сети нужен обычный HTTPS/WSS reverse proxy. В MVP нет сервиса аккаунтов; имя игрока не является подтверждённой личностью. Контрольный токен выдаётся только владельцу сервера, клиент его не получает.
|
||||
|
||||
`--data` — каталог WorldStore, `--cache-sections` — ёмкость общего кэша (по умолчанию 64). `--client` и `--packages` задают каталоги статического клиента и проверенных пакетов. Скрипт запуска явно задаёт пути относительно распакованного проекта. Остановка — Ctrl+C; уже подтверждённые правки не зависят от корректного завершения процесса.
|
||||
|
||||
Первый запуск создаёт лобби, галерею `gallery`, неизменяемую основу Spleef и две независимые арены. Каталог содержит все состояния Java 26.2 и авторский trampoline. Импортированный WorldStore тоже можно открыть сервером; импортированные миры появятся в выборе миров рядом с демонстрационными. Галерея содержит 1 197 образцов состояний по умолчанию на полу 128×128, с шагом 3; это обзор всех типов блоков, а не всех 32 тысяч вариантов. Начальная позиция галереи — `[-50,2,58]`; клиент подгружает её частями по мере перемещения.
|
||||
|
||||
## Владение состоянием
|
||||
|
||||
Один выделенный поток владеет WorldStore, физикой и игровым состоянием. HTTP и WebSocket ставят ограниченные команды в очередь. Движение считается 20 раз в секунду: скорость 5 блоков/с, гравитация 20 блоков/с², обычный прыжок 7 блоков/с. Игрок занимает AABB 0.6×1.8 блока; позиция задаёт середину стоп. Оси +Y вверх, yaw растёт вправо, pitch вверх; направление взгляда `[sin(yaw)*cos(pitch),sin(pitch),-cos(yaw)*cos(pitch)]`.
|
||||
|
||||
Игровые позиции и точки появления ограничены диапазоном ±32 700 по каждой оси: физика этого клиента использует `f32`. Хранилище и конвертер сохраняют более широкий контракт ±30 000 000; это не обещание игровой физики на дальних координатах. Выход игрока за игровой диапазон возвращает его на spawn, а в активном матче означает выбывание.
|
||||
|
||||
Ввод содержит намерение двигаться, а не позицию. Сервер проверяет дальность 6 блоков, ближайшее пересечение форм, ячейку установки, пересечение с игроком и правила арены. Просроченный ввод обнуляется через секунду. Игровая физика использует ограниченную область соседних блоков на каждый тик; независимые миры без игроков не требуют массива загруженных секций.
|
||||
|
||||
В `worlds.sqlite3` хранятся блоки и их история. В `server.sqlite3` — конфигурация и сохраняемые сущности с отдельной ревизией. Это две транзакционные области: создание мира и добавление его настроек не заявляются одной общей транзакцией. Если сохранение настроек не удалось после создания мира, мир доступен с безопасными настройками по умолчанию; ошибку нельзя интерпретировать как разрешение повторно создать тот же мир. Для сущностей и настроек неуспешная запись восстанавливает состояние из последней сохранённой версии. Если даже чтение SQLite невозможно, операция возвращает ошибку; ошибка хранения не объявляется успешным подтверждением.
|
||||
|
||||
## HTTP и авторизация
|
||||
|
||||
Публичные GET: `/api/health`, `/api/manifest`, `/api/worlds`, `/api/catalog`, `/api/entities`, `/api/metrics`. Каталог принимает `query`, `offset`, `limit` (1–256) `ids` с максимум 128 runtime ID или `states` с каноническими состояниями, разделёнными запятыми вне скобок свойств. Runtime ID берётся из ответа; он не совпадает с числовым ID Minecraft.
|
||||
|
||||
`POST /api/control` принимает `{ "method": "world.read", "params": {...} }`, возвращает `{ "result": ... }` или HTTP 400 с `{ "error": "..." }`. Требуется `Authorization: Bearer <token>`. При первом запуске файл `control.token` создаётся с режимом 0600 на Unix. Параметры операций доступны в схемах `tools/list` отдельного MCP. Названия Control API используют точку: `world.edit`, `build.plan`, `camera.capture` и т.д.
|
||||
|
||||
Токен не является секретом от администратора локальной машины. Хеш manifest подтверждает совместимость ресурсов; он не доказывает неизменность исполняемого кода клиента. Защита игрового состояния основана на серверных проверках. Origin для браузерного WebSocket и Control API проверяется; межсайтовый доступ не включён.
|
||||
|
||||
## WebSocket, снимки и правки
|
||||
|
||||
Первое сообщение на `/ws` в течение 10 секунд:
|
||||
|
||||
```json
|
||||
{"type":"join","protocol":1,"manifest_hash":"из /api/manifest","name":"Игрок","world":"lobby"}
|
||||
```
|
||||
|
||||
`welcome` содержит `id`, `world`, `revision`, `blocks:[{pos,block}]`, определения использованных `materials`, `players`, `entities`, `spawn`, `view_center` и `manifest_hash`. Снимок покрывает `[centerX-32, centerY-8, centerZ-32]…[centerX+31, centerY+31, centerZ+31]`, максимумы включительные. При перемещении центра видимости сервер присылает новый `snapshot`; клиент полностью заменяет геометрию. Полный строковый реестр в снимке не передаётся. `materials` ограничен 256 записями и 128 КиБ; остальные определения клиент последовательно запрашивает через `/api/catalog?ids=1,2,...&limit=128`. Это позволяет войти в мир с большой палитрой, не переполняя исходящую очередь.
|
||||
|
||||
Клиент отправляет `input` с `seq,yaw,pitch,forward,strafe,jump`; `break` с `pos`; `place` с `pos,block` или `state`; `switch_world`, `resync`, `respawn`, `start_match`, `chat`, `ping`. Ответ `state` содержит авторитетные позиции, `tick`, подтверждённый `ack` и состояние матча. Блоки приходят в `blocks` с новой ревизией. При пропуске ревизии клиент запрашивает снимок. Подписка и создание снимка сериализованы с правками в одном потоке, поэтому промежуточная правка не теряется.
|
||||
|
||||
Для управляющих правок сохраняется контракт ядра: ожидаемая ревизия, уникальный `operation_id`, durable-подтверждение, повтор того же запроса без второй записи, отказ при конфликте. План строительства занимает не более 32 768 ячеек, хранится 5 минут, максимум 16 планов; preview не меняет мир. Планы эфемерны и исчезают после перезапуска, принятые правки остаются на диске. `camera.capture` возвращает PNG изометрической серверной проекции и точную ревизию; это не кадр WebGL-камеры игрока.
|
||||
|
||||
## Правила Spleef
|
||||
|
||||
Матч проходит `waiting → countdown → active → finished → waiting`. По умолчанию нужны 2 игрока, отсчёт занимает 3 секунды, раунд — до 180 секунд. Разрешено разрушать только `minecraft:snow_block` в активном раунде; установка блоков и разрушение границы запрещены. Падение ниже Y=−6 означает выбывание. При одном оставшемся участнике объявляется победитель; истечение времени с несколькими оставшимися даёт ничью. После результата через 5 секунд мир возвращается к своему закреплённому шаблону, игроки — на spawn. Арены независимы. Отключение/уход из мира исключает участника; повторный вход во время раунда остаётся входом зрителя. После перезапуска прерванный матч сбрасывается к шаблону.
|
||||
|
||||
`arena.configure` сохраняет настройки мира. Изменять их можно между матчами; попытка во время countdown/active/finished отклоняется. Доступны `mode`, `spawn` и следующие поля:
|
||||
|
||||
- `countdown_seconds`: целое 1…30, по умолчанию 3; `round_seconds`: целое 1…3600, по умолчанию 180.
|
||||
- `min_players`: целое 2…32, по умолчанию 2.
|
||||
- `elimination_y`: конечная координата в пределах ±32 700, по умолчанию −6.
|
||||
- `spawn_points`: до 32 точек `[x,y,z]`. Пустой список означает круг радиуса 7 вокруг X/Z=0 на высоте `spawn[1]`. Непустой список должен вмещать минимум участников, а при старте — всех вошедших; точки располагаются не ближе 0.8 блока друг к другу.
|
||||
- `spectator_spawn`: точка зрителя, по умолчанию `[13,2,0]`.
|
||||
- `floor_block`: известное каноническое состояние с коллизией, которое участникам разрешено разрушать. По умолчанию `minecraft:snow_block`. Пол в закреплённой карте должен уже содержать этот материал; configure не перекрашивает и не перестраивает карту.
|
||||
|
||||
Все точки появления находятся в игровом диапазоне, их Y выше `elimination_y + 0.5`. Необязательный `expected_revision` проверяет общую ревизию метаданных, доступную через `entity.list`/`metrics`; это отдельная ревизия от блоков мира. Правила возвращаются в `match.rules`. Старые настройки с двумя полями `mode/spawn` читаются с указанными значениями по умолчанию.
|
||||
|
||||
Журнал идемпотентности ядра относится к правкам блоков, undo/reset и подтверждению плана. Операции сущностей и настройки арен пока не имеют такого журнала: повторный `entity.spawn` создаёт ещё один экземпляр. После неопределённого сетевого результата сначала следует прочитать текущее состояние.
|
||||
|
||||
## Лимиты и наблюдаемость
|
||||
|
||||
- 32 WebSocket-сессии, включая ожидающие join; 16 одновременно обслуживаемых HTTP-запросов, очередь 64 команд.
|
||||
- Входное сообщение WebSocket до 64 КиБ, максимум 80 сообщений/с на соединение; действия блоков не чаще 110 мс, чат не чаще 700 мс, явный resync не чаще 500 мс, переход между мирами не чаще секунды, автоматический снимок при смене секции не чаще 2 секунд.
|
||||
- Control API до 2 МиБ JSON; штатная правка до 32 768 ячеек; чтение до 262 144 ячеек и 6 МиБ ответа; при превышении байтового лимита требуется уменьшить область.
|
||||
- Исходящая очередь на клиента: 16 сообщений и 8 МиБ. При переполнении или превышении срока отправки медленное соединение закрывается. Общее верхнее ограничение очередей зависит от числа клиентов; эти байты не следует путать с кэшем секций.
|
||||
- Метаданные сервера до 8 МиБ, до 4096 сущностей, свойства одной сущности до 16 КиБ. В игровые снимки входят компактные представления без произвольного JSON свойств; полные свойства читаются через `entity.list` с offset и limit (по умолчанию 64, максимум 128).
|
||||
- RAM процесса в `/api/metrics` — фактический Linux VmRSS, а `storage.cache_payload_bytes` — только полезные байты кодированных секций. Каталог, строки, SQLite, сетевые буферы, временные чтения и память аллокатора существуют отдельно.
|
||||
|
||||
Максимальная разрешённая конфигурация лимитов не равна измеренному целевому профилю нагрузки. Условия и результаты benchmark публикуются в VERIFICATION; заявлений о выигрыше относительно Paper без сопоставимого прогона нет.
|
||||
|
||||
## Сущности и совместимость
|
||||
|
||||
158 определений сущностей имеют реальные размеры и авторские процедурные модели. Сохраняемые экземпляры создаются, перемещаются и удаляются через MCP. Полноценные ванильные AI, redstone, жидкости, инвентари и игровая логика Minecraft не реализованы. Формы контекстно зависимых блоков явно отмечены в каталоге. Изменённые и неизвестные данные конвертер обрабатывает по правилам `docs/interop.md`, без молчаливого объявления lossless-совместимости.
|
||||
+21
-39
@@ -1,49 +1,31 @@
|
||||
# Состояние проекта
|
||||
|
||||
Обновлено: 2026-09-14. Рабочая папка: `/home/emil/Desktop/shacraft-core`.
|
||||
Обновлено 2026-09-14. Реализован и локально проверен весь согласованный профиль MVP. Рабочая папка: `/home/emil/Desktop/shacraft-core`. Исходный план сохранён до реализации коммитом `ddfcef2`; результаты старой облачной среды не использовались как доказательство.
|
||||
|
||||
## Завершено: планирование и первый этап хранения
|
||||
## Готово
|
||||
|
||||
План сохранён **до реализации**, отдельным Git-коммитом `ddfcef2`. Исходники старой облачной среды не восстановлены и не использованы как доказательство работы новой реализации.
|
||||
- Самостоятельное ядро Rust: компактные секции, общий ограниченный LRU, долговечные правки и история, immutable snapshots, независимые миры, revision/idempotency, undo/reset.
|
||||
- Отдельный авторитетный сервер с игровым циклом 20 Гц, проверкой движения/форм/дальности, ограниченными очередями, сохраняемыми настройками и сущностями.
|
||||
- Собственный браузерный WebGL2-клиент: строительство, каталог, игроки/сущности, чат, миры, загрузка пакетов и диагностика. Нет зависимости от готового игрового движка.
|
||||
- Настоящий отдельный stdio MCP: 17 инструментов, 4 ресурса, строительный prompt, планы/commit, визуальный PNG и проверка через независимый MCP SDK.
|
||||
- Полный каталог Java 26.2: 1 196 блоков, 32 366 состояний, 158 сущностей, DataVersion 4903. Коллизии измерены через официальный API, модели авторские; контекстные случаи отмечены.
|
||||
- Пакеты: точные версии, граф зависимостей, SHA-256/размеры, приватные серверные файлы, проверяемый клиентский кэш. Действующий WASM `on_jump`, пользовательские определения блоков и собственные текстуры/шейдер/звук.
|
||||
- Spleef: две независимые арены общей карты, отсчёт, выбывание, победитель/ничья, сброс, spectators, сохраняемые правила, восстановление после прерывания. Лобби и галерея 1 197 образцов.
|
||||
- Anvil и Sponge v3 import/export: типизированный NBT, сохранение оригинала, атомарная публикация, отчёты о потерях/преобразованиях, перенос игровых правок блоков и сущностей.
|
||||
|
||||
Работают две crate:
|
||||
## Проверено
|
||||
|
||||
- `shacraft-core`: versioned codec секций 16³ (uniform/palette/dense), компактный LRU, долговечный реестр, SQLite/WAL/FULL, блокировка второго писателя, неизменяемые снимки, независимые экземпляры, атомарные edit, revision/idempotency, undo/reset и явная уборка недостижимых blobs.
|
||||
- `shacraft-tools`: локальная JSON-lines сессия для тестирования API, demo, stats и воспроизводимый storage benchmark. Это не MCP и не игровой сервер.
|
||||
84 Rust-теста, 6 JS-тестов, fmt и Clippy без предупреждений; 14 групп реальных HTTP/WebSocket-сценариев, отдельная MCP SDK-сессия, SIGKILL и восстановление. Экспорты прочитаны официальными Java 26.2 codecs и независимым NBT-читателем. Клиент осмотрен в реальном браузере; подтверждены отрисовка, поиск состояний, переход в галерею, догрузка материалов, повторное соединение и кэш ресурсов.
|
||||
|
||||
Проверено на Linux x86_64 с Rust 1.96.0:
|
||||
Финальный release-стенд: 100 пустующих экземпляров карты — 50.50 МиБ RSS; 10 движущихся клиентов — максимум 59.65 МиБ RSS/VmHWM, p95 выборки работы тика 7.33–9.54 мс. Это ограниченный локальный профиль, не сравнение с Paper. Команды и доказательства — [VERIFICATION](VERIFICATION.md).
|
||||
|
||||
- `cargo fmt` и Clippy с запретом предупреждений.
|
||||
- 29 Rust-тестов: 11 codec, 8 black-box контрактов, 10 хранения.
|
||||
- Отдельные процессы: 80 подтверждённых правок → SIGKILL → восстановление реестра/данных/replay; прерывание транзакции на 32768 ячеек → отсутствие частичного состояния → безопасный повтор.
|
||||
- Вытеснение различных секций при превышении кэша; однородная секция остаётся компактной и в RAM.
|
||||
- Release demo и синтетический benchmark: 256 секций, 100 независимых экземпляров, чтение сверх кэша, edit/reset каждого экземпляра.
|
||||
## Точные границы
|
||||
|
||||
Команды и результаты: [DEVELOPMENT](DEVELOPMENT.md), [VERIFICATION](VERIFICATION.md), `docs/verification/`.
|
||||
- Это самостоятельная миниигровая платформа с каталогом Minecraft, не полная ванильная симуляция: нет полного AI, redstone, инвентарей или симуляции жидкостей. Формы с зависимостью от контекста помечены; модели не повторяют ванильные ассеты.
|
||||
- `exact` консервативно отказывает после правок; `best-effort` отражает изменения и выдаёт отчёт. Между Anvil и `.schem` не вся семантика биомов/block entities преобразуется — исходные данные архивируются. Ограничения версий, размеров и контекстов описаны в interop.md.
|
||||
- История блоков хранится на диске без автоматического удаления. Undo допускает только текущую целевую правку. Preview-планы эфемерны, 5 минут. Метаданные сущностей/правил имеют отдельную ревизию; их API не заявляет журнал идемпотентности ядра.
|
||||
- Предел игрового пространства с f32-физикой — ±32700; хранение и конвертер поддерживают координаты ядра ±30 миллионов. Память ограничивается несколькими структурами, а не одним магическим RSS-лимитом.
|
||||
- Проверены Linux x86_64 и локальная нагрузка. Длительный продакшен soak, аппаратное отключение питания, все ОС и игровой плейтест экспорта в запущенном Minecraft не заявляются пройденными.
|
||||
- Лаунчер, аккаунты, продакшен и существующие серверы — последующая интеграция. Ничего не опубликовано и не развёрнуто на удалённых машинах.
|
||||
|
||||
## Следующий этап: сервер и тестовый клиент
|
||||
|
||||
1. Добавить crate сервера с игровым циклом отдельно от долговечных редакторских транзакций. Не выполнять SQL/FULL commit каждого игрового ввода.
|
||||
2. Уточнить протокол resync, подписку без разрыва между снимком и изменениями, переключение миров и лимиты очередей.
|
||||
3. Реализовать серверные движение/коллизии/взаимодействия и минимальный WebGL2-клиент по решению D003.
|
||||
4. Пройти сценарий двух клиентов с общими блоками и независимыми мирами.
|
||||
5. Затем подключить собственный MCP через общий Control API, полный каталог и конвертер согласно PLAN.
|
||||
|
||||
## Ограничения первого этапа
|
||||
|
||||
- Полный MVP ещё не завершён. Нет игрового сервера, графического клиента, MCP, каталога 26.2, конвертера, системы исполняемых модулей и миниигры.
|
||||
- Undo консервативный: только целевая правка на текущей ревизии, без последующих операций. Каждый новый успешный edit, включая no-op, повышает ревизию. Retry не повышает.
|
||||
- История остаётся на диске без автоматического удаления. GC ограничивает число удалённых blobs, но не время сканирования. Он не удаляет историю/снимки и не уменьшает физический файл через VACUUM.
|
||||
- Реестр строк находится в RAM: максимум 262144 состояния по 512 ASCII-байт. Кэш секций не является лимитом всей памяти процесса. SQLite page-cache budget — настройка движка, не жёсткий RSS-лимит.
|
||||
- Benchmark проверяет хранение, не игроков/тики/полный каталог и не сравнительную экономию относительно Paper.
|
||||
- SIGKILL не имитирует отключение питания и отказы накопителя. Платформы вне Linux пока не проверены.
|
||||
- Продакшен, существующий лаунчер и удалённые репозитории не изменялись.
|
||||
|
||||
## Правила продолжения
|
||||
|
||||
- Перед работой прочитать REQUIREMENTS, PLAN, DECISIONS, CONTRACT и этот файл.
|
||||
- Новые результаты подтверждать командами, тестами или видимым поведением; старый облачный отчёт не использовать как доказательство.
|
||||
- Не помечать весь MVP готовым по завершению одного этапа.
|
||||
- Отмечать точные ограничения контента, конвертера, клиента, модулей и безопасности.
|
||||
- Команды запуска и проверки сохранять в репозитории; изменения коммитить в локальный Git после проверки.
|
||||
- Не помещать токены, данные реальных игроков и проприетарные ассеты в Git и архивы.
|
||||
Контракты компонентов: SERVER.md, MCP.md, CONTENT.md, PACKAGES.md, interop.md. Исходники и release-бинарные файлы упаковывает `scripts/make_release.py`; в архивы не входят токены, локальные миры и Minecraft JAR.
|
||||
|
||||
+50
-22
@@ -1,32 +1,60 @@
|
||||
# Проверки первого этапа, 14 сентября 2026
|
||||
# Проверка локального MVP
|
||||
|
||||
Проверены библиотека хранения и CLI. Рубеж A из ACCEPTANCE.md целиком ещё не пройден: сервер/клиент/MCP отсутствуют. Результаты ниже не объявляют полный MVP готовым.
|
||||
Дата: 2026-09-14. Стенд: Linux x86_64, AMD Ryzen 7 1700, Rust/Cargo 1.96.0, Node 22.22.3, Python 3.14.4. Точные версии — [environment.json](verification/environment.json). Исходная ревизия выпуска фиксируется в `RELEASE.json` каждого архива.
|
||||
|
||||
## Автоматические проверки
|
||||
|
||||
Команда `bash scripts/verify.sh` завершилась с кодом 0. В [полном журнале](verification/checks.txt) сохранены:
|
||||
|
||||
- форматирование и Clippy `--workspace --all-targets --locked -- -D warnings` без ошибок;
|
||||
- 29 Rust-тестов (11 секционного формата, 8 публичных контрактов, 10 хранилища);
|
||||
- сборка CLI;
|
||||
- девять проверок отдельного процесса, включая принудительное завершение и восстановление.
|
||||
|
||||
В аварийном сценарии 80 правок были подтверждены процессом до SIGKILL и сохранились после повторного открытия. Повтор старой операции вернул её исходный результат без повторной правки. Большая операция на 32768 блоков, завершения которой вызывающий код не наблюдал, восстановилась целиком или отсутствовала целиком; безопасный повтор завершился на одной ревизии. Дополнительно SQL-тест принудительно вызывает отказ в середине изменения и проверяет откат данных, истории и ревизии.
|
||||
|
||||
## Проверка работы и хранения
|
||||
|
||||
`cargo build --release -p shacraft-tools --locked` завершилась успешно. [Результат demo](verification/demo.json) подтверждает сценарий общей карты, двух экземпляров, изолированной правки, undo и reset.
|
||||
|
||||
[Сырой результат benchmark](verification/storage-benchmark.json) получен командой:
|
||||
|
||||
```bash
|
||||
target/release/shacraft-tools --data <пустой-временный-каталог> --cache 8 benchmark --sections 256 --worlds 100
|
||||
bash scripts/verify.sh
|
||||
cargo build --release --workspace --locked
|
||||
node scripts/check_server.mjs --binary target/release/shacraft-server --output artifacts/server-e2e-release.json
|
||||
node scripts/check_mcp.mjs --binary target/release/shacraft-server --output artifacts/mcp-sdk.json
|
||||
node scripts/benchmark_server.mjs --binary target/release/shacraft-server --output artifacts/server-benchmark-final.json
|
||||
```
|
||||
|
||||
После создания 100 экземпляров количество blobs осталось 256: карты разделяют неизменяемые данные. После обхода карты резидентный кэш остался в пределах восьми записей и зафиксировал вытеснения. Все экземпляры прошли независимую правку и сброс. RSS, размеры файлов и длительности сохранены в JSON вместе с версией Rust и системой.
|
||||
`verify.sh` завершился с кодом 0: fmt, Clippy `-D warnings`, **84 Rust-теста** (core 30, content 11, compat 17, MCP 8, server/WASM 18), **6 JS-тестов**, отдельная аварийная проверка хранилища и debug HTTP/WebSocket-сценарий. [Полный журнал](verification/mvp-checks.txt).
|
||||
|
||||
Это один синтетический прогон на небольшом реестре из двух состояний, без сети, игроков и симуляции. Не использовать его для оценки готового сервера или сравнения с Paper. Байты закодированных секций не включают служебные структуры/аллокатор; RSS измеряется отдельно.
|
||||
Release-прогон прошёл **14 групп**: проверка ресурсов/авторизации, несовместимый manifest, два клиента, авторитетное движение, недостижимая правка, реальные break/place, последовательные ревизии, late join, immutable template/изоляция, чат, события сущностей, reconnect, полный Spleef и reset, удаление отключённых игроков, **SIGKILL → восстановление блоков, свойств сущностей и настроек**. [Отчёт](verification/server-e2e.json).
|
||||
|
||||
## Ограничения доказательств
|
||||
Настоящий MCP SDK 1.30.0 прошёл initialize/notification/ping, 17 schemas, 4 resources, prompt, durable edit/replay/conflict/undo, plan/commit/retry, PNG image content, entities CRUD, настройки, metrics/reset. [Отчёт SDK](verification/mcp-sdk.json). Для воспроизведения установить независимый клиент:
|
||||
|
||||
Подтверждена работа на Linux x86_64. Тесты завершения процесса не имитируют отключение питания или аппаратные сбои. Часть защиты от повреждений проверяется на намеренно изменённых временных SQLite-базах; реальные миры пользователя не затрагиваются. Интеграция с Minecraft, лаунчером и MCP относится к следующим этапам и пока не проверялась.
|
||||
```bash
|
||||
npm install --prefix artifacts/mcp-sdk --no-package-lock @modelcontextprotocol/sdk@1.30.0
|
||||
SHACRAFT_MCP_SDK="$PWD/artifacts/mcp-sdk/node_modules/@modelcontextprotocol/sdk" node scripts/check_mcp.mjs --binary target/release/shacraft-server --output artifacts/mcp-sdk.json
|
||||
```
|
||||
|
||||
В репозитории нет зависимости рабочего клиента от Node/MCP SDK. Они нужны только проверке.
|
||||
|
||||
## Каталог и совместимость
|
||||
|
||||
Каталог сравнивается с независимой проекцией официальных отчётов по всем каноническим состояниям, properties/defaults и множеству сущностей. Отдельно проверены геометрия, ориентации плит/ступеней/дверей, контекстные случаи, entity dimensions. Повторный полный генератор отчётов/API и авторских ресурсов воспроизвёл файлы побайтно: [reproducibility](verification/catalog-reproducibility.json).
|
||||
|
||||
17 converter-тестов покрывают типизированный NBT, compression modes/external chunks, палитры, negative coordinates, unknown data, offset/3D biomes, удаление stale chest data, original checksums, no-overwrite, invalid input, создание/перемещение/удаление сущностей и отдельный entity fingerprint.
|
||||
|
||||
Официальный Java 26.2 прочитал exact (1 chunk, 3 блока, 1 сущность), edited (2 chunks, 3 блока, 1 сущность) и новый экспорт (2 chunks, 2 блока). nbtlib 2.0.4 проверил Sponge-теги и их числовые типы. [Машиночитаемый результат](verification/interop.json), [команды и ограничения](interop.md). Использовались публичные codecs без запуска Minecraft-сервера и без принятия EULA.
|
||||
|
||||
## Память и тик
|
||||
|
||||
Бюджеты записаны в скрипте **до прогона**: 256 МиБ для процесса с 10 клиентами, p95 выборки работы тика ≤50 мс. Полный каталог включён. Один сервер содержит 5 служебных/демонстрационных миров и 1, 10, затем 100 дополнительных immutable forks лобби. Активные этапы: 10 клиентов в одной области общего мира и в отдельных мирах, движение/прыжок с вводом 20 Гц, по 15 секунд; метрики каждые 100 мс. Затем все клиенты отключаются. Linux VmHWM дополнительно фиксирует пик процесса, включая промежутки между выборками.
|
||||
|
||||
Финальный прогон:
|
||||
|
||||
- 1 / 10 / 100 пустующих forks: максимальный RSS **50.28 / 50.35 / 50.50 МиБ**.
|
||||
- 10 клиентов в общем мире: RSS/VmHWM **57.00 МиБ**, p95 работы тика **9.54 мс**.
|
||||
- 10 клиентов в 10 мирах: RSS/VmHWM **59.65 МиБ**, p95 **7.33 мс**.
|
||||
- После отключения: 0 игроков и 0 активных миров; RSS остаётся **59.65 МиБ** из-за удерживаемого кэша/аллокатора. Нулевое число активных миров не означает нулевой RSS.
|
||||
- Ограничение кэша выполнено во всех выборках, исходящие очереди и активные миры измерены. Оба заранее заданных бюджета пройдены.
|
||||
|
||||
[Полные фазы и исходные выборки](verification/server-benchmark.json). p95 рассчитан по выборке последней работы тика, не по сетевой задержке и не по всей непрерывной последовательности тиков. Это короткий локальный тест движения; нагрузка ванильных механик, тысячи игроков и бесконечные пользовательские миры не включены. С Paper/NeoForge результат не сравнивался.
|
||||
|
||||
Отдельный storage benchmark этапа 1 сохраняется в [storage-benchmark.json](verification/storage-benchmark.json): 256 уникальных секций, кэш 8, 100 forks, обход сверх кэша и независимые edit/reset. После создания forks число immutable blobs осталось 256. Это синтетический профиль ядра с двумя состояниями, отдельный от полного сервера.
|
||||
|
||||
## Браузер и поставка
|
||||
|
||||
[Браузерный протокол проверки](verification/browser.md) фиксирует реальные снимки/DOM, каталог, неполные формы, сущности, чистое подключение без полного registry, галерею, повторное соединение и проверенный ресурсный кэш. На этом стенде наблюдались 144 FPS; переносить это число на другие устройства нельзя.
|
||||
|
||||
`python3 scripts/make_release.py` и вариант `--binaries` создают архив из чистого Git-коммита, добавляют `RELEASE.json` с SHA-256 каждого файла и проверяют каждый файл при повторном чтении архива. Соседний `.sha256` проверяет весь архив. Поставка проверяется повторной распаковкой, offline Cargo-проверкой исходников и запуском извлечённого Linux-бинарного файла с ресурсами из архива. Итоговый журнал упаковки находится рядом с архивами в `artifacts/`.
|
||||
|
||||
## Границы приёмки
|
||||
|
||||
Основные рубежи A-CORE/SERVER/CLIENT/MCP/SPLEEF подтверждены перечисленными тестами и браузерным осмотром. B-CONTENT, B-PACKAGES, B-INTEROP, B-PERSISTENCE выполнены в документированном профиле. B-MEMORY подтверждён указанной нагрузкой, а не произвольным масштабом. Более широкие пункты исходного ACCEPTANCE (долгий soak, все игровые контексты Minecraft, все варианты внешних миров и реальный Minecraft-плейтест) не объявляются пройденными. Заявления о полной ванильной симуляции, аппаратной отказоустойчивости или экономии относительно Paper отсутствуют.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Interchange MVP: Java 26.2 и Sponge v3
|
||||
|
||||
`shacraft-compat` — отдельная Rust-библиотека и автономная CLI. Она действительно переносит состояния блоков в `WorldStore`: браузер, Control API и MCP редактируют импортированные данные, а экспорт читает текущие секции. Исходный мир хранится отдельно на диске с SHA-256. Возврат оригинала без изменений является отдельным проверенным режимом.
|
||||
|
||||
Целевая версия: **Minecraft Java 26.2, DataVersion 4903**. Библиотека не запускает JVM. Java 25 нужна только воспроизводимой проверке и генератору собственных тестовых сохранений.
|
||||
|
||||
## Команды
|
||||
|
||||
Команды выполняются из корня репозитория. Все выходные пути должны отсутствовать. Каждый экспорт публикует **каталог**, даже экспорт одной постройки: в нём находятся `world.schem` и `conversion-report.json`. Если исходный мир уже содержит файл с этим именем, новый отчёт получает числовой суффикс; исходный файл сохраняется.
|
||||
|
||||
```bash
|
||||
cargo build --release -p shacraft-compat -p shacraft-server
|
||||
|
||||
# Импорт остановленной копии Java-мира в новый native store.
|
||||
target/release/shacraft-compat import-anvil /path/to/java-world data/imported
|
||||
|
||||
# Открыть импорт в браузере через сервер; в списке миров выбрать main.
|
||||
target/release/shacraft-server --data data/imported --listen 127.0.0.1:4000
|
||||
|
||||
# После остановки сервера: все импортированные измерения вместе.
|
||||
target/release/shacraft-compat export-anvil data/imported artifacts/return-exact
|
||||
target/release/shacraft-compat export-anvil data/imported artifacts/return-edited --mode best-effort
|
||||
|
||||
# Новый мир Shacraft: полноценный каталог сохранения Java 26.2.
|
||||
target/release/shacraft-compat export-anvil data artifacts/new-java-world --world lobby --mode best-effort
|
||||
|
||||
# Sponge v3; Offset переносится в координаты native мира.
|
||||
target/release/shacraft-compat import-schem /path/to/build.schem data/build --world main
|
||||
target/release/shacraft-compat export-schem data/build artifacts/build-edited --mode best-effort
|
||||
|
||||
# Экспорт ограниченного объёма нового native мира; границы включительные.
|
||||
target/release/shacraft-compat export-schem data artifacts/build --world lobby --min=-16,-1,-16 --max=16,15,16
|
||||
```
|
||||
|
||||
CLI печатает отчёт JSON в stdout, ошибки — JSON в stderr и ненулевой exit code. Конвертация выполняется offline: `WorldStore` удерживает ту же блокировку единственного писателя, что сервер, поэтому параллельное редактирование не смешивает ревизии в одном экспорте. Сам исходный Java-мир должен быть остановленной копией: изменения размера/времени файла во время копирования обнаруживаются, но это не заменяет согласованный backup работающего Minecraft.
|
||||
|
||||
## Что реализовано
|
||||
|
||||
- Big-endian NBT: все 12 payload-типов, числовые разрядности, float/double bit patterns, signed arrays, Java modified UTF-8/CESU-8, Unicode, тип элемента пустого списка и неизвестные compound-поля. Duplicate compound keys, невозможные длины, превышение глубины и лишние распакованные байты отклоняются.
|
||||
- Anvil: `level.dat`, обычные измерения, `dimensions/<namespace>/<path>`, секционные палитры `Name`/`Properties`, современные непересекающие 64-битную границу индексы, отрицательные координаты, единообразные секции, биомы. Чтение gzip, zlib, raw и LZ4Block с checksum, включая внешние `c.x.z.mcc`. Запись zlib и внешний payload для chunk более 255 секторов.
|
||||
- Исходные `entities`, `block_entities`, POI, биомы, inventories, playerdata, datapacks, неизвестные поля и произвольные обычные файлы сохраняются на диске. Отдельные entity region-файлы действительно читаются для серверного представления, а не только копируются.
|
||||
- Sponge v3: `Blocks`, sparse palette indices и varints, block entities, сущности, полный 3D-контейнер биомов, размеры, Offset, Metadata и неизвестные теги. Контейнер `Schematic` вложен в корневой NBT compound согласно спецификации.
|
||||
- Новый Anvil-мир использует собственный маленький шаблон сохранения, созданный public API Java 26.2. В 26.2 параметры генерации и game rules находятся в `data/minecraft/world_gen_settings.dat` и `game_rules.dat`; экспорт включает эти файлы. Генератор — пустой flat overworld, creative, три стандартных измерения, биом plains в новых секциях, высота редактируемого экспорта −64…319.
|
||||
|
||||
## Сущности между MCP и файлами мира
|
||||
|
||||
Импорт создаёт совместимый `server.sqlite3`, таблица `metadata(id,json)`. До 4 096 сущностей становятся доступны через сервер и MCP с исходными UUID, типом, координатами и поворотом. Остальные сущности, неподходящие координаты и неподдерживаемые записи остаются в оригинале и отмечаются `preserved`; они не превращаются в пропавшие данные.
|
||||
|
||||
Исходный типизированный NBT каждой редактируемой сущности находится в `compat/entities/<uuid>.nbt`; его хеш закреплён в provenance. `compat/native-entities.json` содержит исходное серверное представление. Экспорт отдельно сравнивает fingerprint сущностей и ревизии блоков, поэтому перемещение сущности без правки блоков также отклоняет `exact`.
|
||||
|
||||
`best-effort` применяет создание, удаление и перемещение сущностей между chunks/regions и измерениями, а также yaw в радианах из серверного API. Явно поддерживаются свойства `Health`, `CustomName`, `NoGravity`, `Invisible`, `Invulnerable`, `Glowing`, `Silent`, `CustomNameVisible` с соответствующими числовыми/строковыми/логическими типами. Прочие свойства JSON сохраняются в `shacraft-native-entities*.json` и получают `entity_property_unmapped`; они не выдаются за vanilla NBT. Оригинальный NBT сохраняет остальные поля. AI, passengers, leash и связанные UUID не симулируются и не получают обещания точной семантики после перемещения.
|
||||
|
||||
## Exact и best-effort
|
||||
|
||||
`exact` использует DataVersion 4903 и тот же формат, что источник. Пока **любая новая ревизия блоков или изменённое серверное представление сущностей** консервативно запрещает точный возврат исходного мира/постройки. Это касается даже отменённой правки, восстановившей те же блоки: ревизия не возвращается назад. Без правок все исходные файлы побайтно идентичны после проверки SHA-256.
|
||||
|
||||
Для нового native мира `exact` поддерживает представимый Sponge v3. Создание нового Anvil-окружения и межформатное преобразование требуют `best-effort`, поскольку свет и производные данные нуждаются в восстановлении Minecraft. Режим не содержит скрытых замен блоков. Неизвестный namespace остаётся в палитре и получает `unsupported`: целевому Minecraft нужен соответствующий мод, иначе он может заменить неизвестный блок воздухом.
|
||||
|
||||
В изменённых Anvil chunks экспорт обновляет палитры/блоки, удаляет сохранённые light arrays и heightmaps, устанавливает `isLightOn=false`, удаляет устаревшие block entities и scheduled ticks непосредственно в изменённых координатах, инвалидирует POI record изменённого chunk. Все эти действия перечислены в отчёте, оригиналы находятся в `shacraft-source-sidecar*`. Это проверенный профиль сериализации и инвалидации; прохождение полной игровой симуляции света/POI не заявляется.
|
||||
|
||||
При экспорте между `.schem` и Anvil переносится блоковая модель и явный entity bridge. Биомная сетка, block entities и произвольные metadata разных форматов пока архивируются без преобразования и получают `cross_format_opaque_data`. Оригинал находится рядом с результатом. Неподдерживаемые версии не мигрируют через DataFixer: `exact` отказывает, а `best-effort` сохраняет исходный DataVersion и диагностирует непроверенную схему.
|
||||
|
||||
## Ограничения ресурсов и публикация
|
||||
|
||||
- Не более 16 MiB распакованного NBT на chunk/постройку, глубина 64, до 1 000 000 NBT nodes. Это лимиты входных данных, не обещание 16 MiB RSS: Rust-структуры и строки занимают дополнительную память.
|
||||
- Sponge: максимум 262 144 вокселя; Anvil обрабатывается по одному chunk, экспортная очередь координат находится на диске, страницы секций — не более 4 096 ключей. Кэш ядра конвертера — 32 секции плюс ограниченный SQLite cache.
|
||||
- Entity bridge: 4 096 записей, до 16 KiB properties на сущность, ограничение сериализованного metadata 8 MiB (импортируемый entity subset ограничен 6 MiB с запасом для настроек сервера). Избыточные исходные сущности сохраняются без загрузки всех записей в серверную RAM.
|
||||
- До 1 000 подробных issue в отчёте; `omitted_issues` явно считает оставшиеся события. До 256 импортированных измерений, 1 000 000 файлов, 8 GiB на исходный файл и 1 TiB суммарного источника. Symlinks, специальные файлы, выход из каталога, вложенный в исходный мир destination и повреждённые region headers отклоняются.
|
||||
- Импорт строит весь новый native store, metadata и provenance в соседнем временном каталоге. Экспорт тоже использует staging. Файлы и каталоги синхронизируются, публикация — Linux `renameat2(RENAME_NOREPLACE)` и fsync родителя. Конвертер никогда не перезаписывает существующий destination и не меняет исходный Java-мир.
|
||||
- При штатной ошибке staging удаляется; после SIGKILL может остаться непубликованный `.shacraft-convert-*`. Повторный импорт безопасно начинается заново. Возобновление с последнего chunk после аварии ещё не реализовано. Нужен запас диска: исходный архив плюс native DB, а изменённый экспорт сохраняет ещё одну полную копию исходника.
|
||||
|
||||
`.schem` v2, legacy `.schematic`, vanilla structure `.nbt`, произвольные повороты/миграции версий и полная ванильная симуляция не включены в этот профиль. Они не распознаются как v3 по расширению файла.
|
||||
|
||||
## Воспроизводимая проверка
|
||||
|
||||
```bash
|
||||
cargo test -p shacraft-compat
|
||||
cargo clippy -p shacraft-compat --all-targets -- -D warnings
|
||||
python3 -m venv artifacts/compat-venv
|
||||
artifacts/compat-venv/bin/pip install nbtlib==2.0.4
|
||||
artifacts/compat-venv/bin/python scripts/compat_verify.py
|
||||
```
|
||||
|
||||
Для последней команды нужен pinned JAR/JDK из `scripts/catalog_generate.py`; альтернативный JDK передаётся `--java-bin /path/to/jdk25/bin`. Скрипт проверяет SHA-1 JAR, компилирует `compat_java.java`, проверяет оригинальный, изменённый и новый Rust-экспорт через **официальные `NbtIo`, `RegionFile`, `SerializableChunkData`, `PrimaryLevelData`, `WorldGenSettings`**. Независимый `nbtlib 2.0.4` сравнивает типизированные Sponge-теги. Результат и Java logs сохраняются в `artifacts/compat-verification-*/verification.json`.
|
||||
|
||||
Проверка 14 сентября 2026: 17 Rust-тестов и clippy прошли; Java прочитал exact 1 chunk / 3 блока, edited 2 chunks / 3 блока, new 2 chunks / 2 блока. Плюс тесты всех четырёх compression modes, внешнего большого chunk, границ палитр, отрицательных координат, entities move/create/remove, отказа exact после entity edits, checksum и no-overwrite. Java-проверка использует codecs, **не запускает сервер, не принимает EULA и не является игровым плейтестом**.
|
||||
|
||||
Первичные источники: [Sponge v3 specification](https://github.com/SpongePowered/Schematic-Specification/blob/master/versions/schematic-3.md), [WorldEdit AnvilChunk18](https://github.com/EngineHub/WorldEdit/blob/dca10737fe81bd81bb6a096e1801cc2d94f1793b/worldedit-core/src/main/java/com/sk89q/worldedit/world/chunk/AnvilChunk18.java), [lz4-java block stream](https://github.com/lz4/lz4-java/blob/master/src/java/net/jpountz/lz4/LZ4BlockInputStream.java). Runtime реализация написана самостоятельно; код Minecraft и оригинальные игровые ресурсы не включены.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Браузерная проверка 2026-09-14
|
||||
|
||||
Проверялся реальный Codex In-app Browser на `http://127.0.0.1:4000` через CUA. API-тесты проводились отдельными HTTP/WebSocket-процессами.
|
||||
|
||||
- Чистое открытие страницы: manifest, 8 проверенных ресурсов, вход в lobby, собственный WebGL2-рендер без ошибки инициализации.
|
||||
- Лобби видно на реальном снимке: пиксельная площадка, световые столбы, арка, вода, trampoline, небо, солнце, HUD.
|
||||
- Каталог открыл 32 367 состояний; поиск `minecraft:oak_slab` вернул 6 вариантов с точными type/waterlogged. Выбран `type=bottom,waterlogged=false`, быстрый слот сменился на него.
|
||||
- Диагностика показывала live tick/ack, ревизию, координаты, число блоков, RAM и полезный кэш отдельно. В лобби на стенде наблюдались 144 FPS, 11 920 треугольников, 0 ожидающих секций.
|
||||
- После остановки и замены серверного процесса открытая страница автоматически переподключилась. Ранее выбранный материал остался выбранным.
|
||||
- Созданные через Control API сохраняемые сущности пришли в клиент; allay виден на снимке, счётчик сущностей обновился.
|
||||
- Второй чистый браузерный клиент загрузил финальный протокол **без передачи полного registry**. Hotbar заполнился через поиск каталога; 8 ресурсов повторно прошли проверку из кэша.
|
||||
- Выбор `gallery` заменил мир. На реальном снимке видны разные кубические и неполные формы, UI не перекрывает весь мир. Диагностика: 1 614 блоков в текущем окне, 7 808 треугольников, 144 FPS, 0 ожидающих секций. Все 1 197 образцов находятся в мире, а не обязательно в одном окне просмотра.
|
||||
|
||||
FPS описывает этот браузер, оборудование, размер окна и точку наблюдения. Это не минимальное требование и не обещание производительности на другом устройстве. Движение, reach, строительство двух клиентов, Spleef, переподключение и аварийная долговечность дополнительно проверены воспроизводимым `scripts/check_server.mjs`.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"catalog": "byte-for-byte identical after full reports + runtime extraction",
|
||||
"package_assets": "byte-for-byte identical after authoring script",
|
||||
"source_hashes": {
|
||||
"catalog.json.gz": "df889f6ce074b96130011d00b2f3b661cf910aba44064a0b5f361d7c064ce8ed",
|
||||
"report-identities.json.gz": "938232b10d17071d3eefa54ce9a1c3026e17f39b5a2c0f4d6ede2644824d9135"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"date": "2026-09-14",
|
||||
"platform": "Linux",
|
||||
"machine": "x86_64",
|
||||
"kernel": "7.0.0-31-generic",
|
||||
"cpu": "AMD Ryzen 7 1700 Eight-Core Processor",
|
||||
"rustc": "rustc 1.96.0 (ac68faa20 2026-05-25)",
|
||||
"cargo": "cargo 1.96.0 (30a34c682 2026-05-25)",
|
||||
"node": "v22.22.3",
|
||||
"python": "3.14.4"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"success": true,
|
||||
"directory": "/home/emil/Desktop/shacraft-core/artifacts/compat-verification-2rxmacov",
|
||||
"java": [
|
||||
{
|
||||
"java26_2_verified": true,
|
||||
"chunks": 1,
|
||||
"sections": 1,
|
||||
"entities": 1,
|
||||
"nonair_blocks": 3,
|
||||
"level_name": "Shacraft template",
|
||||
"scope": "NbtIo,RegionFile,SerializableChunkData,PrimaryLevelData,WorldGenSettings codecs; no server simulation",
|
||||
"case": "exact"
|
||||
},
|
||||
{
|
||||
"java26_2_verified": true,
|
||||
"chunks": 2,
|
||||
"sections": 25,
|
||||
"entities": 1,
|
||||
"nonair_blocks": 3,
|
||||
"level_name": "Shacraft template",
|
||||
"scope": "NbtIo,RegionFile,SerializableChunkData,PrimaryLevelData,WorldGenSettings codecs; no server simulation",
|
||||
"case": "edited"
|
||||
},
|
||||
{
|
||||
"java26_2_verified": true,
|
||||
"chunks": 2,
|
||||
"sections": 48,
|
||||
"entities": 0,
|
||||
"nonair_blocks": 2,
|
||||
"level_name": "own",
|
||||
"scope": "NbtIo,RegionFile,SerializableChunkData,PrimaryLevelData,WorldGenSettings codecs; no server simulation",
|
||||
"case": "new"
|
||||
}
|
||||
],
|
||||
"nbtlib_version": "2.0.4",
|
||||
"source_jar_sha1": "823e2250d24b3ddac457a60c92a6a941943fcd6a",
|
||||
"source_jar_sha256": "cdacdfb25898de5e4b4b0e5ddcc2722f77067e46605709c2d886c000ebb63ec5",
|
||||
"nbtlib_sponge_typed_roundtrip": true,
|
||||
"exact_source_files_identical": true,
|
||||
"edited_world": {
|
||||
"schema_version": 1,
|
||||
"format": "anvil",
|
||||
"mode": "best-effort",
|
||||
"data_version": 4903,
|
||||
"worlds": {
|
||||
"main": 2
|
||||
},
|
||||
"chunks": 2,
|
||||
"sections": 25,
|
||||
"nonair_blocks": 3,
|
||||
"issues": [
|
||||
{
|
||||
"code": "source_archive",
|
||||
"severity": "info",
|
||||
"action": "preserved",
|
||||
"location": "shacraft-source-sidecar",
|
||||
"detail": "Verified original world retained for unsupported/stale data",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"code": "dependent_records_invalidated",
|
||||
"severity": "warning",
|
||||
"action": "dropped",
|
||||
"location": "main:chunk-1,1",
|
||||
"detail": "Removed1 stale block_entities at edited coordinates; originals retained in sidecar",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"code": "derived_fields_invalidated",
|
||||
"severity": "warning",
|
||||
"action": "approximated",
|
||||
"location": "main:chunk-1,1",
|
||||
"detail": "Removed stored light arrays and heightmaps, marked isLightOn=false for Minecraft reconstruction; unknown fields/entities/biomes preserved. No vanilla simulation guarantee.",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"code": "derived_fields_invalidated",
|
||||
"severity": "warning",
|
||||
"action": "approximated",
|
||||
"location": "main:chunk32,-33",
|
||||
"detail": "Removed stored light arrays and heightmaps, marked isLightOn=false for Minecraft reconstruction; unknown fields/entities/biomes preserved. No vanilla simulation guarantee.",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"code": "entity_edited",
|
||||
"severity": "warning",
|
||||
"action": "approximated",
|
||||
"location": "00000001-0000-0002-0000-000300000004",
|
||||
"detail": "Position/yaw/type and supported scalar properties applied; unknown original NBT retained; passenger/tether/AI relationships not simulated",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"issue_limit": 1000,
|
||||
"omitted_issues": 0,
|
||||
"source_files": 0,
|
||||
"source_bytes": 0,
|
||||
"native_entity_bytes": 0,
|
||||
"profile": "Java 26.2 (DataVersion 4903); blocks editable, other typed NBT preserved on disk; offline writer lock"
|
||||
},
|
||||
"native_world": {
|
||||
"schema_version": 1,
|
||||
"format": "anvil",
|
||||
"mode": "best-effort",
|
||||
"data_version": 4903,
|
||||
"worlds": {
|
||||
"own": 1
|
||||
},
|
||||
"chunks": 2,
|
||||
"sections": 48,
|
||||
"nonair_blocks": 2,
|
||||
"issues": [
|
||||
{
|
||||
"code": "new_world_profile",
|
||||
"severity": "warning",
|
||||
"action": "approximated",
|
||||
"location": "level.dat",
|
||||
"detail": "Creative Java 26.2 overworld, empty flat generator, Y[-64,319], default plains biomes. Minecraft reconstructs light/heightmaps. Native entities use the explicit property bridge.",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"code": "derived_fields_invalidated",
|
||||
"severity": "warning",
|
||||
"action": "approximated",
|
||||
"location": "own:chunk-1,0",
|
||||
"detail": "Removed stored light arrays and heightmaps, marked isLightOn=false for Minecraft reconstruction; unknown fields/entities/biomes preserved. No vanilla simulation guarantee.",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"code": "derived_fields_invalidated",
|
||||
"severity": "warning",
|
||||
"action": "approximated",
|
||||
"location": "own:chunk0,0",
|
||||
"detail": "Removed stored light arrays and heightmaps, marked isLightOn=false for Minecraft reconstruction; unknown fields/entities/biomes preserved. No vanilla simulation guarantee.",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"issue_limit": 1000,
|
||||
"omitted_issues": 0,
|
||||
"source_files": 0,
|
||||
"source_bytes": 0,
|
||||
"native_entity_bytes": 0,
|
||||
"profile": "Java 26.2 (DataVersion 4903); blocks editable, other typed NBT preserved on disk; offline writer lock"
|
||||
},
|
||||
"limitations": "Java checks official disk/container/palette/world-metadata codecs, not running server, client renderer, light simulation or EULA acceptance."
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"passed": true,
|
||||
"server_binary": "/home/emil/Desktop/shacraft-core/target/release/shacraft-server",
|
||||
"mcp_binary": "/home/emil/Desktop/shacraft-core/target/release/shacraft-mcp",
|
||||
"sdk": true,
|
||||
"checks": [
|
||||
"SDK initialize, version negotiation, notification and ping",
|
||||
"17 discoverable tool schemas accepted by SDK",
|
||||
"4 resources read through SDK",
|
||||
"Construction prompt and arguments",
|
||||
"Durable write, read, idempotent retry, conflict content and undo",
|
||||
"Plan and atomic idempotent commit",
|
||||
"MCP PNG image content",
|
||||
"Entity spawn/update/list/delete",
|
||||
"Persisted world configuration and measured metrics",
|
||||
"World reset"
|
||||
],
|
||||
"world": "mcp_sdk_1789395818564"
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
Checking shacraft-compat v0.1.0 (/home/emil/Desktop/shacraft-core/crates/shacraft-compat)
|
||||
Checking shacraft-server v0.1.0 (/home/emil/Desktop/shacraft-core/crates/shacraft-server)
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.77s
|
||||
Compiling shacraft-compat v0.1.0 (/home/emil/Desktop/shacraft-core/crates/shacraft-compat)
|
||||
Compiling shacraft-server v0.1.0 (/home/emil/Desktop/shacraft-core/crates/shacraft-server)
|
||||
Finished `test` profile [unoptimized + debuginfo] target(s) in 3.09s
|
||||
Running unittests src/lib.rs (target/debug/deps/shacraft_compat-cd22d99df205fe27)
|
||||
|
||||
running 8 tests
|
||||
test anvil::tests::lz4_raw_and_compressed ... ok
|
||||
test nbt::tests::rejects_bombs ... ok
|
||||
test schem::tests::varint_validation ... ok
|
||||
test nbt::tests::all_types_roundtrip ... ok
|
||||
test anvil::tests::negative_region_zlib_and_external ... ok
|
||||
test anvil::tests::all_compression_modes_and_external_payloads ... ok
|
||||
test anvil::tests::section_palette_boundaries ... ok
|
||||
test anvil::tests::oversized_chunk_writes_external_and_validates_checksum ... ok
|
||||
|
||||
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.37s
|
||||
|
||||
Running unittests src/main.rs (target/debug/deps/shacraft_compat-bd4306d00d4a6c17)
|
||||
|
||||
running 0 tests
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Running tests/roundtrip.rs (target/debug/deps/roundtrip-f70ba20f52297682)
|
||||
|
||||
running 9 tests
|
||||
test malformed_input_is_not_published ... ok
|
||||
test no_overwrite_and_provenance_hash_guard ... ok
|
||||
test unsupported_data_version_rejects_exact_before_publication ... ok
|
||||
test duplicate_entity_uuid_stays_outside_editable_subset ... ok
|
||||
test sponge_entity_metadata_guard_and_position_roundtrip ... ok
|
||||
test sponge_import_edits_unknown_states_and_preserves_nbt ... ok
|
||||
test native_entity_edit_move_create_remove_roundtrip ... ok
|
||||
test genuine_java_world_import_exact_roundtrip_edit_and_new_chunk ... ok
|
||||
test native_new_world_export_and_schematic ... ok
|
||||
|
||||
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s
|
||||
|
||||
Running unittests src/lib.rs (target/debug/deps/shacraft_content-ee19c845f27f8fb2)
|
||||
|
||||
running 11 tests
|
||||
test shapes::tests::four_quarter_rotations_restore_box ... ok
|
||||
test shapes::tests::connected_fence_has_two_added_rails ... ok
|
||||
test shapes::tests::rail_ascent_reaches_next_block ... ok
|
||||
test packages::tests::changed_resource_rejected ... ok
|
||||
test packages::tests::bundled_packages_hashes_dependencies_and_privacy ... ok
|
||||
test packages::tests::paths_sizes_and_versions_are_validated ... ok
|
||||
test tests::key_collision_states_and_orientation ... ok
|
||||
test tests::official_entity_dimensions_and_authored_models_present ... ok
|
||||
test tests::sampler_defaults_and_palette_are_registered ... ok
|
||||
test tests::every_state_has_valid_shapes_and_explicit_coverage ... ok
|
||||
test tests::complete_against_independent_official_report_projection ... ok
|
||||
|
||||
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.31s
|
||||
|
||||
Running unittests src/lib.rs (target/debug/deps/shacraft_core-f7257583d500a490)
|
||||
|
||||
running 11 tests
|
||||
test section::tests::crc32_matches_standard_check_value ... ok
|
||||
test section::tests::direct_read_rejects_out_of_range_cell - should panic ... ok
|
||||
test section::tests::packed_indices_match_cross_byte_wire_fixture ... ok
|
||||
test section::tests::uniform_round_trip_and_little_endian_layout ... ok
|
||||
test section::tests::invalid_palettes_and_indices_are_rejected ... ok
|
||||
test section::tests::structurally_invalid_headers_are_rejected_even_with_valid_checksum ... ok
|
||||
test section::tests::dense_round_trip_and_size_crossover ... ok
|
||||
test section::tests::truncated_extra_and_corrupted_bytes_are_rejected ... ok
|
||||
test section::tests::every_storage_mode_checks_registry_bounds ... ok
|
||||
test section::tests::palette_width_changes_at_powers_of_two ... ok
|
||||
test section::tests::every_palette_width_round_trips ... ok
|
||||
|
||||
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
|
||||
|
||||
Running tests/contract.rs (target/debug/deps/contract-b050c7d50e9640d3)
|
||||
|
||||
running 8 tests
|
||||
test accepted_noop_advances_revision_and_prevents_undo_of_earlier_edit ... ok
|
||||
test operation_id_collision_rejects_changed_payload_across_restart_and_methods ... ok
|
||||
test edit_replay_precedes_revision_check_and_survives_reset_and_reopen ... ok
|
||||
test invalid_batch_never_partially_writes_or_advances_revision ... ok
|
||||
test reset_is_monotonic_idempotent_durable_and_an_undo_barrier ... ok
|
||||
test undo_restores_inherited_content_and_replays_without_mutating_newer_state ... ok
|
||||
test strict_undo_rejects_later_nonoverlapping_edits_and_aba_cycles ... ok
|
||||
test snapshots_pin_effective_source_contents_and_air_overrides ... ok
|
||||
|
||||
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
|
||||
|
||||
Running tests/section_pages.rs (target/debug/deps/section_pages-92b3be09a3e83895)
|
||||
|
||||
running 1 test
|
||||
test section_pages_are_sorted_bounded_and_include_inherited_air_overrides ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
|
||||
|
||||
Running tests/storage.rs (target/debug/deps/storage-be95f5d467d8c630)
|
||||
|
||||
running 10 tests
|
||||
test lock_is_exclusive_and_released_on_drop ... ok
|
||||
test registry_is_canonical_durable_and_rejects_malformed_identifiers ... ok
|
||||
test corrupted_blob_is_reported_instead_of_air ... ok
|
||||
test revision_overflow_rejects_mutation_but_keeps_original_replay ... ok
|
||||
test sqlite_failure_mid_edit_rolls_back_blobs_history_and_revision ... ok
|
||||
test explicit_gc_preserves_snapshots_history_and_cache_correctness ... ok
|
||||
test oversized_unreachable_blob_is_rejected_before_reinterning ... ok
|
||||
test compact_cache_evicts_distinct_blobs_and_shares_identical_sections ... ok
|
||||
test coordinate_boundaries_and_inclusive_read_limits ... ok
|
||||
test uniform_section_remains_twenty_bytes_in_resident_cache ... ok
|
||||
|
||||
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
|
||||
|
||||
Running unittests src/main.rs (target/debug/deps/shacraft_mcp-8af48a29a5cc1f7b)
|
||||
|
||||
running 8 tests
|
||||
test tests::build_shapes_validate_without_allowing_invented_parameters ... ok
|
||||
test tests::supported_version_is_preserved ... ok
|
||||
test tests::malformed_ids_and_requests_do_not_execute ... ok
|
||||
test tests::notifications_never_generate_responses ... ok
|
||||
test tests::invalid_tool_arguments_are_mcp_content_errors ... ok
|
||||
test tests::unavailable_token_is_a_tool_error_not_transport_error ... ok
|
||||
test tests::lifecycle_negotiates_and_rejects_before_initialization ... ok
|
||||
test tests::bounded_reader_recovers_after_oversized_line ... ok
|
||||
|
||||
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
|
||||
|
||||
Running unittests src/main.rs (target/debug/deps/shacraft_server-33e07bc1da9f7f4e)
|
||||
|
||||
running 18 tests
|
||||
test game::tests::legacy_world_configuration_gets_safe_spleef_defaults ... ok
|
||||
test packages::tests::changed_definition_or_module_fails_hash_verification_before_loading ... ok
|
||||
test game::tests::outbox_has_byte_and_message_limits ... ok
|
||||
test packages::tests::sandbox_executes_and_bounds_fuel_memory_and_results ... ok
|
||||
test packages::tests::verified_custom_package_registers_content_and_binds_real_wasm ... ok
|
||||
test packages::tests::duplicate_vanilla_and_invalid_geometry_definitions_are_rejected ... ok
|
||||
test game::tests::trampoline_executes_wasm_despite_small_contact_gap ... ok
|
||||
test game::tests::official_collision_shapes_drive_world_physics ... ok
|
||||
test game::tests::arena_configuration_validates_before_mutation ... ok
|
||||
test game::tests::authoritative_actions_reject_forged_targets_and_sync_real_edits ... ok
|
||||
test game::tests::material_payloads_are_bounded_and_runtime_id_lookup_works_without_registry_dump ... ok
|
||||
test game::tests::snapshots_carry_revision_and_slow_consumers_are_disconnected ... ok
|
||||
test game::tests::gallery_contains_every_default_state_over_a_bounded_floor ... ok
|
||||
test game::tests::eliminated_spleef_players_cannot_reenter_and_late_joins_spectate ... ok
|
||||
test game::tests::entities_are_durable_and_failed_commits_do_not_publish_changes ... ok
|
||||
test game::tests::restart_recovers_arena_configuration_and_cleans_interrupted_round ... ok
|
||||
test game::tests::configured_spleef_rules_control_full_round_and_survive_restart ... ok
|
||||
test game::tests::interrupted_creation_of_managed_world_can_finish_its_seed ... ok
|
||||
|
||||
test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 15.29s
|
||||
|
||||
Running unittests src/main.rs (target/debug/deps/shacraft_tools-1836b285f941c1f2)
|
||||
|
||||
running 0 tests
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Doc-tests shacraft_compat
|
||||
|
||||
running 0 tests
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Doc-tests shacraft_content
|
||||
|
||||
running 0 tests
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Doc-tests shacraft_core
|
||||
|
||||
running 0 tests
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Compiling shacraft-server v0.1.0 (/home/emil/Desktop/shacraft-core/crates/shacraft-server)
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.71s
|
||||
{
|
||||
"passed": true,
|
||||
"checks": [
|
||||
"bounded resident cache with verified eviction while reading 80 sections",
|
||||
"second writer rejected while owner is running",
|
||||
"80 acknowledged edits and registry survive SIGKILL without flush",
|
||||
"idempotent replay after SIGKILL precedes current revision check",
|
||||
"invalid batch leaves both blocks and revision unchanged",
|
||||
"interrupted 32768-cell transaction is atomic (recovered revision 0)",
|
||||
"uncertain interrupted request can safely be retried",
|
||||
"invalid request does not terminate the local test session",
|
||||
"4096-cell uniform section occupies 20 encoded payload bytes in resident cache"
|
||||
],
|
||||
"final_metrics": {
|
||||
"process": {
|
||||
"peak_rss_bytes": 8114176,
|
||||
"rss_bytes": 8114176,
|
||||
"source": "/proc/self/status (null when unavailable)"
|
||||
},
|
||||
"storage": {
|
||||
"cache_capacity_sections": 2,
|
||||
"cache_entries": 1,
|
||||
"cache_evictions": 0,
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 1,
|
||||
"cache_payload_bytes": 20,
|
||||
"cache_representation": "encoded_uniform_palette_dense",
|
||||
"commits_this_handle": 0,
|
||||
"coordinate_limit": 30000000,
|
||||
"database_bytes": 647168,
|
||||
"history_change_count": 36944,
|
||||
"history_retention": "all_operations_until_explicit_future_retention_policy",
|
||||
"immutable_blob_count": 18,
|
||||
"last_mutation_duration_us": 0,
|
||||
"max_edit_cells": 32768,
|
||||
"max_read_volume": 262144,
|
||||
"operation_count": 82,
|
||||
"os_rss_bytes": null,
|
||||
"overlay_section_count": 97,
|
||||
"registry_count": 2,
|
||||
"registry_limit_states": 262144,
|
||||
"registry_text_bytes": 32,
|
||||
"schema_version": 1,
|
||||
"shm_bytes": 32768,
|
||||
"snapshot_count": 0,
|
||||
"sqlite_cache_budget_bytes": 4194304,
|
||||
"sqlite_journal_mode": "wal",
|
||||
"sqlite_synchronous": "FULL",
|
||||
"transient_decoded_payload_peak_estimate_bytes": 16384,
|
||||
"wal_bytes": 0,
|
||||
"world_count": 3,
|
||||
"world_limit": 10000
|
||||
}
|
||||
},
|
||||
"limits": [
|
||||
"process crash tested; power loss and faulty hardware are not simulated",
|
||||
"storage-only workload; no Paper or gameplay performance comparison"
|
||||
]
|
||||
}
|
||||
TAP version 13
|
||||
# Subtest: camera axes and world projection match the server coordinate convention
|
||||
ok 1 - camera axes and world projection match the server coordinate convention
|
||||
---
|
||||
duration_ms: 2.623771
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: DDA resolves negative world coordinates and nearest entry face
|
||||
ok 2 - DDA resolves negative world coordinates and nearest entry face
|
||||
---
|
||||
duration_ms: 4.264518
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: ray selection respects slab geometry and reach instead of selecting empty half
|
||||
ok 3 - ray selection respects slab geometry and reach instead of selecting empty half
|
||||
---
|
||||
duration_ms: 8.035945
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: axis aligned ray slabs reject parallel paths outside a box
|
||||
ok 4 - axis aligned ray slabs reject parallel paths outside a box
|
||||
---
|
||||
duration_ms: 0.271763
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: selection intersects a fence extension owned by the cell below
|
||||
ok 5 - selection intersects a fence extension owned by the cell below
|
||||
---
|
||||
duration_ms: 1.042738
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: package geometry is selectable across its documented three-block extent
|
||||
ok 6 - package geometry is selectable across its documented three-block extent
|
||||
---
|
||||
duration_ms: 1.65838
|
||||
type: 'test'
|
||||
...
|
||||
1..6
|
||||
# tests 6
|
||||
# suites 0
|
||||
# pass 6
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 134.144281
|
||||
{
|
||||
"passed": true,
|
||||
"checks": [
|
||||
"Manifest resource SHA-256/size, admin token required, server WASM not public",
|
||||
"Incompatible manifest rejected",
|
||||
"Two clients receive shared snapshot and presence",
|
||||
"Authoritative movement and collision; client position spoof ignored",
|
||||
"Forged out-of-reach block break rejected",
|
||||
"Reachable break/place, server revision ordering and broadcast",
|
||||
"Late join receives current geometry and revision",
|
||||
"Pinned template and independent clone isolation",
|
||||
"Scoped game chat delivered to peer",
|
||||
"Persisted entity events reach both clients",
|
||||
"Reconnect restores authoritative world snapshot",
|
||||
"Full two-player Spleef countdown, elimination, winner and automatic floor reset",
|
||||
"Disconnect removes players and active worlds",
|
||||
"Real SIGKILL/restart preserves committed blocks, entity properties and arena configuration"
|
||||
],
|
||||
"metrics": {
|
||||
"active_worlds": 0,
|
||||
"catalog": {
|
||||
"collision": {
|
||||
"approximate-context": 179,
|
||||
"authored-exact": 1,
|
||||
"measured-empty-context": 32187
|
||||
},
|
||||
"collision_context": "BlockPos.ZERO, air neighbors, empty entity context; contextual classes flagged separately",
|
||||
"data_version": 4903,
|
||||
"entities": 158,
|
||||
"minecraft_blocks": 1196,
|
||||
"minecraft_states": 32366,
|
||||
"minecraft_version": "26.2",
|
||||
"palette": {
|
||||
"fence": "minecraft:oak_fence[east=false,north=false,south=false,waterlogged=false,west=false]",
|
||||
"glass": "minecraft:glass",
|
||||
"grass": "minecraft:grass_block[snowy=false]",
|
||||
"light": "minecraft:sea_lantern",
|
||||
"sand": "minecraft:sand",
|
||||
"slab": "minecraft:oak_slab[type=bottom,waterlogged=false]",
|
||||
"snow": "minecraft:snow_block",
|
||||
"stairs": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]",
|
||||
"stone": "minecraft:stone",
|
||||
"trampoline": "shacraft:trampoline",
|
||||
"water": "minecraft:water[level=0]",
|
||||
"wood": "minecraft:oak_planks"
|
||||
},
|
||||
"render": {
|
||||
"authored-package": 1,
|
||||
"authored-procedural": 32316,
|
||||
"intentionally-invisible": 50
|
||||
},
|
||||
"simulation": "server movement, block editing, authored modules; vanilla AI/redstone/fluid simulation are not implemented",
|
||||
"total_states": 32367
|
||||
},
|
||||
"entities": 1,
|
||||
"entity_revision": 13,
|
||||
"limits": {
|
||||
"build_plans": 16,
|
||||
"bytes_per_client": 8388608,
|
||||
"command_queue": 64,
|
||||
"messages_per_client": 16,
|
||||
"plan_cells": 32768,
|
||||
"players": 32
|
||||
},
|
||||
"max_tick_ms": 3.362786,
|
||||
"plans": 0,
|
||||
"players": 0,
|
||||
"queued_client_bytes": 0,
|
||||
"rss_bytes": 61239296,
|
||||
"slow_clients_disconnected": 1,
|
||||
"storage": {
|
||||
"cache_capacity_sections": 64,
|
||||
"cache_entries": 8,
|
||||
"cache_evictions": 0,
|
||||
"cache_hits": 9,
|
||||
"cache_misses": 8,
|
||||
"cache_payload_bytes": 4304,
|
||||
"cache_representation": "encoded_uniform_palette_dense",
|
||||
"commits_this_handle": 0,
|
||||
"coordinate_limit": 30000000,
|
||||
"database_bytes": 7372800,
|
||||
"history_change_count": 22055,
|
||||
"history_retention": "all_operations_until_explicit_future_retention_policy",
|
||||
"immutable_blob_count": 87,
|
||||
"last_mutation_duration_us": 0,
|
||||
"max_edit_cells": 32768,
|
||||
"max_read_volume": 262144,
|
||||
"operation_count": 11,
|
||||
"os_rss_bytes": null,
|
||||
"overlay_section_count": 93,
|
||||
"registry_count": 32367,
|
||||
"registry_limit_states": 262144,
|
||||
"registry_text_bytes": 2671754,
|
||||
"schema_version": 1,
|
||||
"shm_bytes": 32768,
|
||||
"snapshot_count": 2,
|
||||
"sqlite_cache_budget_bytes": 4194304,
|
||||
"sqlite_journal_mode": "wal",
|
||||
"sqlite_synchronous": "FULL",
|
||||
"transient_decoded_payload_peak_estimate_bytes": 16384,
|
||||
"wal_bytes": 1384352,
|
||||
"world_count": 9,
|
||||
"world_limit": 10000
|
||||
},
|
||||
"tick": 1,
|
||||
"tick_ms": 3.362786,
|
||||
"uptime_seconds": 0,
|
||||
"wasm": {
|
||||
"calls": 0,
|
||||
"fuel_per_call": 10000,
|
||||
"imports": 0,
|
||||
"memory_limit": 65536,
|
||||
"runtime": "wasmi",
|
||||
"traps": 0
|
||||
},
|
||||
"world_count": 9
|
||||
},
|
||||
"binary": "/home/emil/Desktop/shacraft-core/target/debug/shacraft-server",
|
||||
"report_file": "/home/emil/Desktop/shacraft-core/artifacts/server-e2e-debug.json",
|
||||
"data": "/tmp/shacraft-server-test-nBiKWZ"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"passed": true,
|
||||
"checks": [
|
||||
"Manifest resource SHA-256/size, admin token required, server WASM not public",
|
||||
"Incompatible manifest rejected",
|
||||
"Two clients receive shared snapshot and presence",
|
||||
"Authoritative movement and collision; client position spoof ignored",
|
||||
"Forged out-of-reach block break rejected",
|
||||
"Reachable break/place, server revision ordering and broadcast",
|
||||
"Late join receives current geometry and revision",
|
||||
"Pinned template and independent clone isolation",
|
||||
"Scoped game chat delivered to peer",
|
||||
"Persisted entity events reach both clients",
|
||||
"Reconnect restores authoritative world snapshot",
|
||||
"Full two-player Spleef countdown, elimination, winner and automatic floor reset",
|
||||
"Disconnect removes players and active worlds",
|
||||
"Real SIGKILL/restart preserves committed blocks, entity properties and arena configuration"
|
||||
],
|
||||
"metrics": {
|
||||
"active_worlds": 0,
|
||||
"catalog": {
|
||||
"collision": {
|
||||
"approximate-context": 179,
|
||||
"authored-exact": 1,
|
||||
"measured-empty-context": 32187
|
||||
},
|
||||
"collision_context": "BlockPos.ZERO, air neighbors, empty entity context; contextual classes flagged separately",
|
||||
"data_version": 4903,
|
||||
"entities": 158,
|
||||
"minecraft_blocks": 1196,
|
||||
"minecraft_states": 32366,
|
||||
"minecraft_version": "26.2",
|
||||
"palette": {
|
||||
"fence": "minecraft:oak_fence[east=false,north=false,south=false,waterlogged=false,west=false]",
|
||||
"glass": "minecraft:glass",
|
||||
"grass": "minecraft:grass_block[snowy=false]",
|
||||
"light": "minecraft:sea_lantern",
|
||||
"sand": "minecraft:sand",
|
||||
"slab": "minecraft:oak_slab[type=bottom,waterlogged=false]",
|
||||
"snow": "minecraft:snow_block",
|
||||
"stairs": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]",
|
||||
"stone": "minecraft:stone",
|
||||
"trampoline": "shacraft:trampoline",
|
||||
"water": "minecraft:water[level=0]",
|
||||
"wood": "minecraft:oak_planks"
|
||||
},
|
||||
"render": {
|
||||
"authored-package": 1,
|
||||
"authored-procedural": 32316,
|
||||
"intentionally-invisible": 50
|
||||
},
|
||||
"simulation": "server movement, block editing, authored modules; vanilla AI/redstone/fluid simulation are not implemented",
|
||||
"total_states": 32367
|
||||
},
|
||||
"entities": 1,
|
||||
"entity_revision": 13,
|
||||
"limits": {
|
||||
"build_plans": 16,
|
||||
"bytes_per_client": 8388608,
|
||||
"command_queue": 64,
|
||||
"messages_per_client": 16,
|
||||
"plan_cells": 32768,
|
||||
"players": 32
|
||||
},
|
||||
"max_tick_ms": 0.001784,
|
||||
"plans": 0,
|
||||
"players": 0,
|
||||
"queued_client_bytes": 0,
|
||||
"rss_bytes": 51974144,
|
||||
"slow_clients_disconnected": 0,
|
||||
"storage": {
|
||||
"cache_capacity_sections": 64,
|
||||
"cache_entries": 8,
|
||||
"cache_evictions": 0,
|
||||
"cache_hits": 1,
|
||||
"cache_misses": 8,
|
||||
"cache_payload_bytes": 4304,
|
||||
"cache_representation": "encoded_uniform_palette_dense",
|
||||
"commits_this_handle": 0,
|
||||
"coordinate_limit": 30000000,
|
||||
"database_bytes": 7372800,
|
||||
"history_change_count": 22055,
|
||||
"history_retention": "all_operations_until_explicit_future_retention_policy",
|
||||
"immutable_blob_count": 87,
|
||||
"last_mutation_duration_us": 0,
|
||||
"max_edit_cells": 32768,
|
||||
"max_read_volume": 262144,
|
||||
"operation_count": 11,
|
||||
"os_rss_bytes": null,
|
||||
"overlay_section_count": 93,
|
||||
"registry_count": 32367,
|
||||
"registry_limit_states": 262144,
|
||||
"registry_text_bytes": 2671754,
|
||||
"schema_version": 1,
|
||||
"shm_bytes": 32768,
|
||||
"snapshot_count": 2,
|
||||
"sqlite_cache_budget_bytes": 4194304,
|
||||
"sqlite_journal_mode": "wal",
|
||||
"sqlite_synchronous": "FULL",
|
||||
"transient_decoded_payload_peak_estimate_bytes": 16384,
|
||||
"wal_bytes": 1384352,
|
||||
"world_count": 9,
|
||||
"world_limit": 10000
|
||||
},
|
||||
"tick": 1,
|
||||
"tick_ms": 0.001784,
|
||||
"uptime_seconds": 0,
|
||||
"wasm": {
|
||||
"calls": 0,
|
||||
"fuel_per_call": 10000,
|
||||
"imports": 0,
|
||||
"memory_limit": 65536,
|
||||
"runtime": "wasmi",
|
||||
"traps": 0
|
||||
},
|
||||
"world_count": 9
|
||||
},
|
||||
"binary": "/home/emil/Desktop/shacraft-core/target/release/shacraft-server"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"texture": "client/texture.png",
|
||||
"texture_mode": "repeat-tint",
|
||||
"pixel_size": 64,
|
||||
"license": "MIT OR Apache-2.0"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"catalog": "builtin-java-26.2",
|
||||
"data_version": 4903,
|
||||
"coordinates": "+Y up; block models in [0,1], entity models relative feet",
|
||||
"vanilla_assets": false
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"id": "shacraft.base",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": [],
|
||||
"capabilities": [
|
||||
"blocks.define",
|
||||
"entities.define",
|
||||
"client.texture",
|
||||
"client.style"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"path": "common/definitions.json",
|
||||
"scope": "common",
|
||||
"role": "definitions",
|
||||
"size": 181,
|
||||
"sha256": "507e949a255703188fd9af3d8be1c8c1c7e67bd2775d4ed729df8c77f96f6061"
|
||||
},
|
||||
{
|
||||
"path": "client/texture.png",
|
||||
"scope": "client",
|
||||
"role": "texture",
|
||||
"size": 3939,
|
||||
"sha256": "a2972ac39a68292b2fe52bbc246b6f52c639ba2f5d1cd90ec405d0f50e502d86"
|
||||
},
|
||||
{
|
||||
"path": "client/style.json",
|
||||
"scope": "client",
|
||||
"role": "client-style",
|
||||
"size": 140,
|
||||
"sha256": "2a770ffd217e7689df99e01384b93bf8b0f0608d82a46fa57d31fc5a618727f6"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"block": "shacraft:trampoline",
|
||||
"color": [
|
||||
97,
|
||||
205,
|
||||
151
|
||||
],
|
||||
"texture": "client/texture.png",
|
||||
"effect": "bounce",
|
||||
"sound": "client/bounce.wav",
|
||||
"shader": "client/tint.glsl"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
@@ -0,0 +1,4 @@
|
||||
// Original optional Shacraft material function. GLSL ES 3.00.
|
||||
vec3 shacraftBounceTint(vec3 color, float pulse) {
|
||||
return color * (1.0 + 0.12 * clamp(pulse, 0.0, 1.0));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"blocks": [
|
||||
{
|
||||
"state": "shacraft:trampoline",
|
||||
"color": [
|
||||
97,
|
||||
205,
|
||||
151
|
||||
],
|
||||
"collision": [
|
||||
{
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"behavior": {
|
||||
"server_hook": "on_jump"
|
||||
}
|
||||
}
|
||||
],
|
||||
"client_effect": {
|
||||
"on_jump": "bounce"
|
||||
},
|
||||
"capability_contract": {
|
||||
"hook": "on_jump",
|
||||
"parameters": [],
|
||||
"result": "f32",
|
||||
"units": "blocks/second",
|
||||
"fuel": 10000,
|
||||
"memory_pages": 1,
|
||||
"imports": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"id": "shacraft.trampoline",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": [
|
||||
{
|
||||
"id": "shacraft.base",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"blocks.define",
|
||||
"client.texture",
|
||||
"client.style",
|
||||
"client.shader",
|
||||
"client.sound",
|
||||
"server.on_jump"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"path": "common/definitions.json",
|
||||
"scope": "common",
|
||||
"role": "definitions",
|
||||
"size": 650,
|
||||
"sha256": "c010805c969996de33d8c679fba3dd7862fe6d39326d318727cf5748f4e1f22c"
|
||||
},
|
||||
{
|
||||
"path": "client/texture.png",
|
||||
"scope": "client",
|
||||
"role": "texture",
|
||||
"size": 123,
|
||||
"sha256": "c8a96e96e1a859294db67f08dbbbde13b2c6dbdce38360dbbb6a5591b8ec1d95"
|
||||
},
|
||||
{
|
||||
"path": "client/style.json",
|
||||
"scope": "client",
|
||||
"role": "client-style",
|
||||
"size": 216,
|
||||
"sha256": "ba703e1774d8f8859d5e61502d03e98948da811d53589147b886203e4323db9a"
|
||||
},
|
||||
{
|
||||
"path": "client/bounce.wav",
|
||||
"scope": "client",
|
||||
"role": "sound",
|
||||
"size": 7982,
|
||||
"sha256": "3ab75928b1347ad2c37f4708e3a3f035ffe32a71f0ba1702253a2d926cc3f51d"
|
||||
},
|
||||
{
|
||||
"path": "client/tint.glsl",
|
||||
"scope": "client",
|
||||
"role": "shader",
|
||||
"size": 174,
|
||||
"sha256": "ea8dd1286f475cfc25968588425d74f74378bb27b466442398a7a0399433bb4f"
|
||||
},
|
||||
{
|
||||
"path": "server/trampoline.wasm",
|
||||
"scope": "server",
|
||||
"role": "wasm-on-jump",
|
||||
"size": 43,
|
||||
"sha256": "c0c756b7dc5f1e829b75861ae15e83c318e24b143d4970acfa821012764dd7bb"
|
||||
},
|
||||
{
|
||||
"path": "server/trampoline.wat",
|
||||
"scope": "server",
|
||||
"role": "wasm-source",
|
||||
"size": 145,
|
||||
"sha256": "cd27136aec4d3b39865d7229be4968529a3a90557fb72238b38276e37a0c311d"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
;; Original executable server extension. No imports, memory, filesystem or network.
|
||||
(module (func (export "on_jump") (result f32) f32.const 10))
|
||||
@@ -0,0 +1,206 @@
|
||||
// Fixed local release-server budgets. This is a reproducible Shacraft-only workload,
|
||||
// not a comparison with Minecraft and not a production player-capacity claim.
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import {readFile} from "node:fs/promises";
|
||||
import { harness, Client, delay, options, root } from "./server-harness.mjs";
|
||||
const opts = options();
|
||||
opts.binary ??= path.join(root, "target/release/shacraft-server");
|
||||
const budgets = { ten_client_rss_bytes: 256 * 1024 * 1024, p95_tick_ms: 50 };
|
||||
const duration = Number(opts["sample-ms"] || 15000);
|
||||
assert.ok(
|
||||
duration >= 3000 && duration <= 120000,
|
||||
"--sample-ms must be 3000..120000",
|
||||
);
|
||||
const h = await harness(opts),
|
||||
clients = [],
|
||||
samples = [],
|
||||
phases = [];
|
||||
let reportWritten = false;
|
||||
const percentile = (values, p) =>
|
||||
[...values].sort((a, b) => a - b)[
|
||||
Math.min(values.length - 1, Math.floor((values.length - 1) * p))
|
||||
];
|
||||
function summary(name, list) {
|
||||
const rss = list.map((s) => s.rss_bytes).filter(Number.isFinite),
|
||||
ticks = list.map((s) => s.tick_ms).filter(Number.isFinite);
|
||||
assert.ok(
|
||||
rss.length && ticks.length,
|
||||
"Measured RSS and tick durations are required",
|
||||
);
|
||||
const last = list.at(-1);
|
||||
return {
|
||||
phase: name,
|
||||
samples: list.length,
|
||||
rss_bytes: {
|
||||
p50: percentile(rss, 0.5),
|
||||
p95: percentile(rss, 0.95),
|
||||
max: Math.max(...rss),
|
||||
},
|
||||
tick_ms: {
|
||||
p50: percentile(ticks, 0.5),
|
||||
p95: percentile(ticks, 0.95),
|
||||
max: Math.max(...ticks),
|
||||
},
|
||||
process_high_water_bytes: last.process_high_water_bytes,
|
||||
players: last.players,
|
||||
active_worlds: last.active_worlds,
|
||||
world_count: last.world_count,
|
||||
storage: last.storage,
|
||||
slow_clients_disconnected: last.slow_clients_disconnected,
|
||||
queued_client_bytes: last.queued_client_bytes,
|
||||
};
|
||||
}
|
||||
async function sample(name, milliseconds = duration) {
|
||||
const list = [],
|
||||
end = Date.now() + milliseconds;
|
||||
while (Date.now() < end) {
|
||||
const m = await h.get("/api/metrics");
|
||||
const processStatus = await readFile(`/proc/${h.pid}/status`, "utf8");
|
||||
const highWater = Number(processStatus.match(/^VmHWM:\s+(\d+)/m)?.[1]) * 1024;
|
||||
assert.ok(Number.isFinite(highWater), "Linux process high-water RSS required");
|
||||
m.process_high_water_bytes = highWater;
|
||||
const s = { phase: name, time: Date.now(), ...m };
|
||||
samples.push(s);
|
||||
list.push(s);
|
||||
await delay(100);
|
||||
}
|
||||
const result = summary(name, list);
|
||||
phases.push(result);
|
||||
return result;
|
||||
}
|
||||
let mover;
|
||||
async function moving(count, distinct) {
|
||||
const manifest = await h.get("/api/manifest");
|
||||
for (let i = 0; i < count; i++) {
|
||||
const c = new Client(h.url);
|
||||
clients.push(c);
|
||||
await c.join(
|
||||
manifest,
|
||||
`test_bench_${String(distinct ? i : 0).padStart(3, "0")}`,
|
||||
`Bench${i}`,
|
||||
);
|
||||
}
|
||||
const started = Date.now();
|
||||
mover = setInterval(() => {
|
||||
const t = (Date.now() - started) / 1000;
|
||||
for (let i = 0; i < clients.length; i++)
|
||||
clients[i].input({
|
||||
yaw: (t * 0.75 + (i * Math.PI) / 5) % (Math.PI * 2),
|
||||
pitch: -0.1,
|
||||
forward: 1,
|
||||
strafe: 0,
|
||||
jump: Math.floor(t) % 5 === 0,
|
||||
});
|
||||
}, 50);
|
||||
await delay(2000);
|
||||
}
|
||||
async function disconnect() {
|
||||
clearInterval(mover);
|
||||
await Promise.all(clients.splice(0).map((c) => c.close()));
|
||||
for (let n = 0; n < 50; n++) {
|
||||
const m = await h.get("/api/metrics");
|
||||
if (m.players === 0) return;
|
||||
await delay(100);
|
||||
}
|
||||
throw Error("Players did not disconnect within 5 seconds");
|
||||
}
|
||||
try {
|
||||
let created = 0;
|
||||
for (const count of [1, 10, 100]) {
|
||||
while (created < count) {
|
||||
await h.control("world.create", {
|
||||
world: `test_bench_${String(created).padStart(3, "0")}`,
|
||||
template: "lobby",
|
||||
});
|
||||
created++;
|
||||
}
|
||||
await delay(1000);
|
||||
const phase = await sample(`idle_${count}_template_forks`, 3000);
|
||||
assert.equal(phase.players, 0);
|
||||
assert.equal(phase.active_worlds, 0);
|
||||
}
|
||||
await moving(10, false);
|
||||
await sample("10_moving_clients_shared_world");
|
||||
await disconnect();
|
||||
await moving(10, true);
|
||||
await sample("10_moving_clients_separate_worlds");
|
||||
await disconnect();
|
||||
await delay(1500);
|
||||
const idle = await sample("after_disconnect_idle", 3000);
|
||||
assert.equal(idle.players, 0);
|
||||
assert.equal(idle.active_worlds, 0);
|
||||
const active = phases.filter((p) => p.phase.includes("moving_clients"));
|
||||
const budgetResults = {
|
||||
rss_passed: active.every(
|
||||
(p) => p.rss_bytes.max <= budgets.ten_client_rss_bytes && p.process_high_water_bytes <= budgets.ten_client_rss_bytes,
|
||||
),
|
||||
tick_passed: active.every((p) => p.tick_ms.p95 <= budgets.p95_tick_ms),
|
||||
};
|
||||
const cacheSamples = samples.map((s) => {
|
||||
const storage = s.storage;
|
||||
return {
|
||||
entries: storage.cache_entries ?? storage.cache?.entries,
|
||||
capacity:
|
||||
storage.cache_capacity_sections ??
|
||||
storage.cache_capacity ??
|
||||
storage.cache_sections ??
|
||||
storage.cache?.capacity,
|
||||
};
|
||||
});
|
||||
const recognizedCache = cacheSamples.filter(
|
||||
(s) => Number.isFinite(s.entries) && Number.isFinite(s.capacity),
|
||||
);
|
||||
const cacheBounded =
|
||||
recognizedCache.length === cacheSamples.length &&
|
||||
recognizedCache.every((s) => s.entries <= s.capacity);
|
||||
const passed =
|
||||
budgetResults.rss_passed && budgetResults.tick_passed && cacheBounded;
|
||||
await h.report({
|
||||
passed,
|
||||
profile: h.binary.includes("/release/")
|
||||
? "release"
|
||||
: "custom binary; verify optimization separately",
|
||||
binary: h.binary,
|
||||
budgets,
|
||||
budget_results: budgetResults,
|
||||
cache_bounded: cacheBounded,
|
||||
workload: {
|
||||
fork_counts: [1, 10, 100],
|
||||
players: 10,
|
||||
input_hz: 20,
|
||||
sampling_interval_ms: 100,
|
||||
active_sample_ms: duration,
|
||||
template: "lobby",
|
||||
scope:
|
||||
"local seeded map; storage and authoritative movement; not full vanilla mechanics",
|
||||
},
|
||||
limitations: [
|
||||
"No comparison with Minecraft or a modded server",
|
||||
"Tick metrics measure server step work, not end-to-end latency",
|
||||
"Process RSS excludes browser clients and operating-system disk cache",
|
||||
"Cache entries remain bounded after disconnect; zero active worlds does not imply zero retained cache",
|
||||
],
|
||||
phases,
|
||||
samples,
|
||||
});
|
||||
reportWritten = true;
|
||||
assert.ok(
|
||||
passed,
|
||||
"Predetermined benchmark budget or bounded-cache check failed; inspect report",
|
||||
);
|
||||
} catch (error) {
|
||||
if (!reportWritten)
|
||||
await h.report({
|
||||
passed: false,
|
||||
budgets,
|
||||
error: error.stack,
|
||||
server_logs: h.logs(),
|
||||
phases,
|
||||
samples,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await disconnect().catch(() => {});
|
||||
await h.stop();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministically author original PNG/WAV/WASM package resources using stdlib."""
|
||||
from pathlib import Path
|
||||
import hashlib,json,struct,zlib,math,wave
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
def png(path,w,h,pixel):
|
||||
def chunk(kind,data):return struct.pack('>I',len(data))+kind+data+struct.pack('>I',zlib.crc32(kind+data)&0xffffffff)
|
||||
raw=b''.join(b'\x00'+b''.join(bytes(pixel(x,y)) for x in range(w)) for y in range(h))
|
||||
path.write_bytes(b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0))+chunk(b'IDAT',zlib.compress(raw,9))+chunk(b'IEND',b''))
|
||||
|
||||
def js(path,obj):path.write_text(json.dumps(obj,indent=2)+'\n')
|
||||
def manifest(path,ident,deps,caps,resources):
|
||||
js(path/'manifest.json',{'schema':1,'id':ident,'version':'1.0.0','license':'MIT OR Apache-2.0',
|
||||
'dependencies':[{'id':d,'version':'1.0.0'} for d in deps], 'capabilities':caps,
|
||||
'resources':[{'path':p,'scope':scope,'role':role,'size':(path/p).stat().st_size,'sha256':hashlib.sha256((path/p).read_bytes()).hexdigest()}for p,scope,role in resources]})
|
||||
|
||||
base=ROOT/'packages/base'; trampoline=ROOT/'packages/trampoline'
|
||||
for p in [base/'client',base/'common',trampoline/'client',trampoline/'common',trampoline/'server']:p.mkdir(parents=True,exist_ok=True)
|
||||
# Pixel noise tile, generated by integer hash; intentionally original, no external art.
|
||||
def grain(x,y):
|
||||
v=((x*374761393+y*668265263)^((x+y*17)*1274126177))&0xffffffff
|
||||
value=211+((v^(v>>13))%29);return[value,value,value]
|
||||
png(base/'client/texture.png',64,64,grain)
|
||||
js(base/'client/style.json',{'schema':1,'texture':'client/texture.png','texture_mode':'repeat-tint','pixel_size':64,'license':'MIT OR Apache-2.0'})
|
||||
js(base/'common/definitions.json',{'schema':1,'catalog':'builtin-java-26.2','data_version':4903,'coordinates':'+Y up; block models in [0,1], entity models relative feet','vanilla_assets':False})
|
||||
# Authored spring mat: dark frame, mint mesh, small crossing weave.
|
||||
def mat(x,y):
|
||||
if x<3 or y<3 or x>=29 or y>=29:return[53,78,73]
|
||||
if x%4==0 or y%4==0:return[123,217,166]
|
||||
return[75,153,118]
|
||||
png(trampoline/'client/texture.png',32,32,mat)
|
||||
js(trampoline/'client/style.json',{'schema':1,'block':'shacraft:trampoline','color':[97,205,151],'texture':'client/texture.png','effect':'bounce','sound':'client/bounce.wav','shader':'client/tint.glsl'})
|
||||
(trampoline/'client/tint.glsl').write_text('// Original optional Shacraft material function. GLSL ES 3.00.\nvec3 shacraftBounceTint(vec3 color, float pulse) {\n return color * (1.0 + 0.12 * clamp(pulse, 0.0, 1.0));\n}\n')
|
||||
with wave.open(str(trampoline/'client/bounce.wav'),'wb') as wav:
|
||||
rate=22050;n=int(rate*.18);wav.setnchannels(1);wav.setsampwidth(2);wav.setframerate(rate)
|
||||
wav.writeframes(b''.join(struct.pack('<h',int(10000*(1-i/n)**2*math.sin(2*math.pi*(260*i/rate+450*(i/rate)**2))))for i in range(n)))
|
||||
js(trampoline/'common/definitions.json',{'schema':1,'blocks':[{'state':'shacraft:trampoline','color':[97,205,151],'collision':[{'min':[0,0,0],'max':[1,1,1]}],'behavior':{'server_hook':'on_jump'}}], 'client_effect':{'on_jump':'bounce'},'capability_contract':{'hook':'on_jump','parameters':[],'result':'f32','units':'blocks/second','fuel':10000,'memory_pages':1,'imports':[]}})
|
||||
(trampoline/'server/trampoline.wat').write_text(';; Original executable server extension. No imports, memory, filesystem or network.\n(module (func (export "on_jump") (result f32) f32.const 10))\n')
|
||||
# Binary encoding of the tiny WAT above, independently loaded/tested by server wasmi.
|
||||
wasm=b'\x00asm\x01\x00\x00\x00'+bytes([1,5,1,0x60,0,1,0x7d])+bytes([3,2,1,0])+bytes([7,11,1,7])+b'on_jump'+bytes([0,0])+bytes([10,9,1,7,0,0x43])+struct.pack('<f',10.)+bytes([0x0b])
|
||||
(trampoline/'server/trampoline.wasm').write_bytes(wasm)
|
||||
manifest(base,'shacraft.base',[],['blocks.define','entities.define','client.texture','client.style'],[('common/definitions.json','common','definitions'),('client/texture.png','client','texture'),('client/style.json','client','client-style')])
|
||||
manifest(trampoline,'shacraft.trampoline',['shacraft.base'],['blocks.define','client.texture','client.style','client.shader','client.sound','server.on_jump'],[('common/definitions.json','common','definitions'),('client/texture.png','client','texture'),('client/style.json','client','client-style'),('client/bounce.wav','client','sound'),('client/tint.glsl','client','shader'),('server/trampoline.wasm','server','wasm-on-jump'),('server/trampoline.wat','server','wasm-source')])
|
||||
print('Original package assets and checksummed manifests generated.')
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright Shacraft contributors. MIT OR Apache-2.0.
|
||||
// Public API calls only: extract factual registries and collision measurements.
|
||||
// Run by catalog_generate.py; never starts a server or accepts an EULA.
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import com.google.gson.*;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.material.FluidState;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.ai.attributes.DefaultAttributes;
|
||||
import net.minecraft.world.phys.shapes.CollisionContext;
|
||||
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||
|
||||
class catalog_extract {
|
||||
static JsonArray boxes(VoxelShape shape) {
|
||||
var result = new JsonArray();
|
||||
for (var b : shape.toAabbs()) {
|
||||
var box = new JsonArray();
|
||||
for (double x : new double[]{b.minX,b.minY,b.minZ,b.maxX,b.maxY,b.maxZ}) box.add(x);
|
||||
result.add(box);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
static class SampleWorld implements BlockGetter {
|
||||
final BlockState center, other;
|
||||
SampleWorld(BlockState center, BlockState other) { this.center=center; this.other=other; }
|
||||
public BlockEntity getBlockEntity(BlockPos pos) { return null; }
|
||||
public BlockState getBlockState(BlockPos pos) { return pos.equals(BlockPos.ZERO)?center:other; }
|
||||
public FluidState getFluidState(BlockPos pos) { return getBlockState(pos).getFluidState(); }
|
||||
public int getHeight() { return 384; }
|
||||
public int getMinY() { return -64; }
|
||||
}
|
||||
public static void main(String[] args) throws Exception {
|
||||
SharedConstants.tryDetectVersion(); Bootstrap.bootStrap();
|
||||
var root = new JsonObject(); var states = new JsonObject(); var entities = new JsonObject();
|
||||
var air = Block.stateById(0);
|
||||
var stone = BuiltInRegistries.BLOCK.getValue(net.minecraft.resources.Identifier.parse("minecraft:stone")).defaultBlockState();
|
||||
int errors=0, contextual=0;
|
||||
for (var block : BuiltInRegistries.BLOCK) for (var state : block.getStateDefinition().getPossibleStates()) {
|
||||
var out = new JsonObject();
|
||||
var world = new SampleWorld(state,air);
|
||||
try {
|
||||
var shape = boxes(state.getCollisionShape(world,BlockPos.ZERO,CollisionContext.empty()));
|
||||
out.add("collision",shape);
|
||||
boolean context = !shape.equals(boxes(state.getCollisionShape(new SampleWorld(state,stone),BlockPos.ZERO,CollisionContext.empty())))
|
||||
|| !shape.equals(boxes(state.getCollisionShape(world,BlockPos.ZERO,CollisionContext.positionContext(10))))
|
||||
|| !shape.equals(boxes(state.getCollisionShape(world,BlockPos.ZERO,CollisionContext.positionContext(-10))));
|
||||
out.addProperty("sample_context_dependent",context); if(context)contextual++;
|
||||
out.addProperty("air",state.isAir()); out.addProperty("light",state.getLightEmission());
|
||||
out.addProperty("hardness",state.getDestroySpeed(world,BlockPos.ZERO));
|
||||
} catch (Throwable e) { out.addProperty("error",e.toString()); errors++; }
|
||||
states.add(Integer.toString(Block.getId(state)),out);
|
||||
}
|
||||
for(var entity : BuiltInRegistries.ENTITY_TYPE) {
|
||||
var out = new JsonObject();
|
||||
out.addProperty("width",entity.getWidth()); out.addProperty("height",entity.getHeight());
|
||||
out.addProperty("eye_height",entity.getDimensions().eyeHeight());
|
||||
out.addProperty("fixed_dimensions",entity.getDimensions().fixed());
|
||||
out.addProperty("category",entity.getCategory().getName());
|
||||
out.addProperty("summonable",entity.canSummon()); out.addProperty("serializable",entity.canSerialize());
|
||||
out.addProperty("fire_immune",entity.fireImmune()); out.addProperty("tracking_range",entity.clientTrackingRange());
|
||||
out.addProperty("update_interval",entity.updateInterval()); out.addProperty("peaceful_allowed",entity.isAllowedInPeaceful());
|
||||
var attrs = new JsonObject();
|
||||
if(DefaultAttributes.hasSupplier(entity)) {
|
||||
var supplier = DefaultAttributes.getSupplier((EntityType)entity);
|
||||
for(var holder : BuiltInRegistries.ATTRIBUTE.listElements().toList())
|
||||
if(supplier.hasAttribute(holder)) attrs.addProperty(holder.unwrapKey().get().identifier().toString(),supplier.getBaseValue(holder));
|
||||
}
|
||||
out.add("attributes",attrs);
|
||||
entities.add(EntityType.getKey(entity).toString(),out);
|
||||
}
|
||||
root.add("states",states); root.add("entities",entities);
|
||||
root.addProperty("errors",errors); root.addProperty("context_dependent_samples",contextual);
|
||||
Files.writeString(Path.of(args[0]),new GsonBuilder().create().toJson(root));
|
||||
System.out.println("Extracted "+states.size()+" states, "+entities.size()+" entities, "+errors+" errors, "+contextual+" contextual sample differences");
|
||||
if(errors!=0)System.exit(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reproduce the Java 26.2 factual catalog without starting a Minecraft server.
|
||||
|
||||
Uses a pinned official Mojang JAR and public runtime APIs. JAR/runtime/data reports
|
||||
stay in the ignored artifacts cache; no Minecraft textures, sounds, models, or
|
||||
source code are copied. Requires Python 3 and an existing Java 25+ JDK.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import argparse, datetime, gzip, hashlib, json, os, shutil, subprocess, urllib.request, zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
JAR_URL = 'https://piston-data.mojang.com/v1/objects/823e2250d24b3ddac457a60c92a6a941943fcd6a/server.jar'
|
||||
JAR_SHA1 = '823e2250d24b3ddac457a60c92a6a941943fcd6a'
|
||||
JAR_SIZE = 60894273
|
||||
|
||||
def canonical(name, props):
|
||||
return name + ('[' + ','.join(f'{k}={v}' for k,v in sorted(props.items())) + ']' if props else '')
|
||||
|
||||
def digest(path, algorithm='sha256'):
|
||||
h = hashlib.new(algorithm)
|
||||
with open(path,'rb') as f:
|
||||
for part in iter(lambda:f.read(1024*1024), b''): h.update(part)
|
||||
return h.hexdigest()
|
||||
|
||||
def write_gzip(path, obj):
|
||||
raw = json.dumps(obj,sort_keys=True,separators=(',',':'),ensure_ascii=True).encode()
|
||||
path.write_bytes(gzip.compress(raw,mtime=0))
|
||||
|
||||
def generate(cache, java, reuse):
|
||||
cache.mkdir(parents=True,exist_ok=True)
|
||||
jar = cache/'server.jar'
|
||||
if not jar.exists():
|
||||
part=cache/'server.jar.part'
|
||||
urllib.request.urlretrieve(JAR_URL, part)
|
||||
if part.stat().st_size!=JAR_SIZE or digest(part,'sha1')!=JAR_SHA1:
|
||||
raise ValueError('Downloaded artifact does not match pinned Mojang source')
|
||||
part.replace(jar)
|
||||
if jar.stat().st_size!=JAR_SIZE or digest(jar,'sha1')!=JAR_SHA1:
|
||||
raise ValueError('Local artifact does not match pinned Mojang source')
|
||||
java = Path(java).resolve()
|
||||
java_version=subprocess.run([str(java),'-version'],capture_output=True,text=True,check=True).stderr.strip()
|
||||
with zipfile.ZipFile(jar) as z: version=json.loads(z.read('version.json'))
|
||||
if version['id']!='26.2' or version['world_version']!=4903: raise ValueError('Unexpected version')
|
||||
reports=cache/'generated/reports'
|
||||
if not reuse or not (reports/'blocks.json').exists():
|
||||
with open(cache/'reports.log','w') as log:
|
||||
subprocess.run([str(java),'-Xmx1G','-DbundlerMainClass=net.minecraft.data.Main','-jar',str(jar),'--reports','--output',str(cache/'generated')],cwd=cache,stdout=log,stderr=subprocess.STDOUT,check=True)
|
||||
cp=os.pathsep.join(str(x) for x in [cache/'versions/26.2/server-26.2.jar',*sorted((cache/'libraries').rglob('*.jar'))])
|
||||
if not reuse or not (cache/'measurements.json').exists():
|
||||
subprocess.run([str(java.with_name('javac')),'-cp',cp,'-d',str(cache),str(ROOT/'scripts/catalog_extract.java')],check=True)
|
||||
with open(cache/'measurements.log','w') as log:
|
||||
subprocess.run([str(java),'-Xmx1G','-cp',str(cache)+os.pathsep+cp,'catalog_extract',str(cache/'measurements.json')],cwd=cache,stdout=log,stderr=subprocess.STDOUT,check=True)
|
||||
report=json.loads((reports/'blocks.json').read_text())
|
||||
registries=json.loads((reports/'registries.json').read_text())
|
||||
measured=json.loads((cache/'measurements.json').read_text())
|
||||
if measured['errors']: raise ValueError('Collision measurement errors; refusing incomplete catalog')
|
||||
block_defs=[]; states=[]; shapes=[]; shape_ids={}; report_identities={}
|
||||
for name,b in sorted(report.items()):
|
||||
default=next(s for s in b['states'] if s.get('default'))
|
||||
index=len(block_defs)
|
||||
block_defs.append({'name':name,'default_state':canonical(name,default.get('properties',{})),
|
||||
'properties':b.get('properties',{}),'family':b['definition']['type'].removeprefix('minecraft:')})
|
||||
report_identities[name]={'properties':b.get('properties',{}),'states':b['states']}
|
||||
for s in b['states']:
|
||||
m=measured['states'][str(s['id'])]
|
||||
boxes=m['collision']; shape_key=json.dumps(boxes,separators=(',',':'))
|
||||
if shape_key not in shape_ids:
|
||||
shape_ids[shape_key]=len(shapes);shapes.append(boxes)
|
||||
states.append({'state':canonical(name,s.get('properties',{})),'minecraft_id':s['id'],'block':index,
|
||||
'shape':shape_ids[shape_key],'context':m['sample_context_dependent'],'light':m['light'],'hardness':m['hardness']})
|
||||
states.sort(key=lambda x:x['minecraft_id'])
|
||||
entity_ids=sorted(registries['minecraft:entity_type']['entries'])
|
||||
if entity_ids!=sorted(measured['entities']):raise ValueError('Entity measurements do not match official report')
|
||||
if set(s['minecraft_id'] for s in states)!=set(map(int,measured['states'])):raise ValueError('State measurements do not match official report')
|
||||
compact={'version':'26.2','data_version':4903,'blocks':block_defs,'states':states,'shapes':shapes,'entities':measured['entities']}
|
||||
out=ROOT/'crates/shacraft-content/data';out.mkdir(parents=True,exist_ok=True)
|
||||
write_gzip(out/'catalog.json.gz',compact)
|
||||
write_gzip(out/'report-identities.json.gz',{'blocks':report_identities,'entities':entity_ids})
|
||||
provenance={'source':{'url':JAR_URL,'size':JAR_SIZE,'sha1':JAR_SHA1,'sha256':digest(jar)},
|
||||
'version':version,'java':java_version,'extracted_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'command':'python3 scripts/catalog_generate.py --java /path/to/java25/bin/java',
|
||||
'data_command':'java -Xmx1G -DbundlerMainClass=net.minecraft.data.Main -jar server.jar --reports --output generated',
|
||||
'collision_context':'All states at BlockPos.ZERO, with air neighbors and CollisionContext.empty(); compare solid neighbors and positionContext(+10/-10). Entity-dependent/block-entity contexts remain approximate.',
|
||||
'counts':{'blocks':len(block_defs),'states':len(states),'entities':len(entity_ids),'distinct_collision_shapes':len(shapes),'collision_errors':measured['errors'],'context_sample_differences':measured['context_dependent_samples']},
|
||||
'inputs':{p.name:digest(p) for p in [reports/'blocks.json',reports/'registries.json',cache/'measurements.json',ROOT/'scripts/catalog_extract.java']},
|
||||
'outputs':{p.name:digest(p) for p in [out/'catalog.json.gz',out/'report-identities.json.gz']},
|
||||
'license_note':'Factual interoperability data derived through public APIs. No Minecraft visual/audio assets, server binaries or decompiled code are redistributed. Authored runtime and render templates: MIT OR Apache-2.0.'}
|
||||
(out/'provenance.json').write_text(json.dumps(provenance,indent=2)+'\n')
|
||||
print(json.dumps(provenance['counts'],indent=2))
|
||||
|
||||
if __name__=='__main__':
|
||||
ap=argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--cache',type=Path,default=ROOT/'artifacts/catalog-cache')
|
||||
ap.add_argument('--java',default=shutil.which('java'))
|
||||
ap.add_argument('--reuse-reports',action='store_true',help='Skip Java tools only when verified local reports/measurements already exist')
|
||||
args=ap.parse_args();generate(args.cache.resolve(),args.java,args.reuse_reports)
|
||||
@@ -0,0 +1,54 @@
|
||||
// Start an isolated server, then exercise its separate stdio MCP through the real SDK.
|
||||
// --binary is the server; --mcp-binary selects the independent MCP executable.
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import path from "node:path";
|
||||
import { harness, options, root } from "./server-harness.mjs";
|
||||
const opts = options();
|
||||
opts.port ??= "4002";
|
||||
opts.binary ??= path.join(root, "target/release/shacraft-server");
|
||||
const mcpBinary = path.resolve(
|
||||
opts["mcp-binary"] ||
|
||||
path.join(path.dirname(path.resolve(opts.binary)), "shacraft-mcp"),
|
||||
);
|
||||
const h = await harness(opts);
|
||||
try {
|
||||
const script = path.join(root, "crates/shacraft-mcp/tests/sdk-smoke.mjs");
|
||||
const args = [
|
||||
script,
|
||||
"--binary",
|
||||
mcpBinary,
|
||||
"--server",
|
||||
h.url,
|
||||
"--data",
|
||||
h.data,
|
||||
];
|
||||
if (opts.sdk) args.push("--sdk", opts.sdk);
|
||||
const { stdout, stderr } = await promisify(execFile)(process.execPath, args, {
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeout: 120000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
if (stderr.trim()) throw Error(`SDK smoke stderr: ${stderr.trim()}`);
|
||||
const sdk = JSON.parse(stdout);
|
||||
await h.report({
|
||||
passed: true,
|
||||
server_binary: h.binary,
|
||||
mcp_binary: mcpBinary,
|
||||
...sdk,
|
||||
});
|
||||
} catch (error) {
|
||||
await h.report({
|
||||
passed: false,
|
||||
server_binary: h.binary,
|
||||
mcp_binary: mcpBinary,
|
||||
error: error.stack,
|
||||
stdout: error.stdout,
|
||||
stderr: error.stderr,
|
||||
server_logs: h.logs(),
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await h.stop();
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { harness, Client, delay, options } from "./server-harness.mjs";
|
||||
import { raycast, direction, key } from "../client/math.js";
|
||||
const opts = options(),
|
||||
clients = [],
|
||||
checks = [];
|
||||
let h = await harness(opts);
|
||||
const createClient = () => {
|
||||
const c = new Client(h.url);
|
||||
clients.push(c);
|
||||
return c;
|
||||
};
|
||||
try {
|
||||
const manifest = await h.get("/api/manifest");
|
||||
assert.equal(manifest.protocol, 1);
|
||||
for (const pack of manifest.packages)
|
||||
for (const file of pack.files) {
|
||||
assert.notEqual(file.scope, "server");
|
||||
const r = await fetch(h.url + file.url);
|
||||
assert.equal(r.status, 200);
|
||||
const bytes = Buffer.from(await r.arrayBuffer());
|
||||
assert.equal(bytes.length, file.size);
|
||||
assert.equal(
|
||||
createHash("sha256").update(bytes).digest("hex"),
|
||||
file.sha256,
|
||||
);
|
||||
}
|
||||
const denied = await fetch(h.url + "/api/control", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ method: "world.list" }),
|
||||
});
|
||||
assert.equal(denied.status, 401);
|
||||
const secret = await fetch(
|
||||
h.url + "/packages/shacraft.trampoline/1.0.0/server/trampoline.wasm",
|
||||
);
|
||||
assert.equal(secret.status, 404);
|
||||
checks.push(
|
||||
"Manifest resource SHA-256/size, admin token required, server WASM not public",
|
||||
);
|
||||
const bad = createClient();
|
||||
await bad.open;
|
||||
bad.send({
|
||||
type: "join",
|
||||
protocol: 1,
|
||||
manifest_hash: "incorrect",
|
||||
world: "lobby",
|
||||
name: "BadManifest",
|
||||
});
|
||||
assert.match((await bad.next("error")).message, /manifest|package/i);
|
||||
await bad.close();
|
||||
checks.push("Incompatible manifest rejected");
|
||||
const catalog = await h.control("catalog.search", {
|
||||
query: "minecraft:stone",
|
||||
limit: 64,
|
||||
}),
|
||||
stone = catalog.items.find((m) => m.state === "minecraft:stone");
|
||||
assert.ok(stone);
|
||||
await h.control("world.create", { world: "test_e2e_base" });
|
||||
const plan = await h.control("build.plan", {
|
||||
world: "test_e2e_base",
|
||||
expected_revision: 0,
|
||||
operations: [
|
||||
{ type: "box", min: [-8, -1, -8], max: [8, 0, 12], block: stone.id },
|
||||
],
|
||||
});
|
||||
await h.control("build.commit", {
|
||||
plan_id: plan.plan_id,
|
||||
operation_id: "test-floor",
|
||||
});
|
||||
for (const world of ["test_e2e_a", "test_e2e_b"])
|
||||
await h.control("world.create", { world, template: "test_e2e_base" });
|
||||
let a = createClient();
|
||||
const b = createClient();
|
||||
const wa = await a.join(manifest, "test_e2e_a", "TestAlice");
|
||||
await b.join(manifest, "test_e2e_a", "TestBob");
|
||||
assert.equal(wa.revision, 0);
|
||||
assert.ok(wa.blocks.length > 500);
|
||||
await a.state((p, m) => m.players.length === 2);
|
||||
checks.push("Two clients receive shared snapshot and presence");
|
||||
const initial = (await b.state((p) => p.position[1] < 1.15)).players.find(
|
||||
(p) => p.id === b.id,
|
||||
).position;
|
||||
b.input({ forward: 1, position: [1000, 1000, 1000] });
|
||||
const moved = (
|
||||
await b.state((p) => p.position[2] < initial[2] - 0.5)
|
||||
).players.find((p) => p.id === b.id).position;
|
||||
b.input();
|
||||
assert.ok(moved.every((v) => Math.abs(v) < 20));
|
||||
assert.ok(moved[1] < 2);
|
||||
checks.push(
|
||||
"Authoritative movement and collision; client position spoof ignored",
|
||||
);
|
||||
a.send({ type: "break", pos: [1000, 0, 1000] });
|
||||
assert.match((await a.next("error")).message, /reach|sight|ray|target/i);
|
||||
checks.push("Forged out-of-reach block break rejected");
|
||||
const p = (await a.state((p) => p.position[1] < 1.15)).players.find(
|
||||
(p) => p.id === a.id,
|
||||
).position;
|
||||
const materials = new Map(wa.materials.map((m) => [m.id, m]));
|
||||
const blocks = new Map(wa.blocks.map((b) => [key(b.pos), b.block]));
|
||||
const hit = raycast(
|
||||
[p[0], p[1] + 1.62, p[2]],
|
||||
direction(0.2, -1.3),
|
||||
blocks,
|
||||
materials,
|
||||
);
|
||||
assert.ok(hit);
|
||||
const seq = a.input({ yaw: 0.2, pitch: -1.3 });
|
||||
await a.state((_p, m) => m.ack >= seq);
|
||||
await delay(150);
|
||||
a.send({ type: "break", pos: hit.pos });
|
||||
const removed = await a.next("blocks", (m) =>
|
||||
m.changes.some((c) => key(c.pos) === key(hit.pos) && c.block === 0),
|
||||
);
|
||||
await b.next("blocks", (m) => m.revision === removed.revision);
|
||||
assert.equal(removed.revision, 1);
|
||||
await delay(160);
|
||||
a.send({ type: "place", pos: hit.pos, block: stone.id });
|
||||
const restored = await a.next("blocks", (m) => m.revision === 2);
|
||||
await b.next("blocks", (m) => m.revision === 2);
|
||||
assert.equal(restored.changes[0].block, stone.id);
|
||||
checks.push("Reachable break/place, server revision ordering and broadcast");
|
||||
const late = createClient();
|
||||
const lateSnapshot = await late.join(manifest, "test_e2e_a", "TestLate");
|
||||
assert.equal(lateSnapshot.revision, 2);
|
||||
assert.equal(
|
||||
lateSnapshot.blocks.find((c) => key(c.pos) === key(hit.pos)).block,
|
||||
stone.id,
|
||||
);
|
||||
await late.close();
|
||||
checks.push("Late join receives current geometry and revision");
|
||||
await h.control("world.edit", {
|
||||
world: "test_e2e_a",
|
||||
expected_revision: 2,
|
||||
operation_id: "test-isolation",
|
||||
changes: [{ pos: [3, 1, 3], block: stone.id }],
|
||||
});
|
||||
await a.next("blocks", (m) => m.revision === 3);
|
||||
for (const world of ["test_e2e_base", "test_e2e_b"]) {
|
||||
const r = await h.control("world.read", {
|
||||
world,
|
||||
min: [3, 1, 3],
|
||||
max: [3, 1, 3],
|
||||
});
|
||||
assert.equal(r.blocks.length, 0);
|
||||
}
|
||||
checks.push("Pinned template and independent clone isolation");
|
||||
a.send({ type: "chat", text: "local integration check" });
|
||||
const chat = await b.next(
|
||||
"chat",
|
||||
(m) => m.text === "local integration check",
|
||||
);
|
||||
assert.equal(chat.name, "TestAlice");
|
||||
checks.push("Scoped game chat delivered to peer");
|
||||
const entity = await h.control("entity.spawn", {
|
||||
world: "test_e2e_a",
|
||||
kind: "minecraft:pig",
|
||||
position: [3, 2, 3],
|
||||
properties: { name: "TestPig" },
|
||||
});
|
||||
await a.next("entities", (m) =>
|
||||
m.entities.some((e) => e.id === entity.entity.id),
|
||||
);
|
||||
await b.next("entities", (m) =>
|
||||
m.entities.some((e) => e.id === entity.entity.id),
|
||||
);
|
||||
await h.control("entity.delete", { id: entity.entity.id });
|
||||
checks.push("Persisted entity events reach both clients");
|
||||
await a.close();
|
||||
a = createClient();
|
||||
const reconnect = await a.join(manifest, "test_e2e_a", "TestAlice");
|
||||
assert.equal(reconnect.revision, 3);
|
||||
assert.ok(reconnect.blocks.some((c) => key(c.pos) === "3,1,3"));
|
||||
checks.push("Reconnect restores authoritative world snapshot");
|
||||
await h.control("world.create", {
|
||||
world: "test_e2e_arena",
|
||||
template: "spleef_template",
|
||||
});
|
||||
await delay(1150);
|
||||
for (const c of [a, b]) {
|
||||
c.send({ type: "switch_world", world: "test_e2e_arena" });
|
||||
await c.next("snapshot", (m) => m.world === "test_e2e_arena");
|
||||
}
|
||||
a.send({ type: "start_match" });
|
||||
await a.state((_p, m) => m.match?.phase === "countdown");
|
||||
const active = await a.state((_p, m) => m.match?.phase === "active", 7000);
|
||||
const victim = active.players.find((p) => p.id === a.id),
|
||||
at = victim.position.map(Math.floor);
|
||||
const floor = [];
|
||||
for (let x = at[0] - 1; x <= at[0] + 1; x++)
|
||||
for (let z = at[2] - 1; z <= at[2] + 1; z++)
|
||||
floor.push({ pos: [x, 0, z], block: 0 });
|
||||
const arena = (await h.control("world.list")).find(
|
||||
(w) => w.name === "test_e2e_arena",
|
||||
);
|
||||
await h.control("world.edit", {
|
||||
world: "test_e2e_arena",
|
||||
expected_revision: arena.revision,
|
||||
operation_id: "test-eliminate",
|
||||
changes: floor,
|
||||
});
|
||||
const finished = await b.state(
|
||||
(_p, m) => m.match?.phase === "finished",
|
||||
7000,
|
||||
);
|
||||
assert.equal(finished.match.winner, "TestBob");
|
||||
await b.state(
|
||||
(_p, m) => m.tick > finished.tick && m.match?.phase === "waiting",
|
||||
8000,
|
||||
);
|
||||
const reset = await h.control("world.read", {
|
||||
world: "test_e2e_arena",
|
||||
min: [at[0], 0, at[2]],
|
||||
max: [at[0], 0, at[2]],
|
||||
});
|
||||
assert.equal(reset.blocks.length, 1);
|
||||
checks.push(
|
||||
"Full two-player Spleef countdown, elimination, winner and automatic floor reset",
|
||||
);
|
||||
for (const c of clients) await c.close();
|
||||
let metrics;
|
||||
for (let n = 0; n < 30; n++) {
|
||||
metrics = await h.get("/api/metrics");
|
||||
if (metrics.players === 0) break;
|
||||
await delay(100);
|
||||
}
|
||||
assert.equal(metrics.players, 0);
|
||||
assert.equal(metrics.active_worlds, 0);
|
||||
checks.push("Disconnect removes players and active worlds");
|
||||
const durableEdit = await h.control("world.edit", {
|
||||
world: "test_e2e_a",
|
||||
expected_revision: 3,
|
||||
operation_id: "test-durable-edit",
|
||||
changes: [{ pos: [4, 2, 4], block: stone.id }],
|
||||
});
|
||||
const durableEntity = await h.control("entity.spawn", {
|
||||
world: "test_e2e_a",
|
||||
kind: "minecraft:pig",
|
||||
position: [5, 2, 5],
|
||||
properties: { name: "DurablePig", marker: "before-sigkill" },
|
||||
});
|
||||
await h.control("arena.configure", {
|
||||
world: "test_e2e_a",
|
||||
mode: "creative",
|
||||
spawn: [1, 3, 1],
|
||||
countdown_seconds: 2,
|
||||
round_seconds: 120,
|
||||
elimination_y: -5,
|
||||
});
|
||||
const durableData = h.data;
|
||||
await h.kill("SIGKILL");
|
||||
h = await harness({ ...opts, data: durableData });
|
||||
const afterCrash = await h.control("world.read", {
|
||||
world: "test_e2e_a",
|
||||
min: [4, 2, 4],
|
||||
max: [4, 2, 4],
|
||||
});
|
||||
assert.equal(afterCrash.revision, durableEdit.revision);
|
||||
assert.equal(afterCrash.blocks[0]?.block, stone.id);
|
||||
const persisted = await h.control("entity.list", { world: "test_e2e_a" });
|
||||
const pig = persisted.items.find(
|
||||
(entity) => entity.id === durableEntity.entity.id,
|
||||
);
|
||||
assert.equal(pig?.properties.marker, "before-sigkill");
|
||||
// No fields supplied: the server returns and resaves the loaded configuration.
|
||||
const config = await h.control("arena.configure", { world: "test_e2e_a" });
|
||||
assert.equal(config.countdown_seconds, 2);
|
||||
assert.equal(config.round_seconds, 120);
|
||||
assert.equal(config.elimination_y, -5);
|
||||
assert.deepEqual(config.spawn, [1, 3, 1]);
|
||||
const afterRestart = createClient();
|
||||
const restartedWelcome = await afterRestart.join(
|
||||
await h.get("/api/manifest"),
|
||||
"test_e2e_a",
|
||||
"RestartProbe",
|
||||
);
|
||||
assert.deepEqual(restartedWelcome.spawn, [1, 3, 1]);
|
||||
assert.ok(
|
||||
restartedWelcome.blocks.some(
|
||||
(cell) => key(cell.pos) === "4,2,4" && cell.block === stone.id,
|
||||
),
|
||||
);
|
||||
await afterRestart.close();
|
||||
checks.push(
|
||||
"Real SIGKILL/restart preserves committed blocks, entity properties and arena configuration",
|
||||
);
|
||||
metrics = await h.get("/api/metrics");
|
||||
await h.report({ passed: true, checks, metrics, binary: h.binary });
|
||||
} catch (error) {
|
||||
await h.report({
|
||||
passed: false,
|
||||
checks,
|
||||
error: error.stack,
|
||||
server_logs: h.logs(),
|
||||
client_errors: clients.map((c) =>
|
||||
c.queue.filter((m) => m.type === "error"),
|
||||
),
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await Promise.allSettled(clients.map((c) => c.close()));
|
||||
await h.stop();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate tiny original Sponge fixtures using independent nbtlib2.0.4.
|
||||
|
||||
Run with artifacts/compat-venv/bin/python after installing nbtlib==2.0.4.
|
||||
The fixture contains no game assets or third-party map content.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import gzip
|
||||
import io
|
||||
import nbtlib as n
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
out = ROOT / "crates/shacraft-compat/fixtures"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
metadata = n.Compound({
|
||||
"Name": n.String("Шакрафт тест"), "Byte": n.Byte(-128),
|
||||
"Short": n.Short(-32768), "Long": n.Long(-9223372036854775808),
|
||||
"Float": n.Float(1.25), "Double": n.Double(-2.5),
|
||||
"ByteArray": n.ByteArray([-1, 0, 127]),
|
||||
"IntArray": n.IntArray([-2147483648, 2147483647]),
|
||||
"LongArray": n.LongArray([-9223372036854775808, 9223372036854775807]),
|
||||
"EmptyLongList": n.List[n.Long]([]),
|
||||
"Nested": n.Compound({"custom_unknown": n.String("keep me")}),
|
||||
})
|
||||
entity = n.Compound({"Id": n.String("minecraft:armor_stand"),
|
||||
"Pos": n.List[n.Double]([1.5, 1.0, 0.5]),
|
||||
"Data": n.Compound({"UUID": n.IntArray([1, 2, 3, 4]), "NoGravity": n.Byte(1)})})
|
||||
blockentity = n.Compound({"Id": n.String("minecraft:chest"),
|
||||
"Pos": n.IntArray([0, 0, 0]), "Data": n.Compound({
|
||||
"Items": n.List[n.Compound]([n.Compound({"Slot": n.Byte(0),
|
||||
"id": n.String("minecraft:diamond"), "count": n.Int(3)})]),
|
||||
"unknown_inventory_tag": n.Long(12345678901234567),
|
||||
})})
|
||||
states = ["minecraft:air", "minecraft:chest[facing=north,type=single,waterlogged=false]",
|
||||
"minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]",
|
||||
"example:unknown[facing=west]", "minecraft:stone"]
|
||||
indices = [1, 2, 3, 4] + [0] * 20
|
||||
schematic = n.Compound({"Version": n.Int(3), "DataVersion": n.Int(4903),
|
||||
"Width": n.Short(4), "Height": n.Short(3), "Length": n.Short(2),
|
||||
"Offset": n.IntArray([-17, -1, 31]), "Metadata": metadata,
|
||||
"Blocks": n.Compound({"Palette": n.Compound({s: n.Int(i) for i, s in enumerate(states)}),
|
||||
"Data": n.ByteArray(indices), "BlockEntities": n.List[n.Compound]([blockentity])}),
|
||||
"Biomes": n.Compound({"Palette": n.Compound({"minecraft:plains": n.Int(0), "minecraft:desert": n.Int(1)}),
|
||||
"Data": n.ByteArray([i % 2 for i in range(24)])}),
|
||||
"Entities": n.List[n.Compound]([entity]),
|
||||
"UnknownRoot": n.Compound({"opaque": n.String("preserve this")}),
|
||||
})
|
||||
f = n.File({"Schematic": schematic}, root_name="")
|
||||
buffer = io.BytesIO(); f.write(buffer)
|
||||
(out / "sponge-v3.schem").write_bytes(gzip.compress(buffer.getvalue(), mtime=0))
|
||||
print(out / "sponge-v3.schem")
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright Shacraft contributors. MIT OR Apache-2.0.
|
||||
// Generate our own tiny world metadata and validate files using public Java26.2
|
||||
// APIs. Does not start a server, open sockets, or read/accept an EULA.
|
||||
import java.nio.file.*;
|
||||
import java.io.*;
|
||||
import java.util.*;
|
||||
import com.mojang.serialization.*;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.nbt.*;
|
||||
import net.minecraft.core.*;
|
||||
import net.minecraft.core.registries.*;
|
||||
import net.minecraft.data.registries.VanillaRegistries;
|
||||
import net.minecraft.world.level.*;
|
||||
import net.minecraft.world.level.block.*;
|
||||
import net.minecraft.world.level.chunk.*;
|
||||
import net.minecraft.world.level.chunk.storage.*;
|
||||
import net.minecraft.world.level.levelgen.*;
|
||||
import net.minecraft.world.level.levelgen.flat.*;
|
||||
import net.minecraft.world.level.levelgen.presets.*;
|
||||
import net.minecraft.world.level.storage.*;
|
||||
import net.minecraft.world.level.gamerules.*;
|
||||
import net.minecraft.world.level.biome.*;
|
||||
|
||||
class compat_java {
|
||||
static final NbtAccounter BUDGET=NbtAccounter.create(16*1024*1024);
|
||||
static void generate(Path out, HolderLookup.Provider lookup) throws Exception {
|
||||
Files.createDirectories(out);
|
||||
var settings=new LevelSettings("Shacraft template",GameType.CREATIVE,LevelSettings.DifficultySettings.DEFAULT,true,WorldDataConfiguration.DEFAULT);
|
||||
var level=new PrimaryLevelData(settings,PrimaryLevelData.SpecialWorldProperty.FLAT,Lifecycle.stable());
|
||||
level.setInitialized(true);
|
||||
level.setSpawn(LevelData.RespawnData.of(Level.OVERWORLD,new BlockPos(0,80,0),0,0));
|
||||
var root=new CompoundTag();root.put("Data",level.createTag(null));
|
||||
NbtIo.writeCompressed(root,out.resolve("level.dat"));
|
||||
var flat=new FlatLevelGeneratorSettings(Optional.of(HolderSet.direct()),lookup.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.PLAINS),List.of());
|
||||
flat.getLayersInfo().add(new FlatLayerInfo(1,Blocks.AIR));flat.updateLayers();
|
||||
var dims=lookup.lookupOrThrow(Registries.WORLD_PRESET).getOrThrow(WorldPresets.FLAT).value().createWorldDimensions().replaceOverworldGenerator(lookup,new FlatLevelSource(flat));
|
||||
var worldgen=new WorldGenSettings(new WorldOptions(0,false,false),dims);
|
||||
var encoded=WorldGenSettings.CODEC.encodeStart(lookup.createSerializationContext(NbtOps.INSTANCE),worldgen).getOrThrow();
|
||||
var data=new CompoundTag();data.put("data",encoded);NbtUtils.addCurrentDataVersion(data);
|
||||
Path gen=WorldGenSettings.TYPE.id().withSuffix(".dat").resolveAgainst(out.resolve("data"));
|
||||
Files.createDirectories(gen.getParent());NbtIo.writeCompressed(data,gen);
|
||||
var rules=new GameRules(level.enabledFeatures());rules.set(GameRules.SPAWN_MOBS,false,null);rules.set(GameRules.ADVANCE_TIME,false,null);
|
||||
LevelStorageSource.writeGameRules(level,out,rules);
|
||||
System.out.println("Generated metadata with public Java26.2 APIs: "+out);
|
||||
}
|
||||
static PalettedContainerFactory factory(HolderLookup.Provider lookup) {
|
||||
var biomeRegistry=new MappedRegistry<Biome>(Registries.BIOME,Lifecycle.stable());
|
||||
lookup.lookupOrThrow(Registries.BIOME).listElements().forEach(h->biomeRegistry.register(h.key(),h.value(),RegistrationInfo.BUILT_IN));
|
||||
biomeRegistry.freeze();
|
||||
return PalettedContainerFactory.create(new RegistryAccess.ImmutableRegistryAccess(List.of(biomeRegistry)));
|
||||
}
|
||||
static void fixture(Path out,HolderLookup.Provider lookup) throws Exception {
|
||||
generate(out,lookup);
|
||||
var factory=factory(lookup);
|
||||
var section=new LevelChunkSection(factory);
|
||||
section.setBlockState(15,15,15,Blocks.CHEST.defaultBlockState());
|
||||
section.setBlockState(14,15,15,Blocks.OAK_STAIRS.defaultBlockState());
|
||||
section.setBlockState(13,15,15,Blocks.STONE.defaultBlockState());
|
||||
var chest=new CompoundTag();chest.putString("id","minecraft:chest");chest.putInt("x",-1);chest.putInt("y",-1);chest.putInt("z",31);
|
||||
var item=new CompoundTag();item.putByte("Slot",(byte)0);item.putString("id","minecraft:diamond");item.putInt("count",3);
|
||||
var inventory=new ListTag();inventory.add(item);chest.put("Items",inventory);chest.putLong("unknown_inventory_tag",12345678901234567L);
|
||||
var chunk=new SerializableChunkData(factory,new ChunkPos(-1,1),-4,0,0,net.minecraft.world.level.chunk.status.ChunkStatus.FULL,
|
||||
null,null,UpgradeData.EMPTY,null,Map.of(),new ChunkAccess.PackedTicks(List.of(),List.of()),
|
||||
new it.unimi.dsi.fastutil.shorts.ShortList[24],false,
|
||||
List.of(new SerializableChunkData.SectionData(-1,section,null,null)),List.of(),List.of(chest),new CompoundTag());
|
||||
var tag=chunk.write();var unknown=new CompoundTag();unknown.putByte("Byte",(byte)-128);unknown.putShort("Short",(short)-32768);
|
||||
unknown.putLong("Long",Long.MIN_VALUE);unknown.putString("Unicode","Привет🌍\u0000");unknown.putIntArray("UUID",new int[]{1,2,3,4});tag.put("UnknownRoot",unknown);
|
||||
Files.createDirectories(out.resolve("region"));
|
||||
try(var region=new RegionFile(new RegionStorageInfo("compat-fixture",Level.OVERWORLD,"chunk"),out.resolve("region/r.-1.0.mca"),out.resolve("region"),false)) {
|
||||
try(var output=region.getChunkDataOutputStream(new ChunkPos(-1,1))){NbtIo.write(tag,output);}
|
||||
}
|
||||
var entity=new CompoundTag();entity.putString("id","minecraft:armor_stand");entity.putIntArray("UUID",new int[]{1,2,3,4});
|
||||
var pos=new ListTag();pos.add(DoubleTag.valueOf(-0.5));pos.add(DoubleTag.valueOf(0));pos.add(DoubleTag.valueOf(31.5));entity.put("Pos",pos);entity.putBoolean("NoGravity",true);
|
||||
var entities=new ListTag();entities.add(entity);var root=new CompoundTag();root.putInt("DataVersion",4903);root.putIntArray("Position",new int[]{-1,1});root.put("Entities",entities);
|
||||
Files.createDirectories(out.resolve("entities"));
|
||||
try(var region=new RegionFile(new RegionStorageInfo("compat-fixture",Level.OVERWORLD,"entities"),out.resolve("entities/r.-1.0.mca"),out.resolve("entities"),false)) {
|
||||
try(var output=region.getChunkDataOutputStream(new ChunkPos(-1,1))){NbtIo.write(root,output);}
|
||||
}
|
||||
Files.writeString(out.resolve("custom-metadata.txt"),"own fixture; retain unknown file\n");
|
||||
}
|
||||
static void validate(Path world,HolderLookup.Provider lookup) throws Exception {
|
||||
var levelRoot=NbtIo.readCompressed(world.resolve("level.dat"),NbtAccounter.create(16*1024*1024));
|
||||
var level=levelRoot.getCompoundOrEmpty("Data");
|
||||
if(level.getIntOr("DataVersion",-1)!=4903)throw new AssertionError("DataVersion");
|
||||
var dynamic=new Dynamic<>(NbtOps.INSTANCE,level);
|
||||
var config=LevelStorageSource.readDataConfig(dynamic);
|
||||
var settings=LevelSettings.parse(dynamic,config);
|
||||
var parsed=PrimaryLevelData.parse(dynamic,settings,PrimaryLevelData.SpecialWorldProperty.FLAT,Lifecycle.stable());
|
||||
Path gen=WorldGenSettings.TYPE.id().withSuffix(".dat").resolveAgainst(world.resolve("data"));
|
||||
var encoded=NbtIo.readCompressed(gen,NbtAccounter.create(16*1024*1024)).getCompoundOrEmpty("data");
|
||||
var worldgen=WorldGenSettings.CODEC.parse(lookup.createSerializationContext(NbtOps.INSTANCE),encoded).getOrThrow();
|
||||
if(worldgen.dimensions().dimensions().size()!=3)throw new AssertionError("dimensions");
|
||||
var factory=factory(lookup);int chunks=0,sections=0,entities=0;long nonair=0;
|
||||
try(var files=Files.walk(world)) {
|
||||
for(Path path:files.filter(p->p.toString().endsWith(".mca")&&Set.of("region","entities").contains(p.getParent().getFileName().toString())&&!p.toString().contains("source-sidecar")).toList()) {
|
||||
boolean entityRegion=path.getParent().getFileName().toString().equals("entities");
|
||||
String[] parts=path.getFileName().toString().split("\\.");int rx=Integer.parseInt(parts[1]),rz=Integer.parseInt(parts[2]);
|
||||
try(var region=new RegionFile(new RegionStorageInfo("compat-check",Level.OVERWORLD,"chunk"),path,path.getParent(),false)) {
|
||||
for(int i=0;i<1024;i++) {
|
||||
var pos=new ChunkPos(rx*32+i%32,rz*32+i/32);
|
||||
if(!region.hasChunk(pos))continue;
|
||||
try(var input=region.getChunkDataInputStream(pos)) {
|
||||
var tag=NbtIo.read(input,NbtAccounter.create(16*1024*1024));
|
||||
if(entityRegion) {
|
||||
int[] coordinates=tag.getIntArray("Position").orElseThrow();
|
||||
if(coordinates.length!=2||coordinates[0]!=pos.x()||coordinates[1]!=pos.z())throw new AssertionError("entity chunk Position");
|
||||
for(var value:tag.getListOrEmpty("Entities")) {
|
||||
var entity=(CompoundTag)value;
|
||||
if(entity.getIntArray("UUID").orElseThrow().length!=4)throw new AssertionError("entity UUID");
|
||||
if(entity.getListOrEmpty("Pos").size()!=3)throw new AssertionError("entity Pos");
|
||||
if(!BuiltInRegistries.ENTITY_TYPE.containsKey(net.minecraft.resources.Identifier.parse(entity.getString("id").orElseThrow())))throw new AssertionError("unknown vanilla entity type");
|
||||
entities++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
var chunk=SerializableChunkData.parse(LevelHeightAccessor.create(-64,384),factory,tag);
|
||||
if(chunk==null||!chunk.chunkPos().equals(pos))throw new AssertionError("missing/misplaced chunk "+pos);
|
||||
for(var section:chunk.sectionData()) {
|
||||
if(section.chunkSection()!=null){sections++;for(int y=0;y<16;y++)for(int z=0;z<16;z++)for(int x=0;x<16;x++)if(!section.chunkSection().getBlockState(x,y,z).isAir())nonair++;}
|
||||
}
|
||||
chunks++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
System.out.println("{\"java26_2_verified\":true,\"chunks\":"+chunks+",\"sections\":"+sections+",\"entities\":"+entities+",\"nonair_blocks\":"+nonair+",\"level_name\":\""+parsed.getLevelName()+"\",\"scope\":\"NbtIo,RegionFile,SerializableChunkData,PrimaryLevelData,WorldGenSettings codecs; no server simulation\"}");
|
||||
}
|
||||
public static void main(String[] args) throws Exception {
|
||||
SharedConstants.tryDetectVersion();Bootstrap.bootStrap();var lookup=VanillaRegistries.createLookup();
|
||||
if(args[0].equals("generate"))generate(Path.of(args[1]),lookup);
|
||||
else if(args[0].equals("fixture"))fixture(Path.of(args[1]),lookup);
|
||||
else validate(Path.of(args[1]),lookup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent interchange acceptance using nbtlib and official Java26.2 APIs.
|
||||
|
||||
Run: artifacts/compat-venv/bin/python scripts/compat_verify.py
|
||||
Needs nbtlib==2.0.4 and the pinned catalog-cache JAR/JDK25 from catalog generation.
|
||||
Never starts MinecraftServer or accepts an EULA. Writes only artifacts/.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import nbtlib
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument("--java-bin",type=Path,default=Path.home()/".local/share/PrismLauncher/java/java-runtime-epsilon/bin")
|
||||
args=parser.parse_args()
|
||||
output=Path(tempfile.mkdtemp(prefix="compat-verification-",dir=ROOT/"artifacts"))
|
||||
def command(argv,**kwargs):
|
||||
cwd=kwargs.pop("cwd",ROOT)
|
||||
result=subprocess.run(list(map(str,argv)),cwd=cwd,text=True,capture_output=True,timeout=120,**kwargs)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"Command failed: {argv}\n{result.stdout}\n{result.stderr}")
|
||||
return result.stdout
|
||||
command(["cargo","build","-p","shacraft-compat","-p","shacraft-tools"])
|
||||
compat=ROOT/"target/debug/shacraft-compat";tools=ROOT/"target/debug/shacraft-tools"
|
||||
def convert(*argv):return json.loads(command([compat,*argv]))
|
||||
def tool(store,request):
|
||||
result=json.loads(command([tools,"--data",store,"session"],input=json.dumps(request)+"\n"))
|
||||
assert result["ok"],result
|
||||
return result["result"]
|
||||
|
||||
source=ROOT/"crates/shacraft-compat/fixtures/java26_2-world"
|
||||
store=output/"imported"
|
||||
import_report=convert("import-anvil",source,store)
|
||||
exact=output/"exact"
|
||||
convert("export-anvil",store,exact)
|
||||
for f in source.rglob("*"):
|
||||
if f.is_file():assert f.read_bytes()==(exact/f.relative_to(source)).read_bytes()
|
||||
stone=tool(store,{"op":"register","state":"minecraft:stone"})
|
||||
revision=tool(store,{"op":"revision","world":"main"})
|
||||
tool(store,{"op":"edit","world":"main","expected_revision":revision,"operation_id":"independent-edit",
|
||||
"changes":[{"pos":[-1,-1,31],"block":0},{"pos":[512,64,-513],"block":stone}]})
|
||||
with sqlite3.connect(store/"server.sqlite3") as db:
|
||||
meta=json.loads(db.execute("select json from metadata where id=1").fetchone()[0]); entity=next(iter(meta["entities"].values()))
|
||||
entity["position"]=[513.5,65.,-513.5];entity["yaw"]=1.25;entity["properties"]["Invisible"]=True
|
||||
meta["revision"]+=1;db.execute("update metadata set json=? where id=1",(json.dumps(meta),))
|
||||
edited=output/"edited";edited_report=convert("export-anvil",store,edited,"--mode","best-effort")
|
||||
native=output/"native"
|
||||
tool(native,{"op":"create","name":"own","template":None})
|
||||
id=tool(native,{"op":"register","state":"minecraft:stone"})
|
||||
tool(native,{"op":"edit","world":"own","expected_revision":0,"operation_id":"own-build", "changes":[{"pos":[-1,63,0],"block":id},{"pos":[0,64,0],"block":id}]})
|
||||
new=output/"new-world";new_report=convert("export-anvil",native,new,"--world","own","--mode","best-effort")
|
||||
schem_store=output/"schem-store";schem_source=ROOT/"crates/shacraft-compat/fixtures/sponge-v3.schem"
|
||||
convert("import-schem",schem_source,schem_store)
|
||||
revision=tool(schem_store,{"op":"revision","world":"main"})
|
||||
tool(schem_store,{"op":"edit","world":"main","expected_revision":revision,"operation_id":"break-chest", "changes":[{"pos":[-17,-1,31],"block":0}]})
|
||||
schem=output/"schem";convert("export-schem",schem_store,schem,"--mode","best-effort")
|
||||
a=nbtlib.load(schem_source)["Schematic"];b=nbtlib.load(schem/"world.schem")["Schematic"]
|
||||
def typed(tag):
|
||||
if isinstance(tag,nbtlib.Compound):return ("Compound",tuple((k,typed(v)) for k,v in sorted(tag.items())))
|
||||
if isinstance(tag,nbtlib.List):return ("List",tag.subtype.__name__,tuple(typed(v) for v in tag))
|
||||
if hasattr(tag,"dtype"):return (type(tag).__name__,tuple(int(v) for v in tag))
|
||||
return (type(tag).__name__,str(tag))
|
||||
for key in ["Metadata","Biomes","Entities","Offset","UnknownRoot"]:
|
||||
assert typed(a[key])==typed(b[key]),key
|
||||
assert not b["Blocks"]["BlockEntities"]
|
||||
assert isinstance(b["Metadata"]["Long"],nbtlib.Long)
|
||||
assert isinstance(b["Metadata"]["EmptyLongList"],nbtlib.List[nbtlib.Long])
|
||||
|
||||
cache=ROOT/"artifacts/catalog-cache"
|
||||
jar=cache/"server.jar"
|
||||
assert hashlib.sha1(jar.read_bytes()).hexdigest()=="823e2250d24b3ddac457a60c92a6a941943fcd6a"
|
||||
cp=":".join(map(str,[cache/"versions/26.2/server-26.2.jar",*cache.glob("libraries/**/*.jar")]))
|
||||
classes=output/"java-classes";classes.mkdir()
|
||||
command([args.java_bin/"javac","-cp",cp,"-d",classes,ROOT/"scripts/compat_java.java"])
|
||||
java_results=[]
|
||||
for label,world in [("exact",exact),("edited",edited),("new",new)]:
|
||||
text=command([args.java_bin/"java","-Xmx768m","-cp",str(classes)+":"+cp,"compat_java","validate",world],cwd=output)
|
||||
(output/f"java-{label}.log").write_text(text)
|
||||
line=next(line[line.index('{'):] for line in text.splitlines() if '"java26_2_verified"' in line)
|
||||
result=json.loads(line);result["case"]=label;java_results.append(result)
|
||||
assert [r["nonair_blocks"] for r in java_results]==[3,3,2]
|
||||
summary={"success":True,"directory":str(output),"java":java_results,"nbtlib_version":nbtlib.__version__,
|
||||
"source_jar_sha1":hashlib.sha1(jar.read_bytes()).hexdigest(),"source_jar_sha256":hashlib.sha256(jar.read_bytes()).hexdigest(),
|
||||
"nbtlib_sponge_typed_roundtrip":True,"exact_source_files_identical":True,
|
||||
"edited_world":edited_report,"native_world":new_report,
|
||||
"limitations":"Java checks official disk/container/palette/world-metadata codecs, not running server, client renderer, light simulation or EULA acceptance."}
|
||||
(output/"verification.json").write_text(json.dumps(summary,ensure_ascii=False,indent=2)+"\n")
|
||||
print(json.dumps({"success":True,"report":str(output/"verification.json"),"java":java_results},indent=2))
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package a clean, committed source tree and optional Linux executables.
|
||||
|
||||
No user worlds, tokens, Cargo caches, Minecraft JAR, or untracked files enter
|
||||
an archive. Every extracted file is checked against the in-archive manifest.
|
||||
"""
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def git(*args):
|
||||
return subprocess.check_output(['git', *args], cwd=ROOT)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--binaries', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if git('status', '--porcelain').strip():
|
||||
raise SystemExit('Commit the reviewed tree before packaging.')
|
||||
revision = git('rev-parse', 'HEAD').decode().strip()
|
||||
epoch = int(git('show', '-s', '--format=%ct', 'HEAD'))
|
||||
paths = [Path(p.decode()) for p in git('ls-files', '-z').split(b'\0') if p]
|
||||
if args.binaries:
|
||||
paths += [Path('target/release') / name for name in
|
||||
['shacraft-server', 'shacraft-mcp', 'shacraft-compat', 'shacraft-tools']]
|
||||
files = []
|
||||
for relative in sorted(paths):
|
||||
path = ROOT / relative
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise SystemExit(f'Only regular files are distributable: {relative}')
|
||||
if relative.name.endswith('.token') or relative.name == '.env':
|
||||
raise SystemExit(f'Secret filename cannot be distributed: {relative}')
|
||||
data = path.read_bytes()
|
||||
files.append((relative.as_posix(), data, 0o755 if path.stat().st_mode & 0o111 else 0o644))
|
||||
manifest = {'project':'Shacraft Core','git_revision':revision,'source_epoch':epoch,
|
||||
'kind':'linux-x86_64' if args.binaries else 'source',
|
||||
'license':'MIT OR Apache-2.0',
|
||||
'files':{name:{'sha256':hashlib.sha256(data).hexdigest(),'size':len(data)} for name,data,_ in files}}
|
||||
files.append(('RELEASE.json', (json.dumps(manifest,indent=2)+'\n').encode(), 0o644))
|
||||
output = ROOT / 'artifacts' / f"shacraft-core-mvp-{revision[:8]}-{manifest['kind']}.tar.gz"
|
||||
output.parent.mkdir(exist_ok=True)
|
||||
with output.open('wb') as raw, gzip.GzipFile(fileobj=raw,mode='wb',mtime=0) as zipped:
|
||||
with tarfile.open(fileobj=zipped,mode='w') as archive:
|
||||
for name,data,mode in files:
|
||||
info=tarfile.TarInfo('shacraft-core/'+name)
|
||||
info.size=len(data);info.mode=mode;info.mtime=epoch
|
||||
archive.addfile(info,io.BytesIO(data))
|
||||
digest=hashlib.sha256(output.read_bytes()).hexdigest()
|
||||
output.with_name(output.name+'.sha256').write_text(f'{digest} {output.name}\n')
|
||||
with tarfile.open(output,'r:gz') as archive:
|
||||
for name,data,_ in files:
|
||||
actual=archive.extractfile('shacraft-core/'+name).read()
|
||||
if actual!=data:
|
||||
raise SystemExit(f'Archive verification failed for {name}')
|
||||
print(json.dumps({'archive':str(output),'sha256':digest,'bytes':output.stat().st_size,
|
||||
'git_revision':revision,'verified_files':len(files)},indent=2))
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
shacraft_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$shacraft_root"
|
||||
if [[ ! -x target/release/shacraft-server ]]; then
|
||||
cargo build --release --locked -p shacraft-server -p shacraft-mcp -p shacraft-compat
|
||||
fi
|
||||
exec target/release/shacraft-server --client "$shacraft_root/client" --packages "$shacraft_root/packages" "$@"
|
||||
@@ -0,0 +1,261 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
export const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
export function options() {
|
||||
const opts = {};
|
||||
for (let i = 2; i < process.argv.length; i += 2)
|
||||
opts[process.argv[i].replace(/^--/, "")] = process.argv[i + 1];
|
||||
return opts;
|
||||
}
|
||||
export async function harness(opts = {}) {
|
||||
const port = Number(opts.port || 4001),
|
||||
url = `http://127.0.0.1:${port}`;
|
||||
try {
|
||||
await fetch(`${url}/api/health`, { signal: AbortSignal.timeout(500) });
|
||||
throw Error(
|
||||
`Port ${port} is already serving HTTP; choose an unused --port`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.message?.includes("already serving")) throw error;
|
||||
}
|
||||
const data = opts.data
|
||||
? path.resolve(opts.data)
|
||||
: await mkdtemp(path.join(os.tmpdir(), "shacraft-server-test-"));
|
||||
await mkdir(data, { recursive: true });
|
||||
let stderr = "";
|
||||
const binary = path.resolve(
|
||||
opts.binary || path.join(root, "target/debug/shacraft-server"),
|
||||
);
|
||||
const child = spawn(
|
||||
binary,
|
||||
[
|
||||
"--listen",
|
||||
`127.0.0.1:${port}`,
|
||||
"--data",
|
||||
data,
|
||||
"--client",
|
||||
path.join(root, "client"),
|
||||
"--packages",
|
||||
path.join(root, "packages"),
|
||||
],
|
||||
{ cwd: root, stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
child.stdout.on("data", (chunk) => (stderr += chunk));
|
||||
child.stderr.on("data", (chunk) => (stderr += chunk));
|
||||
let failure;
|
||||
child.on("error", (e) => (failure = e));
|
||||
const running = () =>
|
||||
child.exitCode === null && child.signalCode === null && !failure;
|
||||
async function kill(signal = "SIGKILL") {
|
||||
if (!running()) return;
|
||||
const exited = new Promise((resolve) => child.once("exit", resolve));
|
||||
async function waitForExit() {
|
||||
let timer;
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, 5000);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
}
|
||||
child.kill(signal);
|
||||
await waitForExit();
|
||||
if (running()) {
|
||||
child.kill("SIGKILL");
|
||||
await waitForExit();
|
||||
}
|
||||
if (running()) throw Error("Test server did not exit after SIGKILL");
|
||||
}
|
||||
async function stop() {
|
||||
await kill("SIGINT");
|
||||
}
|
||||
try {
|
||||
const deadline = Date.now() + 45000;
|
||||
while (true) {
|
||||
if (failure) throw failure;
|
||||
if (!running()) throw Error(`Server exited: ${stderr}`);
|
||||
try {
|
||||
if ((await fetch(`${url}/api/health`)).ok) break;
|
||||
} catch {}
|
||||
if (Date.now() > deadline)
|
||||
throw Error(`Server startup timeout: ${stderr}`);
|
||||
await delay(100);
|
||||
}
|
||||
} catch (e) {
|
||||
await stop();
|
||||
throw e;
|
||||
}
|
||||
const token = (
|
||||
await readFile(path.join(data, "control.token"), "utf8")
|
||||
).trim();
|
||||
async function get(route) {
|
||||
const r = await fetch(url + route);
|
||||
assert.equal(r.status, 200, `${route}: HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
async function control(method, params = {}) {
|
||||
const response = await fetch(url + "/api/control", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ method, params }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok || body.error)
|
||||
throw Error(`${method}: ${body.error || response.status}`);
|
||||
return body.result;
|
||||
}
|
||||
return {
|
||||
url,
|
||||
data,
|
||||
pid: child.pid,
|
||||
binary,
|
||||
get,
|
||||
control,
|
||||
stop,
|
||||
kill,
|
||||
logs: () => stderr,
|
||||
async report(value) {
|
||||
const output = opts.output
|
||||
? path.resolve(opts.output)
|
||||
: path.join(data, "report.json");
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
await writeFile(output, JSON.stringify(value, null, 2) + "\n");
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
...value,
|
||||
samples: undefined,
|
||||
sample_count: value.samples?.length,
|
||||
report_file: output,
|
||||
data,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
export class Client {
|
||||
constructor(url) {
|
||||
this.ws = new WebSocket(url.replace(/^http/, "ws") + "/ws");
|
||||
this.queue = [];
|
||||
this.waiters = [];
|
||||
this.id = null;
|
||||
this.seq = 0;
|
||||
this.latest = null;
|
||||
this.revision = null;
|
||||
this.error = null;
|
||||
this.open = new Promise((resolve, reject) => {
|
||||
this.ws.addEventListener("open", resolve, { once: true });
|
||||
this.ws.addEventListener(
|
||||
"error",
|
||||
() => reject(Error("WebSocket connection failed")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
let m;
|
||||
try {
|
||||
m = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (m.type === "state") this.latest = m;
|
||||
if (m.type === "welcome") {
|
||||
this.id = m.id;
|
||||
this.welcome = m;
|
||||
}
|
||||
if (["welcome", "snapshot", "blocks"].includes(m.type))
|
||||
this.revision = m.revision;
|
||||
for (let i = 0; i < this.waiters.length; i++) {
|
||||
const w = this.waiters[i];
|
||||
if (w.predicate(m)) {
|
||||
this.waiters.splice(i, 1);
|
||||
clearTimeout(w.timer);
|
||||
w.resolve(m);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.queue.push(m);
|
||||
if (this.queue.length > 500) this.queue.shift();
|
||||
});
|
||||
}
|
||||
send(message) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
async join(manifest, world, name = "TestPlayer") {
|
||||
await this.open;
|
||||
this.send({
|
||||
type: "join",
|
||||
protocol: 1,
|
||||
manifest_hash: manifest.hash,
|
||||
world,
|
||||
name,
|
||||
});
|
||||
return this.next("welcome");
|
||||
}
|
||||
next(type, predicate = () => true, timeout = 10000) {
|
||||
return this.wait((m) => m.type === type && predicate(m), timeout);
|
||||
}
|
||||
wait(predicate, timeout = 10000) {
|
||||
const i = this.queue.findIndex(predicate);
|
||||
if (i >= 0) return Promise.resolve(this.queue.splice(i, 1)[0]);
|
||||
return new Promise((resolve, reject) => {
|
||||
const w = {
|
||||
predicate,
|
||||
resolve,
|
||||
timer: setTimeout(() => {
|
||||
this.waiters = this.waiters.filter((x) => x !== w);
|
||||
reject(Error(`WebSocket message timeout (${timeout} ms)`));
|
||||
}, timeout),
|
||||
};
|
||||
this.waiters.push(w);
|
||||
});
|
||||
}
|
||||
input(fields = {}) {
|
||||
const input = {
|
||||
type: "input",
|
||||
seq: ++this.seq,
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
forward: 0,
|
||||
strafe: 0,
|
||||
jump: false,
|
||||
...fields,
|
||||
};
|
||||
this.send(input);
|
||||
return input.seq;
|
||||
}
|
||||
async state(predicate = () => true, timeout = 10000) {
|
||||
return this.next(
|
||||
"state",
|
||||
(m) =>
|
||||
predicate(
|
||||
m.players.find((p) => p.id === this.id),
|
||||
m,
|
||||
),
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
async close() {
|
||||
if (this.ws.readyState === WebSocket.CLOSED) return;
|
||||
const closed = new Promise((resolve) =>
|
||||
this.ws.addEventListener("close", resolve, { once: true }),
|
||||
);
|
||||
this.ws.close();
|
||||
await Promise.race([closed, delay(2000)]);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -2,8 +2,10 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
cargo fmt --all -- --check
|
||||
rustfmt --edition 2024 --check crates/shacraft-server/src/control.rs
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
cargo build -p shacraft-tools --locked
|
||||
cargo build --workspace --locked
|
||||
python3 scripts/check_storage.py
|
||||
|
||||
node --test client/tests/*.test.js
|
||||
node scripts/check_server.mjs --binary target/debug/shacraft-server --port 4001 --output artifacts/server-e2e-debug.json
|
||||
|
||||
Reference in New Issue
Block a user