Complete local voxel MVP with server, client, MCP, packages and world interchange

This commit is contained in:
Emil
2026-09-14 17:35:17 +03:00
parent cf8d1aae2e
commit b6ba064339
98 changed files with 58116 additions and 194 deletions
+13 -4
View File
@@ -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
+2
View File
@@ -7,3 +7,5 @@ __pycache__/
*.pyc
/graphify-out/
/logs/
Generated
+1708 -16
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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"
+48 -18
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+165
View File
@@ -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
View File
@@ -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
);
}
+1
View File
@@ -0,0 +1 @@
{"name":"shacraft-client","version":"0.1.0","private":true,"type":"module","scripts":{"test":"node --test tests/*.test.js"}}
+569
View File
@@ -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,
);
}
}
+597
View File
@@ -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;
}
}
+72
View File
@@ -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]);
});
+64
View File
@@ -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>
+22
View File
@@ -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
@@ -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.
File diff suppressed because it is too large Load Diff
+677
View File
@@ -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 &current {
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, &current)?;
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 &current {
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(())
}
+306
View File
@@ -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(())
}
+119
View File
@@ -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)
}
+97
View File
@@ -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)
}
}
+335
View File
@@ -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());
}
}
+469
View File
@@ -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());
}
}
+436
View File
@@ -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()
);
}
+15
View File
@@ -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."
}
@@ -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(())
}
+565
View File
@@ -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")
);
}
}
+365
View File
@@ -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());
}
}
+828
View File
@@ -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));
}
}
+39
View File
@@ -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
);
}
+11
View File
@@ -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"] }
+749
View File
@@ -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 = &params["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());
}
}
+214
View File
@@ -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();
}
+27
View File
@@ -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"
+499
View File
@@ -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
+342
View File
@@ -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));
}
+413
View File
@@ -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());
}
}
}
+100
View File
@@ -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);
}
}
}
}
+554
View File
@@ -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
View File
@@ -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 или весь ванильный игровой процесс.
+33
View File
@@ -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
View File
@@ -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 не заявляется.
+91
View File
@@ -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`, как собственный код проекта. Генерация ничего не скачивает.
+9
View File
@@ -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
View File
@@ -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`: подтверждённые блоки, сущности и правила арены должны пережить перезапуск того же каталога данных. Временные тестовые данные сохраняются для разбора.
+43
View File
@@ -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
View File
@@ -2,7 +2,7 @@
План составлен до реализации. Каждый этап заканчивается воспроизводимым результатом в локальном репозитории. `docs/STATUS.md` обновляется после проверок. Пункт не считается выполненным по наличию интерфейса, заглушки, каталога имён или успешной компиляции.
Текущий прогресс: этап 0 и первая реализация этапа 1 завершены. Проверены библиотека хранения, CLI и аварийное восстановление. Этапы 2–6 остаются впереди; подробности в STATUS и VERIFICATION.
Текущий прогресс: этапы 0–6 реализованы для локального профиля MVP. Компоненты интегрированы; фактические проверки, пределы нагрузки и ограничения совместимости описаны в STATUS и VERIFICATION. Исходный порядок этапов ниже сохранён.
## Этап 0. Сохранить контекст и проверить проектирование
+80
View File
@@ -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
View File
@@ -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
View File
@@ -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 отсутствуют.
+88
View File
@@ -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 и оригинальные игровые ресурсы не включены.
+14
View File
@@ -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"
}
}
+11
View File
@@ -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"
}
+146
View File
@@ -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."
}
+19
View File
@@ -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"
}
+402
View File
@@ -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
+119
View File
@@ -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"
}
+7
View File
@@ -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

+7
View File
@@ -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
}
+36
View File
@@ -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.
+13
View File
@@ -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

+4
View File
@@ -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": []
}
}
+71
View File
@@ -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))
+206
View File
@@ -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();
}
+45
View File
@@ -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.')
+85
View File
@@ -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);
}
}
+96
View File
@@ -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)
+54
View File
@@ -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();
}
+305
View File
@@ -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();
}
+51
View File
@@ -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")
+136
View File
@@ -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);
}
}
+94
View File
@@ -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))
+66
View File
@@ -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
View File
@@ -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" "$@"
+261
View File
@@ -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
View File
@@ -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