diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4371734..59965ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ jobs: with: toolchain: 1.96.0 components: rustfmt, clippy + targets: wasm32-unknown-unknown - uses: actions/setup-node@v4 with: node-version: 22 diff --git a/Cargo.lock b/Cargo.lock index 099bc83..e2849ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1467,6 +1467,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "shacraft-physics" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "shacraft-server" version = "0.1.0" @@ -1483,6 +1491,7 @@ dependencies = [ "sha2", "shacraft-content", "shacraft-core", + "shacraft-physics", "tempfile", "tokio", "tower", diff --git a/Cargo.toml b/Cargo.toml index c097e8b..b1be0dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/shacraft-core", "crates/shacraft-tools", "crates/shacraft-server", "crates/shacraft-mcp", "crates/shacraft-content", "crates/shacraft-compat"] +members = ["crates/shacraft-core", "crates/shacraft-tools", "crates/shacraft-server", "crates/shacraft-mcp", "crates/shacraft-content", "crates/shacraft-compat", "crates/shacraft-physics"] [workspace.package] version = "0.1.0" diff --git a/README.md b/README.md index 980ffb9..c16d7f0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ An independent, open-source voxel engine written in Rust. The local MVP includes Minecraft is mentioned to identify compatibility targets and data sources. Minecraft names, brands, and assets remain the property of their respective owners. See the [Minecraft Usage Guidelines](https://www.minecraft.net/en-us/usage-guidelines). +## In-game demonstration + +![An imported minigames lobby at night, with block lighting and another player visible in the Shacraft browser client](docs/images/minigames-lobby-night.png) + +An imported minigames lobby running in Shacraft, with multiplayer avatars and block lighting. This development screenshot uses original Minecraft textures for a local compatibility test; the world and texture pack are not bundled with the repository. Render distance supports up to 64 chunks (1,024 blocks), with progressive loading across the full world height, Y=−64 through 319. + ## Quick start You need Rust 1.96+, a C compiler for bundled SQLite, and a browser with WebGL2. Java and Node.js are not required to play. The first run downloads Cargo dependencies and builds the release binaries; subsequent runs use the existing binaries. @@ -18,9 +24,10 @@ cd shacraft-core bash scripts/run.sh --data data --listen 127.0.0.1:4000 ``` -Open **http://127.0.0.1:4000**. The server creates a lobby, a block gallery, and two Spleef arenas automatically. The MVP client currently uses Russian interface text. +Open **http://127.0.0.1:4000**. The server creates a lobby, a block gallery, two Spleef arenas and a procedural Overworld automatically. Open `http://localhost:4000/?world=overworld` for the large world. The MVP client currently uses Russian interface text. - **WASD** to move, **Space** to jump, and the mouse to look around. Drag to look if pointer lock is unavailable. +- **Ctrl** to sprint, **Shift** to crouch, **double-Space / F** to toggle permitted creative flight; Space/Shift ascend/descend. - **Left/right click** to remove/place a block; **E** for the material library, **T** for chat, and **F3** for diagnostics. - Select a world in the upper-left corner. To play Spleef, two players join the same arena and start a match from the menu. @@ -29,7 +36,8 @@ World data is stored under `--data`. Stop the server with Ctrl+C. Acknowledged b ## What's included - **`shacraft-core`** — compact 16³ sections, a bounded shared cache, SQLite with WAL and FULL synchronization, immutable snapshots, independent world edits, revisions, idempotent operations, undo, and reset. No graphics or networking dependencies. -- **`shacraft-server`** — authoritative movement, collision shapes and interactions, WebSocket/HTTP, persistent entities and settings, and a complete Spleef match cycle. +- **`shacraft-server`** — authoritative movement, collision shapes and interactions, WebSocket/HTTP, persistent entities and settings, and a complete Spleef match cycle. The seeded Overworld spans 384 vertical layers and nearly ±30 million horizontal blocks, with bounded CPU generation and section streaming. +- **`shacraft-physics`** — shared 20 Hz Rust/WASM player movement, client prediction and reconciliation, tested against measured Java 26.2 movement kernels, collision cases and block callbacks. See [physics and its limits](docs/PHYSICS.md). - **`client/`** — a custom renderer, material library, players and entities, chat, world switching, reconnection, and verified package downloads. - **`shacraft-mcp`** — a separate stdio MCP server with 17 tools, resources, and a prompt; build plans, reads, undo, entities, arenas, metrics, and PNG previews. - **`shacraft-content`** — **1,196 blocks, 32,366 states, and 158 entity types from Java 26.2**, DataVersion 4903. Source identifiers, measured collision shapes, and independently authored rendering templates. An additional trampoline block demonstrates extensions. @@ -38,9 +46,9 @@ World data is stored under `--data`. Stop the server with Ctrl+C. Acknowledged b ## Compatibility and scope -This MVP does not implement full vanilla AI, redstone, inventories, or fluid simulation. Context-dependent shapes are marked separately. After edits, the converter uses an explicit `best-effort` mode; an unchanged original can be returned byte for byte. Full Minecraft gameplay or NeoForge mod compatibility is not claimed. Minecraft source code, binaries, and original visual/audio assets are not distributed. +This MVP does not implement full vanilla AI, redstone, inventories, or fluid simulation. Context-dependent shapes are marked separately. After edits, the converter uses an explicit `best-effort` mode; an unchanged original can be returned byte for byte. Full Minecraft gameplay or NeoForge mod compatibility is not claimed. Minecraft source code, binaries, texture packs, and audio files are not bundled. -The release benchmark measured a maximum of **59.65 MiB RSS/VmHWM with 10 moving clients** and **50.50 MiB RSS with 100 idle world forks**. These are results from a short local workload, not a comparison with Paper or a production capacity guarantee. See [verification and limits](docs/VERIFICATION.md). +The earlier MVP release benchmark measured a maximum of **59.65 MiB RSS/VmHWM with 10 moving clients** and **50.50 MiB RSS with 100 idle world forks**. These measurements predate the shared movement solver and are results from a short local workload, not a comparison with Paper or a production capacity guarantee. See [verification and limits](docs/VERIFICATION.md). ## MCP and world conversion @@ -61,13 +69,14 @@ The Control API token is created at `data/control.token` with mode 0600 on Unix ```bash # Rust checks, crash recovery, JavaScript tests, and real HTTP/WebSocket clients: +rustup target add wasm32-unknown-unknown bash scripts/verify.sh # Release benchmark with budgets set before the run: cargo build --release --workspace --locked node scripts/benchmark_server.mjs --output artifacts/server-benchmark.json ``` -Node.js 22+ is required for network and JavaScript checks; Python 3 is required for the storage crash test. The independent MCP SDK and Java/NBT verification procedures are documented in [VERIFICATION](docs/VERIFICATION.md). Recorded checks include 84 Rust tests, 6 JavaScript tests, and 14 HTTP/WebSocket scenario groups. +Node.js 22+ is required for network and JavaScript checks; Python 3 is required for the storage crash test. Verification rebuilds the physics WebAssembly with the Rust `wasm32-unknown-unknown` target; playing uses the included artifact. The independent MCP SDK and Java/NBT verification procedures are documented in [VERIFICATION](docs/VERIFICATION.md). ## Documentation @@ -75,6 +84,8 @@ Node.js 22+ is required for network and JavaScript checks; Python 3 is required - [Requirements](docs/REQUIREMENTS.md), [implementation plan](docs/PLAN.md), and [design decisions](docs/DECISIONS.md) - [Core contracts](docs/CONTRACT.md), [memory and storage](docs/MEMORY_AND_STORAGE.md), and [development tools](docs/DEVELOPMENT.md) - [Server](docs/SERVER.md), [client](docs/CLIENT.md), and [MCP](docs/MCP.md) +- [Player physics, reference measurements and prediction](docs/PHYSICS.md) +- [Procedural worlds, streaming and movement diagnostics](docs/WORLD_STREAMING.md) - [Content catalog](docs/CONTENT.md), [compatibility](docs/COMPATIBILITY.md), [world interchange](docs/interop.md), and [packages](docs/PACKAGES.md) - [Acceptance criteria](docs/ACCEPTANCE.md) and [verification evidence](docs/VERIFICATION.md) diff --git a/client/ambient-occlusion.js b/client/ambient-occlusion.js new file mode 100644 index 0000000..f557831 --- /dev/null +++ b/client/ambient-occlusion.js @@ -0,0 +1,120 @@ +import { unitBox } from "./math.js"; + +const EPSILON = 1e-4; +const LEVELS = Object.freeze([0.76, 0.84, 0.92, 1]); +const OPEN = Object.freeze([1, 1, 1, 1]); + +/** Cutout and translucent surfaces should not stamp solid black corners. */ +export function isAmbientOccluder(material) { + if (!material) return true; // Match the renderer's unknown-block cube. + if (material.transparent || material.cutout || (material.opacity ?? 1) < 1) + return false; + const name = (material.state ?? "").split("[")[0].split(":").pop(); + return !/(?:^|_)(?:glass|leaves|sapling|flower|tulip|orchid|bush|roots|fern|vine|vines|torch|lantern|rail|coral|seagrass|kelp)(?:_|$)/.test(name) && + !/^(?:water|lava|ice|frosted_ice|cobweb|scaffolding|iron_bars|chain|ladder|tripwire|redstone_wire|short_grass|tall_grass|dead_bush|sugar_cane|lily_pad|dandelion|poppy|blue_orchid|allium|azure_bluet|oxeye_daisy|cornflower|lily_of_the_valley|wither_rose|sunflower|lilac|rose_bush|peony|azalea|flowering_azalea|brown_mushroom|red_mushroom|crimson_fungus|warped_fungus)$/.test(name); +} + +function validBoxes(material) { + return (material?.render ?? [unitBox]).filter((box) => + box?.min?.length === 3 && box?.max?.length === 3 && + box.min.every((value, axis) => Number.isFinite(value) && + Number.isFinite(box.max[axis]) && box.max[axis] > value), + ); +} + +/** + * Create once per section rebuild. Voxel and material lookups are cached until + * that rebuild ends; a later edit must use a fresh sampler. + * + * The returned function accepts (position, localBox, axisNormal, faceVertices). + * faceVertices are four normalized 0/1 cube corners, in renderer face.v order. + * Values describe ambient light only: direct sunlight remains independent. + * Optional isOccluder(material, blockId) can reject texture-pack cutout surfaces. + */ +export function createAmbientOcclusionSampler(blocks, materials, isOccluder) { + const materialBoxes = new Map(), voxelBoxes = new Map(), offsets = new Map(); + offsets.set("0,0,0", [0, 0, 0]); + const boxesFor = (id) => { + if (!materialBoxes.has(id)) { + const material = materials.get(id); + materialBoxes.set(id, + isAmbientOccluder(material) && (!isOccluder || isOccluder(material, id)) + ? validBoxes(material) : [], + ); + } + return materialBoxes.get(id); + }; + // Catalog render boxes can extend beyond their owning voxel (e.g. fences). + // Only those actual offsets add lookups; ordinary cubes use a single cell. + for (const id of materials.keys()) + for (const box of boxesFor(id)) { + // Unbounded custom geometry must not turn a section rebuild into a scan + // of the whole world. The renderer/selection catalog uses this same reach. + const min = box.min.map((value) => Math.max(-3, Math.floor(value))); + const max = box.max.map((value) => Math.min(3, Math.ceil(value) - 1)); + for (let x = min[0]; x <= max[0]; x++) + for (let y = min[1]; y <= max[1]; y++) + for (let z = min[2]; z <= max[2]; z++) + offsets.set(`${x},${y},${z}`, [x, y, z]); + } + const ownerOffsets = [...offsets.values()]; + const occupied = (point) => { + const cellX = Math.floor(point[0]), cellY = Math.floor(point[1]), cellZ = Math.floor(point[2]); + for (const offset of ownerOffsets) { + const x = cellX - offset[0], y = cellY - offset[1], z = cellZ - offset[2]; + const key = `${x},${y},${z}`; + if (!voxelBoxes.has(key)) { + const id = blocks.getAt ? blocks.getAt(x,y,z) : blocks.get(key); + voxelBoxes.set(key, id ? boxesFor(id) : []); + } + const localX = point[0] - x, localY = point[1] - y, localZ = point[2] - z; + for (const box of voxelBoxes.get(key)) + if (localX > box.min[0] && localX < box.max[0] && + localY > box.min[1] && localY < box.max[1] && + localZ > box.min[2] && localZ < box.max[2]) return true; + } + return false; + }; + return (pos, box, normal, faceVertices) => { + const normalAxis = normal.findIndex((value) => Math.abs(value) === 1); + if (normalAxis < 0 || normal.some((value, axis) => axis !== normalAxis && value !== 0)) + return OPEN; + const tangents = [0, 1, 2].filter((axis) => axis !== normalAxis); + return faceVertices.map((vertex) => { + const point = vertex.map((value, axis) => + pos[axis] + box.min[axis] + value * (box.max[axis] - box.min[axis]) + + normal[axis] * EPSILON, + ); + const sample = (first, second) => { + const candidate = [...point]; + for (let index = 0; index < 2; index++) { + const axis = tangents[index]; + candidate[axis] += (vertex[axis] < 0.5 ? -1 : 1) * + (index === 0 ? first : second) * EPSILON; + } + return occupied(candidate) ? 1 : 0; + }; + const sideA = sample(1, -1), sideB = sample(-1, 1); + // Both sides enclose the corner even when its diagonal cell is empty. + const level = sideA && sideB ? 0 : 3 - sideA - sideB - sample(1, 1); + return LEVELS[level]; + }); + }; +} + +/** Mesh sections whose corner lighting can depend on an edited block. */ +export function affectedSectionKeys(pos, renderBoxes = [unitBox]) { + const min = [...pos], max = [...pos]; + for (const box of renderBoxes ?? [unitBox]) + for (let axis = 0; axis < 3; axis++) { + min[axis] = Math.min(min[axis], pos[axis] + Math.floor(box.min[axis])); + max[axis] = Math.max(max[axis], pos[axis] + Math.ceil(box.max[axis]) - 1); + } + const lower = min.map((value) => Math.floor((value - 1) / 16)); + const upper = max.map((value) => Math.floor((value + 1) / 16)); + const sections = []; + for (let x = lower[0]; x <= upper[0]; x++) + for (let y = lower[1]; y <= upper[1]; y++) + for (let z = lower[2]; z <= upper[2]; z++) sections.push(`${x},${y},${z}`); + return sections; +} diff --git a/client/app.js b/client/app.js index cc9ffde..4091871 100644 --- a/client/app.js +++ b/client/app.js @@ -1,4 +1,27 @@ -import { Renderer } from "./renderer.js"; +import { EditDiagnostics } from "./edit-diagnostics.js"; +import { DynamicsDiagnostics, drawDynamicsChart } from "./dynamics-diagnostics.js"; +import { normalizeViewDistance, DEFAULT_VIEW_DISTANCE, MAX_VIEW_DISTANCE, viewDistanceProfile } from "./view-distance.js"; +import { Renderer } from "./renderer.js?v=large-world-1"; +import { SectionVoxelMap } from "./section-voxel-map.js"; +import { applySectionView, applySectionBatch, sectionInBounds } from "./section-stream.js"; +import { SectionBlockMap } from "./section-block-map.js"; +import { PointerLockController } from "./pointer-lock.js"; +import { + loadPhysics, + samplePhysicsWorld, + PhysicsClock, + LocalPrediction, +} from "./player-physics.js?v=movement-1"; +import { + updateRemotePlayer, + smoothRemotePlayer, +} from "./player-interpolation.js"; +import { + getViewBounds, + mergeChunkUpdate, + mergeSnapshot, + positionInView, +} from "./world-view.js"; import { clamp, key, @@ -12,9 +35,9 @@ const $ = (id) => document.getElementById(id), const state = { ws: null, id: null, - world: "lobby", + world: new URLSearchParams(location.search).get("world") || "lobby", revision: null, - blocks: new Map(), + blocks: new SectionBlockMap(), materials: new Map(), registry: [], players: new Map(), @@ -24,6 +47,12 @@ const state = { target: [0, 2, 8], yaw: 0, pitch: -0.14, + eyeHeight: 1.62, + eyeTarget: 1.62, + physicsStatus: "Загрузка", + physicsPose: "standing", + playerFlying: false, + playerSprinting: false, connected: false, manifest: null, retry: 0, @@ -39,8 +68,17 @@ const state = { packFiles: 0, packBytes: 0, packCached: 0, + texturePack: "", + textureCount: 0, + textureSize: 0, resync: false, viewCenter: null, + viewBounds: null, + fullSnapshots: 0, + chunkUpdates: 0, + sectionStream: false, viewGeneration: 0, loadedSections: new Set(), totalSections: 0, sectionBatches: 0, + streamApplyMs: 0, networkBytes: 0, waitingTerrain: false, + viewDistance: DEFAULT_VIEW_DISTANCE, viewDistanceSupported: false, metrics: null, name: localStorage.getItem("shacraft:name") || @@ -73,7 +111,13 @@ function bounceFeedback() { source.start(); } } -let pointerFallback = false; +let pointerFallback = false, + physics = null, + prediction = null, + motionSupported = false, + flyToggle = false, + lastSpace = -Infinity; +const physicsClock = new PhysicsClock(); let renderer, wsGeneration = 0, retryTimer, @@ -89,9 +133,37 @@ let renderer, held = new Set(), drag = null, lastAction = 0, - lastInput = 0, lastSelection = "", packageCache = null; +const frameSamples = []; +const dynamics = new DynamicsDiagnostics(); +const editDiagnostics = new EditDiagnostics(); +let lastDynamicsStatus = "idle"; +const pointerLock = new PointerLockController(canvas, { + onChange(locked) { + document.body.classList.toggle("locked", locked); + canvas.dataset.pointerLock = locked ? "locked" : "idle"; + if (locked) { + if (drag && canvas.hasPointerCapture(drag.pointerId)) canvas.releasePointerCapture(drag.pointerId); + drag = null; + pointerFallback = false; + delete canvas.dataset.pointerLockError; + updateMouseHint(); + } else controlsZero(); + }, + onError(error) { + canvas.dataset.pointerLock = "error"; + canvas.dataset.pointerLockError = `${error.name}: ${error.message}`; + canvas.dataset.pointerLockFocused = String(document.hasFocus()); + toast("Браузер не смог захватить мышь. Попробуйте ещё раз или включите перетаскивание в меню.", true); + }, +}); +function updateMouseHint() { + $("mouse-mode").textContent = `Мышь: ${pointerFallback ? "перетаскивание" : "захват"}`; + $("play-hint").textContent = pointerFallback + ? "Удерживайте кнопку для обзора · щелчок — убрать / поставить блок" + : "Нажмите на мир для захвата мыши · Esc — освободить"; +} const niceName = (s) => (s || "") .replace(/^minecraft:/, "") @@ -107,8 +179,10 @@ const bytes = (n) => : `${n} Б` : "—"; const isUIOpen = () => - ["menu", "worlds", "library", "diagnostics"].some((id) => !$(id).hidden) || + ["menu", "worlds", "library"].some((id) => !$(id).hidden) || !$("chat-form").hidden; +const isUIFocused = () => isUIOpen() || + (!$("diagnostics").hidden && $("diagnostics").contains(document.activeElement)); function toast(message, error = false, duration = 4300) { $("toast").textContent = message; $("toast").classList.toggle("error", error); @@ -136,7 +210,11 @@ function send(message) { } function controlsZero() { held.clear(); - if (state.connected) + flyToggle = false; + if (state.connected && motionSupported) { + send({ type: "input_reset" }); + physicsClock.reset(); + } else if (state.connected) send({ type: "input", seq: ++state.seq, @@ -145,18 +223,103 @@ function controlsZero() { forward: 0, strafe: 0, jump: false, + sprint: false, + sneak: false, }); } +function movementInput(consumeToggle = false) { + const automatic=dynamics.controls(consumeToggle,state.playerFlying,performance.now(),Boolean(prediction?.body?.horizontal_collision)); + if(automatic && state.connected && !document.hidden){state.yaw=automatic.yaw;state.pitch=automatic.pitch;return automatic;} + const enabled = !isUIFocused() && !document.hidden, + sneak = enabled && (held.has("ShiftLeft") || held.has("ShiftRight")), + input = { + yaw: Math.fround(state.yaw), + pitch: Math.fround(state.pitch), + forward: enabled + ? Number(held.has("KeyW") || held.has("ArrowUp")) - + Number(held.has("KeyS") || held.has("ArrowDown")) + : 0, + strafe: enabled + ? Number(held.has("KeyD") || held.has("ArrowRight")) - + Number(held.has("KeyA") || held.has("ArrowLeft")) + : 0, + jump: enabled && held.has("Space"), + sprint: + enabled && + !sneak && + (held.has("ControlLeft") || held.has("ControlRight")), + sneak, + fly_toggle: enabled && flyToggle, + }; + if (consumeToggle) flyToggle = false; + return input; +} +function disablePrediction(error) { + console.error("Shacraft prediction disabled", error); + prediction = null; + physics = null; + state.physicsStatus = "Только сервер"; + toast("Локальная физика недоступна. Движение рассчитывает сервер.", true); +} +function receiveMotion(motion, reset = false) { + if (!motion || !prediction) return; + try { + const previous = prediction.authority; + if (prediction.receive(motion, reset)) { + if ( + !reset && + previous && + previous.velocity[1] <= 0 && + motion.body.velocity[1] > 0.4 + ) { + const under = previous.position.map((value, axis) => + Math.floor(value - (axis === 1 ? 0.1 : 0)), + ); + if ( + state.materials.get(state.blocks.get(key(under)))?.effect === "bounce" + ) + bounceFeedback(); + } + motionSupported = true; + state.ack = motion.ack; + state.tick = motion.tick; + state.target = [...prediction.body.position]; + state.physicsPose = prediction.body.pose; + state.playerFlying = Boolean(prediction.body.flying); + state.playerSprinting = Boolean(prediction.body.sprinting); + if (reset) { + state.position = [...prediction.body.position]; + physicsClock.reset(); + } + } + } catch (error) { + disablePrediction(error); + } +} +function inputStep() { + const input = movementInput(true), + seq = state.seq + 1; + if (!send({ type: "input", seq, ...input })) return; + state.seq = seq; + if (motionSupported && prediction) { + try { + prediction.push(seq, input); + } catch (error) { + disablePrediction(error); + } + } +} function closePanels() { - for (const id of ["menu", "worlds", "library", "diagnostics"]) + for (const id of ["menu", "worlds", "library"]) $(id).hidden = true; } function panel(id) { const wasOpen = !$(id).hidden; - closePanels(); - document.exitPointerLock?.(); + if (id !== "diagnostics") closePanels(); + pointerLock.release(); controlsZero(); $(id).hidden = wasOpen; + if (wasOpen) canvas.focus(); if (!wasOpen && id === "library") { loadCatalog(true); $("catalog-search").focus(); @@ -165,7 +328,7 @@ function panel(id) { if (!wasOpen && id === "diagnostics") fetchMetrics(); } function release() { - document.exitPointerLock?.(); + pointerLock.release(); controlsZero(); } function acquire() { @@ -176,22 +339,12 @@ function acquire() { toast("Соединение с сервером ещё не установлено."); return; } - try { - const p = canvas.requestPointerLock?.(); - p?.catch?.(() => { - pointerFallback = true; - toast( - "Захват мыши недоступен: перетаскивайте для обзора, щёлкните для правки.", - ); - }); - } catch { - pointerFallback = true; - toast("Для обзора удерживайте кнопку мыши и двигайте курсор."); - } canvas.focus(); + if (!pointerFallback) pointerLock.request(); } -function addMaterials(items = []) { - let changed = false; +function addMaterials(items = [], remesh = true) { + if (items.length) prediction?.invalidateWorld(); + const added = new Set(); for (const item of items) { if (!item || !Number.isInteger(item.id)) continue; const prev = state.materials.get(item.id), @@ -214,29 +367,68 @@ function addMaterials(items = []) { if (style?.color) mat.color = style.color; mat.effect = style?.effect; state.materials.set(mat.id, mat); - if (!prev) changed = true; + if (!prev) added.add(item.id); } - return changed; + if (added.size && renderer && remesh) + renderer.change( + [...state.blocks] + .filter(([, id]) => added.has(id)) + .map(([position, block]) => ({ + pos: position.split(",").map(Number), + block, + })), + ); + return added.size > 0; } function snapshot(m) { + editDiagnostics.reset(); + const newWorld = + m.type === "welcome" || + (m.world && m.world !== state.world) || + state.revision === null; + if (!newWorld && m.revision < state.revision) return; state.id = m.id ?? state.id; state.world = m.world || state.world; state.registry = m.registry || state.registry; - addMaterials(m.materials); - state.blocks = new Map(); - for (const b of m.blocks || []) - if (b.block) state.blocks.set(key(b.pos), b.block); + addMaterials(m.materials, !newWorld); + state.sectionStream = m.features?.includes("chunk_stream_v2") || false; + state.viewDistanceSupported = m.features?.includes("view_distance_v1") || false; + acceptViewDistance(m); + if (newWorld) state.blocks = state.sectionStream ? new SectionVoxelMap() : new SectionBlockMap(); + if (state.sectionStream) { + state.viewGeneration = m.generation; + state.loadedSections = new Set(); state.blocks.readySections = state.loadedSections; + state.totalSections = m.total_sections || 0; + } + const changes = state.sectionStream && !newWorld ? [] : mergeSnapshot(state.blocks, m.blocks || []); state.revision = m.revision; state.resync = false; state.viewCenter = m.view_center || m.spawn?.map((v) => Math.floor(v / 16) * 16) || null; - if (m.spawn) { + state.viewBounds = getViewBounds(m); + renderer.setLightBounds(state.viewBounds); + if (state.sectionStream && !newWorld) { + const unload=[...state.blocks.loadedSectionKeys()].filter(id=>!sectionInBounds(id.split(",").map(Number),state.viewBounds)).map(id=>id.split(",").map(Number)); + for(const pos of unload)state.blocks.deleteSection(pos.join(",")); + renderer.unloadSections(unload); + } + state.fullSnapshots++; + if (newWorld && m.spawn) { state.position = [...m.spawn]; state.target = [...m.spawn]; } - if (m.players) updatePlayers(m.players, true); + if (newWorld) { + prediction?.reset(); + motionSupported = false; + physicsClock.reset(); + flyToggle = false; + } + if (m.players) updatePlayers(m.players, newWorld); + prediction?.invalidateWorld(); + receiveMotion(m.motion, newWorld); if (m.entities) state.entities = m.entities; - renderer.replace(state.blocks, state.materials); + if (newWorld) renderer.replace(state.blocks, state.materials); + else renderer.change(changes); $("world-button").textContent = `${state.world} ▾`; document.title = `Shacraft · ${state.world}`; if (m.type === "welcome") { @@ -254,6 +446,11 @@ function updatePlayers(players, instant = false) { for (const p of players) { present.add(p.id); if (p.id === state.id) { + if (prediction && motionSupported && !instant) continue; + state.eyeTarget = p.eye_height ?? 1.62; + state.physicsPose = p.pose || "standing"; + state.playerFlying = Boolean(p.flying); + state.playerSprinting = Boolean(p.sprinting); if (p.position) { const under = [ Math.floor(state.target[0]), @@ -277,42 +474,81 @@ function updatePlayers(players, instant = false) { continue; } const old = state.players.get(p.id); - state.players.set(p.id, { - ...p, - position: old && !instant ? old.position : [...p.position], - target: [...p.position], - }); + state.players.set(p.id, updateRemotePlayer(old, p, instant)); } for (const id of state.players.keys()) if (!present.has(id)) state.players.delete(id); } function inView(pos) { - const center = state.viewCenter; - return ( - !center || - pos.every( - (v, i) => v >= center[i] - (i === 1 ? 8 : 32) && v <= center[i] + 31, - ) - ); + return positionInView(pos, state.viewBounds); +} +function requestResync() { + if (state.resync) return; + state.resync = true; + send({ type: "resync" }); + toast("Сверяем изменения с сервером…"); +} +function chunkUpdate(m) { + if (state.revision === null || state.resync) return; + const result = mergeChunkUpdate(state, m, { includeUnloadedChanges: false }); + if (result.status === "resync") { + requestResync(); + return; + } + if (result.status !== "applied") return; + renderer.setLightBounds(state.viewBounds); + addMaterials(m.materials, !renderer.terrain); + renderer.change(result.changes, [...m.sections.map(section => section.section), ...m.unload], m.sections); + renderer.unloadSections(m.unload); + state.chunkUpdates++; + prediction?.invalidateWorld(); + loadMissingMaterials(); +} +function sectionView(m) { + const result=applySectionView(state,m); + if(result==="resync")return requestResync(); + if(result==="ignored")return; + renderer.setLightBounds(state.viewBounds); + renderer.unloadSections(result.unload); + acceptViewDistance(m); + state.chunkUpdates++; + prediction?.invalidateWorld(); +} +function sectionBatch(m) { + const start=performance.now(); + const result=applySectionBatch(state,m); + if(result.status==="resync")return requestResync(); + if(result.status!=="applied")return; + addMaterials(m.materials,!renderer.terrain); + renderer.change([],result.sections.map(s=>s.section),result.sections); + renderer.terrain?.update({columns:result.columns}); + state.totalSections=m.total_sections || state.totalSections; + state.sectionBatches++; + prediction?.invalidateWorld(); + state.streamApplyMs=performance.now()-start; + dynamics.event("sections",state.streamApplyMs,{sections:result.sections.length,loaded:state.loadedSections.size}); + loadMissingMaterials(); } function blockUpdate(m) { + if (m.world && m.world !== state.world) return; if (state.revision === null || state.resync) return; if (m.revision <= state.revision) return; if (m.revision !== state.revision + 1) { - state.resync = true; - send({ type: "resync" }); - toast("Сверяем изменения с сервером…"); + requestResync(); return; } const changes = (m.changes || []).filter((change) => inView(change.pos)); const needed = new Set(changes.map((change) => change.block)); - addMaterials((m.materials || []).filter((mat) => needed.has(mat.id))); + addMaterials((m.materials || []).filter((mat) => needed.has(mat.id)), !renderer.terrain); for (const change of changes) { if (change.block) state.blocks.set(key(change.pos), change.block); else state.blocks.delete(key(change.pos)); } state.revision = m.revision; + prediction?.invalidateWorld(); + editDiagnostics.confirmed(changes); renderer.change(changes); + if(m.columns?.length)renderer.terrain?.update({columns:m.columns.map(item=>({column:item.column,heights:new Int16Array(item.heights)}))}); loadMissingMaterials(); } function onMessage(m) { @@ -324,6 +560,8 @@ function onMessage(m) { case "state": state.tick = m.tick ?? state.tick; state.ack = m.ack ?? state.ack; + state.waitingTerrain = Boolean(m.motion?.waiting_terrain); + receiveMotion(m.motion); if (m.players) updatePlayers(m.players); if (m.entities) state.entities = m.entities; if (m.match) showMatch(m.match); @@ -331,6 +569,11 @@ function onMessage(m) { case "blocks": blockUpdate(m); break; + case "view": sectionView(m); break; + case "sections": sectionBatch(m); break; + case "chunks": + chunkUpdate(m); + break; case "chat": chat(m.name, m.text); break; @@ -370,6 +613,14 @@ async function getJSON(url) { throw Error(`${url.split("?")[0]}: HTTP ${response.status}`); return response.json(); } +function updatePackageStatus() { + const progress = state.packFiles + ? `${state.packFiles} файлов проверено · ${bytes(state.packBytes)} · из кэша ${state.packCached}` + : "Сервер не требует дополнительных файлов."; + $("package-status").textContent = state.texturePack + ? `${progress} · ${state.texturePack}: ${state.textureCount} текстур ${state.textureSize}×${state.textureSize}` + : progress; +} async function verifyPackages(manifest, generation) { if (!crypto.subtle) throw Error( @@ -378,6 +629,10 @@ async function verifyPackages(manifest, generation) { state.packFiles = 0; state.packBytes = 0; state.packCached = 0; + state.texturePack = ""; + state.textureCount = 0; + state.textureSize = 0; + renderer.clearTexturePack(); packageStyles.clear(); try { packageCache = await caches.open("shacraft-packages-v1"); @@ -433,28 +688,38 @@ async function verifyPackages(manifest, generation) { await packageCache?.delete(cacheURL); throw Error(`Хеш ресурса не совпадает: ${pack.id}/${file.path}`); } + if (generation !== wsGeneration) return; if (!cached && packageCache) try { await packageCache.put(cacheURL, new Response(blob)); } catch { /* Quota failures do not weaken integrity verification. */ } + if (generation !== wsGeneration) return; state.packFiles++; state.packBytes += blob.size; state.packCached += cached ? 1 : 0; verifiedFiles.set(file.path, blob); - $("package-status").textContent = - `${state.packFiles} файлов проверено · ${bytes(state.packBytes)} · из кэша ${state.packCached}`; + updatePackageStatus(); connection(`Пакеты ${state.packFiles}`); } // Resolve declared references only after every resource passed integrity checks. - // The supported client hook is declarative `effect: bounce`, with no package JS. + // Texture packs and the bounce hook are declarative, with no package JS. for (const file of resources.filter( (f) => f.role === "client-style" || f.path?.endsWith("style.json"), )) { const style = JSON.parse(await verifiedFiles.get(file.path).text()); + if (generation !== wsGeneration) return; if (style.schema !== 1) throw Error(`Неизвестная схема стиля пакета ${pack.id}`); + if (style.texture_pack) { + await renderer.setTexturePack(style.texture_pack, verifiedFiles); + if (generation !== wsGeneration) return; + state.texturePack = style.texture_pack.name; + state.textureCount = style.texture_pack.textures.length; + state.textureSize = style.texture_pack.pixel_size; + updatePackageStatus(); + } if (typeof style.block === "string") packageStyles.set(style.block, style); const texture = verifiedFiles.get(style.texture); @@ -473,9 +738,7 @@ async function verifyPackages(manifest, generation) { } } } - if (!state.packFiles) - $("package-status").textContent = - "Сервер не требует дополнительных файлов."; + updatePackageStatus(); if (packageCache) { const keys = await packageCache.keys(); if (keys.length > 512) @@ -494,6 +757,9 @@ async function connect() { state.id = null; state.players.clear(); state.seq = 0; + prediction?.reset(); + motionSupported = false; + physicsClock.reset(); connection("Подключение"); controlsZero(); try { @@ -513,15 +779,23 @@ async function connect() { send({ type: "join", protocol: 1, + features: [ + "chunk_stream_v2", "chunk_stream_v1", "view_buffer_v1", "full_height_v1", + ...(physics ? ["movement_prediction_v1"] : []), + ], name: state.name, world: state.world, + view_distance: normalizeViewDistance($("view-distance").value), manifest_hash: manifest.hash, }); }); ws.addEventListener("message", (event) => { if (generation !== wsGeneration) return; try { - onMessage(JSON.parse(event.data)); + const start=performance.now(),message=JSON.parse(event.data); + state.networkBytes+=event.data.length; + onMessage(message); + dynamics.event("network",performance.now()-start,{typeName:message.type,bytes:event.data.length}); } catch (error) { console.error("Shacraft protocol error", error); toast(`Ошибка данных сервера: ${error.message}`, true); @@ -577,7 +851,7 @@ async function loadWorlds() { const title = document.createElement("strong"); title.textContent = world.name; const subtitle = document.createElement("small"); - subtitle.textContent = `Ревизия ${world.revision}${world.template ? ` · экземпляр ${world.template}` : ""}`; + subtitle.textContent = world.terrain ? `Процедурный мир · seed ${world.terrain.seed} · Y −64…319` : `Ревизия ${world.revision}${world.template ? ` · экземпляр ${world.template}` : ""}`; b.append(title, subtitle); b.onclick = () => { if (!state.connected) { @@ -600,7 +874,7 @@ async function loadMissingMaterials() { missingPending = true; try { while (state.connected) { - const missing = [...new Set(state.blocks.values())] + const missing = [...new Set(state.blocks.materialIds ? state.blocks.materialIds() : state.blocks.values())] .filter((id) => !state.materials.has(id)) .slice(0, 128); if (!missing.length) break; @@ -609,15 +883,6 @@ async function loadMissingMaterials() { ); const items = data.items || data; if (!addMaterials(items)) break; - const loaded = new Set(items.map((material) => material.id)); - renderer.change( - [...state.blocks] - .filter(([, id]) => loaded.has(id)) - .map(([position, block]) => ({ - pos: position.split(",").map(Number), - block, - })), - ); await new Promise((resolve) => setTimeout(resolve, 0)); } seedHotbar(); @@ -772,31 +1037,27 @@ function showMatch(m) { `${phases[m.phase] || m.phase}${m.remaining !== undefined ? ` · ${Math.ceil(m.remaining)} с` : ""}${winner}`; } function action(kind) { - if (!state.connected || isUIOpen()) return; + if (!state.connected || isUIFocused()) return; const hit = state.selection; if (!hit) return; const now = performance.now(); if (now - lastAction < 130) return; lastAction = now; - send({ - type: "input", - seq: ++state.seq, - yaw: state.yaw, - pitch: state.pitch, - forward: (held.has("KeyW") ? 1 : 0) - (held.has("KeyS") ? 1 : 0), - strafe: (held.has("KeyD") ? 1 : 0) - (held.has("KeyA") ? 1 : 0), - jump: held.has("Space"), - }); - if (kind === "break") send({ type: "break", pos: hit.pos }); + if (motionSupported) + send({ type: "look", yaw: state.yaw, pitch: state.pitch }); + if (kind === "break") { + if (send({ type: "break", pos: hit.pos })) editDiagnostics.request(hit.pos, 0, now); + } else if (kind === "place") { const mat = state.materials.get(state.hotbar[state.slot]); if (!mat?.id) return; - send({ + const pos = hit.pos.map((v, i) => v + hit.normal[i]); + if (send({ type: "place", - pos: hit.pos.map((v, i) => v + hit.normal[i]), + pos, block: mat.id, state: mat.state, - }); + })) editDiagnostics.request(pos, mat.id, now); } else if (kind === "pick") { state.hotbar[state.slot] = hit.block; renderHotbar(); @@ -810,7 +1071,51 @@ function openChat() { } $("library-button").onclick = () => panel("library"); $("diagnostics-button").onclick = () => panel("diagnostics"); +$("diagnostics").addEventListener("pointerdown", () => { pointerLock.release(); controlsZero(); }); $("menu-button").onclick = () => panel("menu"); +try {$("view-distance").value=String(normalizeViewDistance(localStorage.getItem("shacraft:view-distance")));} catch {} +function updateViewDistanceUI() { + const selected=normalizeViewDistance($("view-distance").value); + $("view-distance-apply").disabled=!state.connected||!state.viewDistanceSupported||selected===state.viewDistance; + $("view-distance-status").textContent = state.viewDistanceSupported + ? `Сейчас: ${state.viewDistance} ${state.viewDistance<5?"чанка":"чанков"} · загружено ${state.loadedSections.size} / ${state.totalSections} секций` + : "Настройка станет доступна после подключения к обновлённому серверу."; + $("view-distance-info").textContent = `${viewDistanceProfile(selected).radius} блоков вокруг игрока. ${selected>=6?"Высокая нагрузка на память и видеокарту; подгрузка займёт больше времени.":"Большая дальность увеличивает расход памяти. По умолчанию — 3 чанка."}`; +} +function acceptViewDistance(message) { + if(Number.isInteger(message.view_distance)&&message.view_distance>=2&&message.view_distance<=MAX_VIEW_DISTANCE) { + state.viewDistance=message.view_distance; + if(renderer)renderer.viewDistance=state.viewDistance; + } + updateViewDistanceUI(); +} +$("view-distance").onchange=updateViewDistanceUI; +$("view-distance-apply").onclick=()=>{ + const chunks=normalizeViewDistance($("view-distance").value); + if(!state.connected||!state.viewDistanceSupported)return; + send({type:"view_distance",chunks}); + try {localStorage.setItem("shacraft:view-distance",String(chunks));} catch {} + $("view-distance-status").textContent="Меняем дальность и подгружаем чанки…"; +}; +updateViewDistanceUI(); +$("mouse-mode").onclick = () => { + pointerFallback = !pointerFallback; + pointerLock.release(); + updateMouseHint(); +}; +updateMouseHint(); +function setTimeOfDay(timeOfDay) { + const night = timeOfDay !== "day"; + renderer.daylight = night ? 0 : 1; + renderer.lightingName = night ? "Moonlight" : "Daylight"; + $("time-button").textContent = `Освещение: ${night ? "ночь" : "день"}`; +} +$("time-button").onclick = () => { + if (!renderer) return; + const timeOfDay = renderer.daylight === 0 ? "day" : "night"; + setTimeOfDay(timeOfDay); + localStorage.setItem("shacraft:time-of-day", timeOfDay); +}; $("world-button").onclick = () => panel("worlds"); for (const b of document.querySelectorAll("[data-close]")) b.onclick = () => ($(b.dataset.close).hidden = true); @@ -853,7 +1158,7 @@ $("chat-form").onsubmit = (e) => { canvas.focus(); }; window.addEventListener("keydown", (e) => { - const editing = /INPUT|TEXTAREA/.test(document.activeElement?.tagName); + const editing = /INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName); if (e.code === "Escape") { if (!$("chat-form").hidden) { $("chat-form").hidden = true; @@ -865,7 +1170,8 @@ window.addEventListener("keydown", (e) => { controlsZero(); return; } - if (editing) return; + if (e.code === "F3") { e.preventDefault(); if (!e.repeat) panel("diagnostics"); return; } + if (editing || (!$("diagnostics").hidden && $("diagnostics").contains(document.activeElement))) return; if ( [ "Space", @@ -879,6 +1185,10 @@ window.addEventListener("keydown", (e) => { "ArrowRight", "Tab", "F3", + "ControlLeft", + "ControlRight", + "ShiftLeft", + "ShiftRight", ].includes(e.code) ) e.preventDefault(); @@ -887,10 +1197,6 @@ window.addEventListener("keydown", (e) => { panel("library"); return; } - if (e.code === "F3") { - panel("diagnostics"); - return; - } if (e.code === "KeyT" || e.code === "Enter") { openChat(); return; @@ -900,7 +1206,17 @@ window.addEventListener("keydown", (e) => { renderHotbar(); return; } - if (!isUIOpen()) held.add(e.code); + if (!isUIFocused()) { + if (e.code === "Space") { + const now = performance.now(); + if (now - lastSpace < 300) { + flyToggle = true; + lastSpace = -Infinity; + } else lastSpace = now; + } + if (e.code === "KeyF") flyToggle = true; + held.add(e.code); + } }); window.addEventListener("keyup", (e) => held.delete(e.code)); window.addEventListener("blur", () => { @@ -908,12 +1224,9 @@ window.addEventListener("blur", () => { controlsZero(); }); document.addEventListener("visibilitychange", () => { - if (document.hidden) controlsZero(); -}); -document.addEventListener("pointerlockchange", () => { - const locked = document.pointerLockElement === canvas; - document.body.classList.toggle("locked", locked); - if (!locked) controlsZero(); + if (document.hidden) {dynamics.stop(performance.now(),'tab-hidden');controlsZero();} + physicsClock.reset(); + lastFrame = performance.now(); }); function look(dx, dy) { const sensitivity = Number($("sensitivity").value); @@ -932,13 +1245,16 @@ canvas.addEventListener("pointerdown", (e) => { unlockAudio(); if (isUIOpen()) { closePanels(); + canvas.focus(); return; } + canvas.focus(); if (document.pointerLockElement === canvas) { action(e.button === 2 ? "place" : e.button === 1 ? "pick" : "break"); return; } drag = { + pointerId: e.pointerId, x: e.clientX, y: e.clientY, startX: e.clientX, @@ -955,7 +1271,6 @@ canvas.addEventListener("pointermove", (e) => { if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) > 3) drag.moved = true; if (drag.moved) { - pointerFallback = true; look(dx, dy); } drag.x = e.clientX; @@ -965,6 +1280,7 @@ canvas.addEventListener("pointerup", (e) => { if (!drag) return; const d = drag; drag = null; + if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); if (!d.moved) { if (d.button === 2) action("place"); else if (d.button === 1) action("pick"); @@ -985,10 +1301,12 @@ canvas.addEventListener( async function fetchMetrics() { try { state.metrics = await getJSON("/api/metrics"); + dynamics.serverSample({tickMs:state.metrics.tick_ms,stream:state.metrics.stream,terrain:state.metrics.terrain,rss:state.metrics.rss_bytes}); } catch {} updateDiagnostics(); } function updateDiagnostics() { + updateViewDistanceUI(); const m = state.metrics || {}, storage = m.storage || m.store || m, mem = m.memory || {}, @@ -1001,11 +1319,47 @@ function updateDiagnostics() { ["Сущности", String(state.entities.length)], ["Игроки", String(state.players.size + (state.id ? 1 : 0))], ["WebGL", renderer?.lost ? "Контекст потерян" : "WebGL2"], + ["Захват мыши", pointerFallback ? "Перетаскивание" : ({ idle: "Нажмите на мир", requesting: "Запрошен", locked: "Активен", error: "Ошибка браузера", unsupported: "Не поддерживается" })[pointerLock.status]], + ["Ошибка мыши", canvas.dataset.pointerLockError || "—"], + ["Освещение", renderer?.lightingName || "—"], + ["Свет блоков / неба", `${renderer?.canvas.dataset.blockLight || 0} / ${renderer?.canvas.dataset.skyLight || 15}`], + ["Источники света", String(renderer?.blockLightField?.sourceCount || 0)], + ["Расчёт света", renderer?.blockLighting.status === "ready" ? "Готов" : "Обновляется"], + ["Тени", renderer?.shadowsEnabled && renderer?.shadowReady ? `Мягкие · ${renderer.shadowSize}²` : "Недоступны"], ["Кадры / с", String(state.fps)], + ["Кадр p95 / максимум", `${state.frameP95 || 0} / ${state.frameMax || 0} мс`], ["Треугольники", renderer?.triangles.toLocaleString("ru-RU") || "0"], ["Ожидают построения", String(renderer?.dirty.size || 0)], + ["Геометрия", renderer?.terrain?.meshWorker ? "Worker · локальный свет" : renderer?.terrain ? "Worker" : "Основной поток"], + ["Ближайшие секции", `${renderer?.canvas.dataset.nearMeshes || 0} построено / ${renderer?.canvas.dataset.nearLoaded || 0} загружено`], + ["Первые чанки", renderer?.canvas.dataset.firstTerrainMs ? `${renderer.canvas.dataset.firstTerrainMs} мс` : "Загрузка…"], + ["Свет секции", `${renderer?.canvas.dataset.localLightMs || 0} мс · ${renderer?.canvas.dataset.localLightCells || 0} ячеек`], + ["Подготовка данных", `${renderer?.canvas.dataset.terrainPrepareMs || 0} мс`], + ["Построение секции", `${renderer?.canvas.dataset.meshBuildMs || 0} мс`], + ["Правка: подготовка", `${renderer?.canvas.dataset.editPrepareMs || 0} мс`], + ["Правка: ответ сервера", editDiagnostics.last ? `${editDiagnostics.last.acknowledgementMs.toFixed(1)} мс` : "—"], + ["Правка: обновление геометрии", editDiagnostics.last ? `${editDiagnostics.last.geometryMs.toFixed(1)} мс` : "—"], + ["Правка: всего", editDiagnostics.last ? `${editDiagnostics.last.totalMs.toFixed(1)} мс` : "—"], + ["Загрузка геометрии", `${renderer?.canvas.dataset.meshUploadMs || 0} мс`], + ["Меши секций", String(renderer?.sections.size || 0)], + ["Перестроения мешей", String(renderer?.meshRebuilds || 0)], + ["Сбросы геометрии", String(renderer?.meshResets || 0)], + ["Полные снимки", String(state.fullSnapshots)], + ["Обновления чанков", String(state.chunkUpdates)], + ["Секции загружены", `${state.loadedSections.size} / ${state.totalSections}`], + ["Пакеты секций", String(state.sectionBatches)], + ["Применение чанков", `${state.streamApplyMs.toFixed(2)} мс`], + ["Массивы блоков", bytes(state.blocks.byteLength)], + ["Генерация сервера", `${m.terrain?.pending || 0} в очереди · ${(m.terrain?.last_generate_ms || 0).toFixed(2)} мс`], + ["Ожидание местности", state.waitingTerrain ? "Да" : "Нет"], ["Тик сервера", String(state.tick)], ["Подтверждён ввод", String(state.ack)], + ["Физика", state.physicsStatus], + ["Положение тела", state.physicsPose], + ["Полёт", state.playerFlying ? "Да" : "Нет"], + ["Бег", state.playerSprinting ? "Да" : "Нет"], + ["Ввод в пути", String(prediction?.pending.length || 0)], + ["Коррекция позиции", `${(prediction?.correction || 0).toFixed(3)} м`], ["Ping", state.ping ? `${state.ping} мс` : "—"], ["Координаты", state.position.map((n) => n.toFixed(1)).join(", ")], ["RAM процесса", bytes(rss)], @@ -1014,6 +1368,12 @@ function updateDiagnostics() { bytes(storage.cache_payload_bytes ?? storage.cache?.payload_bytes), ], ["Пакеты", `${state.packFiles} файлов`], + [ + "Текстурпак", + state.texturePack + ? `${state.texturePack} · ${state.textureCount} текстур ${state.textureSize}×${state.textureSize}` + : "По умолчанию", + ], ]; $("debug-values").replaceChildren(); for (const [label, value] of rows) { @@ -1024,12 +1384,45 @@ function updateDiagnostics() { dd.dataset.metric = label; $("debug-values").append(dt, dd); } + canvas.dataset.editTimings = JSON.stringify(editDiagnostics.last); + canvas.dataset.selection = state.selection ? JSON.stringify({pos:state.selection.pos,normal:state.selection.normal,block:state.selection.block}) : ""; canvas.dataset.world = state.world; canvas.dataset.revision = String(state.revision ?? ""); canvas.dataset.blocks = String(state.blocks.size); canvas.dataset.entities = String(state.entities.length); + canvas.dataset.viewDistance = String(state.viewDistance); + canvas.dataset.viewMin = state.viewBounds?.min.join(",") || ""; + canvas.dataset.viewMax = state.viewBounds?.max.join(",") || ""; + canvas.dataset.fullHeight = String(state.viewBounds?.fullHeight === true); canvas.dataset.webgl = renderer?.lost ? "lost" : "ready"; canvas.dataset.connected = String(state.connected); + canvas.dataset.texturePack = state.texturePack; + canvas.dataset.textureCount = String(state.textureCount); + canvas.dataset.textureSize = String(state.textureSize); + canvas.dataset.fullSnapshots = String(state.fullSnapshots); + canvas.dataset.chunkUpdates = String(state.chunkUpdates); + canvas.dataset.loadedSections = String(state.loadedSections.size); + canvas.dataset.totalSections = String(state.totalSections); + canvas.dataset.sectionBatches = String(state.sectionBatches); + canvas.dataset.streamApplyMs = String(state.streamApplyMs); + canvas.dataset.voxelBytes = String(state.blocks.byteLength || 0); + canvas.dataset.waitingTerrain = String(state.waitingTerrain); + canvas.dataset.meshResets = String(renderer?.meshResets || 0); + canvas.dataset.meshRebuilds = String(renderer?.meshRebuilds || 0); + canvas.dataset.sectionMeshes = String(renderer?.sections.size || 0); + canvas.dataset.viewCenter = state.viewCenter?.join(",") || ""; + canvas.dataset.physics = + motionSupported && prediction + ? prediction.suspended + ? "waiting-world" + : "predicted" + : "server"; + canvas.dataset.predictionPending = String(prediction?.pending.length || 0); + canvas.dataset.predictionCorrection = String(prediction?.correction || 0); + canvas.dataset.playerPosition = state.position.join(","); + canvas.dataset.playerPose = state.physicsPose; + canvas.dataset.playerFlying = String(state.playerFlying); + canvas.dataset.playerSprinting = String(state.playerSprinting); } const playerLabels = new Map(); function updateLabels() { @@ -1049,7 +1442,7 @@ function updateLabels() { label.textContent = p.name; const at = renderer.project([ p.position[0], - p.position[1] + 1.95, + p.position[1] + (p.height ?? 1.8) + 0.15, p.position[2], ]); label.hidden = @@ -1065,17 +1458,48 @@ function updateLabels() { } } function frame(time) { - const dt = Math.min(0.05, (time - lastFrame) / 1000); + const frameStart=performance.now(); + const elapsed = Math.max(0, time - lastFrame), + dt = Math.min(0.05, elapsed / 1000); lastFrame = time; + frameSamples.push(elapsed); + if (frameSamples.length > 240) frameSamples.shift(); state.frames++; + if (state.connected && !document.hidden) + physicsClock.advance(elapsed, inputStep); + else physicsClock.reset(); const blend = 1 - Math.exp(-dt * 19); - for (let i = 0; i < 3; i++) - state.position[i] += (state.target[i] - state.position[i]) * blend; - for (const p of state.players.values()) + let local = null; + if (motionSupported && prediction) { + try { + local = prediction.sample(physicsClock.alpha, dt, movementInput()); + } catch (error) { + disablePrediction(error); + } + } + if (local) { + state.position = local.position; + state.target = [...local.body.position]; + state.physicsPose = local.body.pose; + state.playerFlying = Boolean(local.body.flying); + state.playerSprinting = Boolean(local.body.sprinting); + state.eyeHeight += (local.eyeHeight - state.eyeHeight) * blend; + state.physicsStatus = prediction.suspended + ? "Ожидаем чанки" + : "Rust / WebAssembly · 20 Гц"; + } else { for (let i = 0; i < 3; i++) - p.position[i] += (p.target[i] - p.position[i]) * blend; + state.position[i] += (state.target[i] - state.position[i]) * blend; + state.eyeHeight += (state.eyeTarget - state.eyeHeight) * blend; + if (state.connected) state.physicsStatus = "Только сервер"; + } + for (const p of state.players.values()) smoothRemotePlayer(p, blend); const camera = { - eye: [state.position[0], state.position[1] + 1.62, state.position[2]], + eye: [ + state.position[0], + state.position[1] + state.eyeHeight, + state.position[2], + ], yaw: state.yaw, pitch: state.pitch, }; @@ -1099,53 +1523,88 @@ function frame(time) { ) : ""; } + const drawStart=performance.now(); renderer.draw( camera, [...state.players.values()], state.entities, state.entityDefs, ); + const drawEnd=performance.now(); updateLabels(); - if (state.connected && time - lastInput >= 50) { - lastInput = time; - let forward = isUIOpen() - ? 0 - : (held.has("KeyW") || held.has("ArrowUp") ? 1 : 0) - - (held.has("KeyS") || held.has("ArrowDown") ? 1 : 0), - strafe = isUIOpen() - ? 0 - : (held.has("KeyD") || held.has("ArrowRight") ? 1 : 0) - - (held.has("KeyA") || held.has("ArrowLeft") ? 1 : 0); - send({ - type: "input", - seq: ++state.seq, - yaw: state.yaw, - pitch: state.pitch, - forward, - strafe, - jump: !isUIOpen() && held.has("Space"), - }); + const sample={frameMs:elapsed,physicsMs:drawStart-frameStart,drawMs:drawEnd-drawStart, + position:[...state.position],yaw:state.yaw,correction:prediction?.correction||0, + waiting:state.waitingTerrain || Boolean(prediction?.suspended),dirty:renderer.dirty.size, + viewChanges:state.chunkUpdates,batches:state.sectionBatches,meshResets:renderer.meshResets, + loadedSections:state.loadedSections.size,totalSections:state.totalSections,voxelBytes:state.blocks.byteLength||0}; + dynamics.ready(sample,state.connected&&!sample.waiting&&(!state.sectionStream||sample.loadedSections===sample.totalSections)&&!sample.dirty&&!renderer.meshUpload&&renderer.blockLighting.status==="ready"); + dynamics.frame(sample); + if(dynamics.status!==lastDynamicsStatus){ + lastDynamicsStatus=dynamics.status;canvas.dataset.dynamicsStatus=dynamics.status; + $("dynamics-status").textContent=({idle:"Готов к записи",warming:"Ожидаем готовность чанков и геометрии…",recording:"Запись идёт. Стоп — кнопка в диагностике или Esc для автомаршрута.",done:"Запись завершена",error:"Не удалось дождаться готовности местности"})[dynamics.status]; + if(dynamics.result){$("dynamics-result").hidden=false;$("dynamics-result").textContent=JSON.stringify(dynamics.result,null,2);canvas.dataset.dynamicsResult=JSON.stringify(dynamics.result);$("dynamics-export").disabled=false;} + if(dynamics.status==="done") {controlsZero();toast("Диагностика завершена. Результаты доступны в F3.");} } if (time - fpsTime > 1000) { + const sorted = [...frameSamples].sort((a, b) => a - b); + state.frameP95 = Math.round((sorted[Math.floor((sorted.length - 1) * 0.95)] || 0) * 10) / 10; + state.frameMax = Math.round((sorted.at(-1) || 0) * 10) / 10; state.fps = Math.round((state.frames * 1000) / (time - fpsTime)); state.frames = 0; fpsTime = time; updateDiagnostics(); + if(!$("diagnostics").hidden)drawDynamicsChart($("dynamics-chart"),dynamics.recent); + if(dynamics.status==="recording")fetchMetrics(); } requestAnimationFrame(frame); } -try { - renderer = new Renderer(canvas); - renderHotbar(); - connect(); - requestAnimationFrame(frame); - setInterval(() => { - if (state.connected) send({ type: "ping", client_time: performance.now() }); - if (!$("diagnostics").hidden) fetchMetrics(); - }, 3000); -} catch (error) { - console.error(error); - $("fatal").textContent = error.message; - $("fatal").hidden = false; - connection("Ошибка графики", true); +$("dynamics-start").onclick=()=>{ + controlsZero(); dynamics.arm($("dynamics-scenario").value,30); + $("dynamics-result").hidden=true;$("dynamics-export").disabled=true; + closePanels();canvas.focus(); +}; +$("dynamics-stop").onclick=()=>{dynamics.stop();controlsZero();}; +$("dynamics-export").onclick=()=>{ + const blob=new Blob([JSON.stringify(dynamics.trace(),null,2)],{type:"application/json"}); + const url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download=`shacraft-dynamics-${Date.now()}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000); +}; +try {new PerformanceObserver(list=>{for(const e of list.getEntries())dynamics.event("longtask",e.duration);}).observe({type:"longtask",buffered:false});}catch{} +window.addEventListener("keydown",e=>{if(e.code==="Escape"&&dynamics.scenario!=="record"){dynamics.stop();controlsZero();}}); +async function start() { + try { + renderer = new Renderer(canvas); + renderer.onMeshPublished = (id) => { + const timing = editDiagnostics.published(id); + if (timing) dynamics.event("block-edit", timing.totalMs, timing); + }; + setTimeOfDay(localStorage.getItem("shacraft:time-of-day") || "night"); + renderHotbar(); + requestAnimationFrame(frame); + try { + physics = await loadPhysics(); + prediction = new LocalPrediction(physics, (body) => + samplePhysicsWorld( + body, + state.blocks, + state.materials, + state.viewBounds, + ), + ); + state.physicsStatus = "Готова"; + } catch (error) { + disablePrediction(error); + } + connect(); + setInterval(() => { + if (state.connected) + send({ type: "ping", client_time: performance.now() }); + if (!$("diagnostics").hidden) fetchMetrics(); + }, 3000); + } catch (error) { + console.error(error); + $("fatal").textContent = error.message; + $("fatal").hidden = false; + connection("Ошибка графики", true); + } } +start(); diff --git a/client/block-light-controller.js b/client/block-light-controller.js new file mode 100644 index 0000000..8b524aa --- /dev/null +++ b/client/block-light-controller.js @@ -0,0 +1,104 @@ +import { attachBlockLightSamplers, buildBlockLight } from "./block-light.js"; +import { applyLightProperties, loadLightProperties } from "./light-properties.js"; + +/** Only one build runs at a time. Edits arriving during a build replace its result. */ +export class BlockLightController { + constructor(publish) { + this.publish = publish; + this.generation = 0; + this.busy = false; + this.pending = null; + this.status = "loading"; + this.properties = null; + this.worker = null; + if (typeof Worker === "function") { + try { + this.worker = new Worker(new URL("./block-light-worker.js", import.meta.url), { type: "module" }); + } catch { + // Restricted browser contexts can reject workers before any error event. + // The same solver remains available on the main thread. + } + } + if (this.worker) { + this.worker.onmessage = ({ data }) => this.receive(data); + this.worker.onerror = (event) => { + event.preventDefault(); + this.worker.terminate(); + this.worker = null; + this.busy = false; + // Preserve the latest request and recover with the same implementation. + this.pending ||= this.current; + this.start(); + }; + } + loadLightProperties().then(properties => { + this.properties = properties; + this.start(); + }).catch(error => { + this.status = `error: ${error.message}`; + console.error("Shacraft light metadata", error); + }); + } + request(blocks, materials, bounds) { + this.generation++; + this.pending = { blocks, materials, bounds, generation: this.generation }; + this.status = "pending"; + if (!this.timer) this.timer = setTimeout(() => { this.timer = null; this.start(); }, 25); + } + start() { + if (this.busy || !this.pending || !this.properties) return; + const request = this.pending; + this.pending = null; + this.busy = true; + this.current = request; + this.status = "building"; + const materials = new Map([...request.materials].map(([id, mat]) => [id, + applyLightProperties({ + minecraft_id: mat.minecraft_id, state: mat.state, light: mat.light, + opacity: mat.opacity, render: mat.render, transparent: mat.transparent, + light_dampening: mat.light_dampening, light_occlusion: mat.light_occlusion, + }, this.properties)])); + const packet = { generation: request.generation, bounds: request.bounds, + blocks: [...request.blocks], materials: [...materials] }; + this.current = { ...request, blocks: new Map(packet.blocks), materials }; + if (this.worker) this.worker.postMessage(packet); + else setTimeout(() => { + const start = performance.now(); + try { + this.receive({ generation: request.generation, + field: buildBlockLight(this.current.blocks, materials, request.bounds), + milliseconds: performance.now() - start }); + } catch (error) { this.receive({ generation: request.generation, error: error.message }); } + }, 0); + } + receive(message) { + const current = this.current; + if (!this.busy || message.generation !== current?.generation) return; + this.busy = false; + if (message.generation === this.generation) { + if (message.error) { + this.status = `error: ${message.error}`; + console.error("Shacraft block lighting", message.error); + } else { + this.status = "ready"; + this.milliseconds = message.milliseconds; + this.publish(attachBlockLightSamplers(message.field, current.blocks, current.materials)); + } + } + this.start(); + } +} + +/** In a standalone scene, keep a full light-radius margin around its geometry. */ +export function inferLightBounds(blocks) { + const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]; + for (const key of blocks.keys()) { + const pos = key.split(",").map(Number); + for (let axis = 0; axis < 3; axis++) { + min[axis] = Math.min(min[axis], pos[axis]); + max[axis] = Math.max(max[axis], pos[axis]); + } + } + if (!Number.isFinite(min[0])) return { min: [-16, -16, -16], max: [16, 16, 16] }; + return { min: min.map(v => v - 15), max: max.map(v => v + 15) }; +} diff --git a/client/block-light-worker.js b/client/block-light-worker.js new file mode 100644 index 0000000..d40c57e --- /dev/null +++ b/client/block-light-worker.js @@ -0,0 +1,15 @@ +import { buildBlockLight } from "./block-light.js"; + +self.onmessage = ({ data: request }) => { + try { + const start = performance.now(); + const result = buildBlockLight(new Map(request.blocks), new Map(request.materials), request.bounds); + const { min, size, data, sourceCount, blockLevels, skyLevels } = result; + self.postMessage({ generation: request.generation, + field: { min, size, data, sourceCount, blockLevels, skyLevels }, + milliseconds: performance.now() - start, + }, [data.buffer, blockLevels.buffer, skyLevels.buffer]); + } catch (error) { + self.postMessage({ generation: request.generation, error: error.message }); + } +}; diff --git a/client/block-light.js b/client/block-light.js new file mode 100644 index 0000000..14b384f --- /dev/null +++ b/client/block-light.js @@ -0,0 +1,257 @@ +import { isAmbientOccluder } from "./ambient-occlusion.js"; +import { unitBox } from "./math.js"; +import { MAX_LIGHT_CELLS } from "./view-distance.js"; + +const MAX_CELLS = MAX_LIGHT_CELLS; +const EPSILON = 1e-4; +const DIRECTIONS = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]]; +const OPPOSITE = [1, 0, 3, 2, 5, 4]; +const ZERO = Object.freeze([0, 0, 0]); +const DARK = Object.freeze({ block: ZERO, sky: 0, blockLevel: 0, skyLevel: 0 }); +const integerTriple = (value) => Array.isArray(value) && value.length === 3 && value.every(Number.isSafeInteger); +const lightLevel = (value) => Number.isFinite(value) ? Math.max(0, Math.min(15, Math.floor(value))) : 0; + +/** Canonical source strength comes exclusively from the authoritative state. */ +export function blockEmission(material) { + const level = lightLevel(material?.light); + const state = (material?.state ?? "").split("[")[0].split(":").pop(); + let color = [1, 0.82, 0.57]; + if (/^soul_|soul_fire/.test(state)) color = [0.3, 0.82, 1]; + else if (/sea_lantern|end_rod|beacon/.test(state)) color = [0.76, 0.9, 1]; + else if (/redstone_(?:wall_)?torch|redstone_ore/.test(state)) color = [1, 0.28, 0.13]; + else if (/amethyst|crying_obsidian|respawn_anchor/.test(state)) color = [0.69, 0.42, 1]; + else if (/warped|glow_lichen|verdant_froglight/.test(state)) color = [0.54, 1, 0.79]; + else if (/pearlescent_froglight/.test(state)) color = [1, 0.72, 0.9]; + else if (/lava|fire|campfire|furnace|magma/.test(state)) color = [1, 0.56, 0.24]; + else if (/torch|lantern|candle|glowstone|shroomlight|jack_o_lantern/.test(state)) color = [1, 0.75, 0.43]; + return { level, color }; +} + +function boxesOf(value) { + return (value ?? []).filter((box) => box?.min?.length === 3 && box?.max?.length === 3 && + box.min.every((min, axis) => Number.isFinite(min) && Number.isFinite(box.max[axis]) && box.max[axis] > min)); +} + +function faceRectangles(boxes, direction) { + const axis = direction >> 1, positive = direction % 2 === 0; + const tangents = [0, 1, 2].filter((value) => value !== axis); + const rectangles = []; + for (const box of boxes) { + if (positive ? box.max[axis] < 1 - EPSILON : box.min[axis] > EPSILON) continue; + const rect = [ + Math.max(0, box.min[tangents[0]]), Math.max(0, box.min[tangents[1]]), + Math.min(1, box.max[tangents[0]]), Math.min(1, box.max[tangents[1]]), + ]; + if (rect[2] > rect[0] && rect[3] > rect[1]) rectangles.push(rect); + } + return rectangles; +} + +/** Exact union coverage of axis-aligned boundary rectangles, including slabs. */ +function coversFace(rectangles) { + if (!rectangles.length) return false; + if (rectangles.some((r) => r[0] === 0 && r[1] === 0 && r[2] === 1 && r[3] === 1)) return true; + const xs = [...new Set([0, 1, ...rectangles.flatMap((r) => [r[0], r[2]])])].sort((a, b) => a - b); + const ys = [...new Set([0, 1, ...rectangles.flatMap((r) => [r[1], r[3]])])].sort((a, b) => a - b); + for (let x = 1; x < xs.length; x++) + for (let y = 1; y < ys.length; y++) { + const midX = (xs[x - 1] + xs[x]) / 2, midY = (ys[y - 1] + ys[y]) / 2; + if (!rectangles.some((r) => midX > r[0] && midX < r[2] && midY > r[1] && midY < r[3])) return false; + } + return true; +} + +function materialDescriptors(materials) { + const air = { index: 0, dampening: 0, boxes: [], faces: Array.from({ length: 6 }, () => []), fullFaces: 0, emission: { level: 0, color: ZERO } }; + const descriptors = [air], byId = new Map(), faceCache = new Map(); + const get = (id) => { + if (!id) return air; + if (!byId.has(id)) { + const material = materials.get(id); + const render = boxesOf(material?.render ?? [unitBox]); + const opaque = isAmbientOccluder(material); + const fullCube = opaque && coversFace(faceRectangles(render, 0)) && coversFace(faceRectangles(render, 1)) && + coversFace(faceRectangles(render, 2)) && coversFace(faceRectangles(render, 3)) && + coversFace(faceRectangles(render, 4)) && coversFace(faceRectangles(render, 5)); + const emission = blockEmission(material); + const measuredDampening = material?.light_dampening ?? material?.light_block; + const dampening = Number.isFinite(measuredDampening) ? lightLevel(measuredDampening) : fullCube ? 15 : 0; + // Effective Java light-occlusion shapes are already empty for ordinary + // full cubes and transparent blocks. Opacity handles ordinary solid cubes. + const boxes = boxesOf(material?.light_occlusion ?? (opaque && !fullCube ? render : [])); + const faces = DIRECTIONS.map((_, direction) => faceRectangles(boxes, direction)); + const fullFaces = faces.reduce((mask, rects, direction) => mask | (coversFace(rects) ? 1 << direction : 0), 0); + const descriptor = { index: descriptors.length, dampening, boxes: opaque ? render : [], faces, fullFaces, emission }; + descriptors.push(descriptor); + byId.set(id, descriptor); + } + return byId.get(id); + }; + const blocked = (source, target, direction) => { + if ((source.fullFaces & (1 << direction)) || (target.fullFaces & (1 << OPPOSITE[direction]))) return true; + const first = source.faces[direction], second = target.faces[OPPOSITE[direction]]; + if (!first.length || !second.length) return false; + const key = `${source.index},${target.index},${direction}`; + if (!faceCache.has(key)) faceCache.set(key, coversFace([...first, ...second])); + return faceCache.get(key); + }; + return { get, descriptors, blocked }; +} + +function validateBounds(bounds) { + if (!integerTriple(bounds?.min) || !integerTriple(bounds?.max)) throw new RangeError("Block-light bounds must contain integer min/max triples"); + const min = [...bounds.min], size = bounds.max.map((value, axis) => value - min[axis] + 1); + const count = size[0] * size[1] * size[2]; + if (size.some((value) => value <= 0 || !Number.isSafeInteger(value)) || !Number.isSafeInteger(count) || count > MAX_CELLS) + throw new RangeError(`Block-light bounds must contain 1 to ${MAX_CELLS} cells`); + return { min, size, count }; +} + +/** + * Bounded, deterministic Java-style max-light propagation. Bounds are inclusive; + * x is the fastest axis: x + size[0] * (y + size[1] * z). + * + * RGB is a visual tint of the winning canonical source. Equal-strength sources + * combine their tint by channel maximum, never adding to block-light levels. + * Sky above the supplied top boundary is treated as exposed sky; unknown side + * and bottom boundaries are closed. The caller supplies the full loaded view. + */ +export function buildBlockLight(blocks, materials, bounds, skyColumns = null) { + const { min, size, count } = validateBounds(bounds); + const [sx, sy, sz] = size, plane = sx * sy; + const data = new Uint8Array(count * 4), blockLevels = new Uint8Array(count), skyLevels = new Uint8Array(count); + const types = new Uint32Array(count), definitions = materialDescriptors(materials); + const blockBuckets = Array.from({ length: 16 }, () => []), skyBuckets = Array.from({ length: 16 }, () => []); + let sourceCount = 0; + const addBlock = (wx,wy,wz,id) => { + const x=wx-min[0], y=wy-min[1], z=wz-min[2]; + if(x<0||x>=sx||y<0||y>=sy||z<0||z>=sz)return; + const index=x+sx*(y+sy*z), descriptor=definitions.get(id); + types[index]=descriptor.index; + const {level,color}=descriptor.emission; + if(!level)return; + sourceCount++;blockLevels[index]=level; + for(let channel=0;channel<3;channel++)data[index*4+channel]=Math.round(color[channel]*255); + blockBuckets[level].push(index); + }; + if(blocks.forEachBlock) blocks.forEachBlock(addBlock,bounds); + else for(const [key,id] of blocks) if(id) addBlock(...key.split(",").map(Number),id); + for (let z = 0; z < sz; z++) + for (let x = 0; x < sx; x++) { + if (skyColumns) { + const wx=x+min[0],wz=z+min[2], column=skyColumns.get(`${Math.floor(wx/16)},${Math.floor(wz/16)}`); + const height=column?.[((wx%16+16)%16)+16*((wz%16+16)%16)] ?? 32767; + if(height>=min[1]+sy)continue; + } + const index = x + sx * (sy - 1 + sy * z), descriptor = definitions.descriptors[types[index]]; + if (descriptor.dampening >= 15 || descriptor.fullFaces & (1 << 2)) continue; + const value = 15 - descriptor.dampening; + if (!value) continue; + skyLevels[index] = value; + skyBuckets[value].push(index); + } + const offsets = [1, -1, sx, -sx, plane, -plane]; + const propagate = (levels, buckets, sky) => { + for (let level = 15; level > 0; level--) { + const bucket = buckets[level]; + for (let cursor = 0; cursor < bucket.length; cursor++) { + const index = bucket[cursor]; + if (levels[index] !== level) continue; + const x = index % sx, y = Math.floor(index / sx) % sy, z = Math.floor(index / plane); + const source = definitions.descriptors[types[index]]; + for (let direction = 0; direction < 6; direction++) { + if ((direction === 0 && x === sx - 1) || (direction === 1 && x === 0) || + (direction === 2 && y === sy - 1) || (direction === 3 && y === 0) || + (direction === 4 && z === sz - 1) || (direction === 5 && z === 0)) continue; + const nextIndex = index + offsets[direction], target = definitions.descriptors[types[nextIndex]]; + const attenuation = sky && level === 15 && direction === 3 && target.dampening === 0 ? 0 : Math.max(1, target.dampening); + const value = level - attenuation, previous = levels[nextIndex]; + if (value <= 0 || value < previous || (sky && value === previous) || definitions.blocked(source, target, direction)) continue; + if (value > previous) { + levels[nextIndex] = value; + buckets[value].push(nextIndex); + if (!sky) for (let channel = 0; channel < 3; channel++) data[nextIndex * 4 + channel] = data[index * 4 + channel]; + } else if (!sky) { + // All parents at level + 1 are finalized before this level is + // propagated, so equal-strength tint changes need no extra queue. + for (let channel = 0; channel < 3; channel++) + data[nextIndex * 4 + channel] = Math.max(data[nextIndex * 4 + channel], data[index * 4 + channel]); + } + } + } + } + }; + propagate(blockLevels, blockBuckets, false); + propagate(skyLevels, skyBuckets, true); + for (let index = 0; index < count; index++) { + const level = blockLevels[index], brightness = (level / 15) ** 2; + for (let channel = 0; channel < 3; channel++) data[index * 4 + channel] = Math.round(data[index * 4 + channel] * brightness); + data[index * 4 + 3] = level; + } + return attachBlockLightSamplers({ min, size, data, sourceCount, blockLevels, skyLevels }, blocks, materials); +} + +/** Restore cheap sampling methods after worker ArrayBuffer transfer. */ +export function attachBlockLightSamplers(field, blocks, materials) { + const { min, size, data, blockLevels, skyLevels } = field; + const definitions = materialDescriptors(materials), voxelCache = new Map(), sampleCache = new Map(); + const indexAt = (point) => { + const x = Math.floor(point[0]) - min[0], y = Math.floor(point[1]) - min[1], z = Math.floor(point[2]) - min[2]; + return x < 0 || x >= size[0] || y < 0 || y >= size[1] || z < 0 || z >= size[2] ? -1 : x + size[0] * (y + size[1] * z); + }; + const voxelAt = (point) => { + const x = Math.floor(point[0]), y = Math.floor(point[1]), z = Math.floor(point[2]); + const index = x - min[0] + size[0] * (y - min[1] + size[1] * (z - min[2])); + if (!voxelCache.has(index)) voxelCache.set(index, { pos: [x, y, z], descriptor: definitions.get(blocks.getAt ? blocks.getAt(x,y,z) : blocks.get(`${x},${y},${z}`)) }); + return voxelCache.get(index); + }; + const freeAt = (point) => { + if (indexAt(point) < 0) return false; + const { pos, descriptor } = voxelAt(point); + return !descriptor.boxes.some((box) => point.every((value, axis) => value - pos[axis] > box.min[axis] && value - pos[axis] < box.max[axis])); + }; + const canSampleFrom = (point, anchor) => { + if (indexAt(anchor) < 0 || !freeAt(point)) return false; + const target = voxelAt(point), source = voxelAt(anchor); + let direction = -1, distance = 0; + for (let axis = 0; axis < 3; axis++) { + const delta = target.pos[axis] - source.pos[axis]; + if (delta) { direction = axis * 2 + (delta < 0 ? 1 : 0); distance += Math.abs(delta); } + } + return distance === 0 || (distance === 1 && !definitions.blocked(source.descriptor, target.descriptor, direction)); + }; + field.sample = (point) => { + const index = indexAt(point); + if (index < 0) return DARK; + if (!sampleCache.has(index)) sampleCache.set(index, + { block: [data[index * 4] / 255, data[index * 4 + 1] / 255, data[index * 4 + 2] / 255], + sky: skyLevels[index] / 15, blockLevel: blockLevels[index], skyLevel: skyLevels[index] }); + return sampleCache.get(index); + }; + field.sampleFace = (pos, box, normal, vertices) => { + const axis = normal.findIndex((value) => Math.abs(value) === 1), tangents = [0, 1, 2].filter((value) => value !== axis); + const result = { block: [], sky: [], blockLevels: [], skyLevels: [] }; + for (const vertex of vertices) { + const corner = vertex.map((value, a) => pos[a] + box.min[a] + value * (box.max[a] - box.min[a]) + normal[a] * EPSILON); + const offset = (u, v) => { + const point = [...corner]; + for (let i = 0; i < 2; i++) point[tangents[i]] += (vertex[tangents[i]] < 0.5 ? -1 : 1) * (i ? v : u) * EPSILON; + return point; + }; + // Begin just inside this face's tangent bounds. Interpolate only toward + // accessible neighboring cells, never through an opaque corner or wall. + const base = offset(-1, -1), first = offset(1, -1), second = offset(-1, 1), diagonal = offset(1, 1); + const baseSample = freeAt(base) ? field.sample(base) : DARK; + const firstOpen = canSampleFrom(first, base), secondOpen = canSampleFrom(second, base); + const diagonalOpen = (firstOpen && canSampleFrom(diagonal, first)) || (secondOpen && canSampleFrom(diagonal, second)); + const samples = [baseSample, firstOpen ? field.sample(first) : baseSample, + secondOpen ? field.sample(second) : baseSample, diagonalOpen ? field.sample(diagonal) : baseSample]; + result.block.push([0, 1, 2].map((channel) => samples.reduce((sum, sample) => sum + sample.block[channel], 0) / 4)); + result.sky.push(samples.reduce((sum, sample) => sum + sample.sky, 0) / 4); + result.blockLevels.push(samples.reduce((sum, sample) => sum + sample.blockLevel, 0) / 4); + result.skyLevels.push(samples.reduce((sum, sample) => sum + sample.skyLevel, 0) / 4); + } + return result; + }; + return field; +} diff --git a/client/block-textures.js b/client/block-textures.js new file mode 100644 index 0000000..afc7251 --- /dev/null +++ b/client/block-textures.js @@ -0,0 +1,101 @@ +import { BLOCK_FACES } from "./texture-pack.js"; + +const WHITE = Object.freeze([1, 1, 1]); +const GRASS = Object.freeze([0.58, 0.8, 0.34]); +const LEAVES = Object.freeze([0.46, 0.7, 0.28]); + +// These aliases share a surface material. Similar block names are deliberately +// not enough: stripped logs, other wood species and deepslate ores need assets. +const materials = new Map([ + ["stone", ["stone", "stone_stairs", "stone_slab", "stone_button", "stone_pressure_plate"]], + ["cobblestone", ["cobblestone", "cobblestone_stairs", "cobblestone_slab", "cobblestone_wall"]], + ["oak_planks", ["oak_planks", "oak_stairs", "oak_slab", "oak_fence", "oak_fence_gate", "oak_button", "oak_pressure_plate"]], + ["bricks", ["bricks", "brick_stairs", "brick_slab", "brick_wall"]], + ["stone_bricks", ["stone_bricks", "stone_brick_stairs", "stone_brick_slab", "stone_brick_wall"]], + ["glass", ["glass", "glass_pane"]], + ["snow", ["snow", "snow_block"]], + ...["dirt", "sand", "gravel", "diamond_ore", "iron_ore", "coal_ore", "gold_ore", "redstone_ore", "deepslate", "obsidian", "netherrack"].map((name) => [name, [name]]), +].flatMap(([texture, blocks]) => blocks.map((block) => [block, texture]))); + +/** Face order matches renderer.js: east, west, up, down, south, north. */ +export function getBlockFaceTextures(state, layers) { + const empty = () => Array(6).fill(null); + const match = /^(?:minecraft:)?([a-z0-9_]+)(?:\[([^\]]*)\])?$/.exec(state ?? ""); + if (!match) return empty(); + const [, name, properties = ""] = match; + const props = Object.fromEntries( + properties.split(",").filter(Boolean).map((entry) => entry.split("=")), + ); + const mapping = layers.get(BLOCK_FACES); + if (mapping) { + const base = `minecraft:${name}`; + const canonical = base + (properties ? `[${Object.entries(props).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`).join(",")}]` : ""); + const index = Object.hasOwn(mapping.states, canonical) ? mapping.states[canonical] : mapping.defaults[base]; + if (Number.isInteger(index)) return mapping.sets[index]; + } + const face = (texture, tint = WHITE, cutout = false) => { + const layer = layers.get(texture); + return Number.isInteger(layer) && layer >= 0 ? { layer, tint, cutout } : null; + }; + const all = (texture, tint = WHITE, cutout = false) => + Array.from({ length: 6 }, () => face(texture, tint, cutout)); + + if (name === "grass_block") { + const side = props.snowy === "true" ? null : "grass_block_side"; + return [side, side, "grass_block_top", "dirt", side, side].map( + (texture, index) => texture ? face(texture, index === 2 ? GRASS : WHITE) : null, + ); + } + if (name === "oak_log") { + const caps = { x: [0, 1], y: [2, 3], z: [4, 5] }[props.axis ?? "y"]; + if (!caps) return empty(); + return Array.from({ length: 6 }, (_, index) => + face(caps.includes(index) ? "oak_log_top" : "oak_log"), + ); + } + if (name === "oak_wood") return all("oak_log"); + if (name === "oak_leaves") return all("oak_leaves", LEAVES, true); + if (name === "oak_door") { + if ((props.half ?? "lower") !== "lower") return empty(); + let alongX = ["east", "west"].includes(props.facing ?? "north"); + if (props.open === "true") alongX = !alongX; + const broadFaces = alongX ? [0, 1] : [4, 5]; + return Array.from({ length: 6 }, (_, index) => + face(broadFaces.includes(index) ? "oak_door_bottom" : "oak_planks"), + ); + } + const texture = materials.get(name); + return texture ? all(texture, WHITE, texture === "glass") : empty(); +} + +/** + * UVs address an unflipped bitmap with its first row at V=0. Coordinates stay + * relative to the whole block, so slabs and other boxes crop instead of stretch. + * Horizontal logs rotate back to the vertical-log frame before projection, + * aligning the bark grain with the log axis without mirroring any face. + */ +export function getFaceUV(faceIndex, localPos, state = "", textureFace = null) { + if (textureFace?.uv) { + const uv = textureFace.uv, [x, y, z] = localPos; + return [uv[0]*x + uv[1]*y + uv[2]*z + uv[3], uv[4]*x + uv[5]*y + uv[6]*z + uv[7]]; + } + let [x, y, z] = localPos; + const log = /^(?:minecraft:)?oak_(?:log|wood)\[([^\]]*)\]$/.exec(state); + const axis = log && /(?:^|,)axis=([xz])(?:,|$)/.exec(log[1])?.[1]; + if (axis === "x") { + [x, y, z] = [1 - y, x, z]; + faceIndex = [2, 3, 1, 0, 4, 5][faceIndex]; + } else if (axis === "z") { + [x, y, z] = [x, z, 1 - y]; + faceIndex = [0, 1, 5, 4, 2, 3][faceIndex]; + } + switch (faceIndex) { + case 0: return [1 - z, 1 - y]; + case 1: return [z, 1 - y]; + case 2: return [x, z]; + case 3: return [x, 1 - z]; + case 4: return [x, 1 - y]; + case 5: return [1 - x, 1 - y]; + default: throw new RangeError(`Unknown block face: ${faceIndex}`); + } +} diff --git a/client/dynamics-diagnostics.js b/client/dynamics-diagnostics.js new file mode 100644 index 0000000..c737c6b --- /dev/null +++ b/client/dynamics-diagnostics.js @@ -0,0 +1,60 @@ +const percentile=(values,p)=>{if(!values.length)return 0;const sorted=[...values].sort((a,b)=>a-b);return Math.round(sorted[Math.min(sorted.length-1,Math.floor((sorted.length-1)*p))]*100)/100;}; +const statistics=values=>({p50:percentile(values,.5),p95:percentile(values,.95),p99:percentile(values,.99),max:percentile(values,1)}); +/** Bounded local trace. Durations use monotonic browser time, not wall clocks. */ +export class DynamicsDiagnostics { + constructor(){this.status='idle';this.recent=[];this.frames=[];this.events=[];this.server=[];this.result=null;this.scenario='record';this.duration=30;this.toggleSent=false;} + arm(scenario='record',duration=30){ + this.status='warming';this.scenario=scenario;this.duration=Math.min(120,Math.max(5,duration));this.frames=[];this.events=[];this.server=[];this.result=null;this.toggleSent=false;this.climbUntil=0;this.armed=performance.now();this.stableAt=null; + } + ready(sample,ready,now=performance.now()){ + if(this.status!=='warming')return; + if(ready)this.stableAt??=now;else this.stableAt=null; + if(this.stableAt!==null&&now-this.stableAt>=500){this.status='recording';this.started=now;this.initial=sample;this.yaw=sample.yaw;} + if(now-this.armed>120000){this.status='error';this.result={error:'Terrain did not settle within 120 seconds',sample};} + } + frame(sample,now=performance.now()){ + this.recent.push(sample);if(this.recent.length>300)this.recent.shift(); + if(this.status!=='recording')return; + if(this.frames.length<18000)this.frames.push({...sample,t:now-this.started}); + if(now-this.started>=this.duration*1000)this.stop(now); + } + event(type,milliseconds,detail={}){if(this.status==='recording'&&this.events.length<3000)this.events.push({t:performance.now()-this.started,type,milliseconds,...detail});} + serverSample(sample){if(this.status==='recording'&&this.server.length<150)this.server.push({t:performance.now()-this.started,...sample});} + controls(consume,flying,now=performance.now(),collision=false){ + if(this.status!=='recording'||this.scenario==='record')return null; + const t=(now-this.started)/1000,fly=this.scenario!=='run'; + let fly_toggle=false; + if(fly&&!flying&&!this.toggleSent){fly_toggle=true;if(consume)this.toggleSent=true;} + if(fly&&collision&&consume)this.climbUntil=now+900; + return {forward:fly&&t<2.5?0:1,strafe:0,jump:fly&&(t<2.5||now<(this.climbUntil||0)),sprint:true,sneak:false,fly_toggle, + yaw:this.yaw+(this.scenario==='turns'?Math.sin(t*.8)*1.3:Math.sin(Math.max(0,t-3)*.11)*.35),pitch:fly?-.16:-.08}; + } + stop(now=performance.now(),reason=null){ + if(this.status==='warming'){this.status='idle';return;} + if(this.status!=='recording')return; + const f=this.frames,last=f.at(-1)||this.initial,values=k=>f.map(x=>x[k]||0); + let distance=0;for(let i=1;iv-f[i-1].position[a])); + this.result={schema:1,scenario:this.scenario,interrupted:reason,durationMs:Math.round(now-this.started),frames:f.length, + frameMs:statistics(values('frameMs')),physicsMs:statistics(values('physicsMs')),drawMs:statistics(values('drawMs')), + over25ms:f.filter(x=>x.frameMs>25).length,over50ms:f.filter(x=>x.frameMs>50).length, + streamApplyMs:statistics(this.events.filter(e=>e.type==='sections').map(e=>e.milliseconds)), + networkHandlerMs:statistics(this.events.filter(e=>e.type==='network').map(e=>e.milliseconds)), + longTasks:this.events.filter(e=>e.type==='longtask').length, + maxCorrection:percentile(values('correction'),1),waitingFrames:f.filter(x=>x.waiting).length, + maxDirty:percentile(values('dirty'),1),distance:Math.round(distance*100)/100, + viewChanges:last.viewChanges-this.initial.viewChanges,sectionBatches:last.batches-this.initial.batches, + meshResets:last.meshResets-this.initial.meshResets,loadedSections:last.loadedSections,totalSections:last.totalSections, + voxelBytes:last.voxelBytes,server:this.server.at(-1)||null,start:this.initial.position,end:last.position}; + this.status='done'; + } + trace(){return {summary:this.result,frames:this.frames,events:this.events,server:this.server};} +} +export function drawDynamicsChart(canvas,samples){ + const ctx=canvas.getContext('2d');if(!ctx)return; + const w=canvas.width,h=canvas.height;ctx.fillStyle='#10231b';ctx.fillRect(0,0,w,h); + ctx.font='11px monospace';ctx.strokeStyle='#405448';ctx.fillStyle='#a5b8ac'; + for(const ms of [16.7,33.3,50]){const y=h-ms/70*h;ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(w,y);ctx.stroke();ctx.fillText(`${ms} ms`,4,y-3);} + for(const [key,color] of [['frameMs','#efc46a'],['drawMs','#8bd0b4'],['physicsMs','#8bb6ed']]){ + ctx.strokeStyle=color;ctx.beginPath();samples.forEach((s,i)=>{const x=i/300*w,y=h-Math.min(70,s[key]||0)/70*h;i?ctx.lineTo(x,y):ctx.moveTo(x,y);});ctx.stroke(); + } +} diff --git a/client/edit-diagnostics.js b/client/edit-diagnostics.js new file mode 100644 index 0000000..aa932a2 --- /dev/null +++ b/client/edit-diagnostics.js @@ -0,0 +1,28 @@ +/** Monotonic timings for local requests and authoritative mesh publication. */ +export class EditDiagnostics { + constructor() { this.reset(); } + reset() { this.requests = new Map(); this.sections = new Map(); this.last = null; } + request(pos, block, now = performance.now()) { + for (const [key, value] of this.requests) if (now - value.time > 5000) this.requests.delete(key); + if (this.requests.size >= 64) this.requests.delete(this.requests.keys().next().value); + this.requests.set(pos.join(','), { block, time: now }); + } + confirmed(changes, now = performance.now()) { + for (const { pos, block } of changes) { + const key = pos.join(','), request = this.requests.get(key); + if (!request || request.block !== block || now - request.time > 5000) continue; + this.requests.delete(key); + const id = pos.map(v => Math.floor(v / 16)).join(','); + if (this.sections.size >= 64) this.sections.delete(this.sections.keys().next().value); + this.sections.set(id, { request: request.time, confirmed: now }); + } + } + published(id, now = performance.now()) { + const edit = this.sections.get(id); + if (!edit) return null; + this.sections.delete(id); + this.last = { acknowledgementMs: edit.confirmed - edit.request, + geometryMs: now - edit.confirmed, totalMs: now - edit.request }; + return this.last; + } +} diff --git a/client/edit-mesh-controller.js b/client/edit-mesh-controller.js new file mode 100644 index 0000000..17e314c --- /dev/null +++ b/client/edit-mesh-controller.js @@ -0,0 +1,43 @@ +/** Small independent edit jobs cannot queue behind whole-view lighting. */ +export class EditMeshController { + constructor({capture,onError=()=>{},workerFactory}={}) { + this.capture=capture;this.onError=onError;this.epoch=0;this.job=0; + this.pending=new Map();this.tickets=new Map();this.ready=new Map();this.busy=null; + try { + this.worker=workerFactory ? workerFactory() : new Worker(new URL('./edit-mesh-worker.js',import.meta.url),{type:'module'}); + this.worker.onmessage=({data})=>this.receive(data); + this.worker.onerror=event=>{event.preventDefault();this.fail(event.message);}; + } catch(error) {this.fail(error.message);} + } + fail(error) {this.error=error;this.dispose();this.onError(error);} + request(ids) { + if(this.error||this.disposed)return; + for(const id of ids) { + const job=++this.job; + this.pending.set(id,job);this.tickets.set(id,job);this.ready.delete(id); + } + } + cancel(id) {this.pending.delete(id);this.tickets.delete(id);this.ready.delete(id);} + reset() {this.epoch++;this.pending.clear();this.tickets.clear();this.ready.clear();this.busy=null;} + isCurrent(result) {return result.epoch===this.epoch&&this.tickets.get(result.id)===result.editJob;} + pump() { + if(this.error||this.disposed||this.busy||this.ready.size>=2||!this.pending.size)return; + const [id,editJob]=this.pending.entries().next().value; + this.pending.delete(id); + try { + const start=performance.now(),packet={...this.capture(id),epoch:this.epoch,editJob}; + const transfers=packet.sections.map(s=>s.cells.buffer); + if(packet.light)transfers.push(packet.light.data.buffer,packet.light.blockLevels.buffer,packet.light.skyLevels.buffer); + this.busy={id,editJob,epoch:this.epoch};this.worker.postMessage(packet,transfers); + this.prepareMilliseconds=performance.now()-start; + } catch(error) {this.fail(error.message);} + } + receive(result) { + if(this.disposed||result.epoch!==this.epoch||result.editJob!==this.busy?.editJob)return; + this.busy=null; + if(!this.isCurrent(result))return; + if(result.error){this.fail(result.error);return;} + this.meshMilliseconds=result.milliseconds;this.ready.set(result.id,result); + } + dispose() {this.disposed=true;this.reset();this.worker?.terminate();} +} diff --git a/client/edit-mesh-worker.js b/client/edit-mesh-worker.js new file mode 100644 index 0000000..8861781 --- /dev/null +++ b/client/edit-mesh-worker.js @@ -0,0 +1,10 @@ +import {buildEditMesh} from './edit-mesh.js'; +import {loadLightProperties} from './light-properties.js'; +const properties=loadLightProperties(); +self.onmessage=({data})=>properties.then(table=>{ + const start=performance.now(),result={id:data.id,epoch:data.epoch,editJob:data.editJob,preview:true}; + try { + const mesh=buildEditMesh(data,table); + self.postMessage({...result,...mesh,milliseconds:performance.now()-start},[mesh.vertices.buffer,mesh.transparent.buffer]); + } catch(error) {self.postMessage({...result,error:error.message});} +}).catch(error=>self.postMessage({id:data.id,epoch:data.epoch,editJob:data.editJob,error:error.message})); diff --git a/client/edit-mesh.js b/client/edit-mesh.js new file mode 100644 index 0000000..f706056 --- /dev/null +++ b/client/edit-mesh.js @@ -0,0 +1,54 @@ +import { SectionVoxelMap } from './section-voxel-map.js'; +import { attachBlockLightSamplers } from './block-light.js'; +import { applyLightProperties } from './light-properties.js'; +import { buildSectionMesh } from './mesh-geometry.js'; +import { textureSubset } from './texture-pack.js'; + +/** Copy only the edited section's sampling neighborhood, independent of view radius. */ +export function captureEditMesh(id, blocks, materials, textures, field) { + const base=id.split(',').map(v=>Number(v)*16); + let pad=2; + for(const mat of materials.values()) for(const box of mat.render || []) + for(let axis=0;axis<3;axis++) pad=Math.max(pad,Math.ceil(-box.min[axis])+2,Math.ceil(box.max[axis]-1)+2); + const min=base.map(v=>v-pad),size=Array(3).fill(16+pad*2),sections=[]; + for(let x=Math.floor(min[0]/16);x<=Math.floor((min[0]+size[0]-1)/16);x++) + for(let y=Math.floor(min[1]/16);y<=Math.floor((min[1]+size[1]-1)/16);y++) + for(let z=Math.floor(min[2]/16);z<=Math.floor((min[2]+size[2]-1)/16);z++) { + const section=`${x},${y},${z}`; + if(blocks.getSectionCells) { + const cells=blocks.getSectionCells(section); + if(cells) sections.push({section,cells:cells.slice()}); + } else { + const cells=new Uint32Array(4096); + for(const key of (blocks.keysInSection ? blocks.keysInSection(section) : [...blocks.keys()].filter(key=>key.split(",").map(v=>Math.floor(Number(v)/16)).join(",")===section))) { + const p=key.split(',').map(Number); + cells[SectionVoxelMap.index(...p)]=blocks.get(key); + } + sections.push({section,cells}); + } + } + let light=null; + if(field) { + const count=size[0]*size[1]*size[2]; + light={min,size,data:new Uint8Array(count*4),blockLevels:new Uint8Array(count),skyLevels:new Uint8Array(count)}; + const lo=min.map((v,i)=>Math.max(v,field.min[i])),hi=min.map((v,i)=>Math.min(v+size[i],field.min[i]+field.size[i])); + if(hi[0]>lo[0]) for(let z=lo[2];z used.has(id))); + return {id,sections,materials:[...nearbyMaterials],textures:[...textureSubset(textures, nearbyMaterials)],light}; +} +export function buildEditMesh(packet, properties) { + const blocks=new SectionVoxelMap(); + for(const {section,cells} of packet.sections) blocks.setSection(section,cells); + const materials=new Map(packet.materials.map(([id,mat])=>[id,applyLightProperties(mat,properties)])); + const field=packet.light ? attachBlockLightSamplers(packet.light,blocks,materials) : null; + return buildSectionMesh({id:packet.id,blocks,materials,textureLayers:new Map(packet.textures),blockLightField:field,localCoordinates:true}); +} diff --git a/client/index.html b/client/index.html index e4c39a9..4ab483a 100644 --- a/client/index.html +++ b/client/index.html @@ -14,7 +14,8 @@
@@ -57,6 +58,7 @@
W A S D движениеПробел прыжокCtrl / Shift бег / присестьЛКМ / ПКМ убрать / поставитьT чат
@@ -91,6 +93,29 @@ побеждает.

+ + + +
+ + +
+

Радиус вокруг игрока. Большая дальность увеличивает нагрузку на память и видеокарту.

+

Esc освобождает мышь. Перетаскивание работает и без захвата. Средняя - кнопка выбирает блок под прицелом. + кнопка выбирает блок под прицелом. Ctrl — бег, Shift — присесть и + удержаться у края. Двойной пробел или F включает полёт, если он разрешён + в мире; пробел / Shift — вверх / вниз.

@@ -152,6 +179,16 @@ × +

F3 — скрыть панель. Нажмите на мир, чтобы играть с открытой диагностикой.

+
+ + +
+

Готов к записи. Автомаршрут использует обычное управление игроком.

+ +

Жёлтый — кадр · зелёный — отрисовка · синий — физика.

+ +
Пакеты ещё не загружены
@@ -160,6 +197,6 @@ кэша — разные измерения.

- + diff --git a/client/light-changes.js b/client/light-changes.js new file mode 100644 index 0000000..22d41cb --- /dev/null +++ b/client/light-changes.js @@ -0,0 +1,28 @@ +/** Compare only visible mesh neighborhoods; unchanged retained sections keep their buffers. */ +export function changedLightSections(previous, next, sections) { + if (!previous) return [...sections]; + const index = (field, x, y, z) => { + x -= field.min[0]; y -= field.min[1]; z -= field.min[2]; + if (x < 0 || y < 0 || z < 0 || x >= field.size[0] || y >= field.size[1] || z >= field.size[2]) return -1; + return x + field.size[0] * (y + field.size[1] * z); + }; + const changed = []; + for (const id of sections) { + const base = id.split(",").map(v => Number(v) * 16); + let dirty = false; + outer: for (let z = base[2] - 1; z <= base[2] + 16; z++) + for (let y = base[1] - 1; y <= base[1] + 16; y++) + for (let x = base[0] - 1; x <= base[0] + 16; x++) { + const a = index(previous, x, y, z), b = index(next, x, y, z); + if (a < 0 && b < 0) continue; + if (a < 0 || b < 0 || previous.skyLevels[a] !== next.skyLevels[b] || + previous.data[a * 4] !== next.data[b * 4] || + previous.data[a * 4 + 1] !== next.data[b * 4 + 1] || + previous.data[a * 4 + 2] !== next.data[b * 4 + 2]) { + dirty = true; break outer; + } + } + if (dirty) changed.push(id); + } + return changed; +} diff --git a/client/light-properties.js b/client/light-properties.js new file mode 100644 index 0000000..639d3a0 --- /dev/null +++ b/client/light-properties.js @@ -0,0 +1,78 @@ +// Factual Java 26.2 block-state metadata, generated by scripts/measure_lighting.py. +// The catalog's minecraft_id is distinct from Shacraft's material registry id. +let loading; +const decoded = new WeakMap(); + +export function loadLightProperties() { + loading ??= fetch(new URL("./light-properties.json", import.meta.url)) + .then((response) => { + if (!response.ok) throw new Error(`Light properties HTTP ${response.status}`); + return response.json(); + }) + .then((properties) => { + if (!Array.isArray(properties.codes) || !Array.isArray(properties.shapes) || + !Array.isArray(properties.schemas) || !properties.blocks) { + throw new Error("Invalid block light properties"); + } + return properties; + }) + .catch((error) => { loading = undefined; throw error; }); + return loading; +} + +function stateId(state, properties) { + if (typeof state !== "string") return undefined; + const match = /^([a-z0-9_]+:[a-z0-9_/.]+)(?:\[([^\]]*)\])?$/.exec(state); + if (!match) return undefined; + const definition = properties.blocks?.[match[1]]; + if (!definition) return undefined; + const [first, defaultId, schemaId] = definition; + if (match[2] === undefined || match[2] === "") return defaultId; + const schema = properties.schemas[schemaId]; + const requested = new Map(); + for (const pair of match[2].split(",")) { + const parts = pair.split("="); + if (parts.length !== 2 || requested.has(parts[0])) return undefined; + requested.set(parts[0], parts[1]); + } + const defaults = []; + let remaining = defaultId - first; + for (let index = schema.length - 1; index >= 0; index--) { + const values = schema[index][1]; + defaults[index] = remaining % values.length; + remaining = Math.floor(remaining / values.length); + } + let offset = 0; + for (let index = 0; index < schema.length; index++) { + const [name, values] = schema[index]; + const selected = requested.has(name) ? values.indexOf(requested.get(name)) : defaults[index]; + if (selected < 0) return undefined; + requested.delete(name); + offset = offset * values.length + selected; + } + return requested.size ? undefined : first + offset; +} + +/** Enrich a copy; explicit Minecraft IDs take priority over the state fallback. */ +export function applyLightProperties(material, properties) { + if (!material || !properties?.codes) return { ...material }; + let id = material.minecraft_id; + if (!Number.isInteger(id) || id < 0 || id >= properties.codes.length) id = stateId(material.state, properties); + if (id === undefined) return { ...material }; + const code = properties.codes[id]; + let cache = decoded.get(properties); + if (!cache) { cache = new Map(); decoded.set(properties, cache); } + let lighting = cache.get(code); + if (!lighting) { + const boxes = properties.shapes[Math.floor(code / 16)]; + if (!boxes) return { ...material }; + lighting = { + light_dampening: code % 16, + light_occlusion: Object.freeze(boxes.map((box) => Object.freeze({ + min: Object.freeze(box.slice(0, 3)), max: Object.freeze(box.slice(3, 6)), + }))), + }; + cache.set(code, lighting); + } + return { ...material, ...lighting }; +} diff --git a/client/light-properties.json b/client/light-properties.json new file mode 100644 index 0000000..a2a9b74 --- /dev/null +++ b/client/light-properties.json @@ -0,0 +1 @@ +{"version":"26.2","encoding":"codes[minecraft_id] = light_dampening + 16 * occlusion_shape_index; blocks[name] = [first_state_id, default_state_id, schema_index]","codes":[0,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,15,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,1,1,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,15,15,0,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,16,32,48,64,80,96,15,15,15,15,15,15,1,0,0,0,0,0,0,1,1,1,16,32,48,64,80,96,15,15,15,15,15,15,112,112,128,128,144,144,160,160,176,176,192,192,208,208,224,224,240,240,256,256,272,272,288,288,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,305,304,305,304,305,304,305,304,305,304,305,304,305,304,305,304,321,320,321,320,321,320,321,320,321,320,321,320,321,320,321,320,337,336,337,336,337,336,337,336,337,336,337,336,337,336,337,336,353,352,353,352,353,352,353,352,353,352,353,352,353,352,353,352,305,304,305,304,305,304,305,304,305,304,305,304,305,304,305,304,321,320,321,320,321,320,321,320,321,320,321,320,321,320,321,320,337,336,337,336,337,336,337,336,337,336,337,336,337,336,337,336,353,352,353,352,353,352,353,352,353,352,353,352,353,352,353,352,305,304,305,304,305,304,305,304,305,304,305,304,305,304,305,304,321,320,321,320,321,320,321,320,321,320,321,320,321,320,321,320,337,336,337,336,337,336,337,336,337,336,337,336,337,336,337,336,353,352,353,352,353,352,353,352,353,352,353,352,353,352,353,352,305,304,305,304,305,304,305,304,305,304,305,304,305,304,305,304,321,320,321,320,321,320,321,320,321,320,321,320,321,320,321,320,337,336,337,336,337,336,337,336,337,336,337,336,337,336,337,336,353,352,353,352,353,352,353,352,353,352,353,352,353,352,353,352,305,304,305,304,305,304,305,304,305,304,305,304,305,304,305,304,321,320,321,320,321,320,321,320,321,320,321,320,321,320,321,320,337,336,337,336,337,336,337,336,337,336,337,336,337,336,337,336,353,352,353,352,353,352,353,352,353,352,353,352,353,352,353,352,305,304,305,304,305,304,305,304,305,304,305,304,305,304,305,304,321,320,321,320,321,320,321,320,321,320,321,320,321,320,321,320,337,336,337,336,337,336,337,336,337,336,337,336,337,336,337,336,353,352,353,352,353,352,353,352,353,352,353,352,353,352,353,352,305,304,305,304,305,304,305,304,305,304,305,304,305,304,305,304,321,320,321,320,321,320,321,320,321,320,321,320,321,320,321,320,337,336,337,336,337,336,337,336,337,336,337,336,337,336,337,336,353,352,353,352,353,352,353,352,353,352,353,352,353,352,353,352,305,304,305,304,305,304,305,304,305,304,305,304,305,304,305,304,321,320,321,320,321,320,321,320,321,320,321,320,321,320,321,320,337,336,337,336,337,336,337,336,337,336,337,336,337,336,337,336,353,352,353,352,353,352,353,352,353,352,353,352,353,352,353,352,305,304,305,304,305,304,305,304,305,304,305,304,305,304,305,304,321,320,321,320,321,320,321,320,321,320,321,320,321,320,321,320,337,336,337,336,337,336,337,336,337,336,337,336,337,336,337,336,353,352,353,352,353,352,353,352,353,352,353,352,353,352,353,352,305,304,305,304,305,304,305,304,305,304,305,304,305,304,305,304,321,320,321,320,321,320,321,320,321,320,321,320,321,320,321,320,337,336,337,336,337,336,337,336,337,336,337,336,337,336,337,336,353,352,353,352,353,352,353,352,353,352,353,352,353,352,353,352,305,304,305,304,305,304,305,304,305,304,305,304,305,304,305,304,321,320,321,320,321,320,321,320,321,320,321,320,321,320,321,320,337,336,337,336,337,336,337,336,337,336,337,336,337,336,337,336,353,352,353,352,353,352,353,352,353,352,353,352,353,352,353,352,305,304,305,304,305,304,305,304,305,304,305,304,305,304,305,304,321,320,321,320,321,320,321,320,321,320,321,320,321,320,321,320,337,336,337,336,337,336,337,336,337,336,337,336,337,336,337,336,353,352,353,352,353,352,353,352,353,352,353,352,353,352,353,352,15,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,15,15,15,0,0,0,0,0,0,0,0,752,752,752,752,752,752,752,752,15,15,15,15,15,15,15,15,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,15,15,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,768,784,800,816,832,80,848,879,1,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,15,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,15,15,15,15,15,15,15,15,15,0,0,0,0,0,0,0,0,0,0,15,0,0,15,15,15,15,15,15,15,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,15,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,15,15,0,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,881,880,817,816,15,15,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,15,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,0,0,0,0,80,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,896,896,896,896,912,912,912,912,15,0,15,15,0,0,0,0,0,0,0,0,0,0,0,0,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,15,15,1,0,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,15,15,15,15,15,15,15,15,15,15,15,15,1,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,800,15,15,0,0,0,0,0,0,0,0,0,0,15,15,15,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,15,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,15,15,15,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,15,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,15,15,15,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,15,15,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,752,1,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,1,1,1,1,15,15,15,15,15,15,0,15,15,15,15,15,15,15,15,15,15,15,15,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,15,15,15,15,15,15,15,15,15,15,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,15,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,0,0,0,0,0,0,0,0,0,0,0,0,928,928,928,928,928,928,928,928,928,928,928,928,928,928,928,928,15,944,944,944,944,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0,0,0,15,15,15,15,15,15,15,15,15,15,15,15,15,0,15,0,0,15,15,15,15,15,15,15,15,15,15,15,15,15,0,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,15,881,880,817,816,15,15,881,880,817,816,15,15,0,0,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,1,0,0,0,0,0,0,0,0,0,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,1,15,15,15,15,15,15,15,15,15,0,0,0,0,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,881,880,817,816,15,15,15,15,15,15,881,880,817,816,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,881,880,817,816,15,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,15,15,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,15,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,15,881,880,817,816,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,881,880,817,816,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,15,881,880,817,816,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,15,15,15,15,15,15,881,880,817,816,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,881,880,817,816,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,881,880,817,816,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,15,881,880,817,816,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,881,880,817,816,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,881,880,817,816,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,15,15,1,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,817,816,15,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,1,15,15,817,817,817,817,817,817,817,817,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,881,880,817,816,15,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,15,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,15,15,15,15,15,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,881,880,817,816,15,15,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,881,880,817,816,15,15,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,881,880,817,816,15,15,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,369,368,385,384,401,400,417,416,433,432,449,448,465,464,481,480,497,496,513,512,529,528,545,544,561,560,577,576,593,592,609,608,625,624,641,640,657,656,673,672,689,688,561,560,385,384,593,592,417,416,705,704,641,640,465,464,673,672,497,496,721,720,401,400,545,544,433,432,577,576,737,736,481,480,625,624,513,512,657,656,881,880,817,816,15,15,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,15,15,15,15,15,15,15,15,15,15,0,0,15,15,15,15,15,15,15,15,15,0,15,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"shapes":[[],[[0.0,0.0,0.25,1.0,1.0,1.0]],[[0.0,0.0,0.0,0.75,1.0,1.0]],[[0.0,0.0,0.0,1.0,1.0,0.75]],[[0.25,0.0,0.0,1.0,1.0,1.0]],[[0.0,0.0,0.0,1.0,0.75,1.0]],[[0.0,0.25,0.0,1.0,1.0,1.0]],[[0.0,0.0,0.0,1.0,1.0,0.25],[0.375,0.375,0.25,0.625,0.625,1.0]],[[0.0,0.0,0.0,1.0,1.0,0.25],[0.375,0.375,0.25,0.625,0.625,1.25]],[[0.75,0.0,0.0,1.0,1.0,1.0],[0.0,0.375,0.375,0.75,0.625,0.625]],[[0.75,0.0,0.0,1.0,1.0,1.0],[-0.25,0.375,0.375,0.75,0.625,0.625]],[[0.0,0.0,0.75,1.0,1.0,1.0],[0.375,0.375,0.0,0.625,0.625,0.75]],[[0.0,0.0,0.75,1.0,1.0,1.0],[0.375,0.375,-0.25,0.625,0.625,0.75]],[[0.0,0.0,0.0,0.25,1.0,1.0],[0.25,0.375,0.375,1.0,0.625,0.625]],[[0.0,0.0,0.0,0.25,1.0,1.0],[0.25,0.375,0.375,1.25,0.625,0.625]],[[0.375,0.0,0.375,0.625,1.0,0.625],[0.0,0.75,0.0,0.375,1.0,1.0],[0.375,0.75,0.0,1.0,1.0,0.375],[0.375,0.75,0.625,1.0,1.0,1.0],[0.625,0.75,0.375,1.0,1.0,0.625]],[[0.375,-0.25,0.375,0.625,1.0,0.625],[0.0,0.75,0.0,0.375,1.0,1.0],[0.375,0.75,0.0,1.0,1.0,0.375],[0.375,0.75,0.625,1.0,1.0,1.0],[0.625,0.75,0.375,1.0,1.0,0.625]],[[0.0,0.0,0.0,1.0,0.25,1.0],[0.375,0.25,0.375,0.625,1.0,0.625]],[[0.0,0.0,0.0,1.0,0.25,1.0],[0.375,0.25,0.375,0.625,1.25,0.625]],[[0.0,0.0,0.6875,1.0,0.25,1.0],[0.0,0.25,0.8125,1.0,1.0,1.0],[0.0,0.75,0.6875,1.0,1.0,0.8125]],[[0.0,0.0,0.0,1.0,0.25,0.3125],[0.0,0.25,0.0,1.0,1.0,0.1875],[0.0,0.75,0.1875,1.0,1.0,0.3125]],[[0.6875,0.0,0.0,1.0,0.25,1.0],[0.8125,0.25,0.0,1.0,1.0,1.0],[0.6875,0.75,0.0,0.8125,1.0,1.0]],[[0.0,0.0,0.0,0.3125,0.25,1.0],[0.0,0.25,0.0,0.1875,1.0,1.0],[0.1875,0.75,0.0,0.3125,1.0,1.0]],[[0.0,0.0,0.0,1.0,1.0,0.5],[0.0,0.5,0.5,1.0,1.0,1.0]],[[0.0,0.0,0.0,0.5,1.0,1.0],[0.5,0.0,0.0,1.0,1.0,0.5],[0.5,0.5,0.5,1.0,1.0,1.0]],[[0.0,0.0,0.0,1.0,1.0,0.5],[0.5,0.0,0.5,1.0,1.0,1.0],[0.0,0.5,0.5,0.5,1.0,1.0]],[[0.0,0.0,0.0,0.5,1.0,0.5],[0.0,0.5,0.5,1.0,1.0,1.0],[0.5,0.5,0.0,1.0,1.0,0.5]],[[0.5,0.0,0.0,1.0,1.0,0.5],[0.0,0.5,0.0,0.5,1.0,1.0],[0.5,0.5,0.5,1.0,1.0,1.0]],[[0.0,0.0,0.0,1.0,0.5,1.0],[0.0,0.5,0.0,1.0,1.0,0.5]],[[0.0,0.0,0.0,1.0,0.5,1.0],[0.0,0.5,0.0,0.5,1.0,1.0],[0.5,0.5,0.0,1.0,1.0,0.5]],[[0.0,0.0,0.0,1.0,0.5,1.0],[0.0,0.5,0.0,1.0,1.0,0.5],[0.5,0.5,0.5,1.0,1.0,1.0]],[[0.0,0.0,0.0,1.0,0.5,1.0],[0.0,0.5,0.0,0.5,1.0,0.5]],[[0.0,0.0,0.0,1.0,0.5,1.0],[0.5,0.5,0.0,1.0,1.0,0.5]],[[0.0,0.0,0.5,1.0,1.0,1.0],[0.0,0.5,0.0,1.0,1.0,0.5]],[[0.0,0.0,0.5,1.0,1.0,1.0],[0.5,0.0,0.0,1.0,1.0,0.5],[0.0,0.5,0.0,0.5,1.0,0.5]],[[0.0,0.0,0.0,0.5,1.0,1.0],[0.5,0.0,0.5,1.0,1.0,1.0],[0.5,0.5,0.0,1.0,1.0,0.5]],[[0.5,0.0,0.5,1.0,1.0,1.0],[0.0,0.5,0.0,0.5,1.0,1.0],[0.5,0.5,0.0,1.0,1.0,0.5]],[[0.0,0.0,0.5,0.5,1.0,1.0],[0.0,0.5,0.0,1.0,1.0,0.5],[0.5,0.5,0.5,1.0,1.0,1.0]],[[0.0,0.0,0.0,1.0,0.5,1.0],[0.0,0.5,0.5,1.0,1.0,1.0]],[[0.0,0.0,0.0,1.0,0.5,1.0],[0.0,0.5,0.5,1.0,1.0,1.0],[0.5,0.5,0.0,1.0,1.0,0.5]],[[0.0,0.0,0.0,1.0,0.5,1.0],[0.0,0.5,0.0,0.5,1.0,1.0],[0.5,0.5,0.5,1.0,1.0,1.0]],[[0.0,0.0,0.0,1.0,0.5,1.0],[0.5,0.5,0.5,1.0,1.0,1.0]],[[0.0,0.0,0.0,1.0,0.5,1.0],[0.0,0.5,0.5,0.5,1.0,1.0]],[[0.0,0.0,0.0,0.5,1.0,1.0],[0.5,0.5,0.0,1.0,1.0,1.0]],[[0.0,0.0,0.0,1.0,0.5,1.0],[0.0,0.5,0.0,0.5,1.0,1.0]],[[0.5,0.0,0.0,1.0,1.0,1.0],[0.0,0.5,0.0,0.5,1.0,1.0]],[[0.0,0.0,0.0,1.0,0.5,1.0],[0.5,0.5,0.0,1.0,1.0,1.0]],[[0.0,0.0,0.0,1.0,0.9375,1.0]],[[0.0,0.0,0.0,1.0,0.125,1.0]],[[0.0,0.0,0.0,1.0,0.25,1.0]],[[0.0,0.0,0.0,1.0,0.375,1.0]],[[0.0,0.0,0.0,1.0,0.5,1.0]],[[0.0,0.0,0.0,1.0,0.625,1.0]],[[0.0,0.0,0.0,1.0,0.875,1.0]],[[0.0,0.0,0.0,1.0,1.0,1.0]],[[0.0,0.5,0.0,1.0,1.0,1.0]],[[0.0,0.0,0.0,1.0,0.8125,1.0],[0.25,0.8125,0.25,0.75,1.0,0.75]],[[0.0,0.0,0.0,1.0,0.8125,1.0]],[[0.0,0.0,0.0,1.0,0.125,1.0],[0.25,0.125,0.25,0.75,0.875,0.75]],[[0.0,0.0,0.0,1.0,0.5625,1.0]]],"blocks":{"minecraft:air":[0,0,0],"minecraft:stone":[1,1,0],"minecraft:granite":[2,2,0],"minecraft:polished_granite":[3,3,0],"minecraft:diorite":[4,4,0],"minecraft:polished_diorite":[5,5,0],"minecraft:andesite":[6,6,0],"minecraft:polished_andesite":[7,7,0],"minecraft:grass_block":[8,9,1],"minecraft:dirt":[10,10,0],"minecraft:coarse_dirt":[11,11,0],"minecraft:podzol":[12,13,1],"minecraft:cobblestone":[14,14,0],"minecraft:oak_planks":[15,15,0],"minecraft:spruce_planks":[16,16,0],"minecraft:birch_planks":[17,17,0],"minecraft:jungle_planks":[18,18,0],"minecraft:acacia_planks":[19,19,0],"minecraft:cherry_planks":[20,20,0],"minecraft:dark_oak_planks":[21,21,0],"minecraft:pale_oak_wood":[22,23,2],"minecraft:pale_oak_planks":[25,25,0],"minecraft:mangrove_planks":[26,26,0],"minecraft:bamboo_planks":[27,27,0],"minecraft:bamboo_mosaic":[28,28,0],"minecraft:oak_sapling":[29,29,3],"minecraft:spruce_sapling":[31,31,3],"minecraft:birch_sapling":[33,33,3],"minecraft:jungle_sapling":[35,35,3],"minecraft:acacia_sapling":[37,37,3],"minecraft:cherry_sapling":[39,39,3],"minecraft:dark_oak_sapling":[41,41,3],"minecraft:pale_oak_sapling":[43,43,3],"minecraft:mangrove_propagule":[45,50,4],"minecraft:bedrock":[85,85,0],"minecraft:water":[86,86,5],"minecraft:lava":[102,102,5],"minecraft:sand":[118,118,0],"minecraft:suspicious_sand":[119,119,6],"minecraft:red_sand":[123,123,0],"minecraft:gravel":[124,124,0],"minecraft:suspicious_gravel":[125,125,6],"minecraft:gold_ore":[129,129,0],"minecraft:deepslate_gold_ore":[130,130,0],"minecraft:iron_ore":[131,131,0],"minecraft:deepslate_iron_ore":[132,132,0],"minecraft:coal_ore":[133,133,0],"minecraft:deepslate_coal_ore":[134,134,0],"minecraft:nether_gold_ore":[135,135,0],"minecraft:oak_log":[136,137,2],"minecraft:spruce_log":[139,140,2],"minecraft:birch_log":[142,143,2],"minecraft:jungle_log":[145,146,2],"minecraft:acacia_log":[148,149,2],"minecraft:cherry_log":[151,152,2],"minecraft:dark_oak_log":[154,155,2],"minecraft:pale_oak_log":[157,158,2],"minecraft:mangrove_log":[160,161,2],"minecraft:mangrove_roots":[163,164,7],"minecraft:muddy_mangrove_roots":[165,166,2],"minecraft:bamboo_block":[168,169,2],"minecraft:stripped_spruce_log":[171,172,2],"minecraft:stripped_birch_log":[174,175,2],"minecraft:stripped_jungle_log":[177,178,2],"minecraft:stripped_acacia_log":[180,181,2],"minecraft:stripped_cherry_log":[183,184,2],"minecraft:stripped_dark_oak_log":[186,187,2],"minecraft:stripped_pale_oak_log":[189,190,2],"minecraft:stripped_oak_log":[192,193,2],"minecraft:stripped_mangrove_log":[195,196,2],"minecraft:stripped_bamboo_block":[198,199,2],"minecraft:oak_wood":[201,202,2],"minecraft:spruce_wood":[204,205,2],"minecraft:birch_wood":[207,208,2],"minecraft:jungle_wood":[210,211,2],"minecraft:acacia_wood":[213,214,2],"minecraft:cherry_wood":[216,217,2],"minecraft:dark_oak_wood":[219,220,2],"minecraft:mangrove_wood":[222,223,2],"minecraft:stripped_oak_wood":[225,226,2],"minecraft:stripped_spruce_wood":[228,229,2],"minecraft:stripped_birch_wood":[231,232,2],"minecraft:stripped_jungle_wood":[234,235,2],"minecraft:stripped_acacia_wood":[237,238,2],"minecraft:stripped_cherry_wood":[240,241,2],"minecraft:stripped_dark_oak_wood":[243,244,2],"minecraft:stripped_pale_oak_wood":[246,247,2],"minecraft:stripped_mangrove_wood":[249,250,2],"minecraft:oak_leaves":[252,279,8],"minecraft:spruce_leaves":[280,307,8],"minecraft:birch_leaves":[308,335,8],"minecraft:jungle_leaves":[336,363,8],"minecraft:acacia_leaves":[364,391,8],"minecraft:cherry_leaves":[392,419,8],"minecraft:dark_oak_leaves":[420,447,8],"minecraft:pale_oak_leaves":[448,475,8],"minecraft:mangrove_leaves":[476,503,8],"minecraft:azalea_leaves":[504,531,8],"minecraft:flowering_azalea_leaves":[532,559,8],"minecraft:sponge":[560,560,0],"minecraft:wet_sponge":[561,561,0],"minecraft:glass":[562,562,0],"minecraft:lapis_ore":[563,563,0],"minecraft:deepslate_lapis_ore":[564,564,0],"minecraft:lapis_block":[565,565,0],"minecraft:dispenser":[566,567,9],"minecraft:sandstone":[578,578,0],"minecraft:chiseled_sandstone":[579,579,0],"minecraft:cut_sandstone":[580,580,0],"minecraft:note_block":[581,582,10],"minecraft:white_bed":[1931,1934,11],"minecraft:orange_bed":[1947,1950,11],"minecraft:magenta_bed":[1963,1966,11],"minecraft:light_blue_bed":[1979,1982,11],"minecraft:yellow_bed":[1995,1998,11],"minecraft:lime_bed":[2011,2014,11],"minecraft:pink_bed":[2027,2030,11],"minecraft:gray_bed":[2043,2046,11],"minecraft:light_gray_bed":[2059,2062,11],"minecraft:cyan_bed":[2075,2078,11],"minecraft:purple_bed":[2091,2094,11],"minecraft:blue_bed":[2107,2110,11],"minecraft:brown_bed":[2123,2126,11],"minecraft:green_bed":[2139,2142,11],"minecraft:red_bed":[2155,2158,11],"minecraft:black_bed":[2171,2174,11],"minecraft:powered_rail":[2187,2200,12],"minecraft:detector_rail":[2211,2224,12],"minecraft:sticky_piston":[2235,2241,13],"minecraft:cobweb":[2247,2247,0],"minecraft:short_grass":[2248,2248,0],"minecraft:fern":[2249,2249,0],"minecraft:dead_bush":[2250,2250,0],"minecraft:bush":[2251,2251,0],"minecraft:short_dry_grass":[2252,2252,0],"minecraft:tall_dry_grass":[2253,2253,0],"minecraft:seagrass":[2254,2254,0],"minecraft:tall_seagrass":[2255,2256,14],"minecraft:piston":[2257,2263,13],"minecraft:piston_head":[2269,2271,15],"minecraft:white_wool":[2293,2293,0],"minecraft:orange_wool":[2294,2294,0],"minecraft:magenta_wool":[2295,2295,0],"minecraft:light_blue_wool":[2296,2296,0],"minecraft:yellow_wool":[2297,2297,0],"minecraft:lime_wool":[2298,2298,0],"minecraft:pink_wool":[2299,2299,0],"minecraft:gray_wool":[2300,2300,0],"minecraft:light_gray_wool":[2301,2301,0],"minecraft:cyan_wool":[2302,2302,0],"minecraft:purple_wool":[2303,2303,0],"minecraft:blue_wool":[2304,2304,0],"minecraft:brown_wool":[2305,2305,0],"minecraft:green_wool":[2306,2306,0],"minecraft:red_wool":[2307,2307,0],"minecraft:black_wool":[2308,2308,0],"minecraft:moving_piston":[2309,2309,16],"minecraft:dandelion":[2321,2321,0],"minecraft:golden_dandelion":[2322,2322,0],"minecraft:torchflower":[2323,2323,0],"minecraft:poppy":[2324,2324,0],"minecraft:blue_orchid":[2325,2325,0],"minecraft:allium":[2326,2326,0],"minecraft:azure_bluet":[2327,2327,0],"minecraft:red_tulip":[2328,2328,0],"minecraft:orange_tulip":[2329,2329,0],"minecraft:white_tulip":[2330,2330,0],"minecraft:pink_tulip":[2331,2331,0],"minecraft:oxeye_daisy":[2332,2332,0],"minecraft:cornflower":[2333,2333,0],"minecraft:wither_rose":[2334,2334,0],"minecraft:lily_of_the_valley":[2335,2335,0],"minecraft:brown_mushroom":[2336,2336,0],"minecraft:red_mushroom":[2337,2337,0],"minecraft:gold_block":[2338,2338,0],"minecraft:iron_block":[2339,2339,0],"minecraft:bricks":[2340,2340,0],"minecraft:tnt":[2341,2342,17],"minecraft:bookshelf":[2343,2343,0],"minecraft:chiseled_bookshelf":[2344,2407,18],"minecraft:acacia_shelf":[2600,2609,19],"minecraft:bamboo_shelf":[2664,2673,19],"minecraft:birch_shelf":[2728,2737,19],"minecraft:cherry_shelf":[2792,2801,19],"minecraft:crimson_shelf":[2856,2865,19],"minecraft:dark_oak_shelf":[2920,2929,19],"minecraft:jungle_shelf":[2984,2993,19],"minecraft:mangrove_shelf":[3048,3057,19],"minecraft:oak_shelf":[3112,3121,19],"minecraft:pale_oak_shelf":[3176,3185,19],"minecraft:spruce_shelf":[3240,3249,19],"minecraft:warped_shelf":[3304,3313,19],"minecraft:mossy_cobblestone":[3368,3368,0],"minecraft:obsidian":[3369,3369,0],"minecraft:torch":[3370,3370,0],"minecraft:wall_torch":[3371,3371,20],"minecraft:fire":[3375,3406,21],"minecraft:soul_fire":[3887,3887,0],"minecraft:spawner":[3888,3888,0],"minecraft:creaking_heart":[3889,3896,22],"minecraft:oak_stairs":[3907,3918,23],"minecraft:chest":[3987,3988,24],"minecraft:redstone_wire":[4011,5171,25],"minecraft:diamond_ore":[5307,5307,0],"minecraft:deepslate_diamond_ore":[5308,5308,0],"minecraft:diamond_block":[5309,5309,0],"minecraft:crafting_table":[5310,5310,0],"minecraft:wheat":[5311,5311,26],"minecraft:farmland":[5319,5319,27],"minecraft:furnace":[5327,5328,28],"minecraft:oak_sign":[5335,5352,29],"minecraft:spruce_sign":[5367,5384,29],"minecraft:birch_sign":[5399,5416,29],"minecraft:acacia_sign":[5431,5448,29],"minecraft:cherry_sign":[5463,5480,29],"minecraft:jungle_sign":[5495,5512,29],"minecraft:dark_oak_sign":[5527,5544,29],"minecraft:pale_oak_sign":[5559,5576,29],"minecraft:mangrove_sign":[5591,5608,29],"minecraft:bamboo_sign":[5623,5640,29],"minecraft:oak_door":[5655,5666,30],"minecraft:ladder":[5719,5720,31],"minecraft:rail":[5727,5728,32],"minecraft:cobblestone_stairs":[5747,5758,23],"minecraft:oak_wall_sign":[5827,5828,31],"minecraft:spruce_wall_sign":[5835,5836,31],"minecraft:birch_wall_sign":[5843,5844,31],"minecraft:acacia_wall_sign":[5851,5852,31],"minecraft:cherry_wall_sign":[5859,5860,31],"minecraft:jungle_wall_sign":[5867,5868,31],"minecraft:dark_oak_wall_sign":[5875,5876,31],"minecraft:pale_oak_wall_sign":[5883,5884,31],"minecraft:mangrove_wall_sign":[5891,5892,31],"minecraft:bamboo_wall_sign":[5899,5900,31],"minecraft:oak_hanging_sign":[5907,5956,33],"minecraft:spruce_hanging_sign":[5971,6020,33],"minecraft:birch_hanging_sign":[6035,6084,33],"minecraft:acacia_hanging_sign":[6099,6148,33],"minecraft:cherry_hanging_sign":[6163,6212,33],"minecraft:jungle_hanging_sign":[6227,6276,33],"minecraft:dark_oak_hanging_sign":[6291,6340,33],"minecraft:pale_oak_hanging_sign":[6355,6404,33],"minecraft:crimson_hanging_sign":[6419,6468,33],"minecraft:warped_hanging_sign":[6483,6532,33],"minecraft:mangrove_hanging_sign":[6547,6596,33],"minecraft:bamboo_hanging_sign":[6611,6660,33],"minecraft:oak_wall_hanging_sign":[6675,6676,31],"minecraft:spruce_wall_hanging_sign":[6683,6684,31],"minecraft:birch_wall_hanging_sign":[6691,6692,31],"minecraft:acacia_wall_hanging_sign":[6699,6700,31],"minecraft:cherry_wall_hanging_sign":[6707,6708,31],"minecraft:jungle_wall_hanging_sign":[6715,6716,31],"minecraft:dark_oak_wall_hanging_sign":[6723,6724,31],"minecraft:pale_oak_wall_hanging_sign":[6731,6732,31],"minecraft:mangrove_wall_hanging_sign":[6739,6740,31],"minecraft:crimson_wall_hanging_sign":[6747,6748,31],"minecraft:warped_wall_hanging_sign":[6755,6756,31],"minecraft:bamboo_wall_hanging_sign":[6763,6764,31],"minecraft:lever":[6771,6780,34],"minecraft:stone_pressure_plate":[6795,6796,35],"minecraft:iron_door":[6797,6808,30],"minecraft:oak_pressure_plate":[6861,6862,35],"minecraft:spruce_pressure_plate":[6863,6864,35],"minecraft:birch_pressure_plate":[6865,6866,35],"minecraft:jungle_pressure_plate":[6867,6868,35],"minecraft:acacia_pressure_plate":[6869,6870,35],"minecraft:cherry_pressure_plate":[6871,6872,35],"minecraft:dark_oak_pressure_plate":[6873,6874,35],"minecraft:pale_oak_pressure_plate":[6875,6876,35],"minecraft:mangrove_pressure_plate":[6877,6878,35],"minecraft:bamboo_pressure_plate":[6879,6880,35],"minecraft:redstone_ore":[6881,6882,36],"minecraft:deepslate_redstone_ore":[6883,6884,36],"minecraft:redstone_torch":[6885,6885,36],"minecraft:redstone_wall_torch":[6887,6887,28],"minecraft:stone_button":[6895,6904,34],"minecraft:snow":[6919,6919,37],"minecraft:ice":[6927,6927,0],"minecraft:snow_block":[6928,6928,0],"minecraft:cactus":[6929,6929,38],"minecraft:cactus_flower":[6945,6945,0],"minecraft:clay":[6946,6946,0],"minecraft:sugar_cane":[6947,6947,38],"minecraft:jukebox":[6963,6964,39],"minecraft:oak_fence":[6965,6996,40],"minecraft:netherrack":[6997,6997,0],"minecraft:soul_sand":[6998,6998,0],"minecraft:soul_soil":[6999,6999,0],"minecraft:basalt":[7000,7001,2],"minecraft:polished_basalt":[7003,7004,2],"minecraft:soul_torch":[7006,7006,0],"minecraft:soul_wall_torch":[7007,7007,20],"minecraft:copper_torch":[7011,7011,0],"minecraft:copper_wall_torch":[7012,7012,20],"minecraft:glowstone":[7016,7016,0],"minecraft:nether_portal":[7017,7017,41],"minecraft:carved_pumpkin":[7019,7019,20],"minecraft:jack_o_lantern":[7023,7023,20],"minecraft:cake":[7027,7027,42],"minecraft:repeater":[7034,7037,43],"minecraft:white_stained_glass":[7098,7098,0],"minecraft:orange_stained_glass":[7099,7099,0],"minecraft:magenta_stained_glass":[7100,7100,0],"minecraft:light_blue_stained_glass":[7101,7101,0],"minecraft:yellow_stained_glass":[7102,7102,0],"minecraft:lime_stained_glass":[7103,7103,0],"minecraft:pink_stained_glass":[7104,7104,0],"minecraft:gray_stained_glass":[7105,7105,0],"minecraft:light_gray_stained_glass":[7106,7106,0],"minecraft:cyan_stained_glass":[7107,7107,0],"minecraft:purple_stained_glass":[7108,7108,0],"minecraft:blue_stained_glass":[7109,7109,0],"minecraft:brown_stained_glass":[7110,7110,0],"minecraft:green_stained_glass":[7111,7111,0],"minecraft:red_stained_glass":[7112,7112,0],"minecraft:black_stained_glass":[7113,7113,0],"minecraft:oak_trapdoor":[7114,7129,44],"minecraft:spruce_trapdoor":[7178,7193,44],"minecraft:birch_trapdoor":[7242,7257,44],"minecraft:jungle_trapdoor":[7306,7321,44],"minecraft:acacia_trapdoor":[7370,7385,44],"minecraft:cherry_trapdoor":[7434,7449,44],"minecraft:dark_oak_trapdoor":[7498,7513,44],"minecraft:pale_oak_trapdoor":[7562,7577,44],"minecraft:mangrove_trapdoor":[7626,7641,44],"minecraft:bamboo_trapdoor":[7690,7705,44],"minecraft:stone_bricks":[7754,7754,0],"minecraft:mossy_stone_bricks":[7755,7755,0],"minecraft:cracked_stone_bricks":[7756,7756,0],"minecraft:chiseled_stone_bricks":[7757,7757,0],"minecraft:packed_mud":[7758,7758,0],"minecraft:mud_bricks":[7759,7759,0],"minecraft:infested_stone":[7760,7760,0],"minecraft:infested_cobblestone":[7761,7761,0],"minecraft:infested_stone_bricks":[7762,7762,0],"minecraft:infested_mossy_stone_bricks":[7763,7763,0],"minecraft:infested_cracked_stone_bricks":[7764,7764,0],"minecraft:infested_chiseled_stone_bricks":[7765,7765,0],"minecraft:brown_mushroom_block":[7766,7766,45],"minecraft:red_mushroom_block":[7830,7830,45],"minecraft:mushroom_stem":[7894,7894,45],"minecraft:iron_bars":[7958,7989,40],"minecraft:copper_bars":[7990,8021,40],"minecraft:exposed_copper_bars":[8022,8053,40],"minecraft:weathered_copper_bars":[8054,8085,40],"minecraft:oxidized_copper_bars":[8086,8117,40],"minecraft:waxed_copper_bars":[8118,8149,40],"minecraft:waxed_exposed_copper_bars":[8150,8181,40],"minecraft:waxed_weathered_copper_bars":[8182,8213,40],"minecraft:waxed_oxidized_copper_bars":[8214,8245,40],"minecraft:iron_chain":[8246,8249,46],"minecraft:copper_chain":[8252,8255,46],"minecraft:exposed_copper_chain":[8258,8261,46],"minecraft:weathered_copper_chain":[8264,8267,46],"minecraft:oxidized_copper_chain":[8270,8273,46],"minecraft:waxed_copper_chain":[8276,8279,46],"minecraft:waxed_exposed_copper_chain":[8282,8285,46],"minecraft:waxed_weathered_copper_chain":[8288,8291,46],"minecraft:waxed_oxidized_copper_chain":[8294,8297,46],"minecraft:glass_pane":[8300,8331,40],"minecraft:pumpkin":[8332,8332,0],"minecraft:melon":[8333,8333,0],"minecraft:attached_pumpkin_stem":[8334,8334,20],"minecraft:attached_melon_stem":[8338,8338,20],"minecraft:pumpkin_stem":[8342,8342,26],"minecraft:melon_stem":[8350,8350,26],"minecraft:vine":[8358,8389,47],"minecraft:glow_lichen":[8390,8517,48],"minecraft:resin_clump":[8518,8645,48],"minecraft:oak_fence_gate":[8646,8653,49],"minecraft:brick_stairs":[8678,8689,23],"minecraft:stone_brick_stairs":[8758,8769,23],"minecraft:mud_brick_stairs":[8838,8849,23],"minecraft:mycelium":[8918,8919,1],"minecraft:lily_pad":[8920,8920,0],"minecraft:resin_block":[8921,8921,0],"minecraft:resin_bricks":[8922,8922,0],"minecraft:resin_brick_stairs":[8923,8934,23],"minecraft:resin_brick_slab":[9003,9006,50],"minecraft:resin_brick_wall":[9009,9012,51],"minecraft:chiseled_resin_bricks":[9333,9333,0],"minecraft:nether_bricks":[9334,9334,0],"minecraft:nether_brick_fence":[9335,9366,40],"minecraft:nether_brick_stairs":[9367,9378,23],"minecraft:nether_wart":[9447,9447,52],"minecraft:enchanting_table":[9451,9451,0],"minecraft:brewing_stand":[9452,9459,53],"minecraft:cauldron":[9460,9460,0],"minecraft:water_cauldron":[9461,9461,54],"minecraft:lava_cauldron":[9464,9464,0],"minecraft:powder_snow_cauldron":[9465,9465,54],"minecraft:end_portal":[9468,9468,0],"minecraft:end_portal_frame":[9469,9473,55],"minecraft:end_stone":[9477,9477,0],"minecraft:dragon_egg":[9478,9478,0],"minecraft:redstone_lamp":[9479,9480,36],"minecraft:cocoa":[9481,9481,56],"minecraft:sandstone_stairs":[9493,9504,23],"minecraft:emerald_ore":[9573,9573,0],"minecraft:deepslate_emerald_ore":[9574,9574,0],"minecraft:ender_chest":[9575,9576,31],"minecraft:tripwire_hook":[9583,9592,57],"minecraft:tripwire":[9599,9726,58],"minecraft:emerald_block":[9727,9727,0],"minecraft:spruce_stairs":[9728,9739,23],"minecraft:birch_stairs":[9808,9819,23],"minecraft:jungle_stairs":[9888,9899,23],"minecraft:command_block":[9968,9974,59],"minecraft:beacon":[9980,9980,0],"minecraft:cobblestone_wall":[9981,9984,51],"minecraft:mossy_cobblestone_wall":[10305,10308,51],"minecraft:flower_pot":[10629,10629,0],"minecraft:potted_torchflower":[10630,10630,0],"minecraft:potted_oak_sapling":[10631,10631,0],"minecraft:potted_spruce_sapling":[10632,10632,0],"minecraft:potted_birch_sapling":[10633,10633,0],"minecraft:potted_jungle_sapling":[10634,10634,0],"minecraft:potted_acacia_sapling":[10635,10635,0],"minecraft:potted_cherry_sapling":[10636,10636,0],"minecraft:potted_dark_oak_sapling":[10637,10637,0],"minecraft:potted_pale_oak_sapling":[10638,10638,0],"minecraft:potted_mangrove_propagule":[10639,10639,0],"minecraft:potted_fern":[10640,10640,0],"minecraft:potted_dandelion":[10641,10641,0],"minecraft:potted_golden_dandelion":[10642,10642,0],"minecraft:potted_poppy":[10643,10643,0],"minecraft:potted_blue_orchid":[10644,10644,0],"minecraft:potted_allium":[10645,10645,0],"minecraft:potted_azure_bluet":[10646,10646,0],"minecraft:potted_red_tulip":[10647,10647,0],"minecraft:potted_orange_tulip":[10648,10648,0],"minecraft:potted_white_tulip":[10649,10649,0],"minecraft:potted_pink_tulip":[10650,10650,0],"minecraft:potted_oxeye_daisy":[10651,10651,0],"minecraft:potted_cornflower":[10652,10652,0],"minecraft:potted_lily_of_the_valley":[10653,10653,0],"minecraft:potted_wither_rose":[10654,10654,0],"minecraft:potted_red_mushroom":[10655,10655,0],"minecraft:potted_brown_mushroom":[10656,10656,0],"minecraft:potted_dead_bush":[10657,10657,0],"minecraft:potted_cactus":[10658,10658,0],"minecraft:carrots":[10659,10659,26],"minecraft:potatoes":[10667,10667,26],"minecraft:oak_button":[10675,10684,34],"minecraft:spruce_button":[10699,10708,34],"minecraft:birch_button":[10723,10732,34],"minecraft:jungle_button":[10747,10756,34],"minecraft:acacia_button":[10771,10780,34],"minecraft:cherry_button":[10795,10804,34],"minecraft:dark_oak_button":[10819,10828,34],"minecraft:pale_oak_button":[10843,10852,34],"minecraft:mangrove_button":[10867,10876,34],"minecraft:bamboo_button":[10891,10900,34],"minecraft:skeleton_skull":[10915,10931,60],"minecraft:skeleton_wall_skull":[10947,10948,61],"minecraft:wither_skeleton_skull":[10955,10971,60],"minecraft:wither_skeleton_wall_skull":[10987,10988,61],"minecraft:zombie_head":[10995,11011,60],"minecraft:zombie_wall_head":[11027,11028,61],"minecraft:player_head":[11035,11051,60],"minecraft:player_wall_head":[11067,11068,61],"minecraft:creeper_head":[11075,11091,60],"minecraft:creeper_wall_head":[11107,11108,61],"minecraft:dragon_head":[11115,11131,60],"minecraft:dragon_wall_head":[11147,11148,61],"minecraft:piglin_head":[11155,11171,60],"minecraft:piglin_wall_head":[11187,11188,61],"minecraft:anvil":[11195,11195,20],"minecraft:chipped_anvil":[11199,11199,20],"minecraft:damaged_anvil":[11203,11203,20],"minecraft:trapped_chest":[11207,11208,24],"minecraft:light_weighted_pressure_plate":[11231,11231,62],"minecraft:heavy_weighted_pressure_plate":[11247,11247,62],"minecraft:comparator":[11263,11264,63],"minecraft:daylight_detector":[11279,11295,64],"minecraft:redstone_block":[11311,11311,0],"minecraft:nether_quartz_ore":[11312,11312,0],"minecraft:hopper":[11313,11313,65],"minecraft:quartz_block":[11323,11323,0],"minecraft:chiseled_quartz_block":[11324,11324,0],"minecraft:quartz_pillar":[11325,11326,2],"minecraft:quartz_stairs":[11328,11339,23],"minecraft:activator_rail":[11408,11421,12],"minecraft:dropper":[11432,11433,9],"minecraft:white_terracotta":[11444,11444,0],"minecraft:orange_terracotta":[11445,11445,0],"minecraft:magenta_terracotta":[11446,11446,0],"minecraft:light_blue_terracotta":[11447,11447,0],"minecraft:yellow_terracotta":[11448,11448,0],"minecraft:lime_terracotta":[11449,11449,0],"minecraft:pink_terracotta":[11450,11450,0],"minecraft:gray_terracotta":[11451,11451,0],"minecraft:light_gray_terracotta":[11452,11452,0],"minecraft:cyan_terracotta":[11453,11453,0],"minecraft:purple_terracotta":[11454,11454,0],"minecraft:blue_terracotta":[11455,11455,0],"minecraft:brown_terracotta":[11456,11456,0],"minecraft:green_terracotta":[11457,11457,0],"minecraft:red_terracotta":[11458,11458,0],"minecraft:black_terracotta":[11459,11459,0],"minecraft:white_stained_glass_pane":[11460,11491,40],"minecraft:orange_stained_glass_pane":[11492,11523,40],"minecraft:magenta_stained_glass_pane":[11524,11555,40],"minecraft:light_blue_stained_glass_pane":[11556,11587,40],"minecraft:yellow_stained_glass_pane":[11588,11619,40],"minecraft:lime_stained_glass_pane":[11620,11651,40],"minecraft:pink_stained_glass_pane":[11652,11683,40],"minecraft:gray_stained_glass_pane":[11684,11715,40],"minecraft:light_gray_stained_glass_pane":[11716,11747,40],"minecraft:cyan_stained_glass_pane":[11748,11779,40],"minecraft:purple_stained_glass_pane":[11780,11811,40],"minecraft:blue_stained_glass_pane":[11812,11843,40],"minecraft:brown_stained_glass_pane":[11844,11875,40],"minecraft:green_stained_glass_pane":[11876,11907,40],"minecraft:red_stained_glass_pane":[11908,11939,40],"minecraft:black_stained_glass_pane":[11940,11971,40],"minecraft:acacia_stairs":[11972,11983,23],"minecraft:cherry_stairs":[12052,12063,23],"minecraft:dark_oak_stairs":[12132,12143,23],"minecraft:pale_oak_stairs":[12212,12223,23],"minecraft:mangrove_stairs":[12292,12303,23],"minecraft:bamboo_stairs":[12372,12383,23],"minecraft:bamboo_mosaic_stairs":[12452,12463,23],"minecraft:slime_block":[12532,12532,0],"minecraft:barrier":[12533,12534,7],"minecraft:light":[12535,12566,66],"minecraft:iron_trapdoor":[12567,12582,44],"minecraft:prismarine":[12631,12631,0],"minecraft:prismarine_bricks":[12632,12632,0],"minecraft:dark_prismarine":[12633,12633,0],"minecraft:prismarine_stairs":[12634,12645,23],"minecraft:prismarine_brick_stairs":[12714,12725,23],"minecraft:dark_prismarine_stairs":[12794,12805,23],"minecraft:prismarine_slab":[12874,12877,50],"minecraft:prismarine_brick_slab":[12880,12883,50],"minecraft:dark_prismarine_slab":[12886,12889,50],"minecraft:sea_lantern":[12892,12892,0],"minecraft:hay_block":[12893,12894,2],"minecraft:white_carpet":[12896,12896,0],"minecraft:orange_carpet":[12897,12897,0],"minecraft:magenta_carpet":[12898,12898,0],"minecraft:light_blue_carpet":[12899,12899,0],"minecraft:yellow_carpet":[12900,12900,0],"minecraft:lime_carpet":[12901,12901,0],"minecraft:pink_carpet":[12902,12902,0],"minecraft:gray_carpet":[12903,12903,0],"minecraft:light_gray_carpet":[12904,12904,0],"minecraft:cyan_carpet":[12905,12905,0],"minecraft:purple_carpet":[12906,12906,0],"minecraft:blue_carpet":[12907,12907,0],"minecraft:brown_carpet":[12908,12908,0],"minecraft:green_carpet":[12909,12909,0],"minecraft:red_carpet":[12910,12910,0],"minecraft:black_carpet":[12911,12911,0],"minecraft:terracotta":[12912,12912,0],"minecraft:coal_block":[12913,12913,0],"minecraft:packed_ice":[12914,12914,0],"minecraft:sunflower":[12915,12916,14],"minecraft:lilac":[12917,12918,14],"minecraft:rose_bush":[12919,12920,14],"minecraft:peony":[12921,12922,14],"minecraft:tall_grass":[12923,12924,14],"minecraft:large_fern":[12925,12926,14],"minecraft:white_banner":[12927,12935,67],"minecraft:orange_banner":[12943,12951,67],"minecraft:magenta_banner":[12959,12967,67],"minecraft:light_blue_banner":[12975,12983,67],"minecraft:yellow_banner":[12991,12999,67],"minecraft:lime_banner":[13007,13015,67],"minecraft:pink_banner":[13023,13031,67],"minecraft:gray_banner":[13039,13047,67],"minecraft:light_gray_banner":[13055,13063,67],"minecraft:cyan_banner":[13071,13079,67],"minecraft:purple_banner":[13087,13095,67],"minecraft:blue_banner":[13103,13111,67],"minecraft:brown_banner":[13119,13127,67],"minecraft:green_banner":[13135,13143,67],"minecraft:red_banner":[13151,13159,67],"minecraft:black_banner":[13167,13175,67],"minecraft:white_wall_banner":[13183,13183,20],"minecraft:orange_wall_banner":[13187,13187,20],"minecraft:magenta_wall_banner":[13191,13191,20],"minecraft:light_blue_wall_banner":[13195,13195,20],"minecraft:yellow_wall_banner":[13199,13199,20],"minecraft:lime_wall_banner":[13203,13203,20],"minecraft:pink_wall_banner":[13207,13207,20],"minecraft:gray_wall_banner":[13211,13211,20],"minecraft:light_gray_wall_banner":[13215,13215,20],"minecraft:cyan_wall_banner":[13219,13219,20],"minecraft:purple_wall_banner":[13223,13223,20],"minecraft:blue_wall_banner":[13227,13227,20],"minecraft:brown_wall_banner":[13231,13231,20],"minecraft:green_wall_banner":[13235,13235,20],"minecraft:red_wall_banner":[13239,13239,20],"minecraft:black_wall_banner":[13243,13243,20],"minecraft:red_sandstone":[13247,13247,0],"minecraft:chiseled_red_sandstone":[13248,13248,0],"minecraft:cut_red_sandstone":[13249,13249,0],"minecraft:red_sandstone_stairs":[13250,13261,23],"minecraft:oak_slab":[13330,13333,50],"minecraft:spruce_slab":[13336,13339,50],"minecraft:birch_slab":[13342,13345,50],"minecraft:jungle_slab":[13348,13351,50],"minecraft:acacia_slab":[13354,13357,50],"minecraft:cherry_slab":[13360,13363,50],"minecraft:dark_oak_slab":[13366,13369,50],"minecraft:pale_oak_slab":[13372,13375,50],"minecraft:mangrove_slab":[13378,13381,50],"minecraft:bamboo_slab":[13384,13387,50],"minecraft:bamboo_mosaic_slab":[13390,13393,50],"minecraft:stone_slab":[13396,13399,50],"minecraft:smooth_stone_slab":[13402,13405,50],"minecraft:sandstone_slab":[13408,13411,50],"minecraft:cut_sandstone_slab":[13414,13417,50],"minecraft:petrified_oak_slab":[13420,13423,50],"minecraft:cobblestone_slab":[13426,13429,50],"minecraft:brick_slab":[13432,13435,50],"minecraft:stone_brick_slab":[13438,13441,50],"minecraft:mud_brick_slab":[13444,13447,50],"minecraft:nether_brick_slab":[13450,13453,50],"minecraft:quartz_slab":[13456,13459,50],"minecraft:red_sandstone_slab":[13462,13465,50],"minecraft:cut_red_sandstone_slab":[13468,13471,50],"minecraft:purpur_slab":[13474,13477,50],"minecraft:smooth_stone":[13480,13480,0],"minecraft:smooth_sandstone":[13481,13481,0],"minecraft:smooth_quartz":[13482,13482,0],"minecraft:smooth_red_sandstone":[13483,13483,0],"minecraft:spruce_fence_gate":[13484,13491,49],"minecraft:birch_fence_gate":[13516,13523,49],"minecraft:jungle_fence_gate":[13548,13555,49],"minecraft:acacia_fence_gate":[13580,13587,49],"minecraft:cherry_fence_gate":[13612,13619,49],"minecraft:dark_oak_fence_gate":[13644,13651,49],"minecraft:pale_oak_fence_gate":[13676,13683,49],"minecraft:mangrove_fence_gate":[13708,13715,49],"minecraft:bamboo_fence_gate":[13740,13747,49],"minecraft:spruce_fence":[13772,13803,40],"minecraft:birch_fence":[13804,13835,40],"minecraft:jungle_fence":[13836,13867,40],"minecraft:acacia_fence":[13868,13899,40],"minecraft:cherry_fence":[13900,13931,40],"minecraft:dark_oak_fence":[13932,13963,40],"minecraft:pale_oak_fence":[13964,13995,40],"minecraft:mangrove_fence":[13996,14027,40],"minecraft:bamboo_fence":[14028,14059,40],"minecraft:spruce_door":[14060,14071,30],"minecraft:birch_door":[14124,14135,30],"minecraft:jungle_door":[14188,14199,30],"minecraft:acacia_door":[14252,14263,30],"minecraft:cherry_door":[14316,14327,30],"minecraft:dark_oak_door":[14380,14391,30],"minecraft:pale_oak_door":[14444,14455,30],"minecraft:mangrove_door":[14508,14519,30],"minecraft:bamboo_door":[14572,14583,30],"minecraft:end_rod":[14636,14640,68],"minecraft:chorus_plant":[14642,14705,45],"minecraft:chorus_flower":[14706,14706,69],"minecraft:purpur_block":[14712,14712,0],"minecraft:purpur_pillar":[14713,14714,2],"minecraft:purpur_stairs":[14716,14727,23],"minecraft:end_stone_bricks":[14796,14796,0],"minecraft:torchflower_crop":[14797,14797,70],"minecraft:pitcher_crop":[14799,14800,71],"minecraft:pitcher_plant":[14809,14810,14],"minecraft:beetroots":[14811,14811,52],"minecraft:dirt_path":[14815,14815,0],"minecraft:end_gateway":[14816,14816,0],"minecraft:repeating_command_block":[14817,14823,59],"minecraft:chain_command_block":[14829,14835,59],"minecraft:frosted_ice":[14841,14841,52],"minecraft:magma_block":[14845,14845,0],"minecraft:nether_wart_block":[14846,14846,0],"minecraft:red_nether_bricks":[14847,14847,0],"minecraft:bone_block":[14848,14849,2],"minecraft:structure_void":[14851,14851,0],"minecraft:observer":[14852,14857,72],"minecraft:shulker_box":[14864,14868,68],"minecraft:white_shulker_box":[14870,14874,68],"minecraft:orange_shulker_box":[14876,14880,68],"minecraft:magenta_shulker_box":[14882,14886,68],"minecraft:light_blue_shulker_box":[14888,14892,68],"minecraft:yellow_shulker_box":[14894,14898,68],"minecraft:lime_shulker_box":[14900,14904,68],"minecraft:pink_shulker_box":[14906,14910,68],"minecraft:gray_shulker_box":[14912,14916,68],"minecraft:light_gray_shulker_box":[14918,14922,68],"minecraft:cyan_shulker_box":[14924,14928,68],"minecraft:purple_shulker_box":[14930,14934,68],"minecraft:blue_shulker_box":[14936,14940,68],"minecraft:brown_shulker_box":[14942,14946,68],"minecraft:green_shulker_box":[14948,14952,68],"minecraft:red_shulker_box":[14954,14958,68],"minecraft:black_shulker_box":[14960,14964,68],"minecraft:white_glazed_terracotta":[14966,14966,20],"minecraft:orange_glazed_terracotta":[14970,14970,20],"minecraft:magenta_glazed_terracotta":[14974,14974,20],"minecraft:light_blue_glazed_terracotta":[14978,14978,20],"minecraft:yellow_glazed_terracotta":[14982,14982,20],"minecraft:lime_glazed_terracotta":[14986,14986,20],"minecraft:pink_glazed_terracotta":[14990,14990,20],"minecraft:gray_glazed_terracotta":[14994,14994,20],"minecraft:light_gray_glazed_terracotta":[14998,14998,20],"minecraft:cyan_glazed_terracotta":[15002,15002,20],"minecraft:purple_glazed_terracotta":[15006,15006,20],"minecraft:blue_glazed_terracotta":[15010,15010,20],"minecraft:brown_glazed_terracotta":[15014,15014,20],"minecraft:green_glazed_terracotta":[15018,15018,20],"minecraft:red_glazed_terracotta":[15022,15022,20],"minecraft:black_glazed_terracotta":[15026,15026,20],"minecraft:white_concrete":[15030,15030,0],"minecraft:orange_concrete":[15031,15031,0],"minecraft:magenta_concrete":[15032,15032,0],"minecraft:light_blue_concrete":[15033,15033,0],"minecraft:yellow_concrete":[15034,15034,0],"minecraft:lime_concrete":[15035,15035,0],"minecraft:pink_concrete":[15036,15036,0],"minecraft:gray_concrete":[15037,15037,0],"minecraft:light_gray_concrete":[15038,15038,0],"minecraft:cyan_concrete":[15039,15039,0],"minecraft:purple_concrete":[15040,15040,0],"minecraft:blue_concrete":[15041,15041,0],"minecraft:brown_concrete":[15042,15042,0],"minecraft:green_concrete":[15043,15043,0],"minecraft:red_concrete":[15044,15044,0],"minecraft:black_concrete":[15045,15045,0],"minecraft:white_concrete_powder":[15046,15046,0],"minecraft:orange_concrete_powder":[15047,15047,0],"minecraft:magenta_concrete_powder":[15048,15048,0],"minecraft:light_blue_concrete_powder":[15049,15049,0],"minecraft:yellow_concrete_powder":[15050,15050,0],"minecraft:lime_concrete_powder":[15051,15051,0],"minecraft:pink_concrete_powder":[15052,15052,0],"minecraft:gray_concrete_powder":[15053,15053,0],"minecraft:light_gray_concrete_powder":[15054,15054,0],"minecraft:cyan_concrete_powder":[15055,15055,0],"minecraft:purple_concrete_powder":[15056,15056,0],"minecraft:blue_concrete_powder":[15057,15057,0],"minecraft:brown_concrete_powder":[15058,15058,0],"minecraft:green_concrete_powder":[15059,15059,0],"minecraft:red_concrete_powder":[15060,15060,0],"minecraft:black_concrete_powder":[15061,15061,0],"minecraft:kelp":[15062,15062,73],"minecraft:kelp_plant":[15088,15088,0],"minecraft:dried_kelp_block":[15089,15089,0],"minecraft:turtle_egg":[15090,15090,74],"minecraft:sniffer_egg":[15102,15102,75],"minecraft:dried_ghast":[15105,15106,76],"minecraft:dead_tube_coral_block":[15137,15137,0],"minecraft:dead_brain_coral_block":[15138,15138,0],"minecraft:dead_bubble_coral_block":[15139,15139,0],"minecraft:dead_fire_coral_block":[15140,15140,0],"minecraft:dead_horn_coral_block":[15141,15141,0],"minecraft:tube_coral_block":[15142,15142,0],"minecraft:brain_coral_block":[15143,15143,0],"minecraft:bubble_coral_block":[15144,15144,0],"minecraft:fire_coral_block":[15145,15145,0],"minecraft:horn_coral_block":[15146,15146,0],"minecraft:dead_tube_coral":[15147,15147,7],"minecraft:dead_brain_coral":[15149,15149,7],"minecraft:dead_bubble_coral":[15151,15151,7],"minecraft:dead_fire_coral":[15153,15153,7],"minecraft:dead_horn_coral":[15155,15155,7],"minecraft:tube_coral":[15157,15157,7],"minecraft:brain_coral":[15159,15159,7],"minecraft:bubble_coral":[15161,15161,7],"minecraft:fire_coral":[15163,15163,7],"minecraft:horn_coral":[15165,15165,7],"minecraft:dead_tube_coral_fan":[15167,15167,7],"minecraft:dead_brain_coral_fan":[15169,15169,7],"minecraft:dead_bubble_coral_fan":[15171,15171,7],"minecraft:dead_fire_coral_fan":[15173,15173,7],"minecraft:dead_horn_coral_fan":[15175,15175,7],"minecraft:tube_coral_fan":[15177,15177,7],"minecraft:brain_coral_fan":[15179,15179,7],"minecraft:bubble_coral_fan":[15181,15181,7],"minecraft:fire_coral_fan":[15183,15183,7],"minecraft:horn_coral_fan":[15185,15185,7],"minecraft:dead_tube_coral_wall_fan":[15187,15187,31],"minecraft:dead_brain_coral_wall_fan":[15195,15195,31],"minecraft:dead_bubble_coral_wall_fan":[15203,15203,31],"minecraft:dead_fire_coral_wall_fan":[15211,15211,31],"minecraft:dead_horn_coral_wall_fan":[15219,15219,31],"minecraft:tube_coral_wall_fan":[15227,15227,31],"minecraft:brain_coral_wall_fan":[15235,15235,31],"minecraft:bubble_coral_wall_fan":[15243,15243,31],"minecraft:fire_coral_wall_fan":[15251,15251,31],"minecraft:horn_coral_wall_fan":[15259,15259,31],"minecraft:sea_pickle":[15267,15267,77],"minecraft:blue_ice":[15275,15275,0],"minecraft:conduit":[15276,15276,7],"minecraft:bamboo_sapling":[15278,15278,0],"minecraft:bamboo":[15279,15279,78],"minecraft:potted_bamboo":[15291,15291,0],"minecraft:void_air":[15292,15292,0],"minecraft:cave_air":[15293,15293,0],"minecraft:bubble_column":[15294,15294,79],"minecraft:polished_granite_stairs":[15296,15307,23],"minecraft:smooth_red_sandstone_stairs":[15376,15387,23],"minecraft:mossy_stone_brick_stairs":[15456,15467,23],"minecraft:polished_diorite_stairs":[15536,15547,23],"minecraft:mossy_cobblestone_stairs":[15616,15627,23],"minecraft:end_stone_brick_stairs":[15696,15707,23],"minecraft:stone_stairs":[15776,15787,23],"minecraft:smooth_sandstone_stairs":[15856,15867,23],"minecraft:smooth_quartz_stairs":[15936,15947,23],"minecraft:granite_stairs":[16016,16027,23],"minecraft:andesite_stairs":[16096,16107,23],"minecraft:red_nether_brick_stairs":[16176,16187,23],"minecraft:polished_andesite_stairs":[16256,16267,23],"minecraft:diorite_stairs":[16336,16347,23],"minecraft:polished_granite_slab":[16416,16419,50],"minecraft:smooth_red_sandstone_slab":[16422,16425,50],"minecraft:mossy_stone_brick_slab":[16428,16431,50],"minecraft:polished_diorite_slab":[16434,16437,50],"minecraft:mossy_cobblestone_slab":[16440,16443,50],"minecraft:end_stone_brick_slab":[16446,16449,50],"minecraft:smooth_sandstone_slab":[16452,16455,50],"minecraft:smooth_quartz_slab":[16458,16461,50],"minecraft:granite_slab":[16464,16467,50],"minecraft:andesite_slab":[16470,16473,50],"minecraft:red_nether_brick_slab":[16476,16479,50],"minecraft:polished_andesite_slab":[16482,16485,50],"minecraft:diorite_slab":[16488,16491,50],"minecraft:brick_wall":[16494,16497,51],"minecraft:prismarine_wall":[16818,16821,51],"minecraft:red_sandstone_wall":[17142,17145,51],"minecraft:mossy_stone_brick_wall":[17466,17469,51],"minecraft:granite_wall":[17790,17793,51],"minecraft:stone_brick_wall":[18114,18117,51],"minecraft:mud_brick_wall":[18438,18441,51],"minecraft:nether_brick_wall":[18762,18765,51],"minecraft:andesite_wall":[19086,19089,51],"minecraft:red_nether_brick_wall":[19410,19413,51],"minecraft:sandstone_wall":[19734,19737,51],"minecraft:end_stone_brick_wall":[20058,20061,51],"minecraft:diorite_wall":[20382,20385,51],"minecraft:scaffolding":[20706,20737,80],"minecraft:loom":[20738,20738,20],"minecraft:barrel":[20742,20743,81],"minecraft:smoker":[20754,20755,28],"minecraft:blast_furnace":[20762,20763,28],"minecraft:cartography_table":[20770,20770,0],"minecraft:fletching_table":[20771,20771,0],"minecraft:grindstone":[20772,20776,82],"minecraft:lectern":[20784,20787,83],"minecraft:smithing_table":[20800,20800,0],"minecraft:stonecutter":[20801,20801,20],"minecraft:bell":[20805,20806,84],"minecraft:lantern":[20837,20840,85],"minecraft:soul_lantern":[20841,20844,85],"minecraft:copper_lantern":[20845,20848,85],"minecraft:exposed_copper_lantern":[20849,20852,85],"minecraft:weathered_copper_lantern":[20853,20856,85],"minecraft:oxidized_copper_lantern":[20857,20860,85],"minecraft:waxed_copper_lantern":[20861,20864,85],"minecraft:waxed_exposed_copper_lantern":[20865,20868,85],"minecraft:waxed_weathered_copper_lantern":[20869,20872,85],"minecraft:waxed_oxidized_copper_lantern":[20873,20876,85],"minecraft:campfire":[20877,20880,86],"minecraft:soul_campfire":[20909,20912,86],"minecraft:sweet_berry_bush":[20941,20941,52],"minecraft:warped_stem":[20945,20946,2],"minecraft:stripped_warped_stem":[20948,20949,2],"minecraft:warped_hyphae":[20951,20952,2],"minecraft:stripped_warped_hyphae":[20954,20955,2],"minecraft:warped_nylium":[20957,20957,0],"minecraft:warped_fungus":[20958,20958,0],"minecraft:warped_wart_block":[20959,20959,0],"minecraft:warped_roots":[20960,20960,0],"minecraft:nether_sprouts":[20961,20961,0],"minecraft:crimson_stem":[20962,20963,2],"minecraft:stripped_crimson_stem":[20965,20966,2],"minecraft:crimson_hyphae":[20968,20969,2],"minecraft:stripped_crimson_hyphae":[20971,20972,2],"minecraft:crimson_nylium":[20974,20974,0],"minecraft:crimson_fungus":[20975,20975,0],"minecraft:shroomlight":[20976,20976,0],"minecraft:weeping_vines":[20977,20977,73],"minecraft:weeping_vines_plant":[21003,21003,0],"minecraft:twisting_vines":[21004,21004,73],"minecraft:twisting_vines_plant":[21030,21030,0],"minecraft:crimson_roots":[21031,21031,0],"minecraft:crimson_planks":[21032,21032,0],"minecraft:warped_planks":[21033,21033,0],"minecraft:crimson_slab":[21034,21037,50],"minecraft:warped_slab":[21040,21043,50],"minecraft:crimson_pressure_plate":[21046,21047,35],"minecraft:warped_pressure_plate":[21048,21049,35],"minecraft:crimson_fence":[21050,21081,40],"minecraft:warped_fence":[21082,21113,40],"minecraft:crimson_trapdoor":[21114,21129,44],"minecraft:warped_trapdoor":[21178,21193,44],"minecraft:crimson_fence_gate":[21242,21249,49],"minecraft:warped_fence_gate":[21274,21281,49],"minecraft:crimson_stairs":[21306,21317,23],"minecraft:warped_stairs":[21386,21397,23],"minecraft:crimson_button":[21466,21475,34],"minecraft:warped_button":[21490,21499,34],"minecraft:crimson_door":[21514,21525,30],"minecraft:warped_door":[21578,21589,30],"minecraft:crimson_sign":[21642,21659,29],"minecraft:warped_sign":[21674,21691,29],"minecraft:crimson_wall_sign":[21706,21707,31],"minecraft:warped_wall_sign":[21714,21715,31],"minecraft:structure_block":[21722,21723,87],"minecraft:jigsaw":[21726,21736,88],"minecraft:test_block":[21738,21738,89],"minecraft:test_instance_block":[21742,21742,0],"minecraft:composter":[21743,21743,90],"minecraft:target":[21752,21752,62],"minecraft:bee_nest":[21768,21768,91],"minecraft:beehive":[21792,21792,91],"minecraft:honey_block":[21816,21816,0],"minecraft:honeycomb_block":[21817,21817,0],"minecraft:netherite_block":[21818,21818,0],"minecraft:ancient_debris":[21819,21819,0],"minecraft:crying_obsidian":[21820,21820,0],"minecraft:respawn_anchor":[21821,21821,92],"minecraft:potted_crimson_fungus":[21826,21826,0],"minecraft:potted_warped_fungus":[21827,21827,0],"minecraft:potted_crimson_roots":[21828,21828,0],"minecraft:potted_warped_roots":[21829,21829,0],"minecraft:lodestone":[21830,21830,0],"minecraft:blackstone":[21831,21831,0],"minecraft:blackstone_stairs":[21832,21843,23],"minecraft:blackstone_wall":[21912,21915,51],"minecraft:blackstone_slab":[22236,22239,50],"minecraft:polished_blackstone":[22242,22242,0],"minecraft:polished_blackstone_bricks":[22243,22243,0],"minecraft:cracked_polished_blackstone_bricks":[22244,22244,0],"minecraft:chiseled_polished_blackstone":[22245,22245,0],"minecraft:polished_blackstone_brick_slab":[22246,22249,50],"minecraft:polished_blackstone_brick_stairs":[22252,22263,23],"minecraft:polished_blackstone_brick_wall":[22332,22335,51],"minecraft:gilded_blackstone":[22656,22656,0],"minecraft:polished_blackstone_stairs":[22657,22668,23],"minecraft:polished_blackstone_slab":[22737,22740,50],"minecraft:polished_blackstone_pressure_plate":[22743,22744,35],"minecraft:polished_blackstone_button":[22745,22754,34],"minecraft:polished_blackstone_wall":[22769,22772,51],"minecraft:chiseled_nether_bricks":[23093,23093,0],"minecraft:cracked_nether_bricks":[23094,23094,0],"minecraft:quartz_bricks":[23095,23095,0],"minecraft:candle":[23096,23099,93],"minecraft:white_candle":[23112,23115,93],"minecraft:orange_candle":[23128,23131,93],"minecraft:magenta_candle":[23144,23147,93],"minecraft:light_blue_candle":[23160,23163,93],"minecraft:yellow_candle":[23176,23179,93],"minecraft:lime_candle":[23192,23195,93],"minecraft:pink_candle":[23208,23211,93],"minecraft:gray_candle":[23224,23227,93],"minecraft:light_gray_candle":[23240,23243,93],"minecraft:cyan_candle":[23256,23259,93],"minecraft:purple_candle":[23272,23275,93],"minecraft:blue_candle":[23288,23291,93],"minecraft:brown_candle":[23304,23307,93],"minecraft:green_candle":[23320,23323,93],"minecraft:red_candle":[23336,23339,93],"minecraft:black_candle":[23352,23355,93],"minecraft:candle_cake":[23368,23369,36],"minecraft:white_candle_cake":[23370,23371,36],"minecraft:orange_candle_cake":[23372,23373,36],"minecraft:magenta_candle_cake":[23374,23375,36],"minecraft:light_blue_candle_cake":[23376,23377,36],"minecraft:yellow_candle_cake":[23378,23379,36],"minecraft:lime_candle_cake":[23380,23381,36],"minecraft:pink_candle_cake":[23382,23383,36],"minecraft:gray_candle_cake":[23384,23385,36],"minecraft:light_gray_candle_cake":[23386,23387,36],"minecraft:cyan_candle_cake":[23388,23389,36],"minecraft:purple_candle_cake":[23390,23391,36],"minecraft:blue_candle_cake":[23392,23393,36],"minecraft:brown_candle_cake":[23394,23395,36],"minecraft:green_candle_cake":[23396,23397,36],"minecraft:red_candle_cake":[23398,23399,36],"minecraft:black_candle_cake":[23400,23401,36],"minecraft:amethyst_block":[23402,23402,0],"minecraft:budding_amethyst":[23403,23403,0],"minecraft:amethyst_cluster":[23404,23413,94],"minecraft:large_amethyst_bud":[23416,23425,94],"minecraft:medium_amethyst_bud":[23428,23437,94],"minecraft:small_amethyst_bud":[23440,23449,94],"minecraft:tuff":[23452,23452,0],"minecraft:tuff_slab":[23453,23456,50],"minecraft:tuff_stairs":[23459,23470,23],"minecraft:tuff_wall":[23539,23542,51],"minecraft:polished_tuff":[23863,23863,0],"minecraft:polished_tuff_slab":[23864,23867,50],"minecraft:polished_tuff_stairs":[23870,23881,23],"minecraft:polished_tuff_wall":[23950,23953,51],"minecraft:chiseled_tuff":[24274,24274,0],"minecraft:tuff_bricks":[24275,24275,0],"minecraft:tuff_brick_slab":[24276,24279,50],"minecraft:tuff_brick_stairs":[24282,24293,23],"minecraft:tuff_brick_wall":[24362,24365,51],"minecraft:chiseled_tuff_bricks":[24686,24686,0],"minecraft:sulfur":[24687,24687,0],"minecraft:potent_sulfur":[24688,24688,95],"minecraft:sulfur_slab":[24693,24696,50],"minecraft:sulfur_stairs":[24699,24710,23],"minecraft:sulfur_wall":[24779,24782,51],"minecraft:polished_sulfur":[25103,25103,0],"minecraft:polished_sulfur_slab":[25104,25107,50],"minecraft:polished_sulfur_stairs":[25110,25121,23],"minecraft:polished_sulfur_wall":[25190,25193,51],"minecraft:sulfur_bricks":[25514,25514,0],"minecraft:sulfur_brick_slab":[25515,25518,50],"minecraft:sulfur_brick_stairs":[25521,25532,23],"minecraft:sulfur_brick_wall":[25601,25604,51],"minecraft:chiseled_sulfur":[25925,25925,0],"minecraft:cinnabar":[25926,25926,0],"minecraft:cinnabar_slab":[25927,25930,50],"minecraft:cinnabar_stairs":[25933,25944,23],"minecraft:cinnabar_wall":[26013,26016,51],"minecraft:polished_cinnabar":[26337,26337,0],"minecraft:polished_cinnabar_slab":[26338,26341,50],"minecraft:polished_cinnabar_stairs":[26344,26355,23],"minecraft:polished_cinnabar_wall":[26424,26427,51],"minecraft:cinnabar_bricks":[26748,26748,0],"minecraft:cinnabar_brick_slab":[26749,26752,50],"minecraft:cinnabar_brick_stairs":[26755,26766,23],"minecraft:cinnabar_brick_wall":[26835,26838,51],"minecraft:chiseled_cinnabar":[27159,27159,0],"minecraft:calcite":[27160,27160,0],"minecraft:tinted_glass":[27161,27161,0],"minecraft:powder_snow":[27162,27162,0],"minecraft:sculk_sensor":[27163,27164,96],"minecraft:calibrated_sculk_sensor":[27259,27260,97],"minecraft:sculk":[27643,27643,0],"minecraft:sculk_vein":[27644,27771,48],"minecraft:sculk_catalyst":[27772,27773,98],"minecraft:sculk_shrieker":[27774,27781,99],"minecraft:copper_block":[27782,27782,0],"minecraft:exposed_copper":[27783,27783,0],"minecraft:weathered_copper":[27784,27784,0],"minecraft:oxidized_copper":[27785,27785,0],"minecraft:waxed_copper_block":[27786,27786,0],"minecraft:waxed_exposed_copper":[27787,27787,0],"minecraft:waxed_weathered_copper":[27788,27788,0],"minecraft:waxed_oxidized_copper":[27789,27789,0],"minecraft:copper_ore":[27790,27790,0],"minecraft:deepslate_copper_ore":[27791,27791,0],"minecraft:cut_copper":[27792,27792,0],"minecraft:exposed_cut_copper":[27793,27793,0],"minecraft:weathered_cut_copper":[27794,27794,0],"minecraft:oxidized_cut_copper":[27795,27795,0],"minecraft:waxed_cut_copper":[27796,27796,0],"minecraft:waxed_exposed_cut_copper":[27797,27797,0],"minecraft:waxed_weathered_cut_copper":[27798,27798,0],"minecraft:waxed_oxidized_cut_copper":[27799,27799,0],"minecraft:chiseled_copper":[27800,27800,0],"minecraft:exposed_chiseled_copper":[27801,27801,0],"minecraft:weathered_chiseled_copper":[27802,27802,0],"minecraft:oxidized_chiseled_copper":[27803,27803,0],"minecraft:waxed_chiseled_copper":[27804,27804,0],"minecraft:waxed_exposed_chiseled_copper":[27805,27805,0],"minecraft:waxed_weathered_chiseled_copper":[27806,27806,0],"minecraft:waxed_oxidized_chiseled_copper":[27807,27807,0],"minecraft:cut_copper_stairs":[27808,27819,23],"minecraft:exposed_cut_copper_stairs":[27888,27899,23],"minecraft:weathered_cut_copper_stairs":[27968,27979,23],"minecraft:oxidized_cut_copper_stairs":[28048,28059,23],"minecraft:waxed_cut_copper_stairs":[28128,28139,23],"minecraft:waxed_exposed_cut_copper_stairs":[28208,28219,23],"minecraft:waxed_weathered_cut_copper_stairs":[28288,28299,23],"minecraft:waxed_oxidized_cut_copper_stairs":[28368,28379,23],"minecraft:cut_copper_slab":[28448,28451,50],"minecraft:exposed_cut_copper_slab":[28454,28457,50],"minecraft:weathered_cut_copper_slab":[28460,28463,50],"minecraft:oxidized_cut_copper_slab":[28466,28469,50],"minecraft:waxed_cut_copper_slab":[28472,28475,50],"minecraft:waxed_exposed_cut_copper_slab":[28478,28481,50],"minecraft:waxed_weathered_cut_copper_slab":[28484,28487,50],"minecraft:waxed_oxidized_cut_copper_slab":[28490,28493,50],"minecraft:copper_door":[28496,28507,30],"minecraft:exposed_copper_door":[28560,28571,30],"minecraft:weathered_copper_door":[28624,28635,30],"minecraft:oxidized_copper_door":[28688,28699,30],"minecraft:waxed_copper_door":[28752,28763,30],"minecraft:waxed_exposed_copper_door":[28816,28827,30],"minecraft:waxed_weathered_copper_door":[28880,28891,30],"minecraft:waxed_oxidized_copper_door":[28944,28955,30],"minecraft:copper_trapdoor":[29008,29023,44],"minecraft:exposed_copper_trapdoor":[29072,29087,44],"minecraft:weathered_copper_trapdoor":[29136,29151,44],"minecraft:oxidized_copper_trapdoor":[29200,29215,44],"minecraft:waxed_copper_trapdoor":[29264,29279,44],"minecraft:waxed_exposed_copper_trapdoor":[29328,29343,44],"minecraft:waxed_weathered_copper_trapdoor":[29392,29407,44],"minecraft:waxed_oxidized_copper_trapdoor":[29456,29471,44],"minecraft:copper_grate":[29520,29521,7],"minecraft:exposed_copper_grate":[29522,29523,7],"minecraft:weathered_copper_grate":[29524,29525,7],"minecraft:oxidized_copper_grate":[29526,29527,7],"minecraft:waxed_copper_grate":[29528,29529,7],"minecraft:waxed_exposed_copper_grate":[29530,29531,7],"minecraft:waxed_weathered_copper_grate":[29532,29533,7],"minecraft:waxed_oxidized_copper_grate":[29534,29535,7],"minecraft:copper_bulb":[29536,29539,100],"minecraft:exposed_copper_bulb":[29540,29543,100],"minecraft:weathered_copper_bulb":[29544,29547,100],"minecraft:oxidized_copper_bulb":[29548,29551,100],"minecraft:waxed_copper_bulb":[29552,29555,100],"minecraft:waxed_exposed_copper_bulb":[29556,29559,100],"minecraft:waxed_weathered_copper_bulb":[29560,29563,100],"minecraft:waxed_oxidized_copper_bulb":[29564,29567,100],"minecraft:copper_chest":[29568,29569,24],"minecraft:exposed_copper_chest":[29592,29593,24],"minecraft:weathered_copper_chest":[29616,29617,24],"minecraft:oxidized_copper_chest":[29640,29641,24],"minecraft:waxed_copper_chest":[29664,29665,24],"minecraft:waxed_exposed_copper_chest":[29688,29689,24],"minecraft:waxed_weathered_copper_chest":[29712,29713,24],"minecraft:waxed_oxidized_copper_chest":[29736,29737,24],"minecraft:copper_golem_statue":[29760,29761,101],"minecraft:exposed_copper_golem_statue":[29792,29793,101],"minecraft:weathered_copper_golem_statue":[29824,29825,101],"minecraft:oxidized_copper_golem_statue":[29856,29857,101],"minecraft:waxed_copper_golem_statue":[29888,29889,101],"minecraft:waxed_exposed_copper_golem_statue":[29920,29921,101],"minecraft:waxed_weathered_copper_golem_statue":[29952,29953,101],"minecraft:waxed_oxidized_copper_golem_statue":[29984,29985,101],"minecraft:lightning_rod":[30016,30035,102],"minecraft:exposed_lightning_rod":[30040,30059,102],"minecraft:weathered_lightning_rod":[30064,30083,102],"minecraft:oxidized_lightning_rod":[30088,30107,102],"minecraft:waxed_lightning_rod":[30112,30131,102],"minecraft:waxed_exposed_lightning_rod":[30136,30155,102],"minecraft:waxed_weathered_lightning_rod":[30160,30179,102],"minecraft:waxed_oxidized_lightning_rod":[30184,30203,102],"minecraft:dripstone_block":[30208,30208,0],"minecraft:pointed_dripstone":[30209,30214,103],"minecraft:sulfur_spike":[30229,30234,103],"minecraft:cave_vines":[30249,30250,104],"minecraft:cave_vines_plant":[30301,30302,105],"minecraft:spore_blossom":[30303,30303,0],"minecraft:azalea":[30304,30304,0],"minecraft:flowering_azalea":[30305,30305,0],"minecraft:moss_carpet":[30306,30306,0],"minecraft:pink_petals":[30307,30307,106],"minecraft:wildflowers":[30323,30323,106],"minecraft:leaf_litter":[30339,30339,107],"minecraft:moss_block":[30355,30355,0],"minecraft:big_dripleaf":[30356,30357,108],"minecraft:big_dripleaf_stem":[30388,30389,31],"minecraft:small_dripleaf":[30396,30399,109],"minecraft:hanging_roots":[30412,30413,7],"minecraft:rooted_dirt":[30414,30414,0],"minecraft:mud":[30415,30415,0],"minecraft:deepslate":[30416,30417,2],"minecraft:cobbled_deepslate":[30419,30419,0],"minecraft:cobbled_deepslate_stairs":[30420,30431,23],"minecraft:cobbled_deepslate_slab":[30500,30503,50],"minecraft:cobbled_deepslate_wall":[30506,30509,51],"minecraft:polished_deepslate":[30830,30830,0],"minecraft:polished_deepslate_stairs":[30831,30842,23],"minecraft:polished_deepslate_slab":[30911,30914,50],"minecraft:polished_deepslate_wall":[30917,30920,51],"minecraft:deepslate_tiles":[31241,31241,0],"minecraft:deepslate_tile_stairs":[31242,31253,23],"minecraft:deepslate_tile_slab":[31322,31325,50],"minecraft:deepslate_tile_wall":[31328,31331,51],"minecraft:deepslate_bricks":[31652,31652,0],"minecraft:deepslate_brick_stairs":[31653,31664,23],"minecraft:deepslate_brick_slab":[31733,31736,50],"minecraft:deepslate_brick_wall":[31739,31742,51],"minecraft:chiseled_deepslate":[32063,32063,0],"minecraft:cracked_deepslate_bricks":[32064,32064,0],"minecraft:cracked_deepslate_tiles":[32065,32065,0],"minecraft:infested_deepslate":[32066,32067,2],"minecraft:smooth_basalt":[32069,32069,0],"minecraft:raw_iron_block":[32070,32070,0],"minecraft:raw_copper_block":[32071,32071,0],"minecraft:raw_gold_block":[32072,32072,0],"minecraft:potted_azalea_bush":[32073,32073,0],"minecraft:potted_flowering_azalea_bush":[32074,32074,0],"minecraft:ochre_froglight":[32075,32076,2],"minecraft:verdant_froglight":[32078,32079,2],"minecraft:pearlescent_froglight":[32081,32082,2],"minecraft:frogspawn":[32084,32084,0],"minecraft:reinforced_deepslate":[32085,32085,0],"minecraft:decorated_pot":[32086,32095,110],"minecraft:crafter":[32102,32147,111],"minecraft:trial_spawner":[32150,32156,112],"minecraft:vault":[32162,32166,113],"minecraft:heavy_core":[32194,32195,7],"minecraft:pale_moss_block":[32196,32196,0],"minecraft:pale_moss_carpet":[32197,32197,114],"minecraft:pale_hanging_moss":[32359,32359,115],"minecraft:open_eyeblossom":[32361,32361,0],"minecraft:closed_eyeblossom":[32362,32362,0],"minecraft:potted_open_eyeblossom":[32363,32363,0],"minecraft:potted_closed_eyeblossom":[32364,32364,0],"minecraft:firefly_bush":[32365,32365,0]},"schemas":[[],[["snowy",["true","false"]]],[["axis",["x","y","z"]]],[["stage",["0","1"]]],[["age",["0","1","2","3","4"]],["hanging",["true","false"]],["stage",["0","1"]],["waterlogged",["true","false"]]],[["level",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]]],[["dusted",["0","1","2","3"]]],[["waterlogged",["true","false"]]],[["distance",["1","2","3","4","5","6","7"]],["persistent",["true","false"]],["waterlogged",["true","false"]]],[["facing",["north","east","south","west","up","down"]],["triggered",["true","false"]]],[["instrument",["harp","basedrum","snare","hat","bass","flute","bell","guitar","chime","xylophone","iron_xylophone","cow_bell","didgeridoo","bit","banjo","pling","trumpet","trumpet_exposed","trumpet_oxidized","trumpet_weathered","zombie","skeleton","creeper","dragon","wither_skeleton","piglin","custom_head"]],["note",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22","23","24"]],["powered",["true","false"]]],[["facing",["north","south","west","east"]],["occupied",["true","false"]],["part",["head","foot"]]],[["powered",["true","false"]],["shape",["north_south","east_west","ascending_east","ascending_west","ascending_north","ascending_south"]],["waterlogged",["true","false"]]],[["extended",["true","false"]],["facing",["north","east","south","west","up","down"]]],[["half",["upper","lower"]]],[["facing",["north","east","south","west","up","down"]],["short",["true","false"]],["type",["normal","sticky"]]],[["facing",["north","east","south","west","up","down"]],["type",["normal","sticky"]]],[["unstable",["true","false"]]],[["facing",["north","south","west","east"]],["slot_0_occupied",["true","false"]],["slot_1_occupied",["true","false"]],["slot_2_occupied",["true","false"]],["slot_3_occupied",["true","false"]],["slot_4_occupied",["true","false"]],["slot_5_occupied",["true","false"]]],[["facing",["north","south","west","east"]],["powered",["true","false"]],["side_chain",["unconnected","right","center","left"]],["waterlogged",["true","false"]]],[["facing",["north","south","west","east"]]],[["age",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]],["east",["true","false"]],["north",["true","false"]],["south",["true","false"]],["up",["true","false"]],["west",["true","false"]]],[["axis",["x","y","z"]],["creaking_heart_state",["uprooted","dormant","awake"]],["natural",["true","false"]]],[["facing",["north","south","west","east"]],["half",["top","bottom"]],["shape",["straight","inner_left","inner_right","outer_left","outer_right"]],["waterlogged",["true","false"]]],[["facing",["north","south","west","east"]],["type",["single","left","right"]],["waterlogged",["true","false"]]],[["east",["up","side","none"]],["north",["up","side","none"]],["power",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]],["south",["up","side","none"]],["west",["up","side","none"]]],[["age",["0","1","2","3","4","5","6","7"]]],[["moisture",["0","1","2","3","4","5","6","7"]]],[["facing",["north","south","west","east"]],["lit",["true","false"]]],[["rotation",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]],["waterlogged",["true","false"]]],[["facing",["north","south","west","east"]],["half",["upper","lower"]],["hinge",["left","right"]],["open",["true","false"]],["powered",["true","false"]]],[["facing",["north","south","west","east"]],["waterlogged",["true","false"]]],[["shape",["north_south","east_west","ascending_east","ascending_west","ascending_north","ascending_south","south_east","south_west","north_west","north_east"]],["waterlogged",["true","false"]]],[["attached",["true","false"]],["rotation",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]],["waterlogged",["true","false"]]],[["face",["floor","wall","ceiling"]],["facing",["north","south","west","east"]],["powered",["true","false"]]],[["powered",["true","false"]]],[["lit",["true","false"]]],[["layers",["1","2","3","4","5","6","7","8"]]],[["age",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]]],[["has_record",["true","false"]]],[["east",["true","false"]],["north",["true","false"]],["south",["true","false"]],["waterlogged",["true","false"]],["west",["true","false"]]],[["axis",["x","z"]]],[["bites",["0","1","2","3","4","5","6"]]],[["delay",["1","2","3","4"]],["facing",["north","south","west","east"]],["locked",["true","false"]],["powered",["true","false"]]],[["facing",["north","south","west","east"]],["half",["top","bottom"]],["open",["true","false"]],["powered",["true","false"]],["waterlogged",["true","false"]]],[["down",["true","false"]],["east",["true","false"]],["north",["true","false"]],["south",["true","false"]],["up",["true","false"]],["west",["true","false"]]],[["axis",["x","y","z"]],["waterlogged",["true","false"]]],[["east",["true","false"]],["north",["true","false"]],["south",["true","false"]],["up",["true","false"]],["west",["true","false"]]],[["down",["true","false"]],["east",["true","false"]],["north",["true","false"]],["south",["true","false"]],["up",["true","false"]],["waterlogged",["true","false"]],["west",["true","false"]]],[["facing",["north","south","west","east"]],["in_wall",["true","false"]],["open",["true","false"]],["powered",["true","false"]]],[["type",["top","bottom","double"]],["waterlogged",["true","false"]]],[["east",["none","low","tall"]],["north",["none","low","tall"]],["south",["none","low","tall"]],["up",["true","false"]],["waterlogged",["true","false"]],["west",["none","low","tall"]]],[["age",["0","1","2","3"]]],[["has_bottle_0",["true","false"]],["has_bottle_1",["true","false"]],["has_bottle_2",["true","false"]]],[["level",["1","2","3"]]],[["eye",["true","false"]],["facing",["north","south","west","east"]]],[["age",["0","1","2"]],["facing",["north","south","west","east"]]],[["attached",["true","false"]],["facing",["north","south","west","east"]],["powered",["true","false"]]],[["attached",["true","false"]],["disarmed",["true","false"]],["east",["true","false"]],["north",["true","false"]],["powered",["true","false"]],["south",["true","false"]],["west",["true","false"]]],[["conditional",["true","false"]],["facing",["north","east","south","west","up","down"]]],[["powered",["true","false"]],["rotation",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]]],[["facing",["north","south","west","east"]],["powered",["true","false"]]],[["power",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]]],[["facing",["north","south","west","east"]],["mode",["compare","subtract"]],["powered",["true","false"]]],[["inverted",["true","false"]],["power",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]]],[["enabled",["true","false"]],["facing",["down","north","south","west","east"]]],[["level",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]],["waterlogged",["true","false"]]],[["rotation",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]]],[["facing",["north","east","south","west","up","down"]]],[["age",["0","1","2","3","4","5"]]],[["age",["0","1"]]],[["age",["0","1","2","3","4"]],["half",["upper","lower"]]],[["facing",["north","east","south","west","up","down"]],["powered",["true","false"]]],[["age",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22","23","24","25"]]],[["eggs",["1","2","3","4"]],["hatch",["0","1","2"]]],[["hatch",["0","1","2"]]],[["facing",["north","south","west","east"]],["hydration",["0","1","2","3"]],["waterlogged",["true","false"]]],[["pickles",["1","2","3","4"]],["waterlogged",["true","false"]]],[["age",["0","1"]],["leaves",["none","small","large"]],["stage",["0","1"]]],[["drag",["true","false"]]],[["bottom",["true","false"]],["distance",["0","1","2","3","4","5","6","7"]],["waterlogged",["true","false"]]],[["facing",["north","east","south","west","up","down"]],["open",["true","false"]]],[["face",["floor","wall","ceiling"]],["facing",["north","south","west","east"]]],[["facing",["north","south","west","east"]],["has_book",["true","false"]],["powered",["true","false"]]],[["attachment",["floor","ceiling","single_wall","double_wall"]],["facing",["north","south","west","east"]],["powered",["true","false"]]],[["hanging",["true","false"]],["waterlogged",["true","false"]]],[["facing",["north","south","west","east"]],["lit",["true","false"]],["signal_fire",["true","false"]],["waterlogged",["true","false"]]],[["mode",["save","load","corner","data"]]],[["orientation",["down_east","down_north","down_south","down_west","up_east","up_north","up_south","up_west","west_up","east_up","north_up","south_up"]]],[["mode",["start","log","fail","accept"]]],[["level",["0","1","2","3","4","5","6","7","8"]]],[["facing",["north","south","west","east"]],["honey_level",["0","1","2","3","4","5"]]],[["charges",["0","1","2","3","4"]]],[["candles",["1","2","3","4"]],["lit",["true","false"]],["waterlogged",["true","false"]]],[["facing",["north","east","south","west","up","down"]],["waterlogged",["true","false"]]],[["potent_sulfur_state",["dry","wet","dormant","erupting","continuous"]]],[["power",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]],["sculk_sensor_phase",["inactive","active","cooldown"]],["waterlogged",["true","false"]]],[["facing",["north","south","west","east"]],["power",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]],["sculk_sensor_phase",["inactive","active","cooldown"]],["waterlogged",["true","false"]]],[["bloom",["true","false"]]],[["can_summon",["true","false"]],["shrieking",["true","false"]],["waterlogged",["true","false"]]],[["lit",["true","false"]],["powered",["true","false"]]],[["copper_golem_pose",["standing","sitting","running","star"]],["facing",["north","south","west","east"]],["waterlogged",["true","false"]]],[["facing",["north","east","south","west","up","down"]],["powered",["true","false"]],["waterlogged",["true","false"]]],[["thickness",["tip_merge","tip","frustum","middle","base"]],["vertical_direction",["up","down"]],["waterlogged",["true","false"]]],[["age",["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22","23","24","25"]],["berries",["true","false"]]],[["berries",["true","false"]]],[["facing",["north","south","west","east"]],["flower_amount",["1","2","3","4"]]],[["facing",["north","south","west","east"]],["segment_amount",["1","2","3","4"]]],[["facing",["north","south","west","east"]],["tilt",["none","unstable","partial","full"]],["waterlogged",["true","false"]]],[["facing",["north","south","west","east"]],["half",["upper","lower"]],["waterlogged",["true","false"]]],[["cracked",["true","false"]],["facing",["north","south","west","east"]],["waterlogged",["true","false"]]],[["crafting",["true","false"]],["orientation",["down_east","down_north","down_south","down_west","up_east","up_north","up_south","up_west","west_up","east_up","north_up","south_up"]],["triggered",["true","false"]]],[["ominous",["true","false"]],["trial_spawner_state",["inactive","waiting_for_players","active","waiting_for_reward_ejection","ejecting_reward","cooldown"]]],[["facing",["north","south","west","east"]],["ominous",["true","false"]],["vault_state",["inactive","active","unlocking","ejecting"]]],[["bottom",["true","false"]],["east",["none","low","tall"]],["north",["none","low","tall"]],["south",["none","low","tall"]],["west",["none","low","tall"]]],[["tip",["true","false"]]]],"provenance":{"source_url":"https://piston-data.mojang.com/v1/objects/823e2250d24b3ddac457a60c92a6a941943fcd6a/server.jar","source_sha1":"823e2250d24b3ddac457a60c92a6a941943fcd6a","source_sha256":"cdacdfb25898de5e4b4b0e5ddcc2722f77067e46605709c2d886c000ebb63ec5","executable_sha256":"183c0499c5f855570ee487dd38e141a53f0121f83a0b07a3bac2d8b6698823e8","probe_sha256":"8663e2f81af754fa4063a6f0a109fa27b8c1fae06a5a393d7ca42ac243a252bb","command":"python3 scripts/measure_lighting.py --java /path/to/java25/bin/java"}} diff --git a/client/lighting-shaders.js b/client/lighting-shaders.js new file mode 100644 index 0000000..edc6134 --- /dev/null +++ b/client/lighting-shaders.js @@ -0,0 +1,321 @@ +// One direction drives visible sunlight, surface lighting, and the shadow camera. +const sun = [-0.5, 0.78, 0.37]; +export const SUN_DIRECTION = Object.freeze(sun.map((v) => v / Math.hypot(...sun))); +const sunlight = `const vec3 SUN_DIRECTION = vec3(${SUN_DIRECTION.join(",")});`; + +// Atlas cells are sampled as integer texels, so neighboring sprites never bleed. +const blockTextureSampling = ` +uniform sampler2DArray uBlockTextures; +uniform vec2 uBlockAtlasGrid; +uniform vec2 uGrassOverlay; +vec4 blockPixelRaw(vec2 uv, float layer) { + if (uBlockAtlasGrid.x < 1.0) return texture(uBlockTextures, vec3(uv, layer)); + ivec2 cellSize = textureSize(uBlockTextures, 0).xy / ivec2(uBlockAtlasGrid); + ivec2 cell = ivec2(mod(layer, uBlockAtlasGrid.x), floor(layer / uBlockAtlasGrid.x)); + ivec2 pixel = min(ivec2(floor(fract(uv) * vec2(cellSize))), cellSize - 1); + return texelFetch(uBlockTextures, ivec3(cell * cellSize + pixel, 0), 0); +} +vec4 blockPixel(vec2 uv, float layer) { + vec4 pixel = blockPixelRaw(uv, layer); + if (uGrassOverlay.x >= 0.0 && abs(layer - uGrassOverlay.x) < 0.1) { + vec4 overlay = blockPixelRaw(uv, uGrassOverlay.y); + pixel.rgb = mix(pixel.rgb, overlay.rgb * vec3(0.58, 0.8, 0.34), overlay.a); + } + return pixel; +}`; + +const colorSpace = ` +vec3 toLinear(vec3 color) { + color = max(color, vec3(0.0)); + return mix(color / 12.92, pow((color + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), color)); +} +vec3 toSRGB(vec3 color) { + color = max(color, vec3(0.0)); + return mix(color * 12.92, 1.055 * pow(color, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), color)); +}`; + +// Linear scene colors. The world fog and water reflection sample this same sky. +const atmosphere = ` +${sunlight} +uniform float uDaylight; +vec3 daylightSky(vec3 ray) { + float altitude = clamp(ray.y, 0.0, 1.0); + vec3 horizon = vec3(0.67, 0.77, 0.80); + vec3 zenith = vec3(0.105, 0.315, 0.59); + vec3 sky = mix(horizon, zenith, pow(altitude, 0.48)); + float sunAngle = max(dot(ray, SUN_DIRECTION), 0.0); + // Wide scattering is faint; the small solar disc is rendered separately. + sky += vec3(0.12, 0.075, 0.025) * pow(sunAngle, 24.0); + sky = mix(vec3(0.46, 0.51, 0.48), sky, smoothstep(-0.24, 0.015, ray.y)); + vec3 nightHorizon = vec3(0.009, 0.015, 0.028); + vec3 nightZenith = vec3(0.0014, 0.0034, 0.011); + vec3 night = mix(nightHorizon, nightZenith, pow(altitude, 0.48)); + night += vec3(0.0025, 0.0035, 0.006) * pow(sunAngle, 36.0); + night = mix(vec3(0.0035, 0.005, 0.009), night, smoothstep(-0.24, 0.015, ray.y)); + return mix(night, sky, clamp(uDaylight, 0.0, 1.0)); +}`; + +export const worldVertex = `#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; +layout(location=5) in vec3 aBlockTexture; +layout(location=6) in vec2 aLight; +// RGB is linear block irradiance; alpha is the normalized skylight level. +layout(location=7) in vec4 aBlockLight; +uniform mat4 uVP; +uniform vec3 uOffset; +out vec3 vPos; +out vec3 vColor; +out vec3 vNormal; +flat out float vMaterial; +out float vOpacity; +out vec2 vBlockUV; +flat out float vBlockLayer; +out vec2 vLight; +out vec4 vBlockLight; +void main() { + vPos = aPos + uOffset; + vColor = aColor; + vNormal = aNormal; + vMaterial = aMaterial; + vOpacity = aOpacity; + vBlockUV = aBlockTexture.xy; + vBlockLayer = aBlockTexture.z; + vLight = aLight; + vBlockLight = aBlockLight; + gl_Position = uVP * vec4(vPos, 1.0); +}`; + +export const worldFragment = `#version 300 es +precision highp float; +precision highp sampler2DArray; +in vec3 vPos; +in vec3 vColor; +in vec3 vNormal; +flat in float vMaterial; +in float vOpacity; +in vec2 vBlockUV; +flat in float vBlockLayer; +in vec2 vLight; +in vec4 vBlockLight; +uniform vec3 uEye; +uniform sampler2D uTexture; +uniform bool uTextured; +uniform sampler2D uEffectTexture; +uniform bool uEffectTextured; +${blockTextureSampling} +uniform float uPulse; +uniform float uFogDistance; +uniform float uViewDistance; +uniform mat4 uLightVP; +uniform sampler2D uShadowMap; +uniform bool uShadows; +uniform float uShadowTexel; +uniform vec3 uShadowCenter; +uniform float uShadowRadius; +out vec4 outColor; +${colorSpace} +${atmosphere} + +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;} + +float sunVisibility(vec3 normal, float incidence) { + if (!uShadows || incidence <= 0.0) return 1.0; + vec4 clip = uLightVP * vec4(vPos + normal * 0.025, 1.0); + vec3 projected = clip.xyz / clip.w * 0.5 + 0.5; + if (any(lessThan(projected, vec3(0.0))) || any(greaterThan(projected, vec3(1.0)))) return 1.0; + float bias = 0.00010 + 0.00028 * (1.0 - incidence); + float visible = 0.0; + // A compact, stable PCF kernel keeps the edges soft without moving grain. + for (int y = -1; y <= 1; ++y) { + for (int x = -1; x <= 1; ++x) { + float depth = texture(uShadowMap, projected.xy + vec2(float(x), float(y)) * uShadowTexel).r; + visible += step(projected.z - bias, depth); + } + } + float coverage = 1.0 - smoothstep(max(0.0, uShadowRadius - 6.0), uShadowRadius, distance(vPos, uShadowCenter)); + vec2 border = min(projected.xy, 1.0 - projected.xy); + coverage *= smoothstep(0.0, 0.018, min(border.x, border.y)); + return mix(1.0, visible / 9.0, coverage); +} + +float textureAlpha = 1.0; +vec3 surfaceColor(vec3 normal) { + if (vBlockLayer >= 0.0) { + vec4 pixel = blockPixel(vBlockUV, vBlockLayer); + if (pixel.a < (uBlockAtlasGrid.x > 0.0 ? 0.01 : 0.5)) discard; + if (uBlockAtlasGrid.x > 0.0 && pixel.a < 0.99) textureAlpha = pixel.a; + return clamp(pixel.rgb * vColor, 0.0, 1.0); + } + vec2 uv = abs(normal.y) > 0.5 ? vPos.xz : (abs(normal.x) > 0.5 ? vPos.zy : vPos.xy); + vec3 p = floor((vPos + normal * 0.002) * 16.0); + float noise = hash(p) * 0.14 - 0.07; + float pattern = 1.0; + if (vMaterial > 1.5 && vMaterial < 2.5) { + pattern = 0.89 + 0.11 * step(0.07, fract(uv.y * 4.0)); + pattern *= 0.9 + 0.1 * step(0.055, fract(uv.x * 0.5 + floor(uv.y * 4.0) * 0.5)); + } + if (vMaterial > 2.5 && vMaterial < 3.5) { + pattern = 0.87 + 0.13 * step(0.06, fract(uv.y * 4.0)); + pattern *= 0.84 + 0.16 * step(0.045, fract(uv.x * 2.0 + floor(uv.y * 4.0) * 0.5)); + } + if (vMaterial > 3.5 && vMaterial < 4.5) { + pattern = 0.91 + 0.09 * sin(floor(uv.x * 16.0) * 1.73 + sin(floor(uv.y * 16.0) * 0.22)); + } + float tex = uTextured ? mix(0.84, 1.12, texture(uTexture, uv).r) : 1.0; + if (vMaterial > 4.5 && vMaterial < 5.5 && uEffectTextured) { + tex = mix(0.75, 1.2, texture(uEffectTexture, uv).g); + } + vec3 color = clamp((vColor + noise) * pattern * tex, 0.0, 1.0); + if (vMaterial > 0.5 && vMaterial < 1.5 && normal.y < 0.5) { + color = mix(color, vec3(0.38, 0.29, 0.17), 0.65); + } + return color; +} + +void main() { + vec3 normal = normalize(vNormal); + vec3 albedo = surfaceColor(normal); + if (vMaterial > 4.5 && vMaterial < 5.5) albedo = shacraftBounceTint(albedo, uPulse); + albedo = toLinear(albedo); + float occlusion = clamp(vLight.x, 0.0, 1.0); + float emission = clamp(vLight.y, 0.0, 1.0); + float skyLevel = clamp(vBlockLight.a, 0.0, 1.0); + float skyExposure = skyLevel * skyLevel; + vec3 blockLight = max(vBlockLight.rgb, vec3(0.0)); + float incidence = max(dot(normal, SUN_DIRECTION), 0.0); + float sunlight = skyExposure > 0.0 ? sunVisibility(normal, incidence) * skyExposure : 0.0; + // The lower hemisphere has muted ground bounce, keeping undersides legible. + float hemisphere = normal.y * 0.5 + 0.5; + vec3 ambient = mix(vec3(0.15, 0.145, 0.13), vec3(0.29, 0.355, 0.44), hemisphere); + float daylight = clamp(uDaylight, 0.0, 1.0); + vec3 moonAmbient = mix(vec3(0.009, 0.011, 0.018), vec3(0.025, 0.036, 0.060), hemisphere); + ambient = mix(moonAmbient, ambient, daylight); + // Closed rooms receive a small visibility floor, not the outdoor hemisphere. + ambient *= mix(0.20, 1.0, occlusion) * mix(0.015, 1.0, skyExposure); + vec3 direct = mix(vec3(0.034, 0.050, 0.080), vec3(0.94, 0.865, 0.735), daylight) * incidence * sunlight; + // Voxel propagation already accounts for walls and distance to emitters. + // A weak contact term keeps corners grounded without swallowing torchlight. + vec3 localLight = blockLight * 1.1 * mix(0.65, 1.0, occlusion); + vec3 color = albedo * (ambient + direct + localLight); + color = mix(color, albedo * 1.14, emission); + + vec3 eyeDelta = uEye - vPos; + vec3 toEye = eyeDelta / max(length(eyeDelta), 0.0001); + if (vMaterial > 5.5 && vMaterial < 6.5) { + // Flat, quiet voxel water: the silhouette and texture remain pixel aligned. + float fresnel = 0.035 + 0.58 * pow(1.0 - max(dot(normal, toEye), 0.0), 5.0); + vec3 reflection = daylightSky(reflect(-toEye, normal)) * 0.82 * skyExposure + blockLight * 0.08; + color = mix(color, reflection, fresnel); + vec3 halfVector = normalize(SUN_DIRECTION + toEye); + float glint = pow(max(dot(normal, halfVector), 0.0), 160.0); + color += mix(vec3(0.045, 0.060, 0.090), vec3(0.45, 0.38, 0.24), daylight) * glint * sunlight; + } + + // Matching atmosphere avoids a separate tinted band along the horizon. + float fog = (1.0 - exp(-pow(length(eyeDelta) / uFogDistance, 2.0))) * 0.92; + // Fade completely before the prefetched ring can be unloaded. + float viewEdge = max(abs(eyeDelta.x), abs(eyeDelta.z)); + fog = max(fog, smoothstep(uViewDistance * 0.82, uViewDistance, viewEdge)); + color = mix(color, daylightSky(-toEye) * skyExposure, fog); + outColor = vec4(toSRGB(clamp(color, 0.0, 1.0)), textureAlpha < 0.99 ? textureAlpha : vOpacity); +}`; + +export const skyVertex = `#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.0 - 1.0, 1.0, 1.0); +}`; + +export const skyFragment = `#version 300 es +precision highp float; +in vec2 vUV; +uniform float uPitch; +uniform float uYaw; +uniform float uAspect; +uniform float uTanHalfFov; +uniform float uSkyExposure; +out vec4 outColor; +${colorSpace} +${atmosphere} +float starHash(vec2 cell) { + return fract(sin(dot(cell, vec2(127.1, 311.7))) * 43758.5453); +} +vec3 nightStars(vec3 ray) { + // A fixed world-space hemisphere avoids stars sliding with the camera. + vec2 skyPlane = ray.xz / max(ray.y + 1.15, 0.2) * 128.0; + vec2 cell = floor(skyPlane); + float seed = starHash(cell); + vec2 center = vec2(starHash(cell + 19.4), starHash(cell + 71.2)) * 0.6 + 0.2; + float radius = mix(0.065, 0.13, starHash(cell + 37.8)); + float distanceToStar = length(fract(skyPlane) - center); + float edge = max(fwidth(distanceToStar), 0.01); + float star = 1.0 - smoothstep(max(0.0, radius - edge), radius + edge, distanceToStar); + star *= step(0.994, seed) * smoothstep(0.025, 0.20, ray.y); + vec3 tint = mix(vec3(0.48, 0.59, 0.80), vec3(0.82, 0.74, 0.60), starHash(cell + 113.0)); + return tint * star * mix(0.32, 0.72, starHash(cell + 53.0)); +} +void main() { + vec3 forward = vec3(sin(uYaw) * cos(uPitch), sin(uPitch), -cos(uYaw) * cos(uPitch)); + vec3 right = vec3(cos(uYaw), 0.0, sin(uYaw)); + vec3 up = vec3(-sin(uYaw) * sin(uPitch), cos(uPitch), cos(uYaw) * sin(uPitch)); + vec2 screen = vUV * 2.0 - 1.0; + vec3 ray = normalize(forward + right * screen.x * uAspect * uTanHalfFov + up * screen.y * uTanHalfFov); + vec3 color = daylightSky(ray); + float daylight = clamp(uDaylight, 0.0, 1.0); + color += nightStars(ray) * (1.0 - daylight); + float sunAngle = dot(ray, SUN_DIRECTION); + float rim = max(fwidth(sunAngle), 0.000001); + float angularRadius = mix(0.0125, 0.0105, daylight); + float disc = smoothstep(cos(angularRadius) - rim, cos(angularRadius) + rim, sunAngle); + vec3 moonRight = normalize(cross(SUN_DIRECTION, vec3(0.0, 1.0, 0.0))); + vec3 moonUp = cross(moonRight, SUN_DIRECTION); + vec2 moonUV = vec2(dot(ray, moonRight), dot(ray, moonUp)) / sin(angularRadius); + vec2 craterA = moonUV - vec2(-0.24, 0.22), craterB = moonUV - vec2(0.28, -0.27); + float craters = 0.12 * exp(-dot(craterA, craterA) * 22.0) + 0.075 * exp(-dot(craterB, craterB) * 36.0); + float moonShade = 0.78 + 0.22 * sqrt(max(0.0, 1.0 - dot(moonUV, moonUV))) - craters; + vec3 moon = vec3(0.62, 0.70, 0.84) * moonShade; + color = mix(color, mix(moon, vec3(1.0, 0.965, 0.84), daylight), disc); + // Match the distant backdrop to cave fog when the streamed window ends + // underground; missing geometry must not reveal a bright surface sky. + outColor = vec4(toSRGB(clamp(color * uSkyExposure, 0.0, 1.0)), 1.0); +}`; + +export const shadowVertex = `#version 300 es +precision highp float; +layout(location=0) in vec3 aPos; +layout(location=4) in float aOpacity; +layout(location=5) in vec3 aBlockTexture; +uniform mat4 uLightVP; +uniform vec3 uOffset; +out vec2 vBlockUV; +flat out float vBlockLayer; +out float vOpacity; +void main() { + vBlockUV = aBlockTexture.xy; + vBlockLayer = aBlockTexture.z; + vOpacity = aOpacity; + gl_Position = uLightVP * vec4(aPos + uOffset, 1.0); +}`; + +export const shadowFragment = `#version 300 es +precision highp float; +precision highp sampler2DArray; +in vec2 vBlockUV; +flat in float vBlockLayer; +in float vOpacity; +${blockTextureSampling} +void main() { + if (vOpacity < 0.99) discard; + if (vBlockLayer >= 0.0 && blockPixel(vBlockUV, vBlockLayer).a < 0.5) discard; +}`; diff --git a/client/local-light-bounds.js b/client/local-light-bounds.js new file mode 100644 index 0000000..190431b --- /dev/null +++ b/client/local-light-bounds.js @@ -0,0 +1,10 @@ +// Fifteen light levels plus the halo sampled by faces and partial models. +export const LOCAL_LIGHT_HALO = 18; +export function localLightBounds(id, view) { + if (!view) return null; + const [x,,z] = id.split(',').map(value=>Math.floor(Number(value)/2)*32); + return { + min:[Math.max(view.min[0],x-LOCAL_LIGHT_HALO),view.min[1],Math.max(view.min[2],z-LOCAL_LIGHT_HALO)], + max:[Math.min(view.max[0],x+31+LOCAL_LIGHT_HALO),view.max[1],Math.min(view.max[2],z+31+LOCAL_LIGHT_HALO)], + }; +} diff --git a/client/math.js b/client/math.js index 26045c8..dbe1668 100644 --- a/client/math.js +++ b/client/math.js @@ -167,6 +167,7 @@ export function isFullCube(mat) { b?.length === 1 && b[0].min.every((v) => v === 0) && b[0].max.every((v) => v === 1) && + (mat.opacity ?? 1) >= 1 && !mat.transparent ); } diff --git a/client/mesh-geometry.js b/client/mesh-geometry.js new file mode 100644 index 0000000..4bf9143 --- /dev/null +++ b/client/mesh-geometry.js @@ -0,0 +1,189 @@ +import { key, isFullCube, unitBox } from "./math.js"; +import { getBlockFaceTextures, getFaceUV } from "./block-textures.js"; +import { createAmbientOcclusionSampler } from "./ambient-occlusion.js"; + +/** Shared terrain and actor format; all values become Float32 at upload. */ +export const MESH_VERTEX_STRIDE = 20; + +export 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], + ], + }, +]; +export function boxVertices( + out, + pos, + box, + color, + material = 0, + skip = () => false, + yaw = 0, + opacity = 1, + textures = null, + state = "", + ambient = null, + emission = 0, + blockLight = null, + dynamicLight = false, + origin = [0,0,0], +) { + 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, + ]; + const light = ambient ? ambient(pos, box, face.n, face.v) : [1, 1, 1, 1]; + const propagated = !dynamicLight && blockLight ? blockLight.sampleFace(pos, box, face.n, face.v) : null; + // Flip the diagonal so occlusion interpolation has no dark triangular seam. + const indices = light[0] + light[2] > light[1] + light[3] + ? [0, 1, 3, 1, 2, 3] : [0, 1, 2, 0, 2, 3]; + for (const i of indices) { + const v = face.v[i].map( + (v, k) => box.min[k] + v * (box.max[k] - box.min[k]), + ); + const point = [pos[0] + v[0] * c - v[2] * s, pos[1] + v[1], pos[2] + v[0] * s + v[2] * c]; + const local = dynamicLight && blockLight ? blockLight.sample(point.map((p, axis) => p + n[axis] * 0.02)) : null; + out.push( + point[0]-origin[0], point[1]-origin[1], point[2]-origin[2], + ...(textures?.[f]?.tint || color), + ...n, + material, + opacity, + ...getFaceUV(f, v, state, textures?.[f]), + textures?.[f]?.layer ?? -1, + light[i], + emission, + ...(propagated?.block[i] || local?.block || [0, 0, 0]), + propagated?.sky[i] ?? local?.sky ?? 1, + ); + } + } +} +export function materialType(state = "", effect) { + if (effect === "bounce") return 5; + if (/^minecraft:(water|bubble_column)(\[|$)/.test(state)) return 6; + 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; +} +/** + * Pure section meshing for both worker and synchronous fallback. World maps and + * the light field are immutable for a build. A caller may reuse faceTextureCache + * between sections; clear it whenever texture layers change. + */ +export function buildSectionMesh({ + id, blocks, materials, textureLayers = new Map(), blockLightField = null, + faceTextureCache = new Map(), localCoordinates = false, +}) { + const faceTextures = (mat) => { + if (!textureLayers.size) return null; + if (!faceTextureCache.has(mat.state)) + faceTextureCache.set(mat.state, getBlockFaceTextures(mat.state, textureLayers)); + return faceTextureCache.get(mat.state); + }; + const base = id.split(",").map((value) => Number(value) * 16), + vertices = [], transparent = [], + ambient = createAmbientOcclusionSampler(blocks, materials, + (mat) => !mat || (mat.opacity ?? 1) >= 1 && !faceTextures(mat)?.some(face => face?.cutout)); + 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 = blocks.getAt ? blocks.getAt(x,y,z) : blocks.get(key(pos)); + if (!block) continue; + const mat = materials.get(block) || { + color: [150, 152, 142], + render: [unitBox], + }, + color = mat.color.map((v) => v / 255), + textures = faceTextures(mat), + cutout = textures?.some((face) => face?.cutout), + opacity = cutout ? 1 : (mat.opacity ?? 1), + full = isFullCube(mat) && !cutout; + for (const box of mat.render || [unitBox]) { + boxVertices( + opacity < 1 ? transparent : vertices, + pos, + box, + color, + materialType(mat.state, mat.effect), + (n) => { + if (!full) return false; + const neighbor = materials.get( + (blocks.getAt ? blocks.getAt(pos[0]+n[0],pos[1]+n[1],pos[2]+n[2]) : blocks.get(key(pos.map((v, i) => v + n[i])))), + ); + return ( + isFullCube(neighbor) && + !faceTextures(neighbor)?.some((face) => face?.cutout) + ); + }, + 0, + opacity, + textures, + mat.state, + ambient, + Math.max(0, Math.min(1, (mat.light || 0) / 15)), + blockLightField, false, localCoordinates ? base : [0,0,0], + ); + } + } + return { vertices: new Float32Array(vertices), transparent: new Float32Array(transparent), origin: localCoordinates ? base : [0,0,0] }; +} diff --git a/client/physics.wasm b/client/physics.wasm new file mode 100755 index 0000000..2946513 Binary files /dev/null and b/client/physics.wasm differ diff --git a/client/player-interpolation.js b/client/player-interpolation.js new file mode 100644 index 0000000..e413270 --- /dev/null +++ b/client/player-interpolation.js @@ -0,0 +1,21 @@ +/** Separate the last server pose from the pose currently drawn on screen. */ +export function updateRemotePlayer(previous, packet, instant = false) { + const preserve = previous && !instant; + const targetYaw = packet.yaw ?? 0; + return { + ...packet, + position: preserve ? previous.position : [...packet.position], + target: [...packet.position], + yaw: preserve ? previous.yaw : targetYaw, + targetYaw, + }; +} + +export function smoothRemotePlayer(player, blend) { + for (let axis = 0; axis < 3; axis++) + player.position[axis] += + (player.target[axis] - player.position[axis]) * blend; + // Follow the shortest arc, including when server angles cross ±π or 2π. + const delta = player.targetYaw - player.yaw; + player.yaw += Math.atan2(Math.sin(delta), Math.cos(delta)) * blend; +} diff --git a/client/player-physics.js b/client/player-physics.js new file mode 100644 index 0000000..025ba01 --- /dev/null +++ b/client/player-physics.js @@ -0,0 +1,256 @@ +/** The browser and authoritative server execute the same 20 Hz Rust solver. */ +export const PHYSICS_TICK_MS = 50; +export const EYE_HEIGHT = { standing: 1.62, crouching: 1.27, swimming: 0.4 }; + +const copyBody = (body) => ({ + ...body, + position: [...body.position], + velocity: [...body.velocity], +}); +const validBody = (body) => + [body?.position, body?.velocity].every( + (v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite), + ); + +export async function loadPhysics(url = "/physics.wasm?v=movement-1") { + const response = await fetch(url, { cache: "no-cache" }); + if (!response.ok) throw Error(`Physics module: HTTP ${response.status}`); + let result; + try { + result = await WebAssembly.instantiateStreaming(response.clone(), {}); + } catch { + // Some local servers do not supply application/wasm. + result = await WebAssembly.instantiate(await response.arrayBuffer(), {}); + } + const api = result.instance.exports; + if ( + api.physics_version?.() !== 1 || + !api.memory || + ![ + "physics_input", + "physics_run", + "physics_output", + "physics_output_len", + ].every((name) => typeof api[name] === "function") + ) + throw Error("Incompatible physics module"); + const encoder = new TextEncoder(), + decoder = new TextDecoder(); + return (request) => { + const input = encoder.encode(JSON.stringify(request)); + if (input.length > 16 * 1024 * 1024) + throw Error("Physics input exceeds 16 MiB"); + const pointer = api.physics_input(input.length); + if (!pointer) throw Error("Physics input allocation failed"); + // The allocator/solver can grow memory: never retain an old typed view. + new Uint8Array(api.memory.buffer, pointer, input.length).set(input); + const status = api.physics_run(); + const output = JSON.parse( + decoder.decode( + new Uint8Array( + api.memory.buffer, + api.physics_output(), + api.physics_output_len(), + ), + ), + ); + if (status || !validBody(output)) + throw Error(output.error || "Invalid physics output"); + return output; + }; +} + +/** Build only the swept collision neighbourhood, including non-solid fluids. */ +export function samplePhysicsWorld(body, blocks, materials, bounds) { + if (!bounds || !validBody(body)) return null; + const min = [], + max = []; + for (let axis = 0; axis < 3; axis++) { + const pos = body.position[axis], + velocity = body.velocity[axis]; + min[axis] = Math.floor( + Math.min(pos, pos + velocity) - (axis === 1 ? 3 : 2), + ); + max[axis] = Math.floor( + Math.max(pos, pos + velocity) + (axis === 1 ? 4 : 2), + ); + // Unknown chunks are not empty air. Keep authority until they arrive. + if (!(axis === 1 && bounds.fullHeight) && (min[axis] < bounds.min[axis] || max[axis] > bounds.max[axis])) + return null; + } + if (bounds.fullHeight) { + // Above/below the declared world height is known void, not missing chunks. + min[1] = Math.max(min[1], bounds.min[1]); + max[1] = Math.min(max[1], bounds.max[1]); + } + if (blocks.hasSection) { + for(let x=Math.floor(min[0]/16);x<=Math.floor(max[0]/16);x++) + for(let y=Math.floor(min[1]/16);y<=Math.floor(max[1]/16);y++) + for(let z=Math.floor(min[2]/16);z<=Math.floor(max[2]/16);z++) + if(!blocks.hasSection(`${x},${y},${z}`) || blocks.readySections && !blocks.readySections.has(`${x},${y},${z}`))return null; + } + const records = []; + for (let x = min[0]; x <= max[0]; x++) + for (let y = min[1]; y <= max[1]; y++) + for (let z = min[2]; z <= max[2]; z++) { + const id = blocks.getAt ? blocks.getAt(x,y,z) : blocks.get(`${x},${y},${z}`); + if (!id) continue; + const material = materials.get(id); + if (!material) return null; + records.push({ + pos: [x, y, z], + state: material.state, + collision: material.collision, + }); + } + return { blocks: records }; +} + +/** Fixed steps are independent of display FPS; long stalls never create a burst. */ +export class PhysicsClock { + constructor() { + this.reset(); + } + reset() { + this.accumulator = 0; + } + advance(elapsed, step) { + this.accumulator += Math.max(0, Math.min(250, elapsed)); + let count = 0; + while (this.accumulator + 1e-7 >= PHYSICS_TICK_MS && count < 5) { + this.accumulator -= PHYSICS_TICK_MS; + count++; + step(); + } + return count; + } + get alpha() { + return Math.max(0, Math.min(1, this.accumulator / PHYSICS_TICK_MS)); + } +} + +/** Sequence-based reconciliation; physics state is never replaced by visual lerp. */ +export class LocalPrediction { + constructor(step, getWorld, historyLimit = 120) { + this.step = step; + this.getWorld = getWorld; + this.historyLimit = historyLimit; + this.reset(); + } + reset() { + this.body = null; + this.authority = null; + this.settings = {}; + this.pending = []; + this.ack = -1; + this.tick = -1; + this.epoch = null; + this.offset = [0, 0, 0]; + this.correction = 0; + this.waitingThrough = -1; + this.suspended = false; + this.preview = null; + } + simulate(body, input) { + const world = this.getWorld(body); + return world + ? this.step({ body, input, world, settings: this.settings }) + : null; + } + receive(motion, reset = false) { + if ( + !validBody(motion?.body) || + !Number.isSafeInteger(motion.ack) || + !Number.isSafeInteger(motion.tick) + ) + return false; + if (!reset && (motion.tick < this.tick || motion.ack < this.ack)) + return false; + if (!reset && this.epoch !== null && (motion.epoch ?? 0) < this.epoch) + return false; + const epochChanged = this.epoch !== null && motion.epoch !== this.epoch; + const teleport = + this.authority && + Math.hypot( + ...motion.body.position.map((v, i) => v - this.authority.position[i]), + ) > 8; + const instant = reset || epochChanged || teleport || !this.body; + const oldPosition = this.body?.position; + if (instant) { + this.pending = []; + this.offset = [0, 0, 0]; + this.waitingThrough = -1; + } + this.ack = motion.ack; + this.tick = motion.tick; + this.epoch = motion.epoch ?? 0; + this.settings = { ...(motion.settings || {}) }; + this.authority = copyBody(motion.body); + this.body = copyBody(motion.body); + this.pending = this.pending.filter(({ seq }) => seq > this.ack); + this.suspended = this.ack < this.waitingThrough; + if (!this.suspended) { + for (const { input } of this.pending) { + const next = this.simulate(this.body, input); + if (!next) { + this.suspended = true; + break; + } + this.body = next; + } + } + if (this.suspended) this.body = copyBody(this.authority); + this.correction = oldPosition + ? Math.hypot(...this.body.position.map((v, i) => v - oldPosition[i])) + : 0; + if (!instant && this.correction < 4) + for (let i = 0; i < 3; i++) + this.offset[i] += oldPosition[i] - this.body.position[i]; + else this.offset = [0, 0, 0]; + this.preview = null; + return true; + } + push(seq, input) { + if ( + !this.body || + seq <= this.ack || + seq <= (this.pending.at(-1)?.seq ?? -1) + ) + return false; + this.pending.push({ seq, input: { ...input } }); + if (this.pending.length > this.historyLimit) { + this.waitingThrough = this.pending.shift().seq; + this.suspended = true; + } + if (!this.suspended) { + const next = this.simulate(this.body, input); + if (next) this.body = next; + else this.suspended = true; + } + this.preview = null; + return !this.suspended; + } + sample(alpha, dt, input) { + if (!this.body) return null; + const damping = Math.exp(-15 * Math.max(0, dt)); + this.offset = this.offset.map((value) => value * damping); + let next = this.body; + if (!this.suspended) { + // This partial render step is disposable and is never sent or acknowledged. + const signature = JSON.stringify(input); + if (this.preview?.signature !== signature) + this.preview = { signature, body: this.simulate(this.body, input) }; + next = this.preview?.body || this.body; + } + return { + position: this.body.position.map( + (v, i) => v + (next.position[i] - v) * alpha + this.offset[i], + ), + eyeHeight: EYE_HEIGHT[this.body.pose] ?? 1.62, + body: this.body, + }; + } + invalidateWorld() { + this.preview = null; + } +} diff --git a/client/pointer-lock.js b/client/pointer-lock.js new file mode 100644 index 0000000..fca5d4a --- /dev/null +++ b/client/pointer-lock.js @@ -0,0 +1,115 @@ +/** Keep pointer capture retryable after browser errors and ordinary unlocks. */ +export class PointerLockController { + constructor(canvas, { onChange = () => {}, onError = () => {} } = {}) { + this.canvas = canvas; + this.document = canvas.ownerDocument; + this.onChange = onChange; + this.onError = onError; + this.status = this.locked ? "locked" : "idle"; + this.lastError = null; + this.generation = 0; + this.disposed = false; + this.cancelled = false; + this.observedLocked = this.locked; + this.changeListener = () => this.changed(); + this.errorListener = (event) => + this.failed( + event.error || + new Error(event.message || "Pointer lock was denied by the browser."), + this.generation, + ); + this.document.addEventListener("pointerlockchange", this.changeListener); + this.document.addEventListener("pointerlockerror", this.errorListener); + } + + get locked() { + return this.document.pointerLockElement === this.canvas; + } + + request() { + if (this.disposed || this.locked || this.status === "requesting") + return false; + const generation = ++this.generation; + this.cancelled = false; + this.lastError = null; + this.status = "requesting"; + if (typeof this.canvas.requestPointerLock !== "function") { + const error = new Error("This browser does not support pointer lock."); + error.name = "NotSupportedError"; + this.failed(error, generation, "unsupported"); + return false; + } + try { + if (this.canvas.tabIndex < 0) this.canvas.tabIndex = 0; + this.canvas.focus({ preventScroll: true }); + // Start synchronously: awaiting anything here loses the user's gesture. + const result = this.canvas.requestPointerLock(); + result?.then?.( + () => { + if (this.cancelled && this.locked) this.document.exitPointerLock?.(); + else if ( + !this.disposed && + generation === this.generation && + this.locked + ) + this.changed(); + }, + (error) => this.failed(error, generation), + ); + return true; + } catch (error) { + this.failed(error, generation); + return false; + } + } + + failed(error, generation, status = "error") { + if ( + this.disposed || + generation !== this.generation || + this.locked || + this.status !== "requesting" + ) + return; + this.generation++; + this.status = status; + this.lastError = error?.message + ? error + : new Error(String(error || "Pointer lock failed.")); + this.onError(this.lastError); + } + + changed() { + if (this.disposed) return; + if (this.locked && this.cancelled) { + this.document.exitPointerLock?.(); + return; + } + const locked = this.locked; + this.generation++; + this.status = locked ? "locked" : "idle"; + this.lastError = null; + if (locked !== this.observedLocked) { + this.observedLocked = locked; + this.onChange(locked); + } + } + + release() { + if (this.disposed) return; + const requesting = this.status === "requesting"; + this.generation++; + this.cancelled = true; + this.status = "idle"; + this.lastError = null; + if (this.locked || requesting) this.document.exitPointerLock?.(); + } + + dispose() { + if (this.disposed) return; + this.release(); + this.disposed = true; + this.document.removeEventListener("pointerlockchange", this.changeListener); + this.document.removeEventListener("pointerlockerror", this.errorListener); + } +} diff --git a/client/renderer.js b/client/renderer.js index f8b22a0..7385804 100644 --- a/client/renderer.js +++ b/client/renderer.js @@ -1,88 +1,31 @@ +import { EditMeshController } from "./edit-mesh-controller.js"; +import { captureEditMesh } from "./edit-mesh.js"; 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.);}`; +import { getBlockFaceTextures } from "./block-textures.js"; +import { prepareTexturePack } from "./texture-pack.js"; +import { boxVertices, buildSectionMesh } from "./mesh-geometry.js"; +import { TerrainController } from "./terrain-controller.js"; +import { affectedSectionKeys } from "./ambient-occlusion.js"; +import { shadowFrame } from "./shadow-frame.js"; +import { BlockLightController, inferLightBounds } from "./block-light-controller.js"; +import { changedLightSections } from "./light-changes.js"; +import { viewDistanceProfile } from "./view-distance.js"; +import { + worldVertex as vertex, worldFragment as fragment, + skyVertex as skyVert, skyFragment as skyFrag, + shadowVertex, shadowFragment, SUN_DIRECTION, +} from "./lighting-shaders.js"; 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.);}`; +precision highp float;layout(location=0)in vec3 aPos;uniform mat4 uVP;uniform vec3 uOffset;void main(){gl_Position=uVP*vec4(aPos+uOffset,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 [ @@ -102,54 +45,100 @@ function program(gl, vs, fs) { 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]), +/** Avatar geometry follows the public physics pose; yaw remains interpolated. */ +export function playerAvatarParts(player) { + const pose = player.pose || "standing", + swimming = pose === "swimming", + crouching = pose === "crouching", + defaultHeight = swimming ? 0.6 : crouching ? 1.5 : 1.8, + height = Number.isFinite(player.height) + ? Math.max(0.4, Math.min(2.5, player.height)) + : defaultHeight, + eye = Number.isFinite(player.eye_height) + ? player.eye_height + : height - (swimming ? 0.2 : crouching ? 0.23 : 0.18), + shirt = player.color || [0.29, 0.46, 0.36], + skin = [0.77, 0.61, 0.43], + trousers = [0.25, 0.3, 0.32], + parts = [], + add = (part, min, max, color, material = 0) => + parts.push({ part, box: { min, max }, color, material }); + if (swimming) { + // The collider is short while the visible body lies along its facing axis. + add( + "torso", + [-0.21, height * 0.24, -0.36], + [0.21, height * 0.7, 0.28], + shirt, + 2, + ); + add("head", [-0.23, height * 0.2, -0.82], [0.23, height, -0.36], skin); + for (const side of [-1, 1]) { + add( + "leg", + [side * 0.13 - 0.085, height * 0.27, 0.28], + [side * 0.13 + 0.085, height * 0.57, 0.89], + trousers, ); - 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, + add( + "arm", + [side * 0.31 - 0.085, height * 0.22, -0.38], + [side * 0.31 + 0.085, height * 0.55, 0.24], + shirt, ); } + add( + "eyes", + [-0.18, eye - 0.04, -0.824], + [0.18, eye + 0.04, -0.816], + [0.13, 0.19, 0.15], + ); + } else { + const headBottom = height - 0.5, + hip = crouching ? headBottom - 0.55 : headBottom - 0.64, + lean = crouching ? -0.13 : 0; + add( + "torso", + [-0.21, hip, -0.13 + lean * 0.5], + [0.21, headBottom, 0.13 + lean * 0.5], + shirt, + 2, + ); + add( + "head", + [-0.23, headBottom, -0.23 + lean], + [0.23, height, 0.23 + lean], + skin, + ); + for (const side of [-1, 1]) { + const knee = crouching ? 0.07 : 0; + add( + "leg", + [side * 0.13 - 0.085, 0.02, -0.09 + knee], + [side * 0.13 + 0.085, hip, 0.09 + knee], + trousers, + ); + add( + "arm", + [side * 0.31 - 0.085, hip, -0.1 + lean], + [side * 0.31 + 0.085, headBottom - 0.03, 0.1 + lean], + shirt, + ); + } + add( + "eyes", + [-0.18, eye - 0.04, -0.234 + lean], + [0.18, eye + 0.04, -0.226 + lean], + [0.13, 0.19, 0.15], + ); } -} -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; + return parts; } const section = (p) => p.map((v) => Math.floor(v / 16)).join(","); export class Renderer { constructor(canvas) { this.canvas = canvas; + this.renderOrigin = [0,0,0]; this.selectionOrigin = [0,0,0]; const gl = (this.gl = canvas.getContext("webgl2", { antialias: true, alpha: false, @@ -163,10 +152,44 @@ export class Renderer { this.program = program(gl, vertex, fragment); this.sky = program(gl, skyVert, skyFrag); this.line = program(gl, lineVertex, lineFragment); + this.shadowProgram = program(gl, shadowVertex, shadowFragment); + this.shadowSize = Math.min(2048, gl.getParameter(gl.MAX_TEXTURE_SIZE)); + this.shadowRadius = 48; + this.shadowsEnabled = true; + this.shadowReady = this.createShadowMap(); + this.lightingName = "Daylight"; + this.daylight = 1; this.sections = new Map(); + this.publishedSections = new Set(); + this.loadStarted = performance.now(); + this.firstTerrainMs = null; this.dirty = new Set(); this.blocks = new Map(); this.materials = new Map(); + this.blockLightField = null; + this.lightViewBounds = null; + this.meshUpload = null; + this.terrain = new TerrainController({ + onLight: field => { this.blockLightField = field; }, + onDirty: ids => { + for (const id of ids) if (this.sectionInView(id)) this.dirty.add(id); + }, + onError: () => this.enableTerrainFallback(), + }); + if (this.terrain.error) { + // Some embedded browsers do not support workers. Keep the game usable + // through the former CPU path, and identify it in diagnostics. + this.terrain.dispose(); + this.terrain = null; + this.blockLighting = new BlockLightController(field => { + for (const id of changedLightSections(this.blockLightField, field, this.sections.keys())) this.dirty.add(id); + this.blockLightField = field; + }); + } else this.blockLighting = this.terrain; + if (this.terrain) this.edits = new EditMeshController({ + capture: id => captureEditMesh(id, this.blocks, this.materials, this.textureLayers, this.blockLightField), + onError: error => console.warn("Shacraft edit worker unavailable; using terrain queue", error), + }); this.texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, this.texture); gl.texImage2D( @@ -199,10 +222,18 @@ export class Renderer { 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.blockTexture = this.createBlockTexture(1, 1); + this.blockAtlasGrid = [0, 0]; + this.blockGrassOverlay = [-1, -1]; + this.textureLayers = new Map(); + this.faceTextureCache = new Map(); + this.texturePackGeneration = 0; this.bounceAt = -10000; this.dynamic = this.mesh([]); this.lines = this.mesh([], 3); this.triangles = 0; + this.meshRebuilds = 0; + this.meshResets = 0; this.vp = new Float32Array(16); this.lost = false; canvas.addEventListener("webglcontextlost", (e) => { @@ -211,7 +242,63 @@ export class Renderer { }); canvas.addEventListener("webglcontextrestored", () => location.reload()); } - mesh(data, stride = 11) { + createShadowMap() { + const gl = this.gl; + this.shadowTexture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, + this.shadowSize, this.shadowSize, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + this.shadowFramebuffer = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFramebuffer); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, this.shadowTexture, 0); + gl.drawBuffers([gl.NONE]); + gl.readBuffer(gl.NONE); + const ready = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + return ready; + } + drawShadows(frame) { + if (!this.shadowsEnabled || !this.shadowReady) return; + const gl = this.gl, p = this.shadowProgram; + gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFramebuffer); + gl.viewport(0, 0, this.shadowSize, this.shadowSize); + gl.colorMask(false, false, false, false); + gl.depthMask(true); + gl.clear(gl.DEPTH_BUFFER_BIT); + gl.enable(gl.DEPTH_TEST); + gl.disable(gl.BLEND); + gl.enable(gl.CULL_FACE); + gl.cullFace(gl.BACK); + gl.frontFace(gl.CCW); + gl.enable(gl.POLYGON_OFFSET_FILL); + gl.polygonOffset(1.1, 2.); + gl.useProgram(p); + gl.uniformMatrix4fv(gl.getUniformLocation(p, "uLightVP"), false, frame.matrix); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.blockTexture); + gl.uniform1i(gl.getUniformLocation(p, "uBlockTextures"), 2); + gl.uniform2fv(gl.getUniformLocation(p, "uBlockAtlasGrid"), this.blockAtlasGrid); + gl.uniform2fv(gl.getUniformLocation(p, "uGrassOverlay"), this.blockGrassOverlay); + for (const mesh of this.sections.values()) { + this.meshOffset(p,mesh.origin); + gl.bindVertexArray(mesh.vao); + gl.drawArrays(gl.TRIANGLES, 0, mesh.count); + } + this.meshOffset(p,this.renderOrigin); + gl.bindVertexArray(this.dynamic.vao); + gl.drawArrays(gl.TRIANGLES, 0, this.dynamic.count); + gl.disable(gl.POLYGON_OFFSET_FILL); + gl.colorMask(true, true, true, true); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + meshOffset(program,origin=[0,0,0]) { + this.gl.uniform3fv(this.gl.getUniformLocation(program,"uOffset"),origin.map((v,i)=>v-this.renderOrigin[i])); + } + mesh(data, stride = 20) { const gl = this.gl, vao = gl.createVertexArray(), buffer = gl.createBuffer(); @@ -226,6 +313,9 @@ export class Renderer { [2, 3, 6], [3, 1, 9], [4, 1, 10], + [5, 3, 11], + [6, 2, 14], + [7, 4, 16], ]) { gl.enableVertexAttribArray(loc); gl.vertexAttribPointer( @@ -244,7 +334,7 @@ export class Renderer { this.gl.deleteBuffer(mesh.buffer); this.gl.deleteVertexArray(mesh.vao); } - upload(mesh, data, stride = 11) { + upload(mesh, data, stride = 20) { const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW); @@ -291,61 +381,259 @@ export class Renderer { if (effect) this.effectTextured = true; else this.textured = true; } + createBlockTexture(size, layers, height = size) { + const gl = this.gl, + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D_ARRAY, texture); + gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, gl.RGBA8, size, height, layers); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT); + return texture; + } + clearTexturePack() { + this.texturePackGeneration++; + this.blockAtlasGrid = [0, 0]; + this.blockGrassOverlay = [-1, -1]; + this.textureLayers.clear(); + this.faceTextureCache.clear(); + this.gl.deleteTexture(this.blockTexture); + this.blockTexture = this.createBlockTexture(1, 1); + this.invalidate(); + } + async setTexturePack(descriptor, files) { + const gl = this.gl; + const {layers, width, height, atlas, entries, grassOverlay} = prepareTexturePack(descriptor, files, { + layers: gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS), size: gl.getParameter(gl.MAX_TEXTURE_SIZE), + }); + const bitmaps = []; + const generation = ++this.texturePackGeneration; + let next; + try { + for (const entry of atlas ? [atlas] : entries) { + const bitmap = await createImageBitmap(files.get(entry.path), { + premultiplyAlpha: "none", + colorSpaceConversion: "none", + }); + bitmaps.push(bitmap); + if (generation !== this.texturePackGeneration) return; + if (bitmap.width !== width || bitmap.height !== height) + throw Error(`Текстура ${entry.name || "atlas"} должна быть ${width}×${height}.`); + } + // Separate array layers prevent atlas bleeding; only native pixel data reaches the GPU. + next = this.createBlockTexture(width, bitmaps.length, height); + for (let i = 0; i < bitmaps.length; i++) + gl.texSubImage3D( + gl.TEXTURE_2D_ARRAY, + 0, + 0, + 0, + i, + width, + height, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + bitmaps[i], + ); + gl.deleteTexture(this.blockTexture); + this.blockTexture = next; + next = null; + this.textureLayers = layers; + this.blockAtlasGrid = atlas ? [atlas.columns, atlas.rows] : [0, 0]; + this.blockGrassOverlay = grassOverlay; + this.faceTextureCache.clear(); + this.invalidate(); + } finally { + for (const bitmap of bitmaps) bitmap.close(); + if (next) gl.deleteTexture(next); + } + } + faceTextures(mat) { + if (!this.textureLayers.size) return null; + if (!this.faceTextureCache.has(mat.state)) + this.faceTextureCache.set( + mat.state, + getBlockFaceTextures(mat.state, this.textureLayers), + ); + return this.faceTextureCache.get(mat.state); + } replace(blocks, materials) { + this.meshResets++; + this.loadStarted = performance.now(); this.firstTerrainMs = null; + this.publishedSections?.clear(); + this.edits?.reset(); 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))); + this.blockLightField = null; + if (this.terrain) this.terrain.reset(blocks, materials, this.textureLayers, this.lightViewBounds); + else this.requestBlockLighting(); + if (blocks.loadedSectionKeys) for (const id of blocks.loadedSectionKeys()) this.dirty.add(id); + else for (const k of blocks.keys()) this.dirty.add(section(k.split(",").map(Number))); } - change(changes) { + change(changes, sectionIds = null, sectionRecords = null) { + if (!sectionIds && changes.length > 64) this.edits?.reset(); + const owners = changes.map(({pos}) => section(pos)); + const urgent = !sectionIds && changes.length <= 64 ? [...new Set([...owners, ...changes.flatMap(({pos}) => + affectedSectionKeys(pos, [{ min: [-2, -2, -2], max: [3, 3, 3] }]).filter(id => this.sections.has(id) || this.edits?.tickets.has(id)))])].filter(id => this.sectionInView(id)) : []; + if (changes.length || sectionRecords) { + if (this.terrain) this.terrain.update({ ...(sectionRecords ? { sections: sectionRecords } : { changes }), materials: this.materials }); + else this.requestBlockLighting(); + } + if (sectionIds) { + const affected = new Set(); + // A stream packet already identifies whole entering/departing sections. + // Mark their halos once instead of repeating the same work per voxel. + for (const coords of sectionIds) + for (let x = -1; x <= 1; x++) + for (let y = -1; y <= 1; y++) + for (let z = -1; z <= 1; z++) { + const id = coords.map((v, axis) => v + [x, y, z][axis]).join(","); + if (this.sectionInView(id)) this.dirty.add(id); + affected.add(id); + } + this.edits?.request([...this.edits.tickets.keys()].filter(id => affected.has(id))); + return; + } + this.edits?.request(urgent); 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]))); + // Geometry and corner lighting can cross section edges diagonally. + for (const id of affectedSectionKeys(pos, [{ min: [-2, -2, -2], max: [3, 3, 3] }])) + this.dirty.add(id); } } + unloadSections(ids) { + if (ids.length) { + if (this.terrain) this.terrain.update({ unload: ids }); + else this.requestBlockLighting(); + } + const removed = new Set(ids.map((id) => id.join(","))); + for (const id of removed) { + this.edits?.cancel(id); + this.publishedSections?.delete(id); + const mesh = this.sections.get(id); + if (mesh) this.disposeMesh(mesh); + this.sections.delete(id); + this.dirty.delete(id); + } + // Retained face and diagonal neighbors may lose an occluder. + const refreshEdits = new Set(); + for (const coords of ids) + for (let x = -1; x <= 1; x++) + for (let y = -1; y <= 1; y++) + for (let z = -1; z <= 1; z++) { + const neighbor = coords.map((v, axis) => v + [x, y, z][axis]).join(","); + if (!removed.has(neighbor) && this.sections.has(neighbor)) this.dirty.add(neighbor); + if (!removed.has(neighbor) && this.edits?.tickets.has(neighbor)) refreshEdits.add(neighbor); + } + this.edits?.request([...refreshEdits]); + } invalidate() { - for (const k of this.blocks.keys()) - this.dirty.add(section(k.split(",").map(Number))); + this.edits?.reset(); + if (this.terrain) this.terrain.update({ materials: this.materials, textures: this.textureLayers }); + else this.requestBlockLighting(); + if (this.blocks.loadedSectionKeys) for (const id of this.blocks.loadedSectionKeys()) this.dirty.add(id); + else for (const k of this.blocks.keys()) this.dirty.add(section(k.split(",").map(Number))); + } + setLightBounds(bounds) { + if (JSON.stringify(bounds) === JSON.stringify(this.lightViewBounds)) return; + this.lightViewBounds = bounds; + if (this.terrain) this.terrain.update({ bounds }); + else this.requestBlockLighting(); + } + requestBlockLighting() { + if (this.terrain) { this.terrain.update({ materials: this.materials }); return; } + if (this.blockLighting) + this.blockLighting.request(this.blocks, this.materials, this.lightViewBounds || inferLightBounds(this.blocks)); + } + enableTerrainFallback() { + if (!this.terrain) return; + this.terrain.dispose(); + this.edits?.dispose(); + this.discardMeshUpload(); + this.terrain = null; + this.blockLighting = new BlockLightController(field => { + for (const id of changedLightSections(this.blockLightField, field, this.sections.keys())) this.dirty.add(id); + this.blockLightField = field; + }); + this.requestBlockLighting(); + for (const key of this.blocks.keys()) this.dirty.add(section(key.split(",").map(Number))); + } + sectionInView(id) { + if (!this.lightViewBounds) return true; + return id.split(",").every((value, axis) => { + const base = Number(value) * 16; + return base <= this.lightViewBounds.max[axis] && base + 15 >= this.lightViewBounds.min[axis]; + }); + } + discardMeshUpload() { + if (!this.meshUpload) return; + for (const part of this.meshUpload.parts) if (part.mesh) this.disposeMesh(part.mesh); + this.meshUpload = null; + } + isMeshCurrent(result) { + return result.editJob !== undefined ? Boolean(this.edits?.isCurrent(result)) : this.terrain.isCurrent(result); + } + /** Upload at most 64 KiB per step; publish both surfaces only when complete. */ + applyTerrainMeshes(budgetMs) { + const start = performance.now(), gl = this.gl; + if (this.meshUpload && !this.isMeshCurrent(this.meshUpload.result)) this.discardMeshUpload(); + while (performance.now() - start < budgetMs) { + if (!this.meshUpload) { + const queue = this.edits?.ready.size ? this.edits.ready : this.terrain.ready; + const result = queue.values().next().value; + if (!result) break; + queue.delete(result.id); + if (!this.isMeshCurrent(result) || !this.sectionInView(result.id)) continue; + const parts = [result.vertices, result.transparent].map(data => ({ data, mesh: null, offset: 0 })); + this.meshUpload = { result, parts }; + } + const pending = this.meshUpload; + const part = pending.parts.find(p => p.offset < p.data.length); + if (part) { + if (!part.mesh) { + part.mesh = this.mesh([]); + part.mesh.count = part.data.length / 20; + gl.bindBuffer(gl.ARRAY_BUFFER, part.mesh.buffer); + gl.bufferData(gl.ARRAY_BUFFER, part.data.byteLength, gl.STATIC_DRAW); + if (performance.now() - start >= budgetMs) break; + } + const end = Math.min(part.offset + 16384, part.data.length); + gl.bindBuffer(gl.ARRAY_BUFFER, part.mesh.buffer); + gl.bufferSubData(gl.ARRAY_BUFFER, part.offset * 4, part.data.subarray(part.offset, end)); + part.offset = end; + } else { + const { id } = pending.result, previous = this.sections.get(id); + const [opaque, transparent] = pending.parts; + if (opaque.mesh || transparent.mesh) { + const next = opaque.mesh || this.mesh([]); + next.transparent = transparent.mesh; + next.origin = pending.result.origin || [0,0,0]; + if (previous) { + const retired = { vao: previous.vao, buffer: previous.buffer, transparent: previous.transparent }; + Object.assign(previous, next); + this.disposeMesh(retired); + } else this.sections.set(id, next); + } else if (previous) { this.disposeMesh(previous); this.sections.delete(id); } + if (!pending.result.preview) { this.dirty.delete(id); this.edits?.cancel(id); } + this.publishedSections?.add(id); + if (this.firstTerrainMs === null && this.sections.has(id)) this.firstTerrainMs=performance.now()-this.loadStarted; + this.onMeshPublished?.(id, pending.result); + this.meshRebuilds++; + this.meshUpload = null; + } + } + this.meshUploadMilliseconds = performance.now() - start; } 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, - ); - } - } + this.meshRebuilds++; + const { vertices, transparent, origin } = buildSectionMesh({ id, blocks: this.blocks, + materials: this.materials, textureLayers: this.textureLayers, + faceTextureCache: this.faceTextureCache, blockLightField: this.blockLightField, localCoordinates: true }); let mesh = this.sections.get(id); if (mesh) this.upload(mesh, vertices); else if (vertices.length || transparent.length) { @@ -353,6 +641,7 @@ export class Renderer { this.sections.set(id, mesh); } if (mesh) { + mesh.origin = origin; if (mesh.transparent) this.upload(mesh.transparent, transparent); else if (transparent.length) mesh.transparent = this.mesh(transparent); } @@ -362,6 +651,7 @@ export class Renderer { } } updateSelection(hit) { + this.selectionOrigin = hit ? hit.pos.map(v=>Math.floor(v/16)*16) : [0,0,0]; const a = []; if (hit) { const mat = this.materials.get(hit.block), @@ -372,7 +662,7 @@ export class Renderer { points.push( [0, 1, 2].map( (k) => - hit.pos[k] + + hit.pos[k] - this.selectionOrigin[k] + ((i >> k) & 1 ? box.max[k] + 0.003 : box.min[k] - 0.003), ), ); @@ -386,6 +676,7 @@ export class Renderer { } draw(camera, players, entities, entityDefinitions) { if (this.lost) return; + const distance = viewDistanceProfile(this.viewDistance); const gl = this.gl, ratio = Math.min(devicePixelRatio || 1, 1.75), w = Math.floor(this.canvas.clientWidth * ratio), @@ -394,40 +685,131 @@ export class Renderer { this.canvas.width = w; this.canvas.height = h; } + if (this.terrain) { + this.edits?.pump(); + this.applyTerrainMeshes(2); + const eye = camera.eye; + let nearest = null, distance = Infinity; + if (!this.terrain.busy && !this.terrain.pending && this.terrain.ready.size < 2) for (const id of this.dirty) { + if (!this.sectionInView(id)) { this.dirty.delete(id); continue; } + if (this.blocks.readySections && !this.blocks.readySections.has(id)) continue; + if (this.blocks.sectionIsEmpty?.(id)) { + const mesh = this.sections.get(id); + if (mesh) this.disposeMesh(mesh); + this.sections.delete(id); this.dirty.delete(id); this.edits?.cancel(id); + this.publishedSections?.add(id); + continue; + } + if (this.terrain.ready.has(id) || this.meshUpload?.result.id === id || this.terrain.busy?.id === id) continue; + const center = id.split(",").map(v => Number(v) * 16 + 8); + const next = center.reduce((sum, v, axis) => sum + (v - eye[axis]) ** 2, 0); + if (next < distance) { distance = next; nearest = id; } + } + if (nearest !== null) this.terrain.requestMesh(nearest); + } else { + const start = performance.now(); + for (const id of this.dirty) { + this.rebuild(id); this.dirty.delete(id); + if (performance.now() - start > 2) break; + } + } + this.renderOrigin = camera.eye.map(v=>Math.floor(v/16)*16); + const localEye = camera.eye.map((v,i)=>v-this.renderOrigin[i]); + const dynamic = []; + for (const p of players) { + for (const part of playerAvatarParts(p)) + boxVertices( + dynamic, + p.position, + part.box, + part.color, + part.material, + () => false, + p.yaw || 0, + 1, null, "", null, 0, this.blockLightField, true, this.renderOrigin, + ); + } + 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, + 1, null, "", null, 0, this.blockLightField, true, this.renderOrigin); + } + this.upload(this.dynamic, dynamic); + const shadow = shadowFrame(localEye, SUN_DIRECTION, this.shadowSize, this.shadowRadius); + this.drawShadows(shadow); + this.canvas.dataset.lighting = this.lightingName; + const center=camera.eye.map(v=>Math.floor(v/16)); + let nearReady=0,nearLoaded=0; + for(let x=-1;x<=1;x++)for(let y=-1;y<=1;y++)for(let z=-1;z<=1;z++) { + const id=`${center[0]+x},${center[1]+y},${center[2]+z}`; + if(this.publishedSections?.has(id))nearReady++; + if(this.blocks.readySections?.has(id))nearLoaded++; + } + this.canvas.dataset.nearMeshes=String(nearReady); + this.canvas.dataset.nearLoaded=String(nearLoaded); + this.canvas.dataset.firstTerrainMs=this.firstTerrainMs===null ? "" : String(Math.round(this.firstTerrainMs)); + this.canvas.dataset.terrainMeshing = this.terrain?.meshWorker ? "local-light-worker" : "shared-worker"; + this.canvas.dataset.localLightCells = String(this.terrain?.localLightCells || 0); + this.canvas.dataset.localLightMs = String(Math.round((this.terrain?.localLightMilliseconds || 0)*100)/100); + this.canvas.dataset.editPrepareMs = String(Math.round((this.edits?.prepareMilliseconds || 0) * 100) / 100); + this.canvas.dataset.editMeshMs = String(Math.round((this.edits?.meshMilliseconds || 0) * 100) / 100); + this.canvas.dataset.editWorker = this.edits && !this.edits.error && !this.edits.disposed ? "ready" : "fallback"; + this.canvas.dataset.terrainMode = this.terrain ? "worker" : "main-thread-fallback"; + this.canvas.dataset.terrainPrepareMs = String(Math.round((this.terrain?.prepareMilliseconds || 0) * 100) / 100); + this.canvas.dataset.meshBuildMs = String(Math.round((this.terrain?.meshMilliseconds || 0) * 100) / 100); + this.canvas.dataset.meshUploadMs = String(Math.round((this.meshUploadMilliseconds || 0) * 100) / 100); + this.canvas.dataset.timeOfDay = this.daylight === 0 ? "night" : "day"; + this.canvas.dataset.shadows = this.shadowsEnabled && this.shadowReady ? String(this.shadowSize) : "off"; + this.canvas.dataset.blockLighting = this.blockLighting.status; + this.canvas.dataset.lightSources = String(this.blockLightField?.sourceCount || 0); + this.canvas.dataset.lightBuildMs = String(Math.round(this.blockLighting.milliseconds || 0)); + const eyeLight = this.blockLightField?.sample(camera.eye); + this.canvas.dataset.blockLight = String(eyeLight?.blockLevel ?? 0); + this.canvas.dataset.skyLight = String(eyeLight?.skyLevel ?? 15); 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, "uDaylight"), this.daylight); + gl.uniform1f(gl.getUniformLocation(this.sky, "uSkyExposure"), ((eyeLight?.skyLevel ?? 15) / 15) ** 2); 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.uniform1f(gl.getUniformLocation(this.sky, "uTanHalfFov"), Math.tan(Math.PI / 5.4)); 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), + perspective(Math.PI / 2.7, w / h, 0.06, distance.farClip), + viewMatrix(localEye, 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.uniform1f(gl.getUniformLocation(this.program, "uDaylight"), this.daylight); + gl.uniform1f(gl.getUniformLocation(this.program, "uFogDistance"), distance.fogDistance); + gl.uniform1f(gl.getUniformLocation(this.program, "uViewDistance"), distance.radius); gl.uniformMatrix4fv( gl.getUniformLocation(this.program, "uVP"), false, this.vp, ); - gl.uniform3fv(gl.getUniformLocation(this.program, "uEye"), camera.eye); + gl.uniform3fv(gl.getUniformLocation(this.program, "uEye"), localEye); + gl.uniformMatrix4fv(gl.getUniformLocation(this.program, "uLightVP"), false, shadow.matrix); + gl.uniform3fv(gl.getUniformLocation(this.program, "uShadowCenter"), shadow.center); + gl.uniform1f(gl.getUniformLocation(this.program, "uShadowRadius"), shadow.radius); + gl.uniform1f(gl.getUniformLocation(this.program, "uShadowTexel"), 1 / this.shadowSize); + gl.uniform1i(gl.getUniformLocation(this.program, "uShadows"), this.shadowsEnabled && this.shadowReady ? 1 : 0); + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexture); + gl.uniform1i(gl.getUniformLocation(this.program, "uShadowMap"), 3); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.texture); gl.uniform1i(gl.getUniformLocation(this.program, "uTexture"), 0); @@ -442,6 +824,11 @@ export class Renderer { gl.getUniformLocation(this.program, "uEffectTextured"), this.effectTextured ? 1 : 0, ); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.blockTexture); + gl.uniform1i(gl.getUniformLocation(this.program, "uBlockTextures"), 2); + gl.uniform2fv(gl.getUniformLocation(this.program, "uBlockAtlasGrid"), this.blockAtlasGrid); + gl.uniform2fv(gl.getUniformLocation(this.program, "uGrassOverlay"), this.blockGrassOverlay); gl.uniform1f( gl.getUniformLocation(this.program, "uPulse"), Math.max(0, 1 - (performance.now() - this.bounceAt) / 600), @@ -450,80 +837,17 @@ export class Renderer { 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 + Math.hypot(center[0] - camera.eye[0], center[2] - camera.eye[2]) > distance.drawRadius ) continue; + this.meshOffset(this.program,m.origin); 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); + // Actor vertices already use the camera origin. Reset the world program's + // offset after terrain; its last section must never displace players/mobs. + this.meshOffset(this.program,this.renderOrigin); gl.bindVertexArray(this.dynamic.vao); gl.drawArrays(gl.TRIANGLES, 0, this.dynamic.count); this.triangles += this.dynamic.count / 3; @@ -540,6 +864,7 @@ export class Renderer { return dist(b[0]) - dist(a[0]); }); for (const [, m] of translucent) { + this.meshOffset(this.program,m.origin); gl.bindVertexArray(m.transparent.vao); gl.drawArrays(gl.TRIANGLES, 0, m.transparent.count); this.triangles += m.transparent.count / 3; @@ -553,6 +878,7 @@ export class Renderer { false, this.vp, ); + this.meshOffset(this.line,this.selectionOrigin); gl.bindVertexArray(this.lines.vao); gl.drawArrays(gl.LINES, 0, this.lines.count); } @@ -560,7 +886,7 @@ export class Renderer { } project(p) { return project( - p, + p.map((v,i)=>v-this.renderOrigin[i]), this.vp, this.canvas.clientWidth, this.canvas.clientHeight, diff --git a/client/section-block-map.js b/client/section-block-map.js new file mode 100644 index 0000000..7e9e1b7 --- /dev/null +++ b/client/section-block-map.js @@ -0,0 +1,68 @@ +const sectionKey = (key) => + key + .split(",") + .map((value) => Math.floor(Number(value) / 16)) + .join(","); + +/** Block positions indexed by their 16³ section without copying block values. */ +export class SectionBlockMap extends Map { + #sections = new Map(); + + constructor(entries) { + super(); + if (entries != null) + for (const [key, value] of entries) this.set(key, value); + } + + set(key, value) { + if (!super.has(key)) this.#index(key, sectionKey(key)); + return super.set(key, value); + } + + /** Insert using a section ID already validated by the caller. */ + setInSection(key, value, section) { + if (!super.has(key)) this.#index(key, section); + return super.set(key, value); + } + + #index(key, section) { + let keys = this.#sections.get(section); + if (!keys) this.#sections.set(section, (keys = new Set())); + keys.add(key); + } + + delete(key) { + if (!super.delete(key)) return false; + const section = sectionKey(key), + keys = this.#sections.get(section); + keys.delete(key); + if (!keys.size) this.#sections.delete(section); + return true; + } + + /** Remove every stored block in a section and return the number removed. */ + deleteSection(section) { + const keys = this.#sections.get(section); + if (!keys) return 0; + const count = keys.size; + for (const key of keys) super.delete(key); + keys.clear(); + this.#sections.delete(section); + return count; + } + + clear() { + super.clear(); + this.#sections.clear(); + } + + /** Live key iterator; an absent or empty section contains no stored blocks. */ + keysInSection(section) { + return this.#sections.get(section)?.keys() ?? [][Symbol.iterator](); + } + + /** Sections containing stored blocks; empty loaded sections have no entries. */ + loadedSectionKeys() { + return this.#sections.keys(); + } +} diff --git a/client/section-stream.js b/client/section-stream.js new file mode 100644 index 0000000..a6f9b17 --- /dev/null +++ b/client/section-stream.js @@ -0,0 +1,64 @@ +import { MAX_VIEW_WIDTH, VIEW_HEIGHT, MAX_VIEW_CELLS, MAX_VIEW_COORDINATE, WORLD_MIN_Y, WORLD_MAX_Y } from "./view-distance.js"; +const triple=p=>Array.isArray(p)&&p.length===3&&p.every(Number.isSafeInteger); +const sectionId=p=>p.join(','); +export function decodeSection(record) { + if(!triple(record?.section)||record.section.some(v=>Math.abs(v*16)>MAX_VIEW_COORDINATE))throw Error('Invalid section coordinates'); + const {palette,runs}=record; + if(!Array.isArray(palette)||!palette.length||palette.length>4096||!palette.every(v=>Number.isInteger(v)&&v>=0&&v<=0xffffffff))throw Error('Invalid section palette'); + if(!Array.isArray(runs)||!runs.length||runs.length>8192||runs.length%2)throw Error('Invalid section runs'); + const uniform = runs.length === 2 && runs[0] === 4096 && Number.isInteger(runs[1]) && runs[1] >= 0 && runs[1] < palette.length; + if (uniform) return {section:[...record.section],cells:new Uint32Array([palette[runs[1]]])}; + const cells=new Uint32Array(4096);let offset=0; + for(let i=0;i=palette.length||offset+count>4096)throw Error('Invalid section run'); + cells.fill(palette[index],offset,offset+count);offset+=count; + } + if(offset!==4096)throw Error('Incomplete section'); + return {section:[...record.section],cells}; +} +export function sectionInBounds(section,bounds) { + return section.every((v,i)=>v*16>=bounds.min[i]&&v*16+15<=bounds.max[i]); +} +export function applySectionView(view,message) { + if(message.world!==view.world)return 'ignored'; + if(!Number.isSafeInteger(message.generation)||message.generation<=view.viewGeneration)return 'ignored'; + if(message.revision!==view.revision||!triple(message.view_center)||message.view_center.some(v=>v%16)||!triple(message.view_min)||!triple(message.view_max)||!Array.isArray(message.unload))return 'resync'; + const bounds={min:message.view_min,max:message.view_max}; + if(message.full_height===true){ + if(bounds.min[1]!==WORLD_MIN_Y||bounds.max[1]!==WORLD_MAX_Y)return 'resync'; + bounds.fullHeight=true; + } + let volume=1; + for(let i=0;i<3;i++) {const length=bounds.max[i]-bounds.min[i]+1;if(length<=0||length>(i===1?VIEW_HEIGHT:MAX_VIEW_WIDTH)||bounds.min[i]%16||(bounds.max[i]+1)%16||Math.abs(bounds.min[i])>MAX_VIEW_COORDINATE||Math.abs(bounds.max[i])>MAX_VIEW_COORDINATE||(!(i===1&&bounds.fullHeight)&&(message.view_center[i]bounds.max[i])))return 'resync';volume*=length;} + if(volume>MAX_VIEW_CELLS||message.total_sections!==undefined&&message.total_sections!==volume/4096)return 'resync'; + const unload=new Set(); + for(const p of message.unload) {if(!triple(p)||sectionInBounds(p,bounds)||unload.has(sectionId(p)))return 'resync';unload.add(sectionId(p));} + // Authoritative bounds determine departing sections; explicit unloads are + // checked above but cannot accidentally leave old sections resident. + for(const id of view.blocks.loadedSectionKeys())if(!sectionInBounds(id.split(',').map(Number),bounds))unload.add(id); + for(const id of unload) {view.blocks.deleteSection(id);view.loadedSections.delete(id);} + view.viewCenter=[...message.view_center];view.viewBounds=bounds;view.viewGeneration=message.generation; + view.totalSections=volume/4096; + return {unload:[...unload].map(id=>id.split(',').map(Number))}; +} +export function applySectionBatch(view,message) { + if(message.world!==view.world||message.generation!==view.viewGeneration)return {status:'ignored'}; + if(message.revision!==view.revision||!Array.isArray(message.sections)||message.sections.length>128)return {status:'resync'}; + const decoded=[],seen=new Set(),columns=[]; + try { + if(message.columns!==undefined&&(!Array.isArray(message.columns)||message.columns.length>128))throw Error('Invalid skylight columns'); + for(const record of message.sections) { + const result=decodeSection(record),id=sectionId(result.section); + if(seen.has(id)||!sectionInBounds(result.section,view.viewBounds))throw Error('Section outside view'); + seen.add(id);decoded.push(result); + } + for(const column of message.columns||[]) { + if(!Array.isArray(column.column)||column.column.length!==2||!column.column.every(Number.isSafeInteger)||!Array.isArray(column.heights)||column.heights.length!==256||!column.heights.every(v=>Number.isInteger(v)&&v>=-32768&&v<=32767))throw Error('Invalid skylight column'); + if(!column.column.every((v,i)=>v*16>=view.viewBounds.min[i*2]&&v*16+15<=view.viewBounds.max[i*2]))throw Error('Skylight column outside view'); + columns.push({column:[...column.column],heights:new Int16Array(column.heights)}); + } + } catch {return {status:'resync'};} + for(const {section,cells} of decoded) {const id=sectionId(section);view.blocks.setSection(id,cells);view.loadedSections.add(id);} + return {status:'applied',sections:decoded,columns}; +} diff --git a/client/section-voxel-map.js b/client/section-voxel-map.js new file mode 100644 index 0000000..6377507 --- /dev/null +++ b/client/section-voxel-map.js @@ -0,0 +1,75 @@ +/** 16³ sections; uniform air/solid sections retain a single uint32 value. + * Compatibility iterators visit non-air blocks; hot paths use numeric access. + */ +export class SectionVoxelMap { + #sections = new Map(); + #bytes = 0; + size = 0; + static sectionKey(x,y,z) { return `${Math.floor(x/16)},${Math.floor(y/16)},${Math.floor(z/16)}`; } + static index(x,y,z) { return ((x%16+16)%16)+16*((z%16+16)%16)+256*((y%16+16)%16); } + getAt(x,y,z) { const cells=this.#sections.get(SectionVoxelMap.sectionKey(x,y,z))?.cells; return cells ? cells[cells.length===1 ? 0 : SectionVoxelMap.index(x,y,z)] : 0; } + get(key) { const p=key.split(',').map(Number); return this.getAt(...p) || undefined; } + has(key) { return this.get(key)!==undefined; } + hasSection(id) { return this.#sections.has(id); } + set(key,value) { + const p=key.split(',').map(Number), id=SectionVoxelMap.sectionKey(...p); + let section=this.#sections.get(id); + if(!section) {section={cells:new Uint32Array(4096),count:0,ids:new Map()};this.#sections.set(id,section);this.#bytes+=16384;} + const index=SectionVoxelMap.index(...p), old=section.cells[section.cells.length===1 ? 0 : index]; + if(old===value) return this; + if(section.cells.length===1) {section.cells=new Uint32Array(4096).fill(old);this.#bytes+=16380;} + if(old) {this.size--;section.count--;const count=section.ids.get(old)-1;if(count) section.ids.set(old,count);else section.ids.delete(old);} + section.cells[index]=value; + if(value) {this.size++;section.count++;section.ids.set(value,(section.ids.get(value)||0)+1);} + return this; + } + setInSection(key,value) {return this.set(key,value);} + delete(key) {if(!this.has(key)) return false;this.set(key,0);return true;} + clear() {this.#sections.clear();this.size=0;this.#bytes=0;} + deleteSection(id) {const previous=this.#sections.get(id);if(!previous)return 0;this.size-=previous.count;this.#bytes-=previous.cells.byteLength;this.#sections.delete(id);return previous.count;} + setSection(id,cells) { + if(!(cells instanceof Uint32Array)||![1,4096].includes(cells.length)) throw Error('A section must contain 4096 uint32 cells or one uniform value'); + const ids=new Map();let count=0; + for(const value of cells) if(value) {const n=cells.length===1 ? 4096 : 1;count+=n;ids.set(value,(ids.get(value)||0)+n);} + this.size+=count-(this.#sections.get(id)?.count||0); + this.#bytes+=cells.byteLength-(this.#sections.get(id)?.cells.byteLength||0); + this.#sections.set(id,{cells,count,ids}); + } + /** Borrowed dense cells or a single uniform value; copy before transferring. */ + getSectionCells(id) { return this.#sections.get(id)?.cells; } + get sectionCount() {return this.#sections.size;} + get byteLength() {return this.#bytes;} + sectionIsEmpty(id) {return this.#sections.get(id)?.count===0;} + loadedSectionKeys() {return this.#sections.keys();} + *sectionEntries() {for(const [id,section] of this.#sections) yield [id,section.cells];} + *materialIds() {const seen=new Set();for(const section of this.#sections.values())for(const id of section.ids.keys())if(!seen.has(id)){seen.add(id);yield id;}} + forEachBlock(callback,bounds=null) { + const selected = function* (sections) { + if (!bounds) {yield* sections;return;} + for(let x=Math.floor(bounds.min[0]/16);x<=Math.floor(bounds.max[0]/16);x++) + for(let y=Math.floor(bounds.min[1]/16);y<=Math.floor(bounds.max[1]/16);y++) + for(let z=Math.floor(bounds.min[2]/16);z<=Math.floor(bounds.max[2]/16);z++) { + const id=`${x},${y},${z}`,section=sections.get(id); + if(section)yield [id,section]; + } + }; + for(const [id,section] of selected(this.#sections)) { + if(!section.count)continue; + const [sx,sy,sz]=id.split(',').map(v=>Number(v)*16), cells=section.cells; + if(bounds&&(sx>bounds.max[0]||sx+15bounds.max[1]||sy+15bounds.max[2]||sz+15>8),z=sz+((i>>4)&15); + if(!bounds||(x>=bounds.min[0]&&x<=bounds.max[0]&&y>=bounds.min[1]&&y<=bounds.max[1]&&z>=bounds.min[2]&&z<=bounds.max[2]))callback(x,y,z,value); + }} + } + } + *keysInSection(id) { + const section=this.#sections.get(id);if(!section?.count)return; + const [sx,sy,sz]=id.split(',').map(v=>Number(v)*16); + for(let i=0;i<4096;i++)if(section.cells[section.cells.length===1?0:i])yield `${sx+(i&15)},${sy+(i>>8)},${sz+((i>>4)&15)}`; + } + *entries() {for(const id of this.#sections.keys())for(const key of this.keysInSection(id))yield [key,this.get(key)];} + *keys() {for(const id of this.#sections.keys())yield* this.keysInSection(id);} + *values() {for(const section of this.#sections.values())if(section.count)for(let i=0;i<4096;i++){const value=section.cells[section.cells.length===1?0:i];if(value)yield value;}} + [Symbol.iterator]() {return this.entries();} +} diff --git a/client/shadow-frame.js b/client/shadow-frame.js new file mode 100644 index 0000000..9f7add1 --- /dev/null +++ b/client/shadow-frame.js @@ -0,0 +1,25 @@ +/** Directional orthographic projection, snapped in light space to avoid crawling shadows. */ +export function shadowFrame(eye, sun, resolution = 2048, radius = 48) { + const length = Math.hypot(...sun), + z = sun.map((v) => v / length), + horizontal = Math.hypot(z[0], z[2]), + x = horizontal > 1e-6 ? [z[2] / horizontal, 0, -z[0] / horizontal] : [1, 0, 0], + y = [z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0]], + dot = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0), + texel = 2 * radius / resolution, + sx = Math.round(dot(x, eye) / texel) * texel, + sy = Math.round(dot(y, eye) / texel) * texel, + sz = dot(z, eye), + depth = radius * 2, + center = eye.map((_, i) => x[i] * sx + y[i] * sy + z[i] * sz); + return { + center, + radius, + matrix: new Float32Array([ + x[0] / radius, y[0] / radius, -z[0] / depth, 0, + x[1] / radius, y[1] / radius, -z[1] / depth, 0, + x[2] / radius, y[2] / radius, -z[2] / depth, 0, + -sx / radius, -sy / radius, sz / depth, 1, + ]), + }; +} diff --git a/client/style.css b/client/style.css index 6ded951..07e3cb3 100644 --- a/client/style.css +++ b/client/style.css @@ -18,7 +18,8 @@ body { overflow: hidden; } button, -input { +input, +select { font: inherit; } button { @@ -33,7 +34,8 @@ button:hover { background: #4c5e4a; } button:focus-visible, -input:focus-visible { +input:focus-visible, +select:focus-visible { outline: 2px solid #dfefba; outline-offset: 3px; } @@ -47,7 +49,7 @@ button.primary { color: #253021; font-weight: 650; } -input { +input, select { background: #202821; color: #f4f4ef; border: 1px solid #576255; @@ -367,7 +369,7 @@ footer { gap: 6px; margin-bottom: 20px; } -.inline input { +.inline input, .inline select { min-width: 0; } .inline button { @@ -595,3 +597,6 @@ body.locked .topbar:hover { inset: 25% 14px auto; } } + +/* Keep the live readout beside menus, while the world remains interactive. */ +#diagnostics { left: 24px; right: auto; } diff --git a/client/terrain-controller.js b/client/terrain-controller.js new file mode 100644 index 0000000..a2dc067 --- /dev/null +++ b/client/terrain-controller.js @@ -0,0 +1,231 @@ +import { localLightBounds } from "./local-light-bounds.js"; +import { attachBlockLightSamplers } from "./block-light.js"; +import { SectionBlockMap } from "./section-block-map.js"; + +const slimMaterial = m => ({ state: m.state, minecraft_id: m.minecraft_id, color: m.color, + render: m.render, opacity: m.opacity, transparent: m.transparent, effect: m.effect, + light: m.light, light_dampening: m.light_dampening, light_occlusion: m.light_occlusion }); + +/** Separate lighting and local mesh workers; one mesh in flight, two ready. */ +export class TerrainController { + constructor({ onLight = () => {}, onDirty = () => {}, onError = () => {}, workerFactory, meshWorkerFactory } = {}) { + this.onLight = onLight; this.onDirty = onDirty; + this.onError = onError; + this.epoch = 0; this.version = 0; this.workerVersion = -1; this.job = 0; + this.meshTickets = new Map(); this.materialSignatures = new Map(); + this.meshSerial = 0; + this.meshWorkerVersion = -1; + this.ready = new Map(); this.busy = null; this.pending = false; this.disposed = false; + this.changes = new SectionBlockMap(); this.sectionChanges = new Map(); + this.unloads = new Map(); this.sentMaterials = new Map(); this.columns = new Map(); this.compact = false; + this.status = "loading"; this.mode = "worker"; + this.materials = new Map(); this.textures = new Map(); this.bounds = null; + this.resetSource = null; this.initial = null; this.preparing = false; + this.resetPending = false; this.texturesPending = false; + try { + this.worker = workerFactory ? workerFactory() : new Worker(new URL("./terrain-worker.js", import.meta.url), { type: "module" }); + this.worker.onmessage = ({ data }) => this.receive(data); + this.worker.onerror = event => { + event.preventDefault(); this.fail(event.message || "Terrain worker failed"); + }; + } catch (error) { this.fail(error.message); } + if (this.worker && (meshWorkerFactory || typeof Worker === "function")) { + try { + this.meshWorker = meshWorkerFactory ? meshWorkerFactory() : new Worker(new URL("./terrain-mesh-worker.js",import.meta.url),{type:"module"}); + this.meshWorker.onmessage = ({data}) => this.receive(data); + this.meshWorker.onerror = event => {event.preventDefault();this.meshFallback(event.message);}; + } catch (error) { this.meshFallback(error.message); } + } + } + meshFallback(error) { + this.meshWorker?.terminate(); this.meshWorker = null; this.busy = null; this.ready.clear(); + this.onDirty([...this.meshTickets.keys()]); this.meshTickets.clear(); + console.warn("Shacraft local meshing unavailable; using terrain worker",error); + } + invalidateMeshes({changes,sections,columns,unload,materials,textures,bounds} = {}) { + if (!this.meshWorker) return; + const boundsChanged = bounds !== undefined && JSON.stringify(bounds) !== JSON.stringify(this.bounds); + let all = Boolean(textures); + if (materials) for (const [id,mat] of materials) { + const signature=JSON.stringify(slimMaterial(mat)); + if (this.materialSignatures.get(id)!==signature) {this.materialSignatures.set(id,signature);all=true;} + } + const affected = new Set((unload || []).map(p=>`${p[0]},${p[2]}`)); + for (const {section} of sections || []) affected.add(`${section[0]},${section[2]}`); + for (const {column} of columns || []) affected.add(column.join(",")); + for (const {pos} of changes || []) affected.add(`${Math.floor(pos[0]/16)},${Math.floor(pos[2]/16)}`); + const coords=[...affected].map(key=>key.split(",").map(Number)),dirty=[]; + for (const id of this.meshTickets.keys()) { + const [x,,z]=id.split(",").map(Number); + const boundaryChanged = boundsChanged && JSON.stringify(localLightBounds(id,bounds)) !== JSON.stringify(localLightBounds(id,this.bounds)); + if (!all && !boundaryChanged && !coords.some(([sx,sz])=>Math.abs(sx-x)<=2&&Math.abs(sz-z)<=2)) continue; + this.meshTickets.set(id,++this.meshSerial);this.ready.delete(id);dirty.push(id); + } + this.onDirty(dirty); + for (const p of unload || []) { const id=p.join(",");this.meshTickets.delete(id);this.ready.delete(id); } + } + fail(message) { + if (this.disposed) return; + this.status = `error: ${message}`; + this.error = message; + console.error("Shacraft terrain", message); + queueMicrotask(() => { if (!this.disposed) this.onError(message); }); + } + reset(blocks, materials, textures, bounds) { + this.epoch++; + this.meshTickets.clear(); this.materialSignatures.clear(); this.ready.clear(); this.meshWorkerVersion=-1; + for (const [id,mat] of materials) this.materialSignatures.set(id,JSON.stringify(slimMaterial(mat))); + this.changes.clear(); this.sectionChanges.clear(); this.unloads.clear(); this.sentMaterials.clear(); this.columns.clear(); + this.compact = typeof blocks.sectionEntries === "function"; + this.materials = materials; this.textures = textures; this.bounds = bounds; + this.resetSource = this.compact ? null : blocks; this.initial = null; this.preparing = false; + if (this.compact) for (const [id,cells] of blocks.sectionEntries()) this.sectionChanges.set(id,cells); + this.resetPending = true; this.texturesPending = true; + this.busy = null; + this.changed(); + } + update({ changes, sections, columns, materials, textures, bounds, unload } = {}) { + this.invalidateMeshes({changes,sections,columns,materials,textures,bounds,unload}); + if (columns) for (const item of columns) this.columns.set(item.column.join(","),item); + if (sections) for (const { section, blocks, cells } of sections) { + const id = section.join(","); + // Complete entering sections supersede older queued edits. Retain their + // numeric records until packing instead of indexing every voxel again. + this.unloads.set(id, section); + this.changes.deleteSection(id); + this.sectionChanges.set(id, cells || blocks); + } + if (changes) for (const change of changes) this.changes.set(change.pos.join(","), change); + if (materials) this.materials = materials; + if (textures) { this.textures = textures; this.texturesPending = true; } + if (bounds !== undefined) this.bounds = bounds; + if (unload) for (const coords of unload) { + const id = coords.join(","); + this.unloads.set(id, coords); + // An unload supersedes older edits; later entering records must survive it. + this.changes.deleteSection(id); + this.sectionChanges.delete(id); + } + this.changed(); + } + changed() { + this.version++; this.workerVersion = -1; + if (!this.meshWorker) this.ready.clear(); + this.pending = true; + if (!this.error) this.status = "pending"; + if (!this.timer) this.timer = setTimeout(() => { this.timer = null; this.flush(); }, 25); + } + prepareReset() { + if (this.preparing) return; + const epoch = this.epoch, source = this.resetSource, iterator = source.entries(); + let packed = new Int32Array(Math.max(1, source.size) * 4), length = 0; + this.preparing = true; + const step = () => { + if (epoch !== this.epoch || this.disposed) return; + const start = performance.now(); + for (let count = 0; ; count++) { + const next = iterator.next(); + if (next.done) { + this.initial = packed.subarray(0, length); + this.resetSource = null; this.preparing = false; + this.flush(); return; + } + if (length + 4 > packed.length) { + const larger = new Int32Array(packed.length * 2); larger.set(packed); packed = larger; + } + const [key, id] = next.value, pos = key.split(",").map(Number); + packed.set([pos[0], pos[1], pos[2], id], length); length += 4; + if (count % 64 === 63 && performance.now() - start >= 2) { + setTimeout(step, 0); return; + } + } + }; + step(); + } + flush() { + if (!this.worker || this.disposed || !this.pending) return; + if (this.resetSource) { this.prepareReset(); return; } + const start = performance.now(); + const materials = []; + for (const [id, material] of this.materials) { + if (this.sentMaterials.get(id) === material) continue; + this.sentMaterials.set(id, material); materials.push([id, slimMaterial(material)]); + } + let count = this.changes.size; + for (const records of this.sectionChanges.values()) if (!(records instanceof Uint32Array)) count += records.length; + const changes = new Int32Array(count * 4); + let i = 0; + const pack = records => { + for (const { pos, block } of records) { + changes[i++] = pos[0]; changes[i++] = pos[1]; changes[i++] = pos[2]; changes[i++] = block; + } + }; + for (const records of this.sectionChanges.values()) if (!(records instanceof Uint32Array)) pack(records); + pack(this.changes.values()); + const packet = { type: "sync", epoch: this.epoch, version: this.version, + reset: this.resetPending, compact: this.compact, initial: this.initial, changes, materials, + sections: [...this.sectionChanges].filter(([,data]) => data instanceof Uint32Array).map(([id,cells]) => ({section:id.split(",").map(Number),cells:cells.slice()})), + columns: [...this.columns.values()], + textures: this.texturesPending ? [...this.textures] : undefined, + bounds: this.bounds, unload: [...this.unloads.values()] }; + const transfers = [changes.buffer, ...packet.sections.map(s => s.cells.buffer)]; + if (this.initial) transfers.push(this.initial.buffer); + this.pending = false; this.resetPending = false; this.texturesPending = false; + this.initial = null; this.changes.clear(); this.sectionChanges.clear(); this.unloads.clear(); this.columns.clear(); + this.status = "building"; + try { + if (this.meshWorker) { + const copy=structuredClone(packet),buffers=[copy.changes.buffer,...copy.sections.map(s=>s.cells.buffer)]; + if(copy.initial)buffers.push(copy.initial.buffer); + this.meshWorker.postMessage(copy,buffers); + } + this.worker.postMessage({...packet,independentMeshes:Boolean(this.meshWorker)}, transfers); + } + catch (error) { this.fail(error.message); } + this.prepareMilliseconds = performance.now() - start; + } + receive(message) { + if (this.disposed || message.epoch !== this.epoch) return; + if (message.type === "error" && message.meshTicket !== undefined) { + if (message.job !== this.busy?.job) return; + this.busy = null; + if (this.isCurrent(message)) this.meshFallback(message.error); + return; + } + if (message.type === "synced") { + this.meshWorkerVersion = Math.max(this.meshWorkerVersion,message.version); + } else if (message.type === "light") { + // Include deltas from skipped intermediate fields too: their union is + // needed when the next field is compared to a field never displayed. + if (!this.meshWorker) this.onDirty(message.dirty); + if (message.version !== this.version) return; + this.workerVersion = message.version; + this.status = "ready"; this.milliseconds = message.milliseconds; + // Dynamic actors only need point samples, not terrain face geometry. + this.onLight(attachBlockLightSamplers(message.field, new Map(), new Map())); + } else if (message.type === "mesh") { + if (message.job !== this.busy?.job) return; + this.busy = null; + if (!this.isCurrent(message) || message.stale) return; + if (message.error) { this.fail(message.error); return; } + this.meshMilliseconds = message.milliseconds; + this.localLightMilliseconds=message.lightMilliseconds; this.localLightCells=message.lightCells; + this.ready.set(message.id, message); + } else if (message.type === "error" && message.version === this.version) this.fail(message.error); + } + isCurrent(result) { return result.epoch === this.epoch && (result.meshTicket !== undefined + ? Boolean(this.meshWorker) && this.meshTickets.get(result.id) === result.meshTicket : result.version === this.version); } + requestMesh(id) { + if (!this.worker || this.disposed || this.busy || this.pending || this.preparing || + (this.meshWorker ? this.meshWorkerVersion < this.version : this.workerVersion !== this.version) || this.ready.size >= 2 || this.ready.has(id)) return false; + this.busy = { type: "mesh", id, epoch: this.epoch, version: this.version, job: ++this.job }; + if (this.meshWorker) { + if(!this.meshTickets.has(id))this.meshTickets.set(id,++this.meshSerial); + this.busy.meshTicket=this.meshTickets.get(id); + } + try { (this.meshWorker || this.worker).postMessage(this.busy); } + catch (error) { this.busy = null; this.fail(error.message); return false; } + return true; + } + dispose() { this.disposed = true; clearTimeout(this.timer); this.worker?.terminate(); this.meshWorker?.terminate(); this.ready.clear(); } +} diff --git a/client/terrain-mesh-worker.js b/client/terrain-mesh-worker.js new file mode 100644 index 0000000..878be3f --- /dev/null +++ b/client/terrain-mesh-worker.js @@ -0,0 +1,13 @@ +import { TerrainState } from './terrain-state.js'; +import { loadLightProperties } from './light-properties.js'; + +// Geometry never queues behind a whole-view lighting build. +const state = loadLightProperties().then(properties => new TerrainState(properties)); +self.onmessage = ({data}) => state.then(terrain => { + if (data.type === 'sync') { + if (terrain.sync(data)) self.postMessage({type:'synced',epoch:data.epoch,version:data.version}); + } else if (data.type === 'mesh') { + const result = terrain.meshLocal(data); + self.postMessage(result, result.vertices ? [result.vertices.buffer,result.transparent.buffer] : []); + } +}).catch(error => self.postMessage({...data,type:'error',error:error.message})); diff --git a/client/terrain-state.js b/client/terrain-state.js new file mode 100644 index 0000000..92b4526 --- /dev/null +++ b/client/terrain-state.js @@ -0,0 +1,192 @@ +import { localLightBounds } from "./local-light-bounds.js"; +import { SectionVoxelMap } from "./section-voxel-map.js"; +import { SectionBlockMap } from "./section-block-map.js"; +import { buildBlockLight, attachBlockLightSamplers } from "./block-light.js"; +import { applyLightProperties } from "./light-properties.js"; +import { changedLightSections } from "./light-changes.js"; +import { buildSectionMesh } from "./mesh-geometry.js"; + +// Compare the transmitted values rather than object identity: structured clone +// creates new objects even when a chunk repeats an unchanged definition. +const materialSignature = material => JSON.stringify([ + material.state, material.minecraft_id, material.color, + material.render?.map(box => [box.min, box.max]), + material.opacity, material.transparent, material.effect, material.light, + material.light_dampening, material.light_occlusion?.map(box => [box.min, box.max]), +]); + +/** Persistent CPU terrain state. Only numeric deltas cross the worker boundary. */ +export class TerrainState { + constructor(properties) { + this.properties = properties; + this.epoch = -1; + this.version = -1; + this.blocks = new SectionBlockMap(); + this.materials = new Map(); + this.materialSignatures = new Map(); + this.changedMaterials = new Set(); + this.textureLayers = new Map(); + this.faceTextureCache = new Map(); + this.meshed = new Set(); + this.field = null; + this.bounds = null; + this.needsLighting = false; + this.skyColumns = new Map(); + this.localLights = new Map(); + } + sync(packet) { + if (packet.epoch < this.epoch || packet.epoch === this.epoch && packet.version < this.version) return false; + if (JSON.stringify(packet.bounds) !== JSON.stringify(this.bounds)) + for (const [key,entry] of this.localLights) { + const [x,z]=key.split(","); + if (JSON.stringify(localLightBounds(`${x},0,${z}`,packet.bounds)) !== JSON.stringify(entry.bounds)) this.localLights.delete(key); + } + const columns = new Set((packet.unload || []).map(p=>`${p[0]},${p[2]}`)); + for (const {section} of packet.sections || []) columns.add(`${section[0]},${section[2]}`); + for (const {column} of packet.columns || []) columns.add(column.join(",")); + for (let i=0;i<(packet.changes?.length || 0);i+=4) columns.add(`${Math.floor(packet.changes[i]/16)},${Math.floor(packet.changes[i+2]/16)}`); + for (const [id,entry] of this.localLights) { + if ([...columns].some(column=>{const [x,z]=column.split(",").map(v=>Number(v)*16);return x<=entry.bounds.max[0]&&x+15>=entry.bounds.min[0]&&z<=entry.bounds.max[2]&&z+15>=entry.bounds.min[2];})) this.localLights.delete(id); + } + if (packet.reset) { + this.localLights.clear(); + this.blocks = packet.compact ? new SectionVoxelMap() : new SectionBlockMap(); + this.skyColumns.clear(); this.materials.clear(); this.meshed.clear(); + this.materialSignatures.clear(); this.changedMaterials.clear(); + this.field = null; + } else if (packet.epoch !== this.epoch) return false; + this.epoch = packet.epoch; + this.version = packet.version; + for (const [id, material] of packet.materials || []) { + const signature = materialSignature(material); + if (this.materialSignatures.get(id) === signature) continue; + this.localLights.clear(); + this.materialSignatures.set(id, signature); + this.changedMaterials.add(id); + this.materials.set(id, applyLightProperties(material, this.properties)); + } + if (packet.textures) { + this.textureLayers = new Map(packet.textures); + this.faceTextureCache.clear(); + } + const apply = data => { + if (!data) return; + for (let i = 0; i < data.length; i += 4) { + const key = `${data[i]},${data[i + 1]},${data[i + 2]}`; + if (data[i + 3]) this.blocks.set(key, data[i + 3]); + else this.blocks.delete(key); + } + }; + apply(packet.initial); + for (const coords of packet.unload || []) { + const id = coords.join(","); + this.blocks.deleteSection(id); + this.meshed.delete(id); + } + for (const {section,cells} of packet.sections || []) this.blocks.setSection(section.join(","),cells); + for (const {column,heights} of packet.columns || []) this.skyColumns.set(column.join(","),heights); + apply(packet.changes); + this.bounds = packet.bounds; + if (this.bounds) for (const id of this.skyColumns.keys()) { + const [x,z]=id.split(",").map(v=>Number(v)*16); + if(x+15this.bounds.max[0]||z+15this.bounds.max[2])this.skyColumns.delete(id); + } + this.needsLighting = true; + return true; + } + light({dirty:compareDirty=true} = {}) { + const start = performance.now(); + const view = this.bounds || inferBounds(this.blocks); + // Terrain meshes compute their own light. The shared field is only for + // nearby moving actors and must stay bounded at any render distance. + const bounds = compareDirty ? view : actorLightBounds(view); + const field = buildBlockLight(this.blocks, this.materials, bounds, this.skyColumns.size ? this.skyColumns : null); + const sections = new Set([...this.blocks.loadedSectionKeys(), ...this.meshed]); + const dirty = new Set(compareDirty ? changedLightSections(this.field, field, sections) : []); + if (compareDirty && this.field && this.changedMaterials.size) { + // A newly learned opaque block can have the same light values as its + // placeholder but a different color, shape or texture. Scan only here in + // the worker, and preserve this union across syncs coalesced before light(). + for (const id of this.blocks.loadedSectionKeys()) { + let affected = false; + for (const key of this.blocks.keysInSection(id)) + if (this.changedMaterials.has(this.blocks.get(key))) { affected = true; break; } + if (!affected) continue; + const section = id.split(",").map(Number); + for (let x = -1; x <= 1; x++) + for (let y = -1; y <= 1; y++) + for (let z = -1; z <= 1; z++) { + const neighbor = `${section[0] + x},${section[1] + y},${section[2] + z}`; + if (sections.has(neighbor)) dirty.add(neighbor); + } + } + } + this.changedMaterials.clear(); + this.field = field; + this.needsLighting = false; + // Keep the worker's sampling arrays; only these copies are transferred. + return { type: "light", epoch: this.epoch, version: this.version, dirty: [...dirty], + milliseconds: performance.now() - start, + field: { min: field.min, size: field.size, sourceCount: field.sourceCount, + data: field.data.slice(), blockLevels: field.blockLevels.slice(), skyLevels: field.skyLevels.slice() } }; + } + meshLocal(packet) { + const result = {type:"mesh",epoch:packet.epoch,version:packet.version,id:packet.id,job:packet.job,meshTicket:packet.meshTicket}; + if (packet.epoch !== this.epoch || packet.version > this.version) return {...result,stale:true}; + const start = performance.now(), [sx,,sz] = packet.id.split(",").map(Number); + const key = `${Math.floor(sx/2)*2},${Math.floor(sz/2)*2}`, view = this.bounds || inferBounds(this.blocks); + // Light levels are at most 15. An 18-cell horizontal halo includes their + // reach and face/partial-model samples; keep full Y for unattenuated sky. + const bounds = localLightBounds(packet.id,view); + if (bounds.min.some((v,i)=>v>bounds.max[i])) return {...result,stale:true}; + let entry = this.localLights.get(key); + const cached = Boolean(entry); + if (!entry) { + const field = buildBlockLight(this.blocks,this.materials,bounds,this.skyColumns.size ? this.skyColumns : null); + entry = {bounds,field}; + if (this.localLights.size >= 8) this.localLights.delete(this.localLights.keys().next().value); + this.localLights.set(key,entry); + } else { this.localLights.delete(key); this.localLights.set(key,entry); } + const lightMilliseconds = performance.now()-start; + // Fresh samplers reference current voxels; their occlusion caches must not + // survive a sync, even when its numeric light field remains valid. + const field = attachBlockLightSamplers({...entry.field},this.blocks,this.materials); + const geometry = buildSectionMesh({id:packet.id,blocks:this.blocks,materials:this.materials, + textureLayers:this.textureLayers,faceTextureCache:this.faceTextureCache,blockLightField:field,localCoordinates:true}); + return {...result,...geometry,milliseconds:performance.now()-start,lightMilliseconds,lightCached:cached, + lightCells:field.size.reduce((a,b)=>a*b,1)}; + } + mesh(packet) { + const result = { type: "mesh", epoch: packet.epoch, version: packet.version, id: packet.id, job: packet.job }; + if (packet.epoch !== this.epoch || packet.version !== this.version || this.needsLighting) + return { ...result, stale: true }; + const start = performance.now(); + const geometry = buildSectionMesh({ id: packet.id, blocks: this.blocks, materials: this.materials, + textureLayers: this.textureLayers, faceTextureCache: this.faceTextureCache, blockLightField: this.field, localCoordinates: true }); + this.meshed.add(packet.id); + return { ...result, ...geometry, milliseconds: performance.now() - start }; + } +} + +export function actorLightBounds(view) { + const min = [...view.min], max = [...view.max]; + for (const axis of [0, 2]) { + const center = Math.floor((view.min[axis] + view.max[axis]) / 32) * 16; + min[axis] = Math.max(min[axis], center - 48); + max[axis] = Math.min(max[axis], center + 63); + } + return {min, max}; +} + +function inferBounds(blocks) { + const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]; + for (const key of blocks.keys()) { + const pos = key.split(",").map(Number); + for (let axis = 0; axis < 3; axis++) { + min[axis] = Math.min(min[axis], pos[axis]); max[axis] = Math.max(max[axis], pos[axis]); + } + } + return Number.isFinite(min[0]) + ? { min: min.map(v => v - 15), max: max.map(v => v + 15) } + : { min: [-16, -16, -16], max: [16, 16, 16] }; +} diff --git a/client/terrain-worker.js b/client/terrain-worker.js new file mode 100644 index 0000000..a42c356 --- /dev/null +++ b/client/terrain-worker.js @@ -0,0 +1,27 @@ +import { TerrainState } from "./terrain-state.js"; +import { loadLightProperties } from "./light-properties.js"; + +const state = loadLightProperties().then(properties => new TerrainState(properties)); +let timer = null; +const send = message => { + const buffers = message.type === "light" + ? [message.field.data.buffer, message.field.blockLevels.buffer, message.field.skyLevels.buffer] + : message.vertices ? [message.vertices.buffer, message.transparent.buffer] : []; + self.postMessage(message, buffers); +}; +self.onmessage = ({ data }) => { + state.then(terrain => { + if (data.type === "sync") { + if (!terrain.sync(data)) return; + clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + try { send(terrain.light({dirty:!data.independentMeshes})); } + catch (error) { send({ type: "error", epoch: terrain.epoch, version: terrain.version, error: error.message }); } + }, data.independentMeshes ? 225 : 0); + } else if (data.type === "mesh") { + try { send(terrain.mesh(data)); } + catch (error) { send({ ...data, error: error.message }); } + } + }).catch(error => send({ type: "error", epoch: data.epoch, version: data.version, error: error.message })); +}; diff --git a/client/tests/actor-rendering.test.js b/client/tests/actor-rendering.test.js new file mode 100644 index 0000000..ba76287 --- /dev/null +++ b/client/tests/actor-rendering.test.js @@ -0,0 +1,91 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Renderer } from "../renderer.js"; + +// Record actual draw-call state: a uniform belongs to one linked program, +// and changing programs does not reset that program's previous uniforms. +function fixture(shadows) { + let active, vao; + const offsets = new Map(), draws = [], errors = []; + const uniform = location => { + if (location.program !== active) { + errors.push(`uniform ${location.name} belongs to ${location.program}, active ${active}`); + return false; + } + return true; + }; + const gl = new Proxy({ + useProgram(program) { active = program; }, + getUniformLocation(program, name) { return { program, name }; }, + uniform3fv(location, values) { + if (uniform(location) && location.name === "uOffset") offsets.set(active, [...values]); + }, + uniform1f: uniform, uniform1i: uniform, uniformMatrix4fv: uniform, + bindVertexArray(value) { vao = value; }, + drawArrays() { draws.push({ program: active, vao, offset: offsets.get(active) }); }, + }, { get(target, name) { return target[name] ?? (/^[A-Z_0-9]+$/.test(name) ? 0 : () => {}); } }); + const renderer = Object.assign(Object.create(Renderer.prototype), { + blocks: new Map(), + gl, canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600, dataset: {} }, + program: "world", shadowProgram: "shadow", sky: "sky", line: "line", + renderOrigin: [0, 0, 0], sections: new Map(), dirty: new Set(), + dynamic: { vao: "actors", count: 0 }, lines: { count: 0 }, + shadowsEnabled: shadows, shadowReady: true, shadowSize: 2048, shadowRadius: 48, + blockLighting: { status: "ready", milliseconds: 0 }, daylight: 1, bounceAt: -1000, + }); + renderer.upload = (mesh, vertices) => { + mesh.vertices = new Float32Array(vertices); + mesh.count = vertices.length / 20; + }; + return { renderer, draws, errors }; +} + +for (const shadows of [true, false]) { + test(`players and entities keep world positions through camera/section changes; shadows ${shadows}`, () => { + const previousRatio = globalThis.devicePixelRatio; + globalThis.devicePixelRatio = 1; + try { + for (const base of [0, -160, 29_999_840]) { + const { renderer, draws, errors } = fixture(shadows); + const player = { position: [base + 18.125, 77, -5.875], yaw: 0.3 }; + const entity = { kind: "crate", position: [base + 21.125, 77, -6.875] }; + let baseline; + for (const [x, y, z, reverse, empty] of [ + [15.99, 79.99, 0.01, false, false], + [16.01, 80.01, -0.01, true, false], + [31.99, 79.99, -16.01, false, false], + [32.01, 80.01, -15.99, true, true], + ]) { + const origins = [[base, 64, -16], [base + 32, 96, 16]]; + if (reverse) origins.reverse(); + renderer.sections = new Map(empty ? [] : origins.map((origin, i) => [ + origin.map(v => v / 16).join(","), + { origin, vao: `terrain-${i}`, count: 36, transparent: { vao: `glass-${i}`, count: 36 } }, + ])); + draws.length = 0; + const eye = [base + x, y, z]; + const direction = [player.position[0] - eye[0], 79 - eye[1], player.position[2] - eye[2]]; + const camera = { eye, yaw: Math.atan2(direction[0], -direction[2]), pitch: Math.atan2(direction[1], Math.hypot(direction[0], direction[2])) }; + renderer.draw(camera, [player], [entity], new Map()); + assert.deepEqual(errors, [], "uniform writes must target the active shader program"); + const actorDraws = draws.filter(draw => draw.vao === "actors"); + assert.equal(actorDraws.length, shadows ? 2 : 1); + for (const draw of actorDraws) assert.deepEqual(draw.offset, [0, 0, 0], `${draw.program} inherited terrain transform`); + // Reverse the GPU transform for every avatar and entity vertex. + // It must recover identical world geometry despite rebasing the camera. + const world = []; + for (let i = 0; i < renderer.dynamic.vertices.length; i += 20) + for (let axis = 0; axis < 3; axis++) + world.push(renderer.dynamic.vertices[i + axis] + renderer.renderOrigin[axis]); + if (baseline) world.forEach((v, i) => assert.ok(Math.abs(v - baseline[i]) < 0.00001, `vertex ${i} moved with camera`)); + else baseline = world; + assert.ok(world.length > 36 * 3, "both an avatar and an entity were drawn"); + assert.ok(renderer.project([player.position[0], 79, player.position[2]]), "label projection uses the same camera origin"); + } + } + } finally { + if (previousRatio === undefined) delete globalThis.devicePixelRatio; + else globalThis.devicePixelRatio = previousRatio; + } + }); +} diff --git a/client/tests/ambient-occlusion.test.js b/client/tests/ambient-occlusion.test.js new file mode 100644 index 0000000..d78c25c --- /dev/null +++ b/client/tests/ambient-occlusion.test.js @@ -0,0 +1,144 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + affectedSectionKeys, + createAmbientOcclusionSampler, + isAmbientOccluder, +} from "../ambient-occlusion.js"; +import { unitBox } from "../math.js"; + +const top = [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]]; +const stone = { state: "minecraft:stone", render: [unitBox] }; +const slab = { min: [0, 0, 0], max: [1, 0.5, 1] }; +const materialMap = () => new Map([ + [1, stone], + [2, { state: "minecraft:stone_slab[type=bottom]", render: [slab] }], +]); +const blockMap = (...cells) => new Map(cells.map(([pos, id = 1]) => [pos.join(","), id])); +const sampleTop = (blocks, materials = materialMap(), box = unitBox, pos = [0, 0, 0]) => + createAmbientOcclusionSampler(blocks, materials)(pos, box, [0, 1, 0], top); + +test("an exposed flat floor has no dark grid or dark silhouette corners", () => { + const blocks = new Map(); + for (let x = -2; x <= 2; x++) + for (let z = -2; z <= 2; z++) blocks.set(`${x},0,${z}`, 1); + assert.deepEqual(sampleTop(blocks), [1, 1, 1, 1]); + assert.deepEqual(sampleTop(blockMap([[0, 0, 0], 1])), [1, 1, 1, 1]); +}); + +test("one adjacent wall shades only its two touching vertices", () => { + assert.deepEqual(sampleTop(blockMap([[-1, 1, 0], 1])), [0.92, 1, 1, 0.92]); +}); + +test("diagonal contact shades one corner and two enclosing walls saturate it", () => { + assert.deepEqual(sampleTop(blockMap([[-1, 1, -1], 1])), [1, 1, 1, 0.92]); + const sides = [[[-1, 1, 0], 1], [[0, 1, -1], 1]]; + assert.deepEqual(sampleTop(blockMap(...sides)), [0.92, 1, 0.92, 0.76]); + assert.deepEqual(sampleTop(blockMap(...sides, [[-1, 1, -1], 1])), + [0.92, 1, 0.92, 0.76]); +}); + +test("all six face orientations sample the outward hemisphere", () => { + for (let axis = 0; axis < 3; axis++) + for (const sign of [-1, 1]) { + const normal = [0, 0, 0]; + normal[axis] = sign; + const tangent = [0, 1, 2].filter((a) => a !== axis); + const vertices = [[0, 0], [1, 0], [1, 1], [0, 1]].map(([u, v]) => { + const result = [0, 0, 0]; + result[axis] = sign > 0 ? 1 : 0; + result[tangent[0]] = u; + result[tangent[1]] = v; + return result; + }); + const diagonal = [0, 0, 0]; + diagonal[axis] = sign; + diagonal[tangent[0]] = -1; + diagonal[tangent[1]] = -1; + const sample = createAmbientOcclusionSampler(blockMap([diagonal, 1]), materialMap()); + assert.deepEqual(sample([0, 0, 0], unitBox, normal, vertices), [0.92, 1, 1, 1], + `normal ${normal}`); + } +}); + +test("actual slab height controls occlusion rather than whole-cell occupancy", () => { + assert.deepEqual(sampleTop(blockMap([[-1, 0, 0], 2]), materialMap(), slab), + [1, 1, 1, 1]); + assert.deepEqual(sampleTop(blockMap([[-1, 0, 0], 1]), materialMap(), slab), + [0.92, 1, 1, 0.92]); + assert.deepEqual(sampleTop(blockMap([[-1, 1, 0], 2])), [0.92, 1, 1, 0.92]); +}); + +test("neighboring boxes within one stair model shade its recessed tread", () => { + const tread = { min: [0, 0, 0], max: [0.5, 0.5, 1] }; + const riser = { min: [0.5, 0, 0], max: [1, 1, 1] }; + const materials = new Map([[1, { ...stone, render: [tread, riser] }]]); + assert.deepEqual(sampleTop(blockMap([[0, 0, 0], 1]), materials, tread), + [1, 0.92, 0.92, 1]); +}); + +test("render boxes extending beyond their owner can shade a neighboring face", () => { + const materials = new Map([[1, { + ...stone, render: [{ min: [0, 0, 0], max: [1, 1.5, 1] }], + }]]); + assert.deepEqual(sampleTop(blockMap([[-1, 0, 0], 1]), materials), + [0.92, 1, 1, 0.92]); +}); + +test("transparent and cutout materials never act like opaque corner cubes", () => { + for (const overrides of [ + { transparent: true }, { opacity: 0.4 }, { cutout: true }, + { state: "minecraft:oak_leaves[persistent=true]" }, + { state: "minecraft:red_stained_glass" }, { state: "minecraft:water[level=0]" }, + { state: "minecraft:cobweb" }, { state: "minecraft:short_grass" }, + ]) { + const material = { ...stone, ...overrides }; + assert.equal(isAmbientOccluder(material), false); + assert.deepEqual(sampleTop(blockMap([[-1, 1, 0], 1]), new Map([[1, material]])), + [1, 1, 1, 1]); + } + assert.equal(isAmbientOccluder({ ...stone, state: "minecraft:grass_block" }), true); + assert.equal(isAmbientOccluder({ ...stone, state: "minecraft:brown_mushroom_block" }), true); +}); + +test("a texture-pack cutout predicate is cached per material", () => { + let calls = 0; + const sample = createAmbientOcclusionSampler(blockMap([[-1, 1, 0], 1]), materialMap(), + (material, id) => { calls++; return id !== 1; }); + assert.deepEqual(sample([0, 0, 0], unitBox, [0, 1, 0], top), [1, 1, 1, 1]); + sample([1, 0, 0], unitBox, [0, 1, 0], top); + assert.equal(calls, 2); +}); + +test("new rebuild samplers observe edits without keeping stale cached air", () => { + const blocks = blockMap(); + assert.deepEqual(sampleTop(blocks), [1, 1, 1, 1]); + blocks.set("-1,1,0", 1); + assert.deepEqual(sampleTop(blocks), [0.92, 1, 1, 0.92]); + blocks.delete("-1,1,0"); + assert.deepEqual(sampleTop(blocks), [1, 1, 1, 1]); +}); + +test("empty render geometry casts no AO and unknown blocks match placeholder cubes", () => { + assert.deepEqual(sampleTop(blockMap([[-1, 1, 0], 1]), new Map([[1, { ...stone, render: [] }]])), + [1, 1, 1, 1]); + assert.deepEqual(sampleTop(blockMap([[-1, 1, 0], 99])), [0.92, 1, 1, 0.92]); +}); + +test("section invalidation includes corners across positive and negative boundaries", () => { + assert.deepEqual(affectedSectionKeys([5, 5, 5]), ["0,0,0"]); + assert.deepEqual(affectedSectionKeys([16, 5, 5]), ["0,0,0", "1,0,0"]); + assert.deepEqual(affectedSectionKeys([15, 15, 15]), [ + "0,0,0", "0,0,1", "0,1,0", "0,1,1", + "1,0,0", "1,0,1", "1,1,0", "1,1,1", + ]); + assert.equal(affectedSectionKeys([-1, -1, -1]).length, 8); + assert.ok(affectedSectionKeys([-1, -1, -1]).includes("0,0,0")); + assert.ok(affectedSectionKeys([0, 0, 0]).includes("-1,-1,-1")); +}); + +test("extended render geometry widens the affected sections", () => { + assert.deepEqual(affectedSectionKeys([5, 13, 5]), ["0,0,0"]); + assert.deepEqual(affectedSectionKeys([5, 13, 5], [{ min: [0, 0, 0], max: [1, 3, 1] }]), + ["0,0,0", "0,1,0"]); +}); diff --git a/client/tests/block-light-controller.test.js b/client/tests/block-light-controller.test.js new file mode 100644 index 0000000..f4d6383 --- /dev/null +++ b/client/tests/block-light-controller.test.js @@ -0,0 +1,227 @@ +import test, { after, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { BlockLightController } from "../block-light-controller.js"; +import { buildBlockLight } from "../block-light.js"; +import { loadLightProperties } from "../light-properties.js"; + +const properties = JSON.parse( + await readFile(new URL("../light-properties.json", import.meta.url)), +); +const savedWorker = globalThis.Worker, + savedFetch = globalThis.fetch; +const controllers = []; +class FakeWorker { + constructor() { + this.packets = []; + this.terminated = false; + } + postMessage(packet) { + this.packets.push(structuredClone(packet)); + } + terminate() { + this.terminated = true; + } + reply(index) { + const packet = this.packets[index]; + const { min, size, data, sourceCount, blockLevels, skyLevels } = + buildBlockLight( + new Map(packet.blocks), + new Map(packet.materials), + packet.bounds, + ); + this.onmessage({ + data: { + generation: packet.generation, + field: { min, size, data, sourceCount, blockLevels, skyLevels }, + milliseconds: 1, + }, + }); + } +} +globalThis.Worker = FakeWorker; +globalThis.fetch = async () => ({ ok: true, json: async () => properties }); +afterEach(() => { + for (const controller of controllers.splice(0)) { + clearTimeout(controller.timer); + controller.worker?.terminate(); + } +}); +after(() => { + if (savedWorker === undefined) delete globalThis.Worker; + else globalThis.Worker = savedWorker; + globalThis.fetch = savedFetch; +}); +const bounds = { min: [-2, 0, -2], max: [2, 3, 2] }; +const materials = new Map([ + [1, { state: "minecraft:torch", light: 14, render: [] }], + [ + 2, + { + state: "minecraft:sea_lantern", + light: 15, + render: [{ min: [0, 0, 0], max: [1, 1, 1] }], + }, + ], +]); +function fixture() { + const published = []; + const controller = new BlockLightController((field) => published.push(field)); + controllers.push(controller); + return { controller, worker: controller.worker, published }; +} + +test("requests awaiting metadata coalesce into the latest complete world", async () => { + const { controller, worker, published } = fixture(); + controller.request(new Map([["0,1,0", 1]]), materials, bounds); + controller.request(new Map([["1,1,0", 2]]), materials, bounds); + await loadLightProperties(); + controller.start(); + assert.equal(worker.packets.length, 1); + assert.equal(worker.packets[0].generation, 2); + assert.deepEqual(worker.packets[0].blocks, [["1,1,0", 2]]); + worker.reply(0); + assert.equal(published.length, 1); + assert.equal(published[0].sample([1, 1, 0]).blockLevel, 15); + assert.equal(controller.status, "ready"); +}); + +test("an old world's in-flight result is discarded before publishing the replacement world", async () => { + const { controller, worker, published } = fixture(); + await loadLightProperties(); + const first = new Map([["0,1,0", 1]]); + controller.request(first, materials, bounds); + controller.start(); + controller.request(new Map([["1,1,0", 2]]), materials, bounds); + first.clear(); + assert.equal(worker.packets.length, 1); + worker.reply(0); + assert.equal(published.length, 0); + assert.equal(worker.packets.length, 2); + worker.reply(1); + assert.equal(published.length, 1); + assert.equal(published[0].sample([1, 1, 0]).blockLevel, 15); + assert.equal(controller.pending, null); + assert.equal(controller.busy, false); +}); + +test("rapid edits during a build coalesce while the last valid field remains available", async () => { + const { controller, worker, published } = fixture(); + await loadLightProperties(); + const blocks = new Map([["0,1,0", 1]]); + controller.request(blocks, materials, bounds); + controller.start(); + worker.reply(0); + const retained = published[0]; + blocks.set("0,1,0", 2); + controller.request(blocks, materials, bounds); + controller.start(); + blocks.set("1,1,0", 1); + controller.request(blocks, materials, bounds); + blocks.clear(); + controller.request(blocks, materials, bounds); + assert.equal(published.length, 1); + assert.equal(retained.sample([0, 1, 0]).blockLevel, 14); + worker.reply(1); + assert.equal(worker.packets.length, 3); + assert.equal(worker.packets[2].generation, 4); + assert.deepEqual(worker.packets[2].blocks, []); + assert.equal(published.length, 1); + worker.reply(2); + assert.equal(published.length, 2); + assert.equal(published[1].sourceCount, 0); + assert.equal(published[1].sample([0, 1, 0]).blockLevel, 0); +}); + +test("worker failure recovers using the newest queued world and the same solver", async () => { + const { controller, worker, published } = fixture(); + await loadLightProperties(); + controller.request(new Map([["0,1,0", 1]]), materials, bounds); + controller.start(); + controller.request(new Map([["1,1,0", 2]]), materials, bounds); + let prevented = false; + worker.onerror({ + preventDefault() { + prevented = true; + }, + }); + assert.equal(prevented, true); + assert.equal(worker.terminated, true); + assert.equal(controller.worker, null); + // Resolve through publish rather than a timing assumption about the fallback. + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(Error("Fallback lighting did not finish")), + 1000, + ); + const publish = controller.publish; + controller.publish = (field) => { + clearTimeout(timeout); + publish(field); + resolve(); + }; + }); + assert.equal(published.length, 1); + assert.equal(published[0].sample([1, 1, 0]).blockLevel, 15); + assert.equal(controller.status, "ready"); +}); + +test("a duplicate stale reply cannot mark a newer build idle or start a concurrent job", async () => { + const { controller, worker, published } = fixture(); + await loadLightProperties(); + controller.request(new Map([["0,1,0", 1]]), materials, bounds); + controller.start(); + controller.request(new Map([["1,1,0", 2]]), materials, bounds); + worker.reply(0); + assert.equal(worker.packets.length, 2); + assert.equal(controller.busy, true); + worker.reply(0); + assert.equal(controller.busy, true); + controller.request(new Map([["-1,1,0", 1]]), materials, bounds); + controller.start(); + assert.equal(worker.packets.length, 2); + assert.equal(published.length, 0); + worker.reply(1); + assert.equal(worker.packets.length, 3); + worker.reply(2); + assert.equal(published.length, 1); + assert.equal(published[0].sample([-1, 1, 0]).blockLevel, 14); + worker.reply(2); + assert.equal(published.length, 1); +}); + +test("synchronous Worker construction failure still publishes light from the fallback solver", async () => { + const previousWorker = globalThis.Worker; + globalThis.Worker = class { + constructor() { + throw new Error("Worker blocked by browser policy"); + } + }; + let context; + try { + context = fixture(); + } finally { + globalThis.Worker = previousWorker; + } + const { controller, published } = context; + assert.equal(controller.worker, null); + const completed = new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(Error("Fallback lighting did not finish")), + 1000, + ); + const publish = controller.publish; + controller.publish = (field) => { + clearTimeout(timeout); + publish(field); + resolve(); + }; + }); + controller.request(new Map([["0,1,0", 1]]), materials, bounds); + await loadLightProperties(); + controller.start(); + await completed; + assert.equal(published.length, 1); + assert.equal(published[0].sample([0, 1, 0]).blockLevel, 14); + assert.equal(controller.status, "ready"); +}); diff --git a/client/tests/block-light.test.js b/client/tests/block-light.test.js new file mode 100644 index 0000000..6696891 --- /dev/null +++ b/client/tests/block-light.test.js @@ -0,0 +1,202 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { attachBlockLightSamplers, blockEmission, buildBlockLight } from "../block-light.js"; +import { unitBox } from "../math.js"; + +const stone = { state: "minecraft:stone", render: [unitBox], light: 0, light_dampening: 15, light_occlusion: [] }; +const torch = { state: "minecraft:torch", render: [], light: 14, light_dampening: 0, light_occlusion: [] }; +const lamp = { ...stone, state: "minecraft:redstone_lamp[lit=true]", light: 15 }; +const materials = () => new Map([ + [1, stone], [2, torch], [3, lamp], + [4, { ...lamp, state: "minecraft:redstone_lamp[lit=false]", light: 0 }], + [5, { ...torch, state: "minecraft:soul_torch", light: 10 }], + [6, { ...torch, state: "minecraft:sea_lantern", light: 15 }], + [7, { ...stone, state: "minecraft:glass", transparent: true, opacity: 0.35, light_dampening: 0 }], +]); +const map = (...cells) => new Map(cells.map(([pos, id]) => [pos.join(","), id])); +const bounds = { min: [-16, -3, -3], max: [16, 5, 3] }; +const sample = (field, pos) => field.sample(pos.map((value) => value + 0.5)); + +test("block light uses canonical source levels, one-cell falloff and finite reach", () => { + const field = buildBlockLight(map([[0, 0, 0], 2]), materials(), bounds); + assert.equal(field.sourceCount, 1); + for (let x = 0; x <= 15; x++) assert.equal(sample(field, [x, 0, 0]).blockLevel, Math.max(0, 14 - x)); + assert.equal(sample(field, [3, 2, 1]).blockLevel, 8); + assert.equal(sample(field, [0, 0, 0]).skyLevel, 15); +}); + +test("closed solid walls occlude light without wraparound in flattened arrays", () => { + const blocks = map([[-2, 0, 0], 3]); + for (let y = -3; y <= 5; y++) for (let z = -3; z <= 3; z++) blocks.set(`0,${y},${z}`, 1); + const field = buildBlockLight(blocks, materials(), bounds); + assert.equal(sample(field, [-1, 0, 0]).blockLevel, 14); + assert.equal(sample(field, [0, 0, 0]).blockLevel, 0); + assert.equal(sample(field, [1, 0, 0]).blockLevel, 0); + assert.deepEqual(sample(field, [1, 0, 0]).block, [0, 0, 0]); +}); + +test("light travels around an open doorway with Manhattan path attenuation", () => { + const blocks = map([[-2, 0, 0], 3]); + for (let y = -3; y <= 5; y++) for (let z = -3; z <= 3; z++) if (y !== 0 || z !== 2) blocks.set(`0,${y},${z}`, 1); + const field = buildBlockLight(blocks, materials(), bounds); + assert.equal(sample(field, [1, 0, 0]).blockLevel, 8); +}); + +test("transparent glass transmits while metadata dampening controls attenuation", () => { + const blocks = map([[-2, 0, 0], 3]); + for (let y = -3; y <= 5; y++) for (let z = -3; z <= 3; z++) blocks.set(`0,${y},${z}`, 7); + const field = buildBlockLight(blocks, materials(), bounds); + assert.equal(sample(field, [1, 0, 0]).blockLevel, 12); + const mats = materials(); + mats.set(7, { ...mats.get(7), light_dampening: 3 }); + assert.equal(sample(buildBlockLight(blocks, mats, bounds), [1, 0, 0]).blockLevel, 10); +}); + +test("authoritative unlit state emits zero and recomputation removes stale light", () => { + const blocks = map([[0, 0, 0], 3]); + assert.equal(sample(buildBlockLight(blocks, materials(), bounds), [1, 0, 0]).blockLevel, 14); + blocks.set("0,0,0", 4); + const off = buildBlockLight(blocks, materials(), bounds); + assert.equal(off.sourceCount, 0); + assert.ok(off.blockLevels.every((level) => level === 0)); + assert.ok(off.data.every((value) => value === 0)); + blocks.delete("0,0,0"); + assert.equal(sample(buildBlockLight(blocks, materials(), bounds), [1, 0, 0]).blockLevel, 0); + blocks.set("0,0,0", 3); + assert.equal(sample(buildBlockLight(blocks, materials(), bounds), [1, 0, 0]).blockLevel, 14); +}); + +test("torch and soul colors retain their tint at distance without colored radius shifts", () => { + const warm = buildBlockLight(map([[0, 0, 0], 2]), materials(), bounds); + const cyan = buildBlockLight(map([[0, 0, 0], 5]), materials(), bounds); + const close = sample(warm, [1, 0, 0]).block, far = sample(warm, [8, 0, 0]).block; + assert.ok(close[0] > close[1] && close[1] > close[2]); + assert.ok(far[0] > far[1] && far[1] > far[2] && far[2] > 0); + assert.ok(Math.abs(close[1] / close[0] - far[1] / far[0]) < 0.02); + const soul = sample(cyan, [1, 0, 0]).block; + assert.ok(soul[2] > soul[1] && soul[1] > soul[0]); + assert.deepEqual(blockEmission({ state: "minecraft:torch" }).level, 0); + const lampTint = blockEmission(lamp).color; + assert.ok(lampTint[1] > 0.7, "a redstone lamp casts warm white light, not red torch light"); +}); + +test("multiple lights combine by maximum level, never by additive strength", () => { + const mats = materials(); + mats.set(5, { ...mats.get(5), light: 14 }); + const field = buildBlockLight(map([[-2, 0, 0], 2], [[2, 0, 0], 5]), mats, bounds); + assert.equal(field.sourceCount, 2); + assert.equal(sample(field, [0, 0, 0]).blockLevel, 12); + const middle = sample(field, [0, 0, 0]).block; + assert.ok(middle[0] > 0.4 && middle[2] > 0.4); + assert.equal(Math.max(...field.blockLevels), 14); +}); + +test("direct sky stays 15 down clear columns and a full roof leaves darkness below", () => { + const open = buildBlockLight(new Map(), materials(), bounds); + assert.ok(open.skyLevels.every((level) => level === 15)); + const blocks = new Map(); + for (let x = -16; x <= 16; x++) for (let z = -3; z <= 3; z++) blocks.set(`${x},2,${z}`, 1); + const covered = buildBlockLight(blocks, materials(), bounds); + assert.equal(sample(covered, [0, 3, 0]).skyLevel, 15); + assert.equal(sample(covered, [0, 2, 0]).skyLevel, 0); + assert.equal(sample(covered, [0, 1, 0]).skyLevel, 0); + assert.equal(sample(covered, [0, -3, 0]).skyLevel, 0); +}); + +test("sky spreads laterally under an overhang with one level of decay per cell", () => { + const blocks = new Map(); + for (let x = 0; x <= 16; x++) for (let z = -3; z <= 3; z++) blocks.set(`${x},2,${z}`, 1); + const field = buildBlockLight(blocks, materials(), bounds); + assert.equal(sample(field, [-1, 1, 0]).skyLevel, 15); + assert.equal(sample(field, [0, 1, 0]).skyLevel, 14); + assert.equal(sample(field, [1, 1, 0]).skyLevel, 13); + // The open exterior column remains level 15 at this height as well. + assert.equal(sample(field, [2, 0, 0]).skyLevel, 12); +}); + +test("slab face geometry blocks downwards sky despite its zero dampening", () => { + const mats = materials(), slab = { min: [0, 0, 0], max: [1, 0.5, 1] }; + mats.set(8, { state: "minecraft:stone_slab[type=bottom]", render: [slab], light: 0, light_dampening: 0, light_occlusion: [slab] }); + const blocks = new Map(); + for (let x = -16; x <= 16; x++) for (let z = -3; z <= 3; z++) blocks.set(`${x},2,${z}`, 8); + const field = buildBlockLight(blocks, mats, bounds); + assert.equal(sample(field, [0, 2, 0]).skyLevel, 15); + assert.equal(sample(field, [0, 1, 0]).skyLevel, 0); +}); + +test("complementary slab faces close their shared boundary exactly", () => { + const bottom = { min: [0, 0, 0], max: [1, 0.5, 1] }, top = { min: [0, 0.5, 0], max: [1, 1, 1] }; + const mats = materials(); + mats.set(8, { state: "minecraft:stone_slab[type=bottom]", render: [bottom], light: 0, light_dampening: 0, light_occlusion: [bottom] }); + mats.set(9, { state: "minecraft:stone_slab[type=top]", render: [top], light: 0, light_dampening: 0, light_occlusion: [top] }); + const narrow = { min: [-2, 0, 0], max: [2, 0, 0] }; + const closed = buildBlockLight(map([[-2, 0, 0], 2], [[-1, 0, 0], 8], [[0, 0, 0], 9]), mats, narrow); + assert.equal(sample(closed, [-1, 0, 0]).blockLevel, 13); + assert.equal(sample(closed, [0, 0, 0]).blockLevel, 0); + assert.equal(sample(closed, [1, 0, 0]).blockLevel, 0); + const open = buildBlockLight(map([[-2, 0, 0], 2], [[-1, 0, 0], 8], [[0, 0, 0], 8]), mats, narrow); + assert.equal(sample(open, [1, 0, 0]).blockLevel, 11); +}); + +test("negative coordinates and chunk boundaries preserve level and indexing", () => { + const region = { min: [-20, -2, -2], max: [20, 2, 2] }; + const field = buildBlockLight(map([[-16, 0, 0], 3], [[16, 0, 0], 3]), materials(), region); + assert.equal(sample(field, [-17, 0, 0]).blockLevel, 14); + assert.equal(sample(field, [-15, 0, 0]).blockLevel, 14); + assert.equal(sample(field, [15, 0, 0]).blockLevel, 14); + assert.equal(sample(field, [17, 0, 0]).blockLevel, 14); + assert.deepEqual(field.sample([-21, 0, 0]), { block: [0, 0, 0], sky: 0, blockLevel: 0, skyLevel: 0 }); +}); + +test("face samples use the outward hemisphere and cannot pull light through a solid roof", () => { + const blocks = map([[0, 3, 0], 3]); + for (let x = -16; x <= 16; x++) for (let z = -3; z <= 3; z++) blocks.set(`${x},2,${z}`, 1); + const field = buildBlockLight(blocks, materials(), bounds); + const bottom = [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]]; + const underside = field.sampleFace([0, 2, 0], unitBox, [0, -1, 0], bottom); + assert.deepEqual(underside.block, [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]); + assert.deepEqual(underside.sky, [0, 0, 0, 0]); + const top = field.sampleFace([1, 2, 0], unitBox, [0, 1, 0], [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]]); + assert.ok(top.block.some((rgb) => rgb[0] > 0)); + assert.ok(top.sky.every((value) => value === 1)); +}); + +test("worker transfer can restore sampling without propagating the field again", () => { + const blocks = map([[0, 0, 0], 2]), mats = materials(); + const field = buildBlockLight(blocks, mats, bounds); + const raw = { min: field.min, size: field.size, data: field.data, blockLevels: field.blockLevels, skyLevels: field.skyLevels, sourceCount: field.sourceCount }; + const transferred = structuredClone(raw, { transfer: [raw.data.buffer, raw.blockLevels.buffer, raw.skyLevels.buffer] }); + const hydrated = attachBlockLightSamplers(transferred, blocks, mats); + assert.equal(sample(hydrated, [1, 0, 0]).blockLevel, 13); + assert.ok(sample(hydrated, [1, 0, 0]).block[0] > 0); +}); + +test("invalid or excessive bounds fail before allocating a field", () => { + for (const invalid of [null, { min: [0, 0, 0], max: [1, 1, 0.5] }, + { min: [1, 0, 0], max: [0, 1, 1] }, { min: [0, 0, 0], max: [1024, 1024, 1024] }]) + assert.throws(() => buildBlockLight(new Map(), materials(), invalid), RangeError); + const field = buildBlockLight(new Map(), materials(), { min: [0, 0, 0], max: [0, 0, 0] }); + assert.equal(field.data.length, 4); + assert.equal(field.skyLevels[0], 15); +}); + +test("78 directional crossings match measured original Java 26.2 attenuation", () => { + const fixture = JSON.parse(readFileSync(new URL("./fixtures/lighting-java26.2.json", import.meta.url))); + const examples = new Map(fixture.examples.map((entry) => [entry.state, entry])); + const directions = { east: [1, 0, 0], west: [-1, 0, 0], up: [0, 1, 0], down: [0, -1, 0], south: [0, 0, 1], north: [0, 0, -1] }; + const measured = (state, light) => { + const entry = examples.get(state); + return { state, light, render: [], light_dampening: entry.dampening, + light_occlusion: entry.occlusion.map((box) => ({ min: box.slice(0, 3), max: box.slice(3, 6) })) }; + }; + assert.equal(fixture.crossings.length, 78); + for (const crossing of fixture.crossings) { + const target = directions[crossing.direction]; + const mats = new Map([[1, measured(crossing.from, 15)], [2, measured(crossing.to, 0)]]); + const field = buildBlockLight(map([[0, 0, 0], 1], [target, 2]), mats, + { min: target.map((value) => Math.min(0, value)), max: target.map((value) => Math.max(0, value)) }); + assert.equal(sample(field, target).blockLevel, Math.max(0, 15 - crossing.block_dampening), + `${crossing.from} -> ${crossing.to}, ${crossing.direction}`); + } +}); diff --git a/client/tests/block-textures.test.js b/client/tests/block-textures.test.js new file mode 100644 index 0000000..28a9cfe --- /dev/null +++ b/client/tests/block-textures.test.js @@ -0,0 +1,106 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getBlockFaceTextures, getFaceUV } from "../block-textures.js"; + +const names = ["stone", "cobblestone", "oak_planks", "oak_log", "oak_log_top", "dirt", "grass_block_top", "grass_block_side", "sand", "gravel", "bricks", "stone_bricks", "glass", "oak_leaves", "diamond_ore", "iron_ore", "coal_ore", "gold_ore", "redstone_ore", "deepslate", "obsidian", "snow", "netherrack", "oak_door_bottom"]; +const layers = new Map(names.map((name, index) => [name, index])); +const textureNames = (state, available = layers) => + getBlockFaceTextures(state, available).map((face) => face ? names[face.layer] : null); + +test("partial packs keep missing and unrelated materials on the fallback path", () => { + for (const state of ["minecraft:spruce_planks", "minecraft:stripped_oak_log[axis=y]", "minecraft:mossy_cobblestone", "minecraft:deepslate_diamond_ore", "mod:stone", "minecraft:oak_trapdoor", "minecraft:stonecutter", "", undefined]) { + assert.deepEqual(textureNames(state), Array(6).fill(null), String(state)); + } + assert.deepEqual(textureNames("minecraft:stone", new Map()), Array(6).fill(null)); + assert.equal(getBlockFaceTextures("minecraft:stone", layers)[0].layer, 0); + assert.deepEqual(textureNames("minecraft:stone", new Map([["stone", -1]])), Array(6).fill(null)); +}); + +test("material variants use their own surface without broad substring matching", () => { + for (const [state, texture] of [["oak_stairs[facing=east,half=bottom]", "oak_planks"], ["cobblestone_wall[north=low]", "cobblestone"], ["stone_brick_slab[type=top]", "stone_bricks"], ["brick_stairs", "bricks"], ["snow_block", "snow"]]) { + assert.deepEqual(textureNames(`minecraft:${state}`), Array(6).fill(texture)); + } +}); + +test("grass uses a tinted top, full-color sides and a dirt bottom independently", () => { + assert.deepEqual(textureNames("minecraft:grass_block[snowy=false]"), ["grass_block_side", "grass_block_side", "grass_block_top", "dirt", "grass_block_side", "grass_block_side"]); + const faces = getBlockFaceTextures("minecraft:grass_block", layers); + assert.ok(faces[2].tint[1] > faces[2].tint[0] && faces[2].tint[0] > faces[2].tint[2]); + assert.deepEqual(faces[0].tint, [1, 1, 1]); + assert.equal(faces[2].cutout, false); + assert.deepEqual(textureNames("minecraft:grass_block[snowy=true]"), [null, null, "grass_block_top", "dirt", null, null]); + const missingSide = new Map(layers); + missingSide.delete("grass_block_side"); + assert.deepEqual(textureNames("minecraft:grass_block", missingSide), [null, null, "grass_block_top", "dirt", null, null]); +}); + +test("log end grain follows every axis while oak wood remains bark on all faces", () => { + for (const [axis, caps] of [["x", [0, 1]], ["y", [2, 3]], ["z", [4, 5]]]) { + assert.deepEqual(textureNames(`minecraft:oak_log[axis=${axis}]`), Array.from({ length: 6 }, (_, face) => caps.includes(face) ? "oak_log_top" : "oak_log")); + } + assert.deepEqual(textureNames("minecraft:oak_log"), textureNames("minecraft:oak_log[axis=y]")); + assert.deepEqual(textureNames("minecraft:oak_wood[axis=x]"), Array(6).fill("oak_log")); +}); + +test("cutout textures preserve full-color glass and tint grayscale leaves", () => { + const glass = getBlockFaceTextures("minecraft:glass", layers); + const leaves = getBlockFaceTextures("minecraft:oak_leaves[persistent=true]", layers); + assert.ok(glass.every((face) => face.cutout)); + assert.ok(leaves.every((face) => face.cutout)); + assert.deepEqual(glass[0].tint, [1, 1, 1]); + assert.ok(leaves[0].tint[1] > leaves[0].tint[0] && leaves[0].tint[0] > leaves[0].tint[2]); +}); + +test("doors use lower artwork only on the lower broad faces, respecting open rotation", () => { + const alongZ = ["oak_planks", "oak_planks", "oak_planks", "oak_planks", "oak_door_bottom", "oak_door_bottom"]; + const alongX = ["oak_door_bottom", "oak_door_bottom", "oak_planks", "oak_planks", "oak_planks", "oak_planks"]; + for (const facing of ["north", "south", "east", "west"]) { + const x = ["east", "west"].includes(facing); + assert.deepEqual(textureNames(`minecraft:oak_door[half=lower,facing=${facing},open=false]`), x ? alongX : alongZ); + assert.deepEqual(textureNames(`minecraft:oak_door[half=lower,facing=${facing},open=true]`), x ? alongZ : alongX); + assert.deepEqual(textureNames(`minecraft:oak_door[half=upper,facing=${facing},open=false]`), Array(6).fill(null)); + } +}); + +test("face UVs orient a top-left source consistently from outside each cube face", () => { + const corners = [ + [[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]], + [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]], + [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]], + [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]], + [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], + [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]], + ]; + for (let face = 0; face < 6; face++) { + assert.deepEqual(corners[face].map((pos) => getFaceUV(face, pos)), [[0, 1], [1, 1], [1, 0], [0, 0]]); + } + assert.deepEqual(getFaceUV(4, [0.25, 0.5, 1]), [0.25, 0.5]); + assert.deepEqual(getFaceUV(0, [1, 1.5, 0.25]), [0.75, -0.5]); + assert.throws(() => getFaceUV(6, [0, 0, 0]), RangeError); +}); + +test("horizontal log bark follows the log axis through a proper cube rotation", () => { + const vertical = [0.25, 0.75, 0.125]; + // Rotate the same point and normal with the complete cube, then compare UVs. + const horizontalX = [vertical[1], 1 - vertical[0], vertical[2]]; + const horizontalZ = [vertical[0], 1 - vertical[2], vertical[1]]; + const faceFromVerticalX = [3, 2, 0, 1, 4, 5]; + const faceFromVerticalZ = [0, 1, 4, 5, 3, 2]; + for (const block of ["oak_log", "oak_wood"]) { + for (let face = 0; face < 6; face++) { + const expected = getFaceUV(face, vertical); + assert.deepEqual(getFaceUV(faceFromVerticalX[face], horizontalX, `minecraft:${block}[axis=x]`), expected); + assert.deepEqual(getFaceUV(faceFromVerticalZ[face], horizontalZ, `minecraft:${block}[axis=z]`), expected); + assert.deepEqual(getFaceUV(face, vertical, `minecraft:${block}[axis=y]`), expected); + } + } + for (const [axis, sideFaces] of [["x", [2, 3, 4, 5]], ["z", [0, 1, 2, 3]]]) { + for (const face of sideFaces) { + const point = [0.25, 0.5, 0.75]; + const coordinate = axis === "x" ? point[0] : point[2]; + assert.equal(getFaceUV(face, point, `minecraft:oak_log[axis=${axis}]`)[1], 1 - coordinate); + } + } + assert.deepEqual(getFaceUV(4, vertical, "minecraft:deepslate[axis=x]"), getFaceUV(4, vertical)); + assert.deepEqual(getFaceUV(4, vertical, "mod:oak_log[axis=x]"), getFaceUV(4, vertical)); +}); diff --git a/client/tests/dynamics-diagnostics.test.js b/client/tests/dynamics-diagnostics.test.js new file mode 100644 index 0000000..f734f50 --- /dev/null +++ b/client/tests/dynamics-diagnostics.test.js @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {DynamicsDiagnostics} from '../dynamics-diagnostics.js'; +const sample=(x=0)=>({position:[x,77,0],yaw:0,frameMs:10,physicsMs:1,drawMs:2,correction:0,waiting:false,dirty:0,viewChanges:x?2:0,batches:x?3:0,meshResets:1,loadedSections:245,totalSections:245,voxelBytes:4014080}); +test('diagnostics exclude warmup, record movement and calculate bounded summaries',()=>{ + const d=new DynamicsDiagnostics();d.arm('flight',5);const t=d.armed; + d.ready(sample(),false,t+100);d.frame(sample(),t+200);assert.equal(d.frames.length,0); + d.ready(sample(),true,t+500);d.ready(sample(),true,t+1001);assert.equal(d.status,'recording'); + d.frame(sample(),t+1100);d.frame({...sample(16),frameMs:40},t+6100); + assert.equal(d.status,'done');assert.equal(d.result.frames,2);assert.equal(d.result.frameMs.max,40);assert.equal(d.result.over25ms,1);assert.equal(d.result.distance,16);assert.equal(d.result.meshResets,0);assert.equal(d.result.viewChanges,2); +}); +test('automatic flight sends one toggle, climbs obstacles, and stops yielding input after recording',()=>{ + const d=new DynamicsDiagnostics();d.arm('flight');d.status='recording';d.started=0;d.yaw=0; + assert.equal(d.controls(false,false,0).fly_toggle,true);assert.equal(d.controls(true,false,0).fly_toggle,true);assert.equal(d.controls(true,false,50).fly_toggle,false); + assert.equal(d.controls(true,true,5000,true).jump,true);assert.equal(d.controls(false,true,5500).jump,true);assert.equal(d.controls(false,true,6100).jump,false); + d.status='done';assert.equal(d.controls(true,true,6200),null); +}); diff --git a/client/tests/edit-diagnostics.test.js b/client/tests/edit-diagnostics.test.js new file mode 100644 index 0000000..7735c10 --- /dev/null +++ b/client/tests/edit-diagnostics.test.js @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {EditDiagnostics} from '../edit-diagnostics.js'; + +test('edit timings separate round trip and mesh publication, and ignore unrelated changes',()=>{ + const d=new EditDiagnostics();d.request([16,2,-1],0,10); + d.confirmed([{pos:[16,2,-1],block:2}],20); + assert.equal(d.published('1,0,-1',30),null); + d.confirmed([{pos:[16,2,-1],block:0}],25); + assert.equal(d.published('0,0,-1',40),null); + assert.deepEqual(d.published('1,0,-1',45),{acknowledgementMs:15,geometryMs:20,totalMs:35}); + assert.equal(d.published('1,0,-1',50),null); +}); +test('expired requests and world resets cannot produce misleading edit timings',()=>{ + const d=new EditDiagnostics();d.request([0,0,0],1,0); + d.confirmed([{pos:[0,0,0],block:1}],6000); + assert.equal(d.published('0,0,0',6001),null); + d.request([0,0,0],1,6002);d.confirmed([{pos:[0,0,0],block:1}],6003);d.reset(); + assert.equal(d.published('0,0,0',6004),null);assert.equal(d.last,null); +}); diff --git a/client/tests/edit-mesh.test.js b/client/tests/edit-mesh.test.js new file mode 100644 index 0000000..7040e7f --- /dev/null +++ b/client/tests/edit-mesh.test.js @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {SectionVoxelMap} from '../section-voxel-map.js'; +import {captureEditMesh,buildEditMesh} from '../edit-mesh.js'; +import {EditMeshController} from '../edit-mesh-controller.js'; +import {TerrainState} from '../terrain-state.js'; +const properties=JSON.parse(await readFile(new URL('../light-properties.json',import.meta.url))); +const cube={min:[0,0,0],max:[1,1,1]}; +const materials=new Map([[1,{state:'minecraft:stone',color:[128,128,128],render:[cube]}],[2,{state:'minecraft:sea_lantern',color:[230,245,250],render:[cube],light:15}],[3,{state:'minecraft:glass',color:[160,210,230],render:[cube],opacity:0.3}]]); + +test('local edit geometry matches full-world meshing at boundaries and distant coordinates without scanning the world',()=>{ + for(const base of [0,-32,29999840]) { + const state=new TerrainState(properties),initial=new Int32Array([base+15,2,0,1,base+16,2,0,1,base+17,2,0,2,base+16,3,0,3]); + state.sync({epoch:1,version:1,reset:true,compact:true,initial,materials:[...materials],textures:[],bounds:{min:[base-2,-2,-3],max:[base+32,6,3]}});state.light(); + // The bulk light calculation is deliberately not run after this removal. + state.blocks.delete(`${base+15},2,0`); + const current=state.mesh({epoch:1,version:1,id:`${base/16+1},0,0`,job:1}); + state.blocks[Symbol.iterator]=()=>{throw Error('No global iteration permitted');}; + state.blocks.sectionEntries=()=>{throw Error('No global section iteration permitted');}; + const packet=captureEditMesh(current.id,state.blocks,state.materials,state.textureLayers,state.field); + assert.ok(packet.sections.length<=27); + assert.ok(packet.light.data.length<=24**3*4); + const before=state.blocks.getSectionCells(current.id).byteLength; + const transfers=[...packet.sections.map(s=>s.cells.buffer),packet.light.data.buffer,packet.light.blockLevels.buffer,packet.light.skyLevels.buffer]; + const transferred=structuredClone(packet,{transfer:transfers}); + assert.equal(state.blocks.getSectionCells(current.id).byteLength,before,'live voxel buffers are never detached'); + assert.ok(state.field.data.byteLength>0,'live lighting buffers remain usable'); + const mesh=buildEditMesh(transferred,properties); + assert.deepEqual(mesh.vertices,current.vertices); + assert.deepEqual(mesh.transparent,current.transparent); + assert.deepEqual(mesh.origin,current.origin); + } +}); + +test('breaks and placements rebuild current geometry while full lighting is still pending',()=>{ + const blocks=new SectionVoxelMap();blocks.set('15,2,0',1);blocks.set('16,2,0',1); + const mesh=()=>buildEditMesh(captureEditMesh('1,0,0',blocks,materials,new Map(),null),properties); + const before=mesh();blocks.delete('15,2,0');const removed=mesh(); + assert.equal(removed.vertices.length,before.vertices.length+120,'the adjacent face appears'); + blocks.set('15,2,0',2);assert.equal(mesh().vertices.length,before.vertices.length); + const placed=buildEditMesh(captureEditMesh('0,0,0',blocks,materials,new Map(),null),properties); + assert.ok(placed.vertices.length>0); + assert.equal(placed.vertices[15],1,'lamp emission is immediately visible'); +}); + +class Worker { + packets=[]; postMessage(packet,transfers){this.packets.push(structuredClone(packet,{transfer:transfers}));} + terminate(){this.terminated=true;} + reply(packet){this.onmessage({data:{...packet,vertices:new Float32Array(),transparent:new Float32Array(),preview:true}});} +} +function fixture(){const worker=new Worker(),captured=[]; + const controller=new EditMeshController({workerFactory:()=>worker,capture:id=>{captured.push(id);return {id,sections:[],materials:[],textures:[],light:null};}}); + return {worker,controller,captured};} + +test('independent edit queue is bounded, coalesces newer edits and rejects stale/reset/unloaded results',()=>{ + const {worker,controller:c}=fixture(); + c.request(['0,0,0']);c.pump();const old=worker.packets[0]; + c.request(['0,0,0']);worker.reply(old);assert.equal(c.ready.size,0); + c.pump();const latest=worker.packets[1];worker.reply(latest);assert.equal(c.ready.size,1); + c.request(['1,0,0','2,0,0']);c.pump();worker.reply(worker.packets.at(-1));c.pump(); + assert.equal(c.ready.size,2);assert.equal(worker.packets.length,3,'two results cap memory before upload'); + c.cancel('0,0,0');assert.equal(c.isCurrent(latest),false);c.pump();const unloaded=worker.packets.at(-1); + c.cancel('2,0,0');worker.reply(unloaded);assert.equal(c.ready.has('2,0,0'),false); + c.request(['3,0,0']);c.pump();const reset=worker.packets.at(-1);c.reset();worker.reply(reset); + assert.equal(c.ready.size,0);assert.equal(c.pending.size,0); + c.dispose();assert.equal(worker.terminated,true); +}); + +test('a ready edit stays valid across unrelated terrain/light work until its own geometry changes',()=>{ + const {worker,controller:c}=fixture();c.request(['0,0,0']);c.pump();worker.reply(worker.packets[0]); + const result=c.ready.get('0,0,0');c.request(['8,0,0']); + assert.equal(c.isCurrent(result),true);c.request(['0,0,0']);assert.equal(c.isCurrent(result),false); + c.dispose(); +}); diff --git a/client/tests/fixtures/lighting-java26.2.json b/client/tests/fixtures/lighting-java26.2.json new file mode 100644 index 0000000..db95318 --- /dev/null +++ b/client/tests/fixtures/lighting-java26.2.json @@ -0,0 +1,984 @@ +{ + "version": "26.2", + "examples": [ + { + "minecraft_id": 0, + "state": "minecraft:air", + "emission": 0, + "dampening": 0, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": true, + "occlusion": [] + }, + { + "minecraft_id": 1, + "state": "minecraft:stone", + "emission": 0, + "dampening": 15, + "can_occlude": true, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 562, + "state": "minecraft:glass", + "emission": 0, + "dampening": 0, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": true, + "occlusion": [] + }, + { + "minecraft_id": 27161, + "state": "minecraft:tinted_glass", + "emission": 0, + "dampening": 15, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 7098, + "state": "minecraft:white_stained_glass", + "emission": 0, + "dampening": 0, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": true, + "occlusion": [] + }, + { + "minecraft_id": 8331, + "state": "minecraft:glass_pane[east=false,north=false,south=false,waterlogged=false,west=false]", + "emission": 0, + "dampening": 0, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": true, + "occlusion": [] + }, + { + "minecraft_id": 6927, + "state": "minecraft:ice", + "emission": 0, + "dampening": 1, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 12914, + "state": "minecraft:packed_ice", + "emission": 0, + "dampening": 15, + "can_occlude": true, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 15275, + "state": "minecraft:blue_ice", + "emission": 0, + "dampening": 15, + "can_occlude": true, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 86, + "state": "minecraft:water[level=0]", + "emission": 0, + "dampening": 1, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 102, + "state": "minecraft:lava[level=0]", + "emission": 15, + "dampening": 1, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 279, + "state": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]", + "emission": 0, + "dampening": 1, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 531, + "state": "minecraft:azalea_leaves[distance=7,persistent=false,waterlogged=false]", + "emission": 0, + "dampening": 1, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 13333, + "state": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "emission": 0, + "dampening": 0, + "can_occlude": true, + "use_shape": true, + "propagates_skylight_down": true, + "occlusion": [ + [ + 0.0, + 0.0, + 0.0, + 1.0, + 0.5, + 1.0 + ] + ] + }, + { + "minecraft_id": 3918, + "state": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]", + "emission": 0, + "dampening": 0, + "can_occlude": true, + "use_shape": true, + "propagates_skylight_down": true, + "occlusion": [ + [ + 0.0, + 0.0, + 0.0, + 1.0, + 0.5, + 1.0 + ], + [ + 0.0, + 0.5, + 0.0, + 1.0, + 1.0, + 0.5 + ] + ] + }, + { + "minecraft_id": 7129, + "state": "minecraft:oak_trapdoor[facing=north,half=bottom,open=false,powered=false,waterlogged=false]", + "emission": 0, + "dampening": 0, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": true, + "occlusion": [] + }, + { + "minecraft_id": 5666, + "state": "minecraft:oak_door[facing=north,half=lower,hinge=left,open=false,powered=false]", + "emission": 0, + "dampening": 0, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": true, + "occlusion": [] + }, + { + "minecraft_id": 6919, + "state": "minecraft:snow[layers=1]", + "emission": 0, + "dampening": 0, + "can_occlude": true, + "use_shape": true, + "propagates_skylight_down": true, + "occlusion": [ + [ + 0.0, + 0.0, + 0.0, + 1.0, + 0.125, + 1.0 + ] + ] + }, + { + "minecraft_id": 6928, + "state": "minecraft:snow_block", + "emission": 0, + "dampening": 15, + "can_occlude": true, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 3370, + "state": "minecraft:torch", + "emission": 14, + "dampening": 0, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": true, + "occlusion": [] + }, + { + "minecraft_id": 3371, + "state": "minecraft:wall_torch[facing=north]", + "emission": 14, + "dampening": 0, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": true, + "occlusion": [] + }, + { + "minecraft_id": 6885, + "state": "minecraft:redstone_torch[lit=true]", + "emission": 7, + "dampening": 0, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": true, + "occlusion": [] + }, + { + "minecraft_id": 7006, + "state": "minecraft:soul_torch", + "emission": 10, + "dampening": 0, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": true, + "occlusion": [] + }, + { + "minecraft_id": 7016, + "state": "minecraft:glowstone", + "emission": 15, + "dampening": 15, + "can_occlude": true, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 12892, + "state": "minecraft:sea_lantern", + "emission": 15, + "dampening": 15, + "can_occlude": true, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 9480, + "state": "minecraft:redstone_lamp[lit=false]", + "emission": 0, + "dampening": 15, + "can_occlude": true, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 20840, + "state": "minecraft:lantern[hanging=false,waterlogged=false]", + "emission": 15, + "dampening": 0, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": true, + "occlusion": [] + }, + { + "minecraft_id": 7023, + "state": "minecraft:jack_o_lantern[facing=north]", + "emission": 15, + "dampening": 15, + "can_occlude": true, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 12566, + "state": "minecraft:light[level=15,waterlogged=false]", + "emission": 15, + "dampening": 0, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": true, + "occlusion": [] + }, + { + "minecraft_id": 27164, + "state": "minecraft:sculk_sensor[power=0,sculk_sensor_phase=inactive,waterlogged=false]", + "emission": 1, + "dampening": 0, + "can_occlude": true, + "use_shape": true, + "propagates_skylight_down": true, + "occlusion": [ + [ + 0.0, + 0.0, + 0.0, + 1.0, + 0.5, + 1.0 + ] + ] + }, + { + "minecraft_id": 29539, + "state": "minecraft:copper_bulb[lit=false,powered=false]", + "emission": 0, + "dampening": 15, + "can_occlude": true, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 29521, + "state": "minecraft:copper_grate[waterlogged=false]", + "emission": 0, + "dampening": 0, + "can_occlude": false, + "use_shape": false, + "propagates_skylight_down": true, + "occlusion": [] + }, + { + "minecraft_id": 9479, + "state": "minecraft:redstone_lamp[lit=true]", + "emission": 15, + "dampening": 15, + "can_occlude": true, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 13331, + "state": "minecraft:oak_slab[type=top,waterlogged=false]", + "emission": 0, + "dampening": 0, + "can_occlude": true, + "use_shape": true, + "propagates_skylight_down": true, + "occlusion": [ + [ + 0.0, + 0.5, + 0.0, + 1.0, + 1.0, + 1.0 + ] + ] + }, + { + "minecraft_id": 13335, + "state": "minecraft:oak_slab[type=double,waterlogged=false]", + "emission": 0, + "dampening": 15, + "can_occlude": true, + "use_shape": false, + "propagates_skylight_down": false, + "occlusion": [] + }, + { + "minecraft_id": 13332, + "state": "minecraft:oak_slab[type=bottom,waterlogged=true]", + "emission": 0, + "dampening": 1, + "can_occlude": true, + "use_shape": true, + "propagates_skylight_down": false, + "occlusion": [ + [ + 0.0, + 0.0, + 0.0, + 1.0, + 0.5, + 1.0 + ] + ] + } + ], + "crossings": [ + { + "from": "minecraft:air", + "to": "minecraft:stone", + "direction": "down", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:stone", + "direction": "up", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:stone", + "direction": "north", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:stone", + "direction": "south", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:stone", + "direction": "west", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:stone", + "direction": "east", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:glass", + "direction": "down", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:glass", + "direction": "up", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:glass", + "direction": "north", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:glass", + "direction": "south", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:glass", + "direction": "west", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:glass", + "direction": "east", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:tinted_glass", + "direction": "down", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:tinted_glass", + "direction": "up", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:tinted_glass", + "direction": "north", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:tinted_glass", + "direction": "south", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:tinted_glass", + "direction": "west", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:tinted_glass", + "direction": "east", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:ice", + "direction": "down", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:ice", + "direction": "up", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:ice", + "direction": "north", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:ice", + "direction": "south", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:ice", + "direction": "west", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:ice", + "direction": "east", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:water[level=0]", + "direction": "down", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:water[level=0]", + "direction": "up", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:water[level=0]", + "direction": "north", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:water[level=0]", + "direction": "south", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:water[level=0]", + "direction": "west", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:water[level=0]", + "direction": "east", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]", + "direction": "down", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]", + "direction": "up", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]", + "direction": "north", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]", + "direction": "south", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]", + "direction": "west", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_leaves[distance=7,persistent=false,waterlogged=false]", + "direction": "east", + "block_dampening": 1, + "sky_column_dampening": 1 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "direction": "down", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "direction": "up", + "block_dampening": 16, + "sky_column_dampening": 16 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "direction": "north", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "direction": "south", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "direction": "west", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "direction": "east", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]", + "direction": "down", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]", + "direction": "up", + "block_dampening": 16, + "sky_column_dampening": 16 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]", + "direction": "north", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]", + "direction": "south", + "block_dampening": 16, + "sky_column_dampening": 16 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]", + "direction": "west", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]", + "direction": "east", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:air", + "to": "minecraft:glowstone", + "direction": "down", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:glowstone", + "direction": "up", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:glowstone", + "direction": "north", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:glowstone", + "direction": "south", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:glowstone", + "direction": "west", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:glowstone", + "direction": "east", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:sea_lantern", + "direction": "down", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:sea_lantern", + "direction": "up", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:sea_lantern", + "direction": "north", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:sea_lantern", + "direction": "south", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:sea_lantern", + "direction": "west", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:air", + "to": "minecraft:sea_lantern", + "direction": "east", + "block_dampening": 15, + "sky_column_dampening": 15 + }, + { + "from": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "to": "minecraft:oak_slab[type=top,waterlogged=false]", + "direction": "down", + "block_dampening": 16, + "sky_column_dampening": 16 + }, + { + "from": "minecraft:oak_slab[type=top,waterlogged=false]", + "to": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "direction": "down", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:glowstone", + "to": "minecraft:air", + "direction": "down", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "to": "minecraft:oak_slab[type=top,waterlogged=false]", + "direction": "up", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:oak_slab[type=top,waterlogged=false]", + "to": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "direction": "up", + "block_dampening": 16, + "sky_column_dampening": 16 + }, + { + "from": "minecraft:glowstone", + "to": "minecraft:air", + "direction": "up", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "to": "minecraft:oak_slab[type=top,waterlogged=false]", + "direction": "north", + "block_dampening": 16, + "sky_column_dampening": 16 + }, + { + "from": "minecraft:oak_slab[type=top,waterlogged=false]", + "to": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "direction": "north", + "block_dampening": 16, + "sky_column_dampening": 16 + }, + { + "from": "minecraft:glowstone", + "to": "minecraft:air", + "direction": "north", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "to": "minecraft:oak_slab[type=top,waterlogged=false]", + "direction": "south", + "block_dampening": 16, + "sky_column_dampening": 16 + }, + { + "from": "minecraft:oak_slab[type=top,waterlogged=false]", + "to": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "direction": "south", + "block_dampening": 16, + "sky_column_dampening": 16 + }, + { + "from": "minecraft:glowstone", + "to": "minecraft:air", + "direction": "south", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "to": "minecraft:oak_slab[type=top,waterlogged=false]", + "direction": "west", + "block_dampening": 16, + "sky_column_dampening": 16 + }, + { + "from": "minecraft:oak_slab[type=top,waterlogged=false]", + "to": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "direction": "west", + "block_dampening": 16, + "sky_column_dampening": 16 + }, + { + "from": "minecraft:glowstone", + "to": "minecraft:air", + "direction": "west", + "block_dampening": 1, + "sky_column_dampening": 0 + }, + { + "from": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "to": "minecraft:oak_slab[type=top,waterlogged=false]", + "direction": "east", + "block_dampening": 16, + "sky_column_dampening": 16 + }, + { + "from": "minecraft:oak_slab[type=top,waterlogged=false]", + "to": "minecraft:oak_slab[type=bottom,waterlogged=false]", + "direction": "east", + "block_dampening": 16, + "sky_column_dampening": 16 + }, + { + "from": "minecraft:glowstone", + "to": "minecraft:air", + "direction": "east", + "block_dampening": 1, + "sky_column_dampening": 0 + } + ], + "measurement_scope": "Public original Java 26.2 block-state light properties and LightEngine.getLightDampeningInto calls. Effective shape is empty unless canOcclude and useShapeForLightOcclusion are both true. Crossings measure directional shape blocking and target attenuation, not a full running light engine or skylight source map.", + "provenance": { + "source_url": "https://piston-data.mojang.com/v1/objects/823e2250d24b3ddac457a60c92a6a941943fcd6a/server.jar", + "source_sha1": "823e2250d24b3ddac457a60c92a6a941943fcd6a", + "source_sha256": "cdacdfb25898de5e4b4b0e5ddcc2722f77067e46605709c2d886c000ebb63ec5", + "executable_sha256": "183c0499c5f855570ee487dd38e141a53f0121f83a0b07a3bac2d8b6698823e8", + "probe_sha256": "8663e2f81af754fa4063a6f0a109fa27b8c1fae06a5a393d7ca42ac243a252bb", + "command": "python3 scripts/measure_lighting.py --java /path/to/java25/bin/java" + } +} diff --git a/client/tests/large-world.test.js b/client/tests/large-world.test.js new file mode 100644 index 0000000..ff579da --- /dev/null +++ b/client/tests/large-world.test.js @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {SectionVoxelMap} from '../section-voxel-map.js'; +import {TerrainController} from '../terrain-controller.js'; +import {TerrainState} from '../terrain-state.js'; +import {buildBlockLight} from '../block-light.js'; +import {buildSectionMesh} from '../mesh-geometry.js'; +test('authoritative columns keep a cave dark when the roof is above the loaded vertical window',()=>{ + const world=new SectionVoxelMap();world.setSection('0,0,0',new Uint32Array(4096)); + const bounds={min:[0,0,0],max:[15,15,15]},columns=new Map([['0,0',new Int16Array(256).fill(30)]]); + assert.equal(buildBlockLight(world,new Map(),bounds,columns).sample([8,8,8]).skyLevel,0); + columns.get('0,0').fill(-1); + assert.equal(buildBlockLight(world,new Map(),bounds,columns).sample([8,8,8]).skyLevel,15); +}); +test('compact controller transfers section copies and preserves the main collision buffers',()=>{ + const packets=[],worker={postMessage(p,transfers){packets.push(structuredClone(p,{transfer:transfers}));},terminate(){}}; + const c=new TerrainController({workerFactory:()=>worker}),world=new SectionVoxelMap(); + const cells=new Uint32Array(4096).fill(3);world.setSection('0,0,0',cells); + const materials=new Map([[3,{state:'minecraft:stone',color:[128,128,128]}]]); + c.reset(world,materials,new Map(),{min:[0,0,0],max:[15,15,15]});c.flush(); + assert.equal(cells.byteLength,16384);assert.equal(packets[0].initial,null);assert.equal(packets[0].compact,true);assert.equal(packets[0].sections[0].cells[0],3); + const state=new TerrainState(null);state.sync(packets[0]);assert.equal(state.blocks.getAt(0,0,0),3); + c.update({unload:[[0,0,0]]});c.update({sections:[{section:[0,0,0],cells:new Uint32Array(4096)}]});c.flush();state.sync(packets[1]); + assert.equal(state.blocks.hasSection('0,0,0'),true);assert.equal(state.blocks.size,0);c.dispose(); +}); +test('section-local mesh coordinates retain fractional geometry near the world border',()=>{ + const world=new SectionVoxelMap(),x=29_999_840;world.set(`${x},0,0`,1); + const materials=new Map([[1,{state:'custom:partial',color:[128,128,128],render:[{min:[.125,.25,.125],max:[.875,.75,.875]}]}]]); + const result=buildSectionMesh({id:`${x/16},0,0`,blocks:world,materials,localCoordinates:true}); + assert.deepEqual(result.origin,[x,0,0]); + const xs=[];for(let i=0;i product * value, 1); + return { + min, + size, + data: new Uint8Array(count * 4), + skyLevels: new Uint8Array(count).fill(15), + }; +} +const index = (field, [x, y, z]) => + x - + field.min[0] + + field.size[0] * (y - field.min[1] + field.size[1] * (z - field.min[2])); + +test("initial lighting marks current sections, while equal fields retain every mesh", () => { + const sections = new Map([ + ["0,0,0", { vao: "first" }], + ["1,0,0", { vao: "retained" }], + ]); + assert.deepEqual(changedLightSections(null, field(), sections.keys()), [ + ...sections.keys(), + ]); + const before = [...sections.values()]; + assert.deepEqual(changedLightSections(field(), field(), sections.keys()), []); + assert.deepEqual([...sections.values()], before); +}); + +test("a corner light delta invalidates all eight sharing neighborhoods but no distant section", () => { + const previous = field(), + next = field(); + next.skyLevels[index(next, [15, 15, 15])] = 14; + const neighbors = []; + for (let x = 0; x <= 1; x++) + for (let y = 0; y <= 1; y++) + for (let z = 0; z <= 1; z++) neighbors.push(`${x},${y},${z}`); + assert.deepEqual( + changedLightSections(previous, next, [...neighbors, "2,0,0"]), + neighbors, + ); +}); + +test("a tint-only change crosses the section boundary and preserves distant retained sections", () => { + const previous = field(), + next = field(); + next.data[index(next, [16, 4, 4]) * 4 + 2] = 180; + const sections = ["0,0,0", "1,0,0", "2,0,0"]; + assert.deepEqual(changedLightSections(previous, next, sections), [ + "0,0,0", + "1,0,0", + ]); +}); + +test("moving field bounds compares world coordinates and only invalidates lost coverage", () => { + const previous = field(); + const shifted = field([-1, -2, -2], [55, 36, 36]); + assert.deepEqual( + changedLightSections(previous, shifted, ["0,0,0", "1,0,0"]), + [], + ); + const clipped = field([0, -2, -2], [54, 36, 36]); + assert.deepEqual( + changedLightSections(previous, clipped, ["0,0,0", "1,0,0"]), + ["0,0,0"], + ); +}); diff --git a/client/tests/light-properties.test.js b/client/tests/light-properties.test.js new file mode 100644 index 0000000..2366f10 --- /dev/null +++ b/client/tests/light-properties.test.js @@ -0,0 +1,72 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { applyLightProperties, loadLightProperties } from "../light-properties.js"; + +const properties = JSON.parse(await readFile(new URL("../light-properties.json", import.meta.url))); +const reference = JSON.parse(await readFile(new URL("./fixtures/lighting-java26.2.json", import.meta.url))); + +test("all original measured examples match by Minecraft ID and canonical state", () => { + for (const example of reference.examples) { + for (const material of [{ minecraft_id: example.minecraft_id }, { state: example.state }]) { + const actual = applyLightProperties(material, properties); + assert.equal(actual.light_dampening, example.dampening, example.state); + assert.deepEqual(actual.light_occlusion, example.occlusion.map((box) => ({ min: box.slice(0, 3), max: box.slice(3, 6) })), example.state); + assert.deepEqual(material, "state" in material ? { state: example.state } : { minecraft_id: example.minecraft_id }); + } + } +}); + +test("partial state strings use default values and accept arbitrary property order", () => { + const top = applyLightProperties({ state: "minecraft:oak_slab[type=top]" }, properties); + assert.equal(top.light_dampening, 0); + assert.deepEqual(top.light_occlusion, [{ min: [0, 0.5, 0], max: [1, 1, 1] }]); + const wet = applyLightProperties({ state: "minecraft:oak_slab[waterlogged=true,type=top]" }, properties); + assert.equal(wet.light_dampening, 1); + assert.deepEqual(wet.light_occlusion, top.light_occlusion); + const iron = applyLightProperties({ state: "minecraft:iron_trapdoor[open=false]" }, properties); + assert.equal(iron.light_dampening, 0); + assert.deepEqual(iron.light_occlusion, []); +}); + +test("custom materials retain their own identity and optional light properties", () => { + const custom = { id: 1, state: "shacraft:trampoline", light: 11, light_dampening: 2, light_occlusion: [] }; + const actual = applyLightProperties(custom, properties); + assert.notEqual(actual, custom); + assert.deepEqual(actual, custom); + for (const state of ["minecraft:oak_slab[type=invalid]", "minecraft:stone[missing=true]", "minecraft:oak_slab[type=top,type=bottom]"]) { + assert.deepEqual(applyLightProperties({ state }, properties), { state }); + } + assert.deepEqual(applyLightProperties({ id: 1 }, properties), { id: 1 }); + const glass = applyLightProperties({ id: 999, minecraft_id: reference.examples.find((e) => e.state === "minecraft:glass").minecraft_id, light: 4 }, properties); + assert.equal(glass.id, 999); + assert.equal(glass.light, 4); + assert.equal(glass.light_dampening, 0); +}); + +test("every registry state is present and every shape index is valid", () => { + assert.equal(properties.codes.length, 32366); + assert.equal(Object.keys(properties.blocks).length, 1196); + assert.equal(properties.shapes.length, 60); + for (const code of properties.codes) { + assert.ok(Number.isInteger(code) && code >= 0); + assert.ok(properties.shapes[Math.floor(code / 16)]); + } +}); + +test("the runtime metadata request is shared by concurrent and later callers", async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async (url) => { + calls++; + assert.equal(url.pathname.endsWith("/light-properties.json"), true); + return { ok: true, json: async () => properties }; + }; + try { + const [first, second] = await Promise.all([loadLightProperties(), loadLightProperties()]); + assert.equal(first, properties); + assert.equal(first, second); + assert.equal(await loadLightProperties(), properties); + assert.equal(calls, 1); + } finally { globalThis.fetch = originalFetch; } +}); diff --git a/client/tests/local-terrain-meshing.test.js b/client/tests/local-terrain-meshing.test.js new file mode 100644 index 0000000..81ee74b --- /dev/null +++ b/client/tests/local-terrain-meshing.test.js @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {TerrainState} from '../terrain-state.js'; +import {TerrainController} from '../terrain-controller.js'; +import {SectionVoxelMap} from '../section-voxel-map.js'; +const properties=JSON.parse(await readFile(new URL('../light-properties.json',import.meta.url))); +const cube={min:[0,0,0],max:[1,1,1]}; +const materials=new Map([[1,{state:'minecraft:stone',color:[128,128,128],render:[cube]}],[2,{state:'minecraft:sea_lantern',color:[240,245,250],render:[cube],light:15}],[3,{state:'minecraft:water',color:[80,110,240],render:[cube],opacity:0.5}],[4,{state:'minecraft:glass',color:[180,220,240],render:[cube],opacity:0.3}]]); + +test('local light meshes match whole-view light with distant sources, a high roof, water and negative coordinates',()=>{ + const blocks=new SectionVoxelMap(),bounds={min:[-48,-16,-48],max:[79,47,63]},columns=[]; + for(let x=-48;x<=79;x++)for(let z=-48;z<=63;z++)blocks.set(`${x},0,${z}`,1); + for(let x=4;x<13;x++)for(let z=5;z<13;z++)blocks.set(`${x},40,${z}`,1); + for(let y=1;y<40;y++)blocks.set(`10,${y},11`,3); + blocks.set('-14,1,4',2);blocks.set('31,1,8',2);blocks.set('3,1,8',4); + for(let x=-3;x<=4;x++)for(let z=-3;z<=3;z++) { + const heights=new Int16Array(256); + for(let lx=0;lx<16;lx++)for(let lz=0;lz<16;lz++)if(x*16+lx>=4&&x*16+lx<13&&z*16+lz>=5&&z*16+lz<13)heights[lx+16*lz]=40; + columns.push({column:[x,z],heights}); + } + const state=new TerrainState(properties); + state.sync({epoch:1,version:1,reset:true,compact:true,sections:[...blocks.sectionEntries()].map(([id,cells])=>({section:id.split(',').map(Number),cells})),materials:[...materials],textures:[],bounds,columns}); + state.light(); + for(const id of ['0,0,0','-1,0,0','0,1,0']) { + const packet={id,epoch:1,version:1,job:1,meshTicket:1}; + const expected=state.mesh(packet),actual=state.meshLocal(packet); + assert.deepEqual(actual.vertices,expected.vertices);assert.deepEqual(actual.transparent,expected.transparent); + assert.ok(actual.lightCells<=68*68*64); + } + assert.equal(state.meshLocal({id:'0,2,0',epoch:1,version:1}).lightCached,true,'vertical sections share their column light'); + state.sync({epoch:1,version:2,materials:[],bounds,changes:new Int32Array([70,1,50,2])}); + assert.equal(state.meshLocal({id:'0,0,0',epoch:1,version:2}).lightCached,true,'distant edits keep the local field'); + state.sync({epoch:1,version:3,materials:[],bounds,changes:new Int32Array([0,1,1,2])}); + assert.equal(state.meshLocal({id:'0,0,0',epoch:1,version:3}).lightCached,false,'near edits invalidate light'); + state.light();assert.deepEqual(state.meshLocal({id:'0,0,0',epoch:1,version:3}).vertices,state.mesh({id:'0,0,0',epoch:1,version:3}).vertices); +}); + +test('a roof outside the vertical view stays dark in local meshes and cache size stays bounded',()=>{ + const state=new TerrainState(properties),bounds={min:[-144,0,-144],max:[159,79,159]},columns=[]; + for(let x=-9;x<=9;x++)for(let z=-9;z<=9;z++)columns.push({column:[x,z],heights:new Int16Array(256).fill(100)}); + state.sync({epoch:1,version:1,reset:true,compact:true,initial:new Int32Array([0,1,0,1]),materials:[...materials],bounds,columns}); + const result=state.meshLocal({epoch:1,version:1,id:'0,0,0'}); + for(let i=19;ip.type==='sync').at(-1);this.reply({type:'synced',epoch:p.epoch,version:p.version});} + mesh(packet=this.packets.at(-1)){this.reply({...packet,vertices:new Float32Array(20),transparent:new Float32Array()});} +} +function fixture(){const light=new Worker(),mesh=new Worker(),dirty=new Set(),c=new TerrainController({workerFactory:()=>light,meshWorkerFactory:()=>mesh,onDirty:ids=>ids.forEach(id=>dirty.add(id))}); + const cells=new Uint32Array(4096).fill(1),blocks=new SectionVoxelMap();blocks.setSection('0,0,0',cells); + c.reset(blocks,materials,new Map(),{min:[-144,-32,-144],max:[159,47,159]});c.flush();mesh.sync();return {light,mesh,c,cells,dirty};} + +test('nearby meshing starts before any global light result and survives repeated far chunk packets',()=>{ + const {light,mesh,c,cells}=fixture(); + try { + assert.equal(c.workerVersion,-1);assert.equal(c.requestMesh('0,0,0'),true); + const job=mesh.packets.at(-1); + for(let i=5;i<9;i++){c.update({sections:[{section:[i,0,i],cells:new Uint32Array(4096)}],materials});c.flush();mesh.sync();} + mesh.mesh(job);const ready=c.ready.get('0,0,0');assert.ok(ready);assert.equal(c.isCurrent(ready),true); + assert.equal(cells.byteLength,16384); + assert.notEqual(light.packets[0].sections[0].cells.buffer,mesh.packets[0].sections[0].cells.buffer); + assert.equal(c.workerVersion,-1,'global lighting is still deliberately unfinished'); + c.update({changes:[{pos:[16,0,0],block:2}]});assert.equal(c.ready.size,0);assert.equal(c.isCurrent(ready),false); + c.flush();mesh.sync();assert.equal(c.requestMesh('0,0,0'),true); + const old=mesh.packets.at(-1);c.update({unload:[[0,0,0]]});mesh.mesh(old);assert.equal(c.ready.size,0); + }finally{c.dispose();} +}); + +test('material, sky-column and world changes reject affected meshes, but repeated definitions do not',()=>{ + const {mesh,c}=fixture();try{ + c.requestMesh('0,0,0');mesh.mesh();let ready=c.ready.get('0,0,0'); + c.update({materials:new Map([...materials].map(([id,m])=>[id,structuredClone(m)]))});assert.equal(c.isCurrent(ready),true); + c.update({columns:[{column:[0,0],heights:new Int16Array(256).fill(100)}]});assert.equal(c.isCurrent(ready),false); + c.flush();mesh.sync();c.requestMesh('0,0,0');mesh.mesh();ready=c.ready.get('0,0,0'); + c.update({materials:new Map([[1,{...materials.get(1),color:[12,34,56]}]])});assert.equal(c.isCurrent(ready),false); + c.reset(new SectionVoxelMap(),materials,new Map(),{min:[0,0,0],max:[15,15,15]});assert.equal(c.isCurrent(ready),false); + }finally{c.dispose();} +}); + +test('unload and re-entry cannot reuse a ticket from an old in-flight mesh',()=>{ + const {mesh,c}=fixture();try{ + c.requestMesh('0,0,0');const old=mesh.packets.at(-1); + c.update({unload:[[0,0,0]]});c.flush();mesh.sync(); + c.update({sections:[{section:[0,0,0],cells:new Uint32Array(4096).fill(2)}]});c.flush();mesh.sync(); + mesh.mesh(old);assert.equal(c.ready.size,0); + c.requestMesh('0,0,0');const current=mesh.packets.at(-1); + assert.notEqual(current.meshTicket,old.meshTicket);assert.equal(c.isCurrent(old),false); + mesh.mesh(current);assert.equal(c.isCurrent(c.ready.get('0,0,0')),true); + }finally{c.dispose();} +}); + +test('horizontal view movement retains interior meshes and invalidates only changed light boundaries',()=>{ + const {mesh,c}=fixture();try{ + c.requestMesh('0,0,0');mesh.mesh();const near=c.ready.get('0,0,0');c.ready.delete('0,0,0'); + c.requestMesh('-9,0,0');mesh.mesh();const edge=c.ready.get('-9,0,0'); + c.update({bounds:{min:[-112,-32,-144],max:[191,47,159]}}); + assert.equal(c.isCurrent(near),true);assert.equal(c.isCurrent(edge),false); + c.update({bounds:{min:[-112,-16,-144],max:[191,63,159]}}); + assert.equal(c.isCurrent(near),false,'moving the top sky boundary can change light throughout a column'); + }finally{c.dispose();} +}); + +test('an obsolete failed mesh releases its job instead of blocking subsequent chunks',()=>{ + const {mesh,c}=fixture();try{ + c.requestMesh('0,0,0');const old=mesh.packets.at(-1); + c.update({changes:[{pos:[0,0,0],block:2}]});c.flush();mesh.sync(); + mesh.reply({...old,type:'error',error:'obsolete work'}); + assert.equal(c.busy,null);assert.equal(c.meshWorker,mesh);assert.equal(c.requestMesh('0,0,0'),true); + }finally{c.dispose();} +}); diff --git a/client/tests/math.test.js b/client/tests/math.test.js index d33d489..d83d07e 100644 --- a/client/tests/math.test.js +++ b/client/tests/math.test.js @@ -9,7 +9,12 @@ import { viewMatrix, project, unitBox, + isFullCube, } from "../math.js"; +test("translucent cubes do not hide opaque surfaces behind glass", () => { + assert.equal(isFullCube({ render: [unitBox], opacity: 0.3 }), false); + assert.equal(isFullCube({ render: [unitBox], opacity: 1 }), true); +}); 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); diff --git a/client/tests/mesh-geometry.test.js b/client/tests/mesh-geometry.test.js new file mode 100644 index 0000000..646ed67 --- /dev/null +++ b/client/tests/mesh-geometry.test.js @@ -0,0 +1,132 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { boxVertices, buildSectionMesh, materialType, MESH_VERTEX_STRIDE } from "../mesh-geometry.js"; +import { buildBlockLight } from "../block-light.js"; +import { unitBox } from "../math.js"; + +const stone = { state: "minecraft:stone", color: [128, 144, 160], render: [unitBox], opacity: 1, light: 0 }; +const materialMap = () => new Map([ + [1, stone], + [2, { ...stone, state: "minecraft:water[level=0]", color: [50, 90, 180], opacity: 0.55, transparent: true }], + [3, { ...stone, state: "minecraft:glass", opacity: 0.35, transparent: true }], + [4, { ...stone, state: "minecraft:oak_leaves", transparent: true }], + [5, { ...stone, state: "minecraft:torch", light: 14, render: [], light_dampening: 0, light_occlusion: [] }], + [6, { ...stone, state: "minecraft:redstone_lamp[lit=true]", light: 15 }], +]); +const blockMap = (...cells) => new Map(cells.map(([pos, id = 1]) => [pos.join(","), id])); +const build = (blocks, options = {}) => buildSectionMesh({ id: "0,0,0", blocks, materials: materialMap(), ...options }); +const rows = (vertices) => Array.from({ length: vertices.length / MESH_VERTEX_STRIDE }, (_, index) => + [...vertices.slice(index * MESH_VERTEX_STRIDE, (index + 1) * MESH_VERTEX_STRIDE)]); + +test("empty sections return transferable empty Float32 arrays", () => { + const result = build(new Map()); + assert.ok(result.vertices instanceof Float32Array); + assert.ok(result.transparent instanceof Float32Array); + assert.equal(result.vertices.length, 0); + assert.equal(result.transparent.length, 0); + const invisible = build(blockMap([[1, 1, 1], 5])); + assert.equal(invisible.vertices.length, 0); +}); + +test("an isolated cube keeps all six faces and the complete 20-float format", () => { + const result = build(blockMap([[2, 3, 4], 1])), vertices = rows(result.vertices); + assert.equal(MESH_VERTEX_STRIDE, 20); + assert.equal(vertices.length, 36); + assert.equal(result.transparent.length, 0); + for (const vertex of vertices) { + assert.ok(vertex.slice(0, 3).every((value, axis) => [2, 3, 4][axis] <= value && value <= [3, 4, 5][axis])); + assert.deepEqual(vertex.slice(3, 6), [128, 144, 160].map((value) => Math.fround(value / 255))); + assert.equal(Math.hypot(...vertex.slice(6, 9)), 1); + assert.deepEqual(vertex.slice(13), [-1, 1, 0, 0, 0, 0, 1]); + } +}); + +test("opaque neighbors cull their shared faces, including across section boundaries", () => { + assert.equal(build(blockMap([[1, 1, 1], 1], [[2, 1, 1], 1])).vertices.length / 20, 60); + const result = build(blockMap([[15, 1, 1], 1], [[16, 1, 1], 1])); + assert.equal(result.vertices.length / 20, 30); + assert.ok(rows(result.vertices).every((vertex) => vertex[6] !== 1)); + const negative = build(blockMap([[-1, 1, 1], 1], [[0, 1, 1], 1]), { id: "-1,0,0" }); + assert.equal(negative.vertices.length / 20, 30); + assert.ok(rows(negative.vertices).every((vertex) => vertex[0] <= 0)); +}); + +test("water goes into the translucent buffer and does not hide a stone face", () => { + const result = build(blockMap([[1, 1, 1], 1], [[2, 1, 1], 2])); + assert.equal(result.vertices.length / 20, 36); + assert.equal(result.transparent.length / 20, 36); + for (const vertex of rows(result.transparent)) { + assert.equal(vertex[9], 6); + assert.equal(vertex[10], Math.fround(0.55)); + } +}); + +test("texture-pack glass and leaves retain cutout faces in the opaque pass", () => { + const blocks = blockMap([[1, 1, 1], 3], [[2, 1, 1], 4]); + const fallback = build(blocks); + assert.equal(fallback.transparent.length / 20, 36); + const result = build(blocks, { textureLayers: new Map([["glass", 2], ["oak_leaves", 3]]) }); + assert.equal(result.vertices.length / 20, 72); + assert.equal(result.transparent.length, 0); + const vertices = rows(result.vertices); + assert.ok(vertices.every((vertex) => vertex[10] === 1)); + assert.equal(vertices.filter((vertex) => vertex[13] === 2).length, 36); + assert.equal(vertices.filter((vertex) => vertex[13] === 3).length, 36); + assert.ok(vertices.filter((vertex) => vertex[13] === 3).every((vertex) => vertex[4] > vertex[3])); +}); + +test("partial geometry preserves its real height and crops texture coordinates", () => { + const materials = materialMap(); + materials.set(7, { ...stone, state: "minecraft:stone_slab[type=bottom]", render: [{ min: [0, 0, 0], max: [1, 0.5, 1] }] }); + const result = build(blockMap([[1, 1, 1], 7]), { materials, textureLayers: new Map([["stone", 8]]) }); + const vertices = rows(result.vertices); + assert.equal(Math.max(...vertices.map((vertex) => vertex[1])), 1.5); + assert.ok(vertices.every((vertex) => vertex[13] === 8)); + const sides = vertices.filter((vertex) => vertex[7] === 0); + assert.ok(sides.every((vertex) => vertex[12] >= 0.5)); +}); + +test("baked AO shades contact corners while exposed faces remain bright", () => { + const result = build(blockMap([[1, 0, 1], 1], [[0, 1, 1], 1], [[1, 1, 0], 1])); + const top = rows(result.vertices).filter((vertex) => vertex[1] === 1 && vertex[7] === 1); + assert.equal(top.length, 6); + assert.ok(top.some((vertex) => vertex[14] === Math.fround(0.76))); + assert.ok(top.some((vertex) => vertex[14] === 1)); +}); + +test("actual propagated torch light and sky are baked without changing emission", () => { + const blocks = blockMap([[4, 0, 4], 1], [[4, 2, 4], 5], [[10, 0, 10], 6]); + const materials = materialMap(); + const blockLightField = buildBlockLight(blocks, materials, { min: [-1, -1, -1], max: [16, 16, 16] }); + const result = build(blocks, { materials, blockLightField }); + const vertices = rows(result.vertices); + const top = vertices.filter((vertex) => vertex[1] === 1 && vertex[7] === 1 && vertex[0] < 6); + assert.ok(top.every((vertex) => vertex[16] > 0 && vertex[16] > vertex[17] && vertex[17] > vertex[18])); + assert.ok(top.every((vertex) => vertex[19] === 1 && vertex[15] === 0)); + assert.ok(vertices.some((vertex) => vertex[15] === 1)); +}); + +test("geometry extraction keeps yaw and per-vertex dynamic lighting for actors", () => { + const out = [], sampled = []; + const field = { sample(point) { sampled.push(point); return { block: [0.1, 0.2, 0.3], sky: 0.4 }; } }; + boxVertices(out, [5, 6, 7], unitBox, [1, 1, 1], 0, () => false, Math.PI / 2, + 1, null, "", null, 0, field, true); + const vertices = rows(new Float32Array(out)); + assert.equal(sampled.length, 36); + assert.ok(vertices.every((vertex) => vertex[0] >= 4 && vertex[0] <= 5 && vertex[2] >= 7 && vertex[2] <= 8)); + assert.deepEqual(vertices[0].slice(16), [0.1, 0.2, 0.3, 0.4].map(Math.fround)); + assert.equal(materialType("minecraft:water[level=0]"), 6); + assert.equal(materialType("shacraft:trampoline", "bounce"), 5); +}); + +test("reused texture caches keep section output byte-identical", () => { + const blocks = blockMap([[1, 1, 1], 1], [[2, 1, 1], 3]); + const textureLayers = new Map([["stone", 4], ["glass", 5]]), faceTextureCache = new Map(); + const first = build(blocks, { textureLayers, faceTextureCache }); + const second = build(blocks, { textureLayers, faceTextureCache }); + assert.deepEqual(second.vertices, first.vertices); + assert.deepEqual(second.transparent, first.transparent); + assert.ok(faceTextureCache.size > 0); + const transferred = structuredClone(first, { transfer: [first.vertices.buffer, first.transparent.buffer] }); + assert.deepEqual(transferred.vertices, second.vertices); +}); diff --git a/client/tests/physics-wasm.test.js b/client/tests/physics-wasm.test.js new file mode 100644 index 0000000..8fc7b6d --- /dev/null +++ b/client/tests/physics-wasm.test.js @@ -0,0 +1,133 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { + loadPhysics, + LocalPrediction, + samplePhysicsWorld, +} from "../player-physics.js"; + +const binary = await readFile(new URL("../physics.wasm", import.meta.url)); +const fixture = JSON.parse( + await readFile( + new URL( + "../../crates/shacraft-physics/tests/fixtures/java26.2.json", + import.meta.url, + ), + "utf8", + ), +); +const originalFetch = globalThis.fetch; +let step; +try { + // Exercise the actual browser ABI and its non-streaming MIME fallback. + globalThis.fetch = async () => + new Response(binary, { + headers: { "Content-Type": "application/octet-stream" }, + }); + step = await loadPhysics("http://local-test/physics.wasm"); +} finally { + globalThis.fetch = originalFetch; +} + +const bounds = { min: [-64, -32, -64], max: [63, 63, 63] }; +function floor(surface = "stone") { + const blocks = new Map(); + for (let x = -4; x <= 4; x++) + for (let z = -55; z <= 5; z++) blocks.set(`${x},-1,${z}`, 1); + const materials = new Map([ + [ + 1, + { + state: `minecraft:${surface}`, + collision: [{ min: [0, 0, 0], max: [1, 1, 1] }], + }, + ], + ]); + return (body) => samplePhysicsWorld(body, blocks, materials, bounds); +} +const settings = { allow_flight: true }; + +test("the shipped browser WebAssembly follows independently measured Java 26.2 trajectories", () => { + let checked = 0; + for (const [name, trajectory] of Object.entries( + fixture.travel_kernel_trajectories, + )) { + if (trajectory.medium !== "air") continue; + const getWorld = floor(trajectory.surface); + let body = { + position: trajectory.initial_position, + velocity: trajectory.initial_velocity, + flying: trajectory.flying, + on_ground: !trajectory.flying, + }; + for (const sample of trajectory.samples) { + const input = { + forward: sample.tick <= trajectory.input_ticks ? 1 : 0, + sprint: trajectory.sprint, + jump: sample.tick === 1 && trajectory.jump_first_tick, + }; + body = step({ body, input, world: getWorld(body), settings }); + for (const field of ["position", "velocity"]) + for (let axis = 0; axis < 3; axis++) { + const expected = sample[field][axis] * (axis === 2 ? -1 : 1); + assert.ok( + Math.abs(body[field][axis] - expected) < 2e-6, + `${name} tick ${sample.tick} ${field}[${axis}]: ${body[field][axis]} != ${expected}`, + ); + } + assert.equal( + body.on_ground, + sample.on_ground, + `${name} tick ${sample.tick} grounded`, + ); + checked++; + } + } + assert.ok(checked >= 200); +}); + +test("real WebAssembly prediction replays delayed authority without introducing trajectory drift", () => { + const getWorld = floor(); + const prediction = new LocalPrediction(step, getWorld); + let authority = { + position: [0, 0, 0], + velocity: [0, -0.0784000015258789, 0], + on_ground: true, + }; + prediction.receive({ body: authority, ack: 0, tick: 0, epoch: 1, settings }); + const packets = []; + for (let tick = 1; tick <= 80; tick++) { + const input = { + forward: tick < 55 ? 1 : 0, + sprint: tick > 20 && tick < 50, + jump: tick === 25, + sneak: tick >= 55, + }; + prediction.push(tick, input); + authority = step({ + body: authority, + input, + world: getWorld(authority), + settings, + }); + packets.push({ body: authority, ack: tick, tick, epoch: 1, settings }); + // Variable 150–250 ms inbound latency; authority still acknowledges order. + if (packets.length > (tick % 3) + 3) prediction.receive(packets.shift()); + for (let axis = 0; axis < 3; axis++) { + assert.ok( + Math.abs(prediction.body.position[axis] - authority.position[axis]) < + 1e-11, + `position drift at tick ${tick}`, + ); + assert.ok( + Math.abs(prediction.body.velocity[axis] - authority.velocity[axis]) < + 1e-11, + `velocity drift at tick ${tick}`, + ); + } + } + for (const packet of packets) prediction.receive(packet); + assert.equal(prediction.pending.length, 0); + assert.deepEqual(prediction.body, authority); +}); diff --git a/client/tests/player-avatar.test.js b/client/tests/player-avatar.test.js new file mode 100644 index 0000000..d8fc5c7 --- /dev/null +++ b/client/tests/player-avatar.test.js @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { playerAvatarParts } from "../renderer.js"; + +test("remote avatar stance and eye placement follow server physics dimensions", () => { + for (const [pose, height, eye_height] of [ + ["standing", 1.8, 1.62], + ["crouching", 1.5, 1.27], + ["swimming", 0.6, 0.4], + ]) { + const parts = playerAvatarParts({ pose, height, eye_height }); + assert.equal(Math.max(...parts.map(({ box }) => box.max[1])), height); + const head = parts.find(({ part }) => part === "head").box; + const eyes = parts.find(({ part }) => part === "eyes").box; + assert.ok(Math.abs((eyes.min[1] + eyes.max[1]) / 2 - eye_height) < 1e-12); + assert.ok(eyes.min[1] >= head.min[1] && eyes.max[1] <= head.max[1]); + for (const { box } of parts) + for (let axis = 0; axis < 3; axis++) { + assert.ok( + Number.isFinite(box.min[axis]) && box.max[axis] > box.min[axis], + ); + } + } +}); + +test("swimming and crawling use a horizontal silhouette while crouching leans forward", () => { + const standing = playerAvatarParts({}); + const crouching = playerAvatarParts({ pose: "crouching" }); + const swimming = playerAvatarParts({ pose: "swimming" }); + const head = (parts) => parts.find(({ part }) => part === "head").box; + assert.ok(head(crouching).min[2] < head(standing).min[2]); + const length = + Math.max(...swimming.map(({ box }) => box.max[2])) - + Math.min(...swimming.map(({ box }) => box.min[2])); + assert.ok(length > 1.6); + assert.ok(Math.max(...swimming.map(({ box }) => box.max[1])) <= 0.6); + assert.equal(swimming.filter(({ part }) => part === "leg").length, 2); + assert.equal(swimming.filter(({ part }) => part === "arm").length, 2); +}); diff --git a/client/tests/player-interpolation.test.js b/client/tests/player-interpolation.test.js new file mode 100644 index 0000000..43ac875 --- /dev/null +++ b/client/tests/player-interpolation.test.js @@ -0,0 +1,140 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + updateRemotePlayer, + smoothRemotePlayer, +} from "../player-interpolation.js"; + +const radians = (degrees) => (degrees * Math.PI) / 180; +const near = (actual, expected, tolerance = 1e-11) => + assert.ok( + Math.abs(actual - expected) < tolerance, + `Expected ${actual} to be within ${tolerance} of ${expected}`, + ); + +test("network updates change targets without snapping the rendered pose", () => { + const previous = updateRemotePlayer(null, { + id: "remote", + position: [1, 2, 3], + yaw: 0.2, + name: "Before", + }); + const packet = { + id: "remote", + position: [5, 6, 7], + yaw: 1.2, + name: "After", + }; + const player = updateRemotePlayer(previous, packet); + + assert.deepEqual(player.position, [1, 2, 3]); + assert.equal(player.yaw, 0.2); + assert.deepEqual(player.target, [5, 6, 7]); + assert.equal(player.targetYaw, 1.2); + assert.equal(player.name, "After"); + + smoothRemotePlayer(player, 0.25); + assert.deepEqual(player.position, [2, 3, 4]); + near(player.yaw, 0.45); + assert.deepEqual(player.target, [5, 6, 7]); + assert.equal(player.targetYaw, 1.2); + assert.deepEqual(packet.position, [5, 6, 7]); +}); + +test("a packet arriving mid-turn continues from the current rendered angle", () => { + let player = updateRemotePlayer(null, { position: [0, 0, 0], yaw: 0 }); + player = updateRemotePlayer(player, { position: [4, 0, 0], yaw: 1 }); + smoothRemotePlayer(player, 0.5); + player = updateRemotePlayer(player, { position: [8, 0, 0], yaw: 2 }); + + assert.deepEqual(player.position, [2, 0, 0]); + near(player.yaw, 0.5); + smoothRemotePlayer(player, 0.5); + assert.deepEqual(player.position, [5, 0, 0]); + near(player.yaw, 1.25); +}); + +test("turns across the angle boundary follow the short arc in both directions", () => { + for (const direction of [1, -1]) { + let player = updateRemotePlayer(null, { + position: [0, 0, 0], + yaw: radians(179 * direction), + }); + player = updateRemotePlayer(player, { + position: [0, 0, 0], + yaw: radians(-179 * direction), + }); + + smoothRemotePlayer(player, 0.5); + near(player.yaw, radians(180 * direction)); + smoothRemotePlayer(player, 0.5); + near(player.yaw, radians(180.5 * direction)); + } +}); + +test("unbounded yaw values and equivalent full turns do not cause long spins", () => { + let player = updateRemotePlayer(null, { + position: [0, 0, 0], + yaw: 30 * Math.PI + 0.2, + }); + player = updateRemotePlayer(player, { + position: [0, 0, 0], + yaw: -18 * Math.PI + 0.6, + }); + smoothRemotePlayer(player, 0.5); + near(player.yaw, 30 * Math.PI + 0.4); + + player = updateRemotePlayer(player, { + position: [0, 0, 0], + yaw: player.yaw + 8 * Math.PI, + }); + const before = player.yaw; + smoothRemotePlayer(player, 0.5); + near(player.yaw, before); +}); + +test("initial and instant updates initialize both the rendered pose and targets", () => { + const first = updateRemotePlayer(undefined, { position: [3, 4, 5] }); + assert.deepEqual(first.position, [3, 4, 5]); + assert.deepEqual(first.target, [3, 4, 5]); + assert.equal(first.yaw, 0); + assert.equal(first.targetYaw, 0); + + const instant = updateRemotePlayer( + first, + { position: [20, 30, 40], yaw: -2.5 }, + true, + ); + assert.deepEqual(instant.position, [20, 30, 40]); + assert.deepEqual(instant.target, [20, 30, 40]); + assert.equal(instant.yaw, -2.5); + assert.equal(instant.targetYaw, -2.5); + smoothRemotePlayer(instant, 0.75); + assert.deepEqual(instant.position, [20, 30, 40]); + assert.equal(instant.yaw, -2.5); +}); + +test("exponential smoothing has the same result at 30, 60, and 144 fps", () => { + const elapsed = 0.5; + const remaining = Math.exp(-elapsed * 19); + for (const fps of [30, 60, 144]) { + let player = updateRemotePlayer(null, { + position: [1, 2, 3], + yaw: radians(179), + }); + player = updateRemotePlayer(player, { + position: [11, 22, 33], + yaw: radians(-179), + }); + const blend = 1 - Math.exp(-19 / fps); + for (let frame = 0; frame < elapsed * fps; frame++) { + smoothRemotePlayer(player, blend); + } + near(player.yaw, radians(181) - radians(2) * remaining); + for (let axis = 0; axis < 3; axis++) { + const start = axis + 1; + const target = (axis + 1) * 11; + near(player.position[axis], target - (target - start) * remaining); + } + } +}); diff --git a/client/tests/player-physics.test.js b/client/tests/player-physics.test.js new file mode 100644 index 0000000..a212287 --- /dev/null +++ b/client/tests/player-physics.test.js @@ -0,0 +1,208 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + PhysicsClock, + LocalPrediction, + samplePhysicsWorld, +} from "../player-physics.js"; + +const body = (x = 0) => ({ + position: [x, 1, 0], + velocity: [0, 0, 0], + pose: "standing", +}); +const motion = (x = 0, ack = 0, tick = 0, epoch = 1) => ({ + body: body(x), + ack, + tick, + epoch, + settings: { movement_speed: 0.1 }, +}); +const step = ({ body: current, input }) => ({ + ...current, + position: [ + current.position[0] + (input.forward || 0), + ...current.position.slice(1), + ], + flying: input.fly_toggle ? !current.flying : current.flying, +}); +const makePrediction = (getWorld = () => ({ blocks: [] }), limit) => + new LocalPrediction(step, getWorld, limit); +const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`); + +test("20 Hz input timing is the same at 30, 60, and 144 render frames per second", () => { + for (const fps of [30, 60, 144]) { + const clock = new PhysicsClock(); + let ticks = 0; + for (let frame = 0; frame < fps * 3; frame++) + clock.advance(1000 / fps, () => ticks++); + assert.equal(ticks, 60); + near(clock.alpha, 0); + } +}); + +test("a long suspended frame cannot create unlimited catch-up inputs", () => { + const clock = new PhysicsClock(); + let ticks = 0; + assert.equal( + clock.advance(60_000, () => ticks++), + 5, + ); + assert.equal( + clock.advance(0, () => ticks++), + 0, + ); + assert.equal(ticks, 5); + clock.advance(25, () => ticks++); + near(clock.alpha, 0.5); + clock.reset(); + near(clock.alpha, 0); +}); + +test("authority acknowledges one input and replays only the still pending commands", () => { + const prediction = makePrediction(); + prediction.receive(motion()); + prediction.push(1, { forward: 1 }); + prediction.push(2, { forward: 1 }); + prediction.push(3, { forward: -1 }); + assert.equal(prediction.body.position[0], 1); + prediction.receive(motion(0.8, 1, 1)); + near(prediction.body.position[0], 0.8); + assert.deepEqual( + prediction.pending.map(({ seq }) => seq), + [2, 3], + ); + near(prediction.offset[0], 0.2); + near(prediction.sample(0, 0, {}).position[0], 1); + near(prediction.sample(0, 1, {}).position[0], 0.8 + 0.2 * Math.exp(-15)); +}); + +test("late ticks, decreasing acknowledgements, and duplicate inputs cannot rewind movement", () => { + const prediction = makePrediction(); + prediction.receive(motion(4, 4, 10)); + prediction.push(5, { forward: 1 }); + assert.equal(prediction.receive(motion(-10, 5, 9)), false); + assert.equal(prediction.receive(motion(-10, 3, 11)), false); + assert.equal(prediction.push(5, { forward: 20 }), false); + assert.equal(prediction.push(4, { forward: 20 }), false); + assert.equal(prediction.body.position[0], 5); +}); + +test("world/respawn/input reset epochs drop old commands and visual correction", () => { + const prediction = makePrediction(); + prediction.receive(motion()); + prediction.push(1, { forward: 1 }); + prediction.receive(motion(0.7, 1, 1)); + prediction.push(2, { forward: 1 }); + prediction.receive(motion(6, 2, 2, 2)); + assert.deepEqual(prediction.pending, []); + assert.deepEqual(prediction.offset, [0, 0, 0]); + assert.equal(prediction.body.position[0], 6); + assert.equal(prediction.receive(motion(-6, 3, 3, 1)), false); + assert.equal(prediction.body.position[0], 6); + prediction.reset(); + assert.equal(prediction.body, null); + assert.equal(prediction.tick, -1); + prediction.receive(motion(30, 0, 0), true); + assert.equal(prediction.body.position[0], 30); +}); + +test("a teleport without an epoch change does not replay pre-teleport movement", () => { + const prediction = makePrediction(); + prediction.receive(motion()); + prediction.push(1, { forward: 1 }); + prediction.receive(motion(100, 0, 1)); + assert.equal(prediction.body.position[0], 100); + assert.equal(prediction.pending.length, 0); +}); + +test("unavailable chunks suspend prediction until authority and a complete context arrive", () => { + let available = false; + const prediction = makePrediction(() => (available ? { blocks: [] } : null)); + prediction.receive(motion()); + assert.equal(prediction.push(1, { forward: 1 }), false); + assert.equal(prediction.suspended, true); + assert.equal(prediction.body.position[0], 0); + available = true; + prediction.receive(motion(0.5, 1, 1)); + prediction.push(2, { forward: 1 }); + assert.equal(prediction.suspended, false); + assert.equal(prediction.body.position[0], 1.5); +}); + +test("bounded history suspends until the discarded sequence has been acknowledged", () => { + const prediction = makePrediction(undefined, 3); + prediction.receive(motion()); + for (let seq = 1; seq <= 5; seq++) prediction.push(seq, { forward: 1 }); + assert.equal(prediction.pending.length, 3); + assert.equal(prediction.suspended, true); + prediction.receive(motion(1, 1, 1)); + assert.equal(prediction.suspended, true); + prediction.receive(motion(2, 2, 2)); + assert.equal(prediction.suspended, false); + assert.equal(prediction.body.position[0], 5); +}); + +test("render previews do not mutate acknowledged bodies, queue commands, or consume a flight toggle", () => { + const prediction = makePrediction(); + prediction.receive(motion()); + for (let i = 0; i < 3; i++) { + near( + prediction.sample(0.5, 0, { forward: 1, fly_toggle: true }).position[0], + 0.5, + ); + assert.equal(prediction.body.flying, undefined); + assert.equal(prediction.pending.length, 0); + } + prediction.push(1, { forward: 1, fly_toggle: true }); + assert.equal(prediction.body.flying, true); + prediction.receive({ ...motion(0, 0, 1), body: body() }); + assert.equal(prediction.body.flying, true); +}); + +test("swept neighbourhood includes fast-fall floors, transparent fluids and local collision boxes", () => { + const player = { ...body(), position: [-0.2, 7, 0], velocity: [0, -3.92, 0] }; + const collision = [{ min: [0, 0, 0], max: [1, 1.5, 1] }]; + const materials = new Map([ + [1, { state: "minecraft:water[level=0]", collision: [] }], + [2, { state: "minecraft:oak_fence", collision }], + ]); + const blocks = new Map([ + ["-1,6,0", 1], + ["-1,2,0", 2], + ["40,0,0", 2], + ]); + const bounds = { min: [-32, -16, -32], max: [31, 31, 31] }; + const world = samplePhysicsWorld(player, blocks, materials, bounds); + assert.equal(world.blocks.length, 2); + assert.deepEqual( + world.blocks.find((b) => b.state.includes("fence")), + { pos: [-1, 2, 0], state: "minecraft:oak_fence", collision }, + ); + assert.deepEqual( + world.blocks.find((b) => b.state.includes("water")).collision, + [], + ); + assert.equal(samplePhysicsWorld(player, blocks, new Map(), bounds), null); + assert.equal( + samplePhysicsWorld(player, blocks, materials, { + min: [-1, 0, -1], + max: [1, 10, 1], + }), + null, + ); +}); + +test("invalid motion packets leave the valid body and history untouched", () => { + const prediction = makePrediction(); + prediction.receive(motion()); + assert.equal( + prediction.receive({ + ...motion(), + body: { position: [NaN, 0, 0], velocity: [0, 0, 0] }, + }), + false, + ); + assert.equal(prediction.receive({ ...motion(), ack: 0.5 }), false); + assert.equal(prediction.body.position[0], 0); +}); diff --git a/client/tests/pointer-lock.test.js b/client/tests/pointer-lock.test.js new file mode 100644 index 0000000..f957736 --- /dev/null +++ b/client/tests/pointer-lock.test.js @@ -0,0 +1,169 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { PointerLockController } from "../pointer-lock.js"; + +function deferred() { + let resolve, reject; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function fixture(t, request) { + const document = new EventTarget(), + calls = [], + changes = [], + errors = []; + document.pointerLockElement = null; + document.exitPointerLock = () => { + calls.push("exit"); + document.pointerLockElement = null; + document.dispatchEvent(new Event("pointerlockchange")); + }; + const canvas = { + ownerDocument: document, + tabIndex: -1, + focus(options) { + calls.push("focus"); + assert.equal(options.preventScroll, true); + }, + requestPointerLock() { + calls.push("request"); + return request?.(); + }, + }; + const controller = new PointerLockController(canvas, { + onChange: (locked) => changes.push(locked), + onError: (error) => errors.push(error), + }); + const lock = () => { + document.pointerLockElement = canvas; + document.dispatchEvent(new Event("pointerlockchange")); + }; + t.after(() => controller.dispose()); + return { controller, canvas, document, calls, changes, errors, lock }; +} + +test("focus precedes the synchronous user-gesture request and duplicate pending calls are ignored", (t) => { + const pending = deferred(), + { controller, canvas, calls } = fixture(t, () => pending.promise); + assert.equal(controller.request(), true); + assert.deepEqual(calls, ["focus", "request"]); + assert.equal(canvas.tabIndex, 0); + assert.equal(controller.status, "requesting"); + assert.equal(controller.request(), false); + assert.deepEqual(calls, ["focus", "request"]); +}); + +test("a rejected request remains retryable and subsequent success clears the error", async (t) => { + const first = deferred(), + second = deferred(); + let count = 0; + const { controller, errors, changes, lock } = fixture(t, () => + ++count === 1 ? first.promise : second.promise, + ); + controller.request(); + const error = new Error("Document not focused"); + first.reject(error); + await Promise.resolve(); + assert.equal(controller.status, "error"); + assert.equal(controller.lastError, error); + assert.deepEqual(errors, [error]); + assert.equal(controller.request(), true); + lock(); + second.resolve(); + await Promise.resolve(); + assert.equal(controller.status, "locked"); + assert.equal(controller.locked, true); + assert.equal(controller.lastError, null); + assert.deepEqual(changes, [true]); +}); + +test("legacy success, ordinary unlock, and recapture follow actual document state", (t) => { + const { controller, document, changes, lock, calls } = fixture(t); + controller.request(); + lock(); + assert.equal(controller.request(), false); + document.exitPointerLock(); + assert.equal(controller.status, "idle"); + assert.equal(controller.locked, false); + assert.equal(controller.request(), true); + lock(); + assert.deepEqual(changes, [true, false, true]); + assert.equal(calls.filter((call) => call === "request").length, 2); +}); + +test("missing APIs and legacy error events report failure without permanently disabling retries", (t) => { + const { controller, canvas, document, errors, lock } = fixture(t); + delete canvas.requestPointerLock; + assert.equal(controller.request(), false); + assert.equal(controller.status, "unsupported"); + assert.equal(errors[0].name, "NotSupportedError"); + canvas.requestPointerLock = () => undefined; + assert.equal(controller.request(), true); + document.dispatchEvent(new Event("pointerlockerror")); + assert.equal(controller.status, "error"); + assert.equal(errors.length, 2); + assert.equal(controller.request(), true); + lock(); + assert.equal(controller.status, "locked"); +}); + +test("a stale promise rejection cannot overwrite a later successful capture", async (t) => { + const first = deferred(); + const { controller, document, errors, lock } = fixture( + t, + () => first.promise, + ); + controller.request(); + document.dispatchEvent(new Event("pointerlockerror")); + controller.request(); + lock(); + first.reject(new Error("Late failure from an old request")); + await Promise.resolve(); + assert.equal(controller.status, "locked"); + assert.equal(controller.lastError, null); + assert.equal(errors.length, 1); +}); + +test("release cancels pending capture and immediately exits a late acquisition", async (t) => { + const pending = deferred(), + { controller, changes, errors, lock, calls } = fixture( + t, + () => pending.promise, + ); + controller.request(); + controller.release(); + assert.equal(controller.status, "idle"); + lock(); + pending.resolve(); + await Promise.resolve(); + assert.equal(controller.locked, false); + assert.equal(controller.status, "idle"); + assert.deepEqual(changes, []); + assert.deepEqual(errors, []); + assert.equal(calls.filter((call) => call === "exit").length, 2); +}); + +test("synchronous failures are retryable and disposal removes event handling", (t) => { + const { controller, canvas, document, errors, changes, lock } = fixture( + t, + () => { + throw new Error("Synchronous rejection"); + }, + ); + assert.equal(controller.request(), false); + assert.equal(controller.status, "error"); + assert.equal(errors.length, 1); + canvas.requestPointerLock = () => undefined; + controller.request(); + lock(); + controller.dispose(); + assert.deepEqual(changes, [true, false]); + assert.equal(controller.request(), false); + lock(); + document.dispatchEvent(new Event("pointerlockerror")); + assert.deepEqual(changes, [true, false]); + assert.equal(errors.length, 1); +}); diff --git a/client/tests/renderer-smoke.html b/client/tests/renderer-smoke.html index 0c14d22..8cf83dd 100644 --- a/client/tests/renderer-smoke.html +++ b/client/tests/renderer-smoke.html @@ -1,64 +1,420 @@ -Shacraft renderer fixture - + material(10, "sea_lantern", [221, 244, 224], { light: 15 }); + material(11, "torch", [229, 158, 70], { + light: 14, + render: [{ min: [0.43, 0, 0.43], max: [0.57, 0.8, 0.57] }], + }); + material(12, "quartz_block", [211, 207, 189]); + for (let x = -18; x <= 18; x++) + for (let z = -20; z <= 16; z++) + put( + x, + 0, + z, + Math.abs(x) <= 2 || (x >= -7 && x <= -3 && z >= -10 && z <= -3) + ? 2 + : 1, + ); + // A tall inside corner and a small roof expose contact and indirect light. + for (let y = 1; y <= 5; y++) { + for (let x = -7; x <= -3; x++) put(x, y, -10, 3); + for (let z = -9; z <= -4; z++) put(-7, y, z, 3); + } + for (let x = -7; x <= -5; x++) + for (let z = -10; z <= -8; z++) put(x, 5, z, 3); + put(-6, 1, -9, 10); + // The right support can be removed independently of the lintel. + const removablePillar = []; + for (let y = 1; y <= 4; y++) { + put(0, y, -6, 4); + put(5, y, -6, 4); + removablePillar.push([5, y, -6]); + } + for (let x = 0; x <= 5; x++) put(x, 5, -6, 5); + for (let y = 1; y <= 3; y++) put(7, y, 1, 4); + for (let y = 1; y <= 5; y++) put(9, y, -3, 3); + for (let x = -5; x <= -2; x++) put(x, 1, 3, 8); + for (let x = -5; x <= -2; x++) put(x, 1, 1, 9); + put(-5, 2, 1, 8); + for (let x = 3; x <= 5; x++) + for (let y = 1; y <= 3; y++) put(x, y, 4, 6); + for (let x = 6; x <= 10; x++) + for (let z = -11; z <= -7; z++) { + put(x, -1, z, 2); + put(x, 0, z, 7); + } + for (let x = 5; x <= 11; x++) { + put(x, 0, -12, 3); + put(x, 0, -6, 3); + } + for (let z = -11; z <= -7; z++) { + put(5, 0, z, 3); + put(11, 0, z, 3); + } + // All exterior faces are solid, including the roof. The window connects + // two indoor chambers so lamp-off darkness is independent of daylight. + for (let x = -17; x <= -9; x++) + for (let z = 4; z <= 14; z++) { + put(x, 0, z, 2); + put(x, 5, z, 12); + if (x === -17 || x === -9 || z === 4 || z === 14) + for (let y = 1; y <= 4; y++) put(x, y, z, 12); + } + for (let z = 5; z <= 13; z++) + for (let y = 1; y <= 4; y++) put(-13, y, z, 12); + put(-13, 2, 5, 6); + put(-13, 3, 5, 6); + const lampPosition = [-15, 2, 8], + partitionPanel = []; + put(-15, 1, 8, 2); + put(...lampPosition, 11); + for (let y = 1; y <= 3; y++) + for (let z = 8; z <= 9; z++) partitionPanel.push([-13, y, z]); + const players = [ + { + id: "fixture-player", + name: "Avatar", + position: [3.5, 1, 1.4], + yaw: -0.5, + pose: "standing", + height: 1.8, + eye_height: 1.62, + color: [0.31, 0.47, 0.64], + }, + ]; + const entities = [ + { kind: "fixture-crate", position: [-3, 1, -4], yaw: 0.3 }, + ]; + const entityDefinitions = new Map([ + [ + "fixture-crate", + { + color: [168, 121, 81], + render: [{ min: [-0.5, 0, -0.5], max: [0.5, 1, 0.5] }], + }, + ], + ]); + renderer.replace(blocks, materials); + renderer.shadowsEnabled = shadowToggle.checked; + let pillarPresent = true, + wallPresent = true, + frameCount = 0, + lastStatus = -Infinity; + const errors = new Set(); + document.body.dataset.camera = cameraSelect.value; + document.body.dataset.pillar = "present"; + document.body.dataset.lamp = lampSelect.value; + document.body.dataset.wall = "present"; + shadowToggle.onchange = () => { + renderer.shadowsEnabled = shadowToggle.checked; + }; + cameraSelect.onchange = () => { + document.body.dataset.camera = cameraSelect.value; + }; + pillarButton.onclick = () => { + pillarPresent = !pillarPresent; + const changes = removablePillar.map((pos) => { + if (pillarPresent) blocks.set(pos.join(","), 4); + else blocks.delete(pos.join(",")); + return { pos, block: pillarPresent ? 4 : 0 }; + }); + renderer.change(changes); + pillarButton.textContent = pillarPresent + ? "Remove pillar" + : "Restore pillar"; + document.body.dataset.pillar = pillarPresent ? "present" : "removed"; + }; + lampSelect.onchange = () => { + const block = { torch: 11, "sea-lantern": 10, off: 0 }[ + lampSelect.value + ]; + if (block) blocks.set(lampPosition.join(","), block); + else blocks.delete(lampPosition.join(",")); + renderer.change([{ pos: lampPosition, block }]); + document.body.dataset.lamp = lampSelect.value; + }; + wallButton.onclick = () => { + wallPresent = !wallPresent; + const changes = partitionPanel.map((pos) => { + if (wallPresent) blocks.set(pos.join(","), 12); + else blocks.delete(pos.join(",")); + return { pos, block: wallPresent ? 12 : 0 }; + }); + renderer.change(changes); + wallButton.textContent = wallPresent + ? "Remove room partition" + : "Restore room partition"; + document.body.dataset.wall = wallPresent ? "present" : "removed"; + }; + const lightSamplePositions = { + source: lampPosition, + near: [-15, 2, 9], + window: [-13, 2, 5], + behindWall: [-12, 2, 8], + far: [-10, 2, 12], + outside: [-15, 2, 15], + }; + function updateLightSamples() { + const field = renderer.blockLightField; + if (typeof field?.sample !== "function") return; + const samples = Object.fromEntries( + Object.entries(lightSamplePositions).map(([name, position]) => { + const sample = field.sample(position); + return [name, { block: sample.blockLevel, sky: sample.skyLevel }]; + }), + ); + document.body.dataset.lightSamples = JSON.stringify(samples); + document.body.dataset.lightSources = String(field.sourceCount); + lightStatus.textContent = `Light 0–15 (block/sky): ${Object.entries( + samples, + ) + .map(([name, sample]) => `${name} ${sample.block}/${sample.sky}`) + .join(" · ")}`; + } + function frame(time) { + try { + renderer.draw( + cameras[cameraSelect.value], + players, + entities, + entityDefinitions, + ); + frameCount++; + const error = renderer.gl.getError(); + if (error !== renderer.gl.NO_ERROR) + errors.add(`0x${error.toString(16)}`); + if (time - lastStatus >= 200) { + lastStatus = time; + const ready = frameCount >= 3 && renderer.dirty.size === 0; + document.body.dataset.status = errors.size + ? `error:WebGL ${[...errors].join(",")}` + : `${ready ? "ready" : "building"}:${renderer.triangles}`; + document.body.dataset.glError = errors.size + ? [...errors].join(",") + : "0"; + document.body.dataset.triangles = String(renderer.triangles); + document.body.dataset.sections = String(renderer.sections.size); + document.body.dataset.pendingSections = String( + renderer.dirty.size, + ); + document.body.dataset.shadowsEnabled = String( + renderer.shadowsEnabled, + ); + document.body.dataset.frames = String(frameCount); + document.body.dataset.meshRebuilds = String( + renderer.meshRebuilds, + ); + updateLightSamples(); + status.textContent = `${errors.size ? "WebGL " + [...errors].join(",") : "WebGL OK"} · ${renderer.triangles.toLocaleString("en-US")} triangles · shadows ${renderer.shadowsEnabled ? "on" : "off"}`; + } + requestAnimationFrame(frame); + } catch (error) { + fail(error); + } + } + requestAnimationFrame(frame); + } catch (error) { + fail(error); + } + + + diff --git a/client/tests/renderer-streaming.test.js b/client/tests/renderer-streaming.test.js new file mode 100644 index 0000000..106f876 --- /dev/null +++ b/client/tests/renderer-streaming.test.js @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Renderer } from "../renderer.js"; + +function meshFixture() { + const renderer = Object.create(Renderer.prototype); + renderer.sections = new Map([ + ["-2,0,0", { name: "leaving" }], + ["-1,0,0", { name: "border" }], + ["0,0,0", { name: "retained" }], + ["1,0,0", { name: "far" }], + ]); + renderer.dirty = new Set(); + renderer.disposed = []; + renderer.disposeMesh = (mesh) => renderer.disposed.push(mesh); + return renderer; +} + +test("stream unload disposes only departing meshes and keeps visible mesh identities", () => { + const renderer = meshFixture(); + const before = new Map(renderer.sections); + renderer.dirty.add("-2,0,0"); + renderer.unloadSections([[-2, 0, 0]]); + assert.deepEqual(renderer.disposed, [before.get("-2,0,0")]); + for (const id of ["-1,0,0", "0,0,0", "1,0,0"]) + assert.equal(renderer.sections.get(id), before.get(id)); + assert.deepEqual([...renderer.dirty], ["-1,0,0"]); +}); + +test("incoming cells queue their section and boundaries without clearing existing geometry", () => { + const renderer = meshFixture(); + const before = [...renderer.sections.values()]; + renderer.change([{ pos: [32, 5, 5], block: 7 }]); + assert.deepEqual([...renderer.sections.values()], before); + assert.equal(renderer.disposed.length, 0); + assert.deepEqual([...renderer.dirty].sort(), ["1,0,0", "2,0,0"]); +}); + +test("unloading adjacent sections does not queue either for reconstruction", () => { + const renderer = meshFixture(); + renderer.change([{ pos: [-17, 0, 0], block: 0 }]); + renderer.unloadSections([[-2, 0, 0], [-1, 0, 0]]); + assert.equal(renderer.dirty.has("-2,0,0"), false); + assert.equal(renderer.dirty.has("-1,0,0"), false); + assert.equal(renderer.dirty.has("0,0,0"), true); + assert.equal(renderer.sections.size, 2); +}); diff --git a/client/tests/section-block-map.test.js b/client/tests/section-block-map.test.js new file mode 100644 index 0000000..7bac0e9 --- /dev/null +++ b/client/tests/section-block-map.test.js @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { SectionBlockMap } from "../section-block-map.js"; + +test("construction and repeated set preserve Map behavior and unique section keys", () => { + const blocks = new SectionBlockMap([ + ["0,0,0", 1], + ["15,15,15", 2], + ["16,0,0", 3], + ["0,0,0", 4], + ]); + assert.ok(blocks instanceof Map); + assert.equal(blocks.size, 3); + assert.equal(blocks.set("0,0,0", 5), blocks); + assert.deepEqual( + [...blocks], + [ + ["0,0,0", 5], + ["15,15,15", 2], + ["16,0,0", 3], + ], + ); + assert.deepEqual([...blocks.keysInSection("0,0,0")], ["0,0,0", "15,15,15"]); + assert.deepEqual([...blocks.loadedSectionKeys()], ["0,0,0", "1,0,0"]); + assert.deepEqual([...new SectionBlockMap(blocks)], [...blocks]); +}); + +test("section boundaries use floor division independently on negative axes", () => { + const blocks = new SectionBlockMap([ + ["-1,-16,-17", 1], + ["-16,-1,-32", 2], + ["-17,0,16", 3], + ["0,-17,-1", 4], + ]); + assert.deepEqual( + [...blocks.keysInSection("-1,-1,-2")], + ["-1,-16,-17", "-16,-1,-32"], + ); + assert.deepEqual([...blocks.keysInSection("-2,0,1")], ["-17,0,16"]); + assert.deepEqual([...blocks.keysInSection("0,-2,-1")], ["0,-17,-1"]); + assert.deepEqual([...blocks.keysInSection("0,0,0")], []); +}); + +test("delete removes empty sections and clear permits clean reuse", () => { + const blocks = new SectionBlockMap([ + ["0,0,0", 1], + ["1,0,0", 2], + ["16,0,0", 3], + ]); + assert.equal(blocks.delete("0,0,0"), true); + assert.equal(blocks.delete("0,0,0"), false); + assert.deepEqual([...blocks.keysInSection("0,0,0")], ["1,0,0"]); + assert.equal(blocks.delete("1,0,0"), true); + assert.deepEqual([...blocks.keysInSection("0,0,0")], []); + assert.deepEqual([...blocks.loadedSectionKeys()], ["1,0,0"]); + assert.equal(blocks.clear(), undefined); + assert.equal(blocks.size, 0); + assert.deepEqual([...blocks.loadedSectionKeys()], []); + assert.deepEqual([...blocks.keysInSection("1,0,0")], []); + blocks.set("0,0,0", 4); + assert.deepEqual([...blocks.keysInSection("0,0,0")], ["0,0,0"]); + assert.deepEqual([...blocks.loadedSectionKeys()], ["0,0,0"]); +}); + +test("section key iterators allow deleting every yielded block without skipping", () => { + const blocks = new SectionBlockMap([ + ["0,0,0", 1], + ["1,0,0", 2], + ["2,0,0", 3], + ["16,0,0", 4], + ]); + for (const key of blocks.keysInSection("0,0,0")) blocks.delete(key); + assert.deepEqual([...blocks], [["16,0,0", 4]]); + assert.deepEqual([...blocks.loadedSectionKeys()], ["1,0,0"]); +}); + +test("validated section insertion and bulk deletion preserve other sections and live iterators", () => { + const blocks = new SectionBlockMap([["0,0,0", 1]]); + assert.equal(blocks.setInSection("-17,-1,16", 2, "-2,-1,1"), blocks); + blocks.setInSection("-18,-2,17", 3, "-2,-1,1"); + blocks.setInSection("-17,-1,16", 4, "-2,-1,1"); + assert.deepEqual( + [...blocks.keysInSection("-2,-1,1")], + ["-17,-1,16", "-18,-2,17"], + ); + const keys = blocks.keysInSection("-2,-1,1"); + assert.equal(blocks.deleteSection("-2,-1,1"), 2); + assert.equal(blocks.deleteSection("-2,-1,1"), 0); + assert.deepEqual([...keys], []); + assert.deepEqual([...blocks], [["0,0,0", 1]]); + assert.deepEqual([...blocks.loadedSectionKeys()], ["0,0,0"]); + blocks.set("-17,-1,16", 5); + assert.deepEqual([...blocks.keysInSection("-2,-1,1")], ["-17,-1,16"]); + assert.equal(blocks.delete("-17,-1,16"), true); + blocks.setInSection("16,0,0", 6, "1,0,0"); + blocks.clear(); + assert.deepEqual([...blocks.loadedSectionKeys()], []); +}); diff --git a/client/tests/section-stream.test.js b/client/tests/section-stream.test.js new file mode 100644 index 0000000..4e27786 --- /dev/null +++ b/client/tests/section-stream.test.js @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {SectionVoxelMap} from '../section-voxel-map.js'; +import {decodeSection,applySectionView,applySectionBatch} from '../section-stream.js'; +import {samplePhysicsWorld} from '../player-physics.js'; +const record=(section,id=1)=>({section,palette:[id],runs:[4096,0]}); +const view=()=>({world:'world',revision:0,blocks:new SectionVoxelMap(),loadedSections:new Set(),viewGeneration:0}); +const message=(generation=1)=>({world:'world',revision:0,generation,view_center:[0,0,0],view_min:[-48,-32,-48],view_max:[63,47,63],unload:[]}); +test('compact sections decode known air and preserve negative coordinates without per-voxel keys',()=>{ + const world=new SectionVoxelMap(),cells=decodeSection(record([-1,-4,1],3)).cells; + world.setSection('-1,-4,1',cells);assert.equal(world.getAt(-1,-64,16),3);assert.equal(world.size,4096);assert.equal(world.byteLength,4); + world.delete('-1,-64,16');assert.equal(world.size,4095);assert.equal(world.getAt(-1,-64,16),0); + world.setSection('0,0,0',new Uint32Array(4096));assert.equal(world.hasSection('0,0,0'),true);assert.equal(world.size,4095); + assert.deepEqual([...world.materialIds()],[3]);world.deleteSection('-1,-4,1');assert.equal(world.size,0);assert.equal(world.sectionCount,1); +}); +test('malformed compact sections are rejected before any valid section is applied',()=>{ + const v=view();applySectionView(v,message()); + const bad={...record([1,0,0]),runs:[4097,0]}; + assert.equal(applySectionBatch(v,{...message(),sections:[record([0,0,0]),bad]}).status,'resync');assert.equal(v.blocks.size,0); + for(const runs of [[4095,0],[4096,1],[0,0],[4096.1,0],[4096,-1]])assert.throws(()=>decodeSection({...record([0,0,0]),runs})); +}); +test('new views retain overlap and reject delayed batches from previous generations',()=>{ + const v=view();applySectionView(v,message()); + applySectionBatch(v,{...message(),sections:[record([-3,0,0]),record([0,0,0])]});const cells=[...v.blocks.sectionEntries()][1][1]; + const moved={...message(2),view_center:[16,0,0],view_min:[-32,-32,-48],view_max:[79,47,63],unload:[[-3,0,0]]}; + assert.deepEqual(applySectionView(v,moved).unload,[[-3,0,0]]);assert.equal(v.blocks.hasSection('-3,0,0'),false); + assert.equal([...v.blocks.sectionEntries()][0][1],cells); + assert.equal(applySectionBatch(v,{...message(),sections:[record([-3,0,0])]}).status,'ignored'); + assert.equal(v.blocks.hasSection('-3,0,0'),false); +}); +test('collision sampling distinguishes missing terrain from a received air section',()=>{ + const world=new SectionVoxelMap(),body={position:[8,3,8],velocity:[0,0,0],pose:'standing'},bounds={min:[0,0,0],max:[15,15,15]}; + assert.equal(samplePhysicsWorld(body,world,new Map(),bounds),null); + world.setSection('0,0,0',new Uint32Array(4096));assert.deepEqual(samplePhysicsWorld(body,world,new Map(),bounds),{blocks:[]}); +}); + +test('uniform sections remain compact across worker copies and expand only when edited',()=>{ + const world=new SectionVoxelMap(); + world.setSection('0,0,0',new Uint32Array([7])); + world.setSection('1,0,0',new Uint32Array([0])); + assert.equal(world.byteLength,8);assert.equal(world.size,4096); + const samples=[];world.forEachBlock((x,y,z,id)=>samples.push([x,y,z,id]),{min:[14,0,0],max:[17,0,0]}); + assert.deepEqual(samples,[[14,0,0,7],[15,0,0,7]]); + assert.equal([...world.keysInSection('1,0,0')].length,0); + world.set('15,0,0',9);assert.equal(world.byteLength,16388); + assert.equal(world.getAt(14,0,0),7);assert.equal(world.getAt(15,0,0),9); + assert.deepEqual([...world.materialIds()],[7,9]); + world.deleteSection('0,0,0');assert.equal(world.byteLength,4);assert.equal(world.size,0); + world.clear();assert.equal(world.byteLength,0); +}); + +test('full-height worlds allow flight above the ceiling but still require interior collision data',()=>{ + const blocks=new SectionVoxelMap(),bounds={min:[-32,-64,-32],max:[47,319,47],fullHeight:true}; + const body={position:[8,400,8],velocity:[0,0,0],pose:'standing'}; + assert.deepEqual(samplePhysicsWorld(body,blocks,new Map(),bounds),{blocks:[]}); + body.position[1]=310;assert.equal(samplePhysicsWorld(body,blocks,new Map(),bounds),null); + blocks.setSection('0,19,0',new Uint32Array([0])); + assert.deepEqual(samplePhysicsWorld(body,blocks,new Map(),bounds),{blocks:[]}); +}); + +test('large uniform batches load compactly and reject oversized batches atomically',()=>{ + const v=view(),m={...message(),view_min:[-48,-64,-48],view_max:[63,319,63],full_height:true}; + applySectionView(v,m); + const sections=Array.from({length:128},(_,i)=>record([i%7-3,Math.floor(i/7)-4,0],0)); + assert.equal(applySectionBatch(v,{...m,sections}).status,'applied'); + assert.equal(v.blocks.sectionCount,128);assert.equal(v.blocks.byteLength,512); + const oversized=[...sections,record([0,0,1])]; + assert.equal(applySectionBatch(v,{...m,sections:oversized}).status,'resync'); + assert.equal(v.blocks.hasSection('0,0,1'),false); +}); diff --git a/client/tests/shadow-frame.test.js b/client/tests/shadow-frame.test.js new file mode 100644 index 0000000..72e145f --- /dev/null +++ b/client/tests/shadow-frame.test.js @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { shadowFrame } from '../shadow-frame.js'; + +const sun = [-.5, .78, .37]; +const clip = (m, p) => [0, 1, 2].map(i => m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12]); +test('shadow projection puts the camera neighborhood in range and light-facing objects closer', () => { + for (const eye of [[0, 2, 8], [-123.5, 70, 400], [32700, 50, -32700]]) { + const { matrix, center } = shadowFrame(eye, sun); + assert.ok(clip(matrix, center).every(v => Math.abs(v) < 1e-4)); + const near = clip(matrix, center.map((v, i) => v + sun[i] * 20)); + const far = clip(matrix, center.map((v, i) => v - sun[i] * 20)); + assert.ok(near[2] < far[2]); + assert.ok([...near, ...far].every(v => v >= -1 && v <= 1)); + } +}); +test('sub-texel camera movement keeps the shadow grid fixed', () => { + const a = shadowFrame([0, 0, 0], sun).matrix; + const b = shadowFrame([.001, .001, .001], sun).matrix; + for (const i of [0, 1, 4, 5, 8, 9, 12, 13]) assert.equal(a[i], b[i]); +}); diff --git a/client/tests/terrain-controller.test.js b/client/tests/terrain-controller.test.js new file mode 100644 index 0000000..7d9d5d8 --- /dev/null +++ b/client/tests/terrain-controller.test.js @@ -0,0 +1,335 @@ +import test, { afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { TerrainController } from "../terrain-controller.js"; +import { TerrainState } from "../terrain-state.js"; +import { SectionBlockMap } from "../section-block-map.js"; + +const properties = JSON.parse( + await readFile(new URL("../light-properties.json", import.meta.url)), +); +const controllers = []; +const bounds = { min: [-1, -1, -1], max: [18, 3, 3] }; +const materials = new Map([ + [1, { state: "minecraft:stone", color: [128, 128, 128] }], + [2, { state: "minecraft:torch", color: [255, 200, 100], light: 14 }], +]); + +class FakeWorker { + packets = []; + transfers = []; + postMessage(packet, transfers = []) { + this.transfers.push(transfers.length); + this.packets.push(structuredClone(packet, { transfer: transfers })); + } + terminate() { + this.terminated = true; + } + reply(message) { + this.onmessage({ data: message }); + } + light(packet, dirty = [], value = 0) { + this.reply({ + type: "light", + epoch: packet.epoch, + version: packet.version, + dirty, + milliseconds: 1, + field: { + min: [0, 0, 0], + size: [1, 1, 1], + sourceCount: value ? 1 : 0, + data: new Uint8Array(4), + blockLevels: new Uint8Array([value]), + skyLevels: new Uint8Array([15]), + }, + }); + } + mesh(packet) { + this.reply({ + ...packet, + vertices: new Float32Array(20), + transparent: new Float32Array(), + milliseconds: 1, + }); + } +} +function fixture() { + const worker = new FakeWorker(), + published = [], + dirty = new Set(); + const controller = new TerrainController({ + workerFactory: () => worker, + onLight: (field) => published.push(field), + onDirty: (ids) => { + for (const id of ids) dirty.add(id); + }, + }); + controllers.push(controller); + return { controller, worker, published, dirty }; +} +afterEach(() => { + for (const controller of controllers.splice(0)) controller.dispose(); +}); + +function queuedClock(t) { + const callbacks = new Map(); + let id = 0, + now = 0; + t.mock.method(globalThis, "setTimeout", (callback) => { + callbacks.set(++id, callback); + return id; + }); + t.mock.method(globalThis, "clearTimeout", (id) => callbacks.delete(id)); + t.mock.method(performance, "now", () => (now += 3)); + return () => { + for (let count = 0; callbacks.size; count++) { + assert.ok(count < 100, "Preparation failed to finish"); + const [id, callback] = callbacks.entries().next().value; + callbacks.delete(id); + callback(); + } + }; +} + +test("edits during incremental reset packing replay over the captured entries coherently", (t) => { + const drain = queuedClock(t), + { controller, worker } = fixture(); + const blocks = new SectionBlockMap( + Array.from({ length: 140 }, (_, x) => [`${x},0,0`, 1]), + ); + controller.reset(blocks, materials, new Map(), bounds); + controller.flush(); + assert.equal(controller.preparing, true); + blocks.delete("0,0,0"); + blocks.set("1,0,0", 2); + blocks.set("200,0,0", 2); + controller.update({ + changes: [ + { pos: [0, 0, 0], block: 0 }, + { pos: [1, 0, 0], block: 2 }, + { pos: [200, 0, 0], block: 2 }, + ], + }); + drain(); + assert.equal(worker.packets.length, 1); + const state = new TerrainState(properties), + packet = worker.packets[0]; + assert.equal(packet.reset, true); + assert.equal(packet.version, controller.version); + assert.ok(packet.initial instanceof Int32Array); + assert.ok(packet.changes instanceof Int32Array); + assert.equal(state.sync(packet), true); + assert.deepEqual([...state.blocks], [...blocks]); +}); + +test("a replacement world cancels old reset preparation and rejects old light and mesh replies", (t) => { + const drain = queuedClock(t), + { controller, worker, published, dirty } = fixture(); + controller.reset( + new SectionBlockMap(Array.from({ length: 140 }, (_, x) => [`${x},0,0`, 1])), + materials, + new Map(), + bounds, + ); + controller.flush(); + assert.equal(controller.preparing, true); + const oldEpoch = controller.epoch; + controller.reset( + new SectionBlockMap([["16,0,0", 2]]), + materials, + new Map(), + bounds, + ); + controller.flush(); + drain(); + assert.equal(worker.packets.length, 1); + const packet = worker.packets[0]; + assert.deepEqual([...packet.initial], [16, 0, 0, 2]); + worker.light(packet, ["1,0,0"], 14); + assert.equal(controller.requestMesh("1,0,0"), true); + const job = controller.busy; + worker.light({ epoch: oldEpoch, version: packet.version }, ["old-world"], 15); + worker.mesh({ ...job, epoch: oldEpoch }); + assert.equal(controller.busy, job); + assert.deepEqual([...dirty], ["1,0,0"]); + assert.equal(published.length, 1); + assert.equal(published[0].sample([0, 0, 0]).blockLevel, 14); + worker.mesh(job); + assert.equal(controller.ready.get("1,0,0").epoch, controller.epoch); +}); + +test("subsequent syncs send deduplicated numeric deltas and only changed materials or textures", () => { + const { controller, worker } = fixture(); + const blocks = new SectionBlockMap([["0,0,0", 1]]), + textures = new Map([["stone", 0]]); + controller.reset(blocks, materials, textures, bounds); + controller.flush(); + blocks.entries = () => { + throw Error("Incremental sync scanned the world"); + }; + blocks[Symbol.iterator] = blocks.entries; + controller.update({ changes: [{ pos: [0, 0, 0], block: 2 }] }); + controller.update({ + changes: [ + { pos: [0, 0, 0], block: 0 }, + { pos: [16, 0, 0], block: 1 }, + ], + }); + controller.flush(); + const delta = worker.packets[1]; + assert.equal(delta.reset, false); + assert.equal(delta.initial, null); + assert.equal(delta.blocks, undefined); + assert.deepEqual([...delta.changes], [0, 0, 0, 0, 16, 0, 0, 1]); + assert.deepEqual(delta.materials, []); + assert.equal(delta.textures, undefined); + assert.equal(worker.transfers[1], 1); + const updated = new Map(materials); + updated.set(2, { + ...materials.get(2), + light: 15, + ignoredMetadata: "not copied", + }); + controller.update({ materials: updated, textures: new Map([["stone", 7]]) }); + controller.flush(); + assert.deepEqual( + worker.packets[2].materials.map(([id]) => id), + [2], + ); + assert.equal(worker.packets[2].materials[0][1].light, 15); + assert.equal(worker.packets[2].materials[0][1].ignoredMetadata, undefined); + assert.deepEqual(worker.packets[2].textures, [["stone", 7]]); +}); + +test("skipped intermediate light fields contribute their dirty union without being published", () => { + const { controller, worker, dirty, published } = fixture(); + controller.reset( + new SectionBlockMap([["0,0,0", 1]]), + materials, + new Map(), + bounds, + ); + controller.flush(); + const old = worker.packets[0]; + controller.update({ changes: [{ pos: [0, 0, 0], block: 2 }] }); + controller.flush(); + const latest = worker.packets[1]; + worker.light(old, ["0,0,0", "1,0,0"], 0); + assert.equal(published.length, 0); + assert.equal(controller.requestMesh("0,0,0"), false); + worker.light(latest, ["1,0,0", "2,0,0"], 14); + assert.equal(published.length, 1); + assert.deepEqual([...dirty], ["0,0,0", "1,0,0", "2,0,0"]); + assert.equal(controller.requestMesh("0,0,0"), true); +}); + +test("mesh requests allow one in flight and two ready, and stale replies cannot release a newer job", () => { + const { controller, worker } = fixture(); + controller.reset( + new SectionBlockMap([["0,0,0", 1]]), + materials, + new Map(), + bounds, + ); + controller.flush(); + worker.light(worker.packets[0]); + assert.equal(controller.requestMesh("0,0,0"), true); + assert.equal(controller.requestMesh("1,0,0"), false); + worker.mesh(controller.busy); + assert.equal(controller.requestMesh("0,0,0"), false); + assert.equal(controller.requestMesh("1,0,0"), true); + worker.mesh(controller.busy); + assert.equal(controller.ready.size, 2); + assert.equal(controller.requestMesh("2,0,0"), false); + controller.ready.delete("0,0,0"); + assert.equal(controller.requestMesh("2,0,0"), true); + const stale = controller.busy; + controller.update({ unload: [[2, 0, 0]] }); + assert.equal(controller.ready.size, 0); + controller.flush(); + worker.light(worker.packets.at(-1)); + assert.equal(controller.requestMesh("0,0,0"), false); + worker.mesh(stale); + assert.equal(controller.ready.has("2,0,0"), false); + assert.equal(controller.requestMesh("0,0,0"), true); + const current = controller.busy; + worker.mesh(stale); + assert.equal(controller.busy, current); + worker.mesh(current); + assert.deepEqual([...controller.ready.keys()], ["0,0,0"]); +}); + +test("a section unloaded then re-entered within one flush keeps only its newer blocks", () => { + const { controller, worker } = fixture(), + state = new TerrainState(properties); + controller.reset( + new SectionBlockMap([ + ["16,0,0", 1], + ["17,0,0", 1], + ]), + materials, + new Map(), + bounds, + ); + controller.flush(); + state.sync(worker.packets[0]); + controller.update({ changes: [{ pos: [17, 0, 0], block: 2 }] }); + controller.update({ unload: [[1, 0, 0]] }); + controller.update({ changes: [{ pos: [16, 0, 0], block: 2 }] }); + controller.flush(); + state.sync(worker.packets[1]); + assert.deepEqual([...state.blocks], [["16,0,0", 2]]); +}); + +test("complete sections replace queued edits and replay newer edits after their numeric records", () => { + const { controller, worker } = fixture(), state = new TerrainState(properties); + controller.reset(new SectionBlockMap([["16,0,0", 1], ["17,0,0", 1]]), materials, new Map(), bounds); + controller.flush(); state.sync(worker.packets[0]); + controller.update({ changes: [{ pos: [17,0,0], block: 2 }] }); + controller.update({ sections: [{ section: [1,0,0], blocks: [{ pos: [18,0,0], block: 1 }] }] }); + controller.update({ changes: [{ pos: [18,0,0], block: 2 }] }); + controller.flush(); state.sync(worker.packets[1]); + assert.deepEqual([...state.blocks], [["18,0,0", 2]]); + assert.deepEqual([...worker.packets[1].changes], [18,0,0,1,18,0,0,2]); + + controller.update({ sections: [{ section: [1,0,0], blocks: [{ pos: [19,0,0], block: 1 }] }] }); + controller.update({ unload: [[1,0,0]] }); + controller.update({ sections: [{ section: [1,0,0], blocks: [{ pos: [20,0,0], block: 2 }] }] }); + controller.flush(); state.sync(worker.packets[2]); + assert.deepEqual([...state.blocks], [["20,0,0", 2]]); + + controller.update({ sections: [{ section: [1,0,0], blocks: [] }] }); + controller.flush(); state.sync(worker.packets[3]); + assert.equal(state.blocks.size, 0, "An empty complete section clears old contents"); +}); + +test("streamed section records are packed at flush without indexing each voxel on arrival", () => { + const { controller, worker } = fixture(); + controller.reset(new SectionBlockMap(), materials, new Map(), bounds); + controller.flush(); + const records = [{ pos: [16,0,0], block: 1 }]; + controller.changes.set = () => { throw Error("Section records must not pass through the per-edit index"); }; + controller.update({ sections: [{ section: [1,0,0], blocks: records }] }); + assert.equal(controller.sectionChanges.get("1,0,0"), records); + controller.flush(); + assert.deepEqual([...worker.packets[1].changes], [16,0,0,1]); + assert.equal(controller.sectionChanges.size, 0); +}); + +test("runtime worker errors notify the renderer asynchronously and stop after disposal", async t => { + t.mock.method(console, "error", () => {}); + const worker = new FakeWorker(), errors = []; + const controller = new TerrainController({ workerFactory: () => worker, onError: message => errors.push(message) }); + controllers.push(controller); + let prevented = false; + worker.onerror({ message: "Worker stopped", preventDefault() { prevented = true; } }); + assert.equal(prevented, true); + assert.deepEqual(errors, []); + await Promise.resolve(); + assert.deepEqual(errors, ["Worker stopped"]); + worker.onerror({ message: "Obsolete error", preventDefault() {} }); + controller.dispose(); + await Promise.resolve(); + assert.deepEqual(errors, ["Worker stopped"]); +}); diff --git a/client/tests/terrain-state.test.js b/client/tests/terrain-state.test.js new file mode 100644 index 0000000..47414a1 --- /dev/null +++ b/client/tests/terrain-state.test.js @@ -0,0 +1,253 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { TerrainState } from "../terrain-state.js"; +import { MESH_VERTEX_STRIDE } from "../mesh-geometry.js"; + +const properties = JSON.parse( + await readFile(new URL("../light-properties.json", import.meta.url)), +); +const cube = { min: [0, 0, 0], max: [1, 1, 1] }; +const stone = { + state: "minecraft:stone", + color: [128, 128, 128], + render: [cube], +}; +const lamp = { + state: "minecraft:sea_lantern", + color: [240, 250, 250], + render: [cube], + light: 15, +}; +const bounds = { min: [-2, -2, -2], max: [18, 3, 3] }; +function packet(overrides = {}) { + return { + type: "sync", + epoch: 1, + version: 1, + reset: true, + initial: new Int32Array([0, 0, 0, 1]), + changes: new Int32Array(), + materials: [ + [1, stone], + [2, lamp], + ], + textures: [], + bounds, + unload: [], + ...overrides, + }; +} +const request = (state, id = "0,0,0") => ({ + epoch: state.epoch, + version: state.version, + id, + job: 1, +}); + +test("reset plus deltas preserves numeric coordinates and authoritative material lighting metadata", () => { + const state = new TerrainState(properties); + assert.equal( + state.sync( + packet({ + initial: new Int32Array([-1, 0, 0, 1, 16, 0, 0, 1]), + changes: new Int32Array([-1, 0, 0, 0, 16, 0, 0, 2]), + }), + ), + true, + ); + assert.deepEqual([...state.blocks], [["16,0,0", 2]]); + assert.deepEqual([...state.blocks.loadedSectionKeys()], ["1,0,0"]); + assert.equal(state.materials.get(1).light_dampening, 15); + assert.equal(stone.light_dampening, undefined); + assert.equal(state.mesh(request(state, "1,0,0")).stale, true); + const result = state.light(); + assert.equal(result.field.sourceCount, 1); + assert.equal(state.field.sample([16, 0, 0]).blockLevel, 15); + assert.notEqual(result.field.data.buffer, state.field.data.buffer); + assert.notEqual( + result.field.blockLevels.buffer, + state.field.blockLevels.buffer, + ); + assert.notEqual(result.field.skyLevels.buffer, state.field.skyLevels.buffer); + assert.ok(state.mesh(request(state, "1,0,0")).vertices.length > 0); +}); + +test("outdated syncs and mesh requests cannot overwrite a new version or world", () => { + const state = new TerrainState(properties); + state.sync(packet()); + state.light(); + state.sync( + packet({ epoch: 2, version: 5, initial: new Int32Array([16, 0, 0, 2]) }), + ); + assert.equal(state.sync(packet({ version: 100 })), false); + assert.equal(state.sync(packet({ epoch: 2, version: 4 })), false); + assert.equal( + state.sync(packet({ epoch: 3, version: 6, reset: false })), + false, + ); + assert.deepEqual([...state.blocks], [["16,0,0", 2]]); + assert.equal(state.field, null); + assert.equal( + state.mesh({ epoch: 1, version: 1, id: "0,0,0", job: 1 }).stale, + true, + ); + state.light(); + assert.equal( + state.mesh({ epoch: 2, version: 4, id: "1,0,0", job: 2 }).stale, + true, + ); + assert.ok(state.mesh(request(state, "1,0,0")).vertices.length > 0); +}); + +test("unloaded sections disappear from persistent storage and cannot be resurrected by old syncs", () => { + const state = new TerrainState(properties); + state.sync(packet({ initial: new Int32Array([0, 0, 0, 1, 16, 0, 0, 2]) })); + state.light(); + state.mesh(request(state, "1,0,0")); + assert.equal(state.meshed.has("1,0,0"), true); + state.sync( + packet({ + reset: false, + version: 2, + initial: null, + materials: [], + unload: [[1, 0, 0]], + }), + ); + assert.equal(state.blocks.has("16,0,0"), false); + assert.deepEqual([...state.blocks.keysInSection("1,0,0")], []); + assert.equal(state.meshed.has("1,0,0"), false); + state.light(); + assert.equal(state.mesh(request(state, "1,0,0")).vertices.length, 0); + assert.equal( + state.sync(packet({ initial: new Int32Array([16, 0, 0, 2]) })), + false, + ); + assert.equal(state.blocks.has("16,0,0"), false); +}); + +test("material and texture replacements affect the next mesh and invalidate face texture cache", () => { + const state = new TerrainState(properties); + state.sync(packet({ textures: [["stone", 3]] })); + state.light(); + const first = state.mesh(request(state)); + assert.equal(first.vertices.length, 36 * MESH_VERTEX_STRIDE); + assert.equal(first.vertices[13], 3); + assert.equal(state.faceTextureCache.get("minecraft:stone")[0].layer, 3); + state.sync( + packet({ + reset: false, + version: 2, + initial: null, + materials: [ + [ + 1, + { + ...stone, + render: [{ min: [0, 0, 0], max: [1, 0.5, 1] }], + light: 7, + }, + ], + ], + textures: [["stone", 9]], + }), + ); + assert.equal(state.faceTextureCache.size, 0); + assert.equal(state.mesh(request(state)).stale, true); + state.light(); + const next = state.mesh(request(state)); + for (let i = 0; i < next.vertices.length; i += MESH_VERTEX_STRIDE) { + assert.ok(next.vertices[i + 1] <= 0.5); + assert.equal(next.vertices[i + 13], 9); + assert.ok(Math.abs(next.vertices[i + 15] - 7 / 15) < 1e-6); + } + assert.equal(state.faceTextureCache.get("minecraft:stone")[0].layer, 9); +}); + +test("lighting changes keep emptied meshed sections dirty until their stale geometry is replaced", () => { + const state = new TerrainState(properties); + state.sync(packet({ initial: new Int32Array([0, 0, 0, 2]) })); + state.light(); + state.mesh(request(state)); + state.sync( + packet({ + reset: false, + version: 2, + initial: null, + materials: [], + changes: new Int32Array([0, 0, 0, 0]), + }), + ); + assert.equal(state.blocks.size, 0); + assert.ok(state.light().dirty.includes("0,0,0")); + assert.equal(state.mesh(request(state)).vertices.length, 0); +}); + +test("a retained unknown cube remeshes when its same-light definition arrives", () => { + const state = new TerrainState(properties); + state.sync(packet({ initial: new Int32Array([15, 0, 0, 99, 16, 0, 0, 1]) })); + state.light(); + const before = state.mesh(request(state)); + state.mesh(request(state, "1,0,0")); + const oldLight = state.field.data.slice(), oldSky = state.field.skyLevels.slice(); + const defined = { ...stone, color: [60, 70, 80] }; + state.sync(packet({ reset: false, version: 2, initial: null, materials: [[99, defined]] })); + const result = state.light(); + assert.deepEqual(state.field.data, oldLight); + assert.deepEqual(state.field.skyLevels, oldSky); + assert.ok(result.dirty.includes("0,0,0"), "the placeholder's owner must be rebuilt even with identical lighting"); + assert.ok(result.dirty.includes("1,0,0"), "neighbor culling/AO must also be refreshed"); + const after = state.mesh(request(state)); + assert.notDeepEqual(after.vertices.slice(3, 6), before.vertices.slice(3, 6)); + assert.deepEqual([...after.vertices.slice(3, 6)], defined.color.map(value => Math.fround(value / 255))); +}); + +test("repeated equivalent material definitions do not spuriously dirty retained sections", () => { + const state = new TerrainState(properties); + state.sync(packet()); + state.light(); state.mesh(request(state)); + const current = state.materials.get(1); + const repeated = { render: [{ max: [1, 1, 1], min: [0, 0, 0] }], color: [128, 128, 128], + state: "minecraft:stone", ignoredMetadata: "does not affect the transmitted material" }; + state.sync(packet({ reset: false, version: 2, initial: null, materials: [[1, repeated]] })); + assert.equal(state.materials.get(1), current); + assert.deepEqual(state.light().dirty, []); +}); + +test("a color-only definition replacement invalidates its mesh without a light delta", () => { + const state = new TerrainState(properties); + state.sync(packet()); + state.light(); state.mesh(request(state)); + const oldLight = state.field.data.slice(), oldSky = state.field.skyLevels.slice(); + state.sync(packet({ reset: false, version: 2, initial: null, materials: [[1, { ...stone, color: [220, 70, 30] }]] })); + assert.ok(state.light().dirty.includes("0,0,0")); + assert.deepEqual(state.field.data, oldLight); + assert.deepEqual(state.field.skyLevels, oldSky); + assert.deepEqual([...state.mesh(request(state)).vertices.slice(3, 6)], [220, 70, 30].map(value => Math.fround(value / 255))); +}); + +test("material invalidations from separate coalesced syncs retain their union", () => { + const state = new TerrainState(properties), wide = { min: [-2, -2, -2], max: [50, 3, 3] }; + state.sync(packet({ initial: new Int32Array([0, 0, 0, 1, 48, 0, 0, 3]), materials: [[1, stone], [3, stone]], bounds: wide })); + state.light(); state.mesh(request(state)); state.mesh(request(state, "3,0,0")); + state.sync(packet({ reset: false, version: 2, initial: null, materials: [[1, { ...stone, color: [10, 20, 30] }]], bounds: wide })); + state.sync(packet({ reset: false, version: 3, initial: null, materials: [[3, { ...stone, color: [30, 20, 10] }]], bounds: wide })); + assert.deepEqual(new Set(state.light().dirty), new Set(["0,0,0", "3,0,0"])); + assert.deepEqual(state.light().dirty, [], "the material union is consumed after a successful field publication"); +}); + +test("world reset clears pending material geometry invalidations", () => { + const state = new TerrainState(properties), wide = { min: [-2, -2, -2], max: [50, 3, 3] }; + state.sync(packet()); state.light(); state.mesh(request(state)); + state.sync(packet({ reset: false, version: 2, initial: null, materials: [[1, { ...stone, color: [10, 20, 30] }]] })); + state.sync(packet({ epoch: 2, version: 3, initial: new Int32Array([48, 0, 0, 1]), materials: [[1, stone]], bounds: wide })); + assert.deepEqual(state.light().dirty, ["3,0,0"]); +}); + +test("a new definition absent from the loaded blocks does not remesh terrain", () => { + const state = new TerrainState(properties); + state.sync(packet()); state.light(); state.mesh(request(state)); + state.sync(packet({ reset: false, version: 2, initial: null, materials: [[99, lamp]] })); + assert.deepEqual(state.light().dirty, []); +}); diff --git a/client/tests/terrain-streaming-smoke.html b/client/tests/terrain-streaming-smoke.html new file mode 100644 index 0000000..81e8e25 --- /dev/null +++ b/client/tests/terrain-streaming-smoke.html @@ -0,0 +1,148 @@ + + + + +Shacraft terrain streaming benchmark + +
Terrain streamingPreparing scene…
+
Initial loading and warmup are excluded from measurements.

64 × 64 visible floor, lamp grid, glass and stairs. Six boundaries at 8 blocks/s. CPU draw measures JavaScript and WebGL submission, not GPU completion. Keep this tab visible.

+ + diff --git a/client/tests/terrain-upload.test.js b/client/tests/terrain-upload.test.js new file mode 100644 index 0000000..38704fa --- /dev/null +++ b/client/tests/terrain-upload.test.js @@ -0,0 +1,236 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Renderer } from "../renderer.js"; + +function fixture(t, { uploadCost = 1, allocationCost = 0 } = {}) { + const clock = { now: 0 }; + t.mock.method(performance, "now", () => clock.now); + let serial = 0, bound; + const gl = { + ARRAY_BUFFER: 1, DYNAMIC_DRAW: 2, STATIC_DRAW: 3, FLOAT: 4, + buffers: new Map(), uploads: [], allocations: [], deletedBuffers: [], deletedVaos: [], + createBuffer() { const buffer = { id: ++serial }; this.buffers.set(buffer, new Uint8Array()); return buffer; }, + createVertexArray() { return { id: ++serial }; }, + bindVertexArray() {}, bindBuffer(_target, buffer) { bound = buffer; }, + enableVertexAttribArray() {}, vertexAttribPointer() {}, + bufferData(_target, data, usage) { + const bytes = typeof data === "number" ? new Uint8Array(data) : + new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice(); + this.buffers.set(bound, bytes); + this.allocations.push({ buffer: bound, byteLength: bytes.byteLength, usage }); + if (typeof data === "number") clock.now += allocationCost; + }, + bufferSubData(_target, offset, data) { + const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + assert.ok(offset + bytes.byteLength <= this.buffers.get(bound).byteLength); + this.buffers.get(bound).set(bytes, offset); + this.uploads.push({ buffer: bound, offset, byteLength: bytes.byteLength }); + clock.now += uploadCost; + }, + deleteBuffer(buffer) { this.deletedBuffers.push(buffer); this.buffers.delete(buffer); }, + deleteVertexArray(vao) { this.deletedVaos.push(vao); }, + }; + const renderer = Object.create(Renderer.prototype); + Object.assign(renderer, { gl, sections: new Map(), dirty: new Set(["0,0,0"]), meshUpload: null, + meshRebuilds: 0, lightViewBounds: { min: [0, 0, 0], max: [31, 31, 31] } }); + const old = renderer.mesh(new Float32Array(60).fill(7)); + old.transparent = renderer.mesh(new Float32Array(60).fill(8)); + old.tag = "retained identity"; + const other = renderer.mesh(new Float32Array(60).fill(9)); + renderer.sections.set("0,0,0", old); + renderer.sections.set("1,0,0", other); + const terrain = { epoch: 1, version: 1, ready: new Map(), + isCurrent(result) { return result.epoch === this.epoch && result.version === this.version; } }; + renderer.terrain = terrain; + gl.allocations.length = 0; + const result = (opaqueVertices = 3000, transparentVertices = 1000, overrides = {}) => { + const vertices = new Float32Array(opaqueVertices * 20), transparent = new Float32Array(transparentVertices * 20); + for (let index = 0; index < vertices.length; index++) vertices[index] = index * 0.25; + for (let index = 0; index < transparent.length; index++) transparent[index] = -index * 0.125; + return { id: "0,0,0", epoch: terrain.epoch, version: terrain.version, vertices, transparent, ...overrides }; + }; + const queue = (message) => terrain.ready.set(message.id, message); + const drain = () => { + for (let frame = 0; frame < 200 && (renderer.meshUpload || terrain.ready.size); frame++) + renderer.applyTerrainMeshes(0.5); + assert.equal(renderer.meshUpload, null); + assert.equal(terrain.ready.size, 0); + }; + return { renderer, terrain, gl, clock, old, other, result, queue, drain }; +} + +test("uploads are capped at 64 KiB and both surfaces publish only after completion", (t) => { + const f = fixture(t), message = f.result(); + const oldBuffer = f.old.buffer, oldTransparent = f.old.transparent, oldVao = f.old.vao; + f.queue(message); + f.renderer.applyTerrainMeshes(0.5); + assert.equal(f.gl.uploads.length, 1); + assert.equal(f.gl.uploads[0].byteLength, 65_536); + assert.equal(f.renderer.sections.get(message.id), f.old); + assert.equal(f.old.buffer, oldBuffer); + assert.equal(f.old.transparent, oldTransparent); + assert.deepEqual(f.gl.deletedBuffers, []); + assert.ok(f.renderer.dirty.has(message.id)); + // Four opaque slices and the first translucent slice still cannot replace + // the visible mesh, even though its opaque replacement is already complete. + for (let frame = 0; frame < 4; frame++) f.renderer.applyTerrainMeshes(0.5); + assert.equal(f.renderer.meshUpload.parts[0].offset, message.vertices.length); + assert.ok(f.renderer.meshUpload.parts[1].offset < message.transparent.length); + assert.equal(f.old.buffer, oldBuffer); + assert.equal(f.old.transparent, oldTransparent); + f.drain(); + assert.ok(f.gl.uploads.every((upload) => upload.byteLength <= 65_536)); + assert.equal(f.renderer.sections.get(message.id), f.old); + assert.equal(f.old.tag, "retained identity"); + assert.notEqual(f.old.buffer, oldBuffer); + assert.notEqual(f.old.vao, oldVao); + assert.notEqual(f.old.transparent, oldTransparent); + assert.equal(f.old.count, 3000); + assert.equal(f.old.transparent.count, 1000); + assert.deepEqual(f.gl.buffers.get(f.old.buffer), new Uint8Array(message.vertices.buffer)); + assert.deepEqual(f.gl.buffers.get(f.old.transparent.buffer), new Uint8Array(message.transparent.buffer)); + assert.ok(f.gl.deletedBuffers.includes(oldBuffer)); + assert.ok(f.gl.deletedBuffers.includes(oldTransparent.buffer)); + assert.equal(f.renderer.sections.get("1,0,0"), f.other); + assert.ok(!f.gl.deletedBuffers.includes(f.other.buffer)); + assert.ok(!f.renderer.dirty.has(message.id)); + assert.equal(f.renderer.meshRebuilds, 1); +}); + +test("a generation change discards partially uploaded buffers and preserves visible geometry", (t) => { + const f = fixture(t), oldBuffer = f.old.buffer, oldTransparent = f.old.transparent; + f.queue(f.result(900, 900)); + for (let frame = 0; frame < 3; frame++) f.renderer.applyTerrainMeshes(0.5); + const staged = f.renderer.meshUpload.parts.map((part) => part.mesh); + assert.ok(staged.every(Boolean), "both replacement surfaces were allocated"); + assert.equal(f.old.buffer, oldBuffer); + f.terrain.version++; + f.renderer.applyTerrainMeshes(0.5); + assert.equal(f.renderer.meshUpload, null); + assert.equal(f.renderer.sections.get("0,0,0"), f.old); + assert.equal(f.old.buffer, oldBuffer); + assert.equal(f.old.transparent, oldTransparent); + assert.ok(f.renderer.dirty.has("0,0,0")); + for (const mesh of staged) { + assert.ok(f.gl.deletedBuffers.includes(mesh.buffer)); + assert.ok(f.gl.deletedVaos.includes(mesh.vao)); + } + assert.ok(!f.gl.deletedBuffers.includes(oldBuffer)); + assert.ok(!f.gl.deletedBuffers.includes(oldTransparent.buffer)); + assert.equal(f.renderer.meshRebuilds, 0); + f.queue(f.result(3, 0)); + f.drain(); + assert.equal(f.old.count, 3); + assert.equal(f.old.transparent, null); + assert.equal(f.renderer.meshRebuilds, 1); +}); + +test("explicit staged-upload disposal is idempotent and owns only replacement resources", (t) => { + const f = fixture(t); + f.queue(f.result()); + f.renderer.applyTerrainMeshes(0.5); + const staged = f.renderer.meshUpload.parts[0].mesh; + f.renderer.discardMeshUpload(); + f.renderer.discardMeshUpload(); + assert.equal(f.renderer.meshUpload, null); + assert.equal(f.gl.deletedBuffers.filter((buffer) => buffer === staged.buffer).length, 1); + assert.equal(f.gl.deletedVaos.filter((vao) => vao === staged.vao).length, 1); + assert.ok(f.gl.buffers.has(f.old.buffer)); + assert.ok(f.gl.buffers.has(f.old.transparent.buffer)); +}); + +test("empty results remove both old surfaces without allocating empty GPU replacements", (t) => { + const f = fixture(t), oldTransparent = f.old.transparent; + f.queue(f.result(0, 0)); + f.drain(); + assert.equal(f.renderer.sections.has("0,0,0"), false); + assert.ok(f.gl.deletedBuffers.includes(f.old.buffer)); + assert.ok(f.gl.deletedBuffers.includes(oldTransparent.buffer)); + assert.equal(f.gl.allocations.length, 0); + assert.equal(f.gl.uploads.length, 0); + assert.equal(f.renderer.sections.get("1,0,0"), f.other); + assert.equal(f.renderer.meshRebuilds, 1); +}); + +test("a new translucent-only section retains an empty opaque handle for draw paths", (t) => { + const f = fixture(t), message = f.result(0, 6, { id: "0,1,0" }); + f.queue(message); + f.drain(); + const mesh = f.renderer.sections.get(message.id); + assert.ok(mesh?.vao && mesh.buffer); + assert.equal(mesh.count, 0); + assert.equal(mesh.transparent.count, 6); + assert.deepEqual(f.gl.buffers.get(mesh.transparent.buffer), new Uint8Array(message.transparent.buffer)); + assert.equal(f.renderer.sections.get("0,0,0"), f.old); +}); + +test("stale or out-of-view results never allocate or replace a mesh", (t) => { + const f = fixture(t); + f.queue(f.result(3, 0, { version: 0 })); + f.renderer.applyTerrainMeshes(0.5); + f.queue(f.result(3, 0, { id: "8,0,0" })); + f.renderer.applyTerrainMeshes(0.5); + assert.equal(f.gl.allocations.length, 0); + assert.equal(f.gl.uploads.length, 0); + assert.equal(f.renderer.sections.get("0,0,0"), f.old); + assert.equal(f.renderer.meshRebuilds, 0); + assert.ok(f.renderer.dirty.has("0,0,0")); +}); + +test("the frame budget stops before further upload steps and preserves queued work", (t) => { + const f = fixture(t); + f.queue(f.result()); + f.renderer.applyTerrainMeshes(0); + assert.equal(f.gl.allocations.length, 0); + assert.equal(f.terrain.ready.size, 1); + f.renderer.applyTerrainMeshes(0.5); + assert.equal(f.gl.uploads.length, 1); + assert.equal(f.renderer.meshUpload.parts[0].offset, 16_384); + assert.equal(f.renderer.meshUploadMilliseconds, 1); + f.renderer.applyTerrainMeshes(0.5); + assert.equal(f.gl.uploads.length, 2); + assert.equal(f.renderer.meshUpload.parts[0].offset, 32_768); +}); + +test("an expensive GPU allocation yields before copying vertex data", (t) => { + const f = fixture(t, { allocationCost: 3 }); + f.queue(f.result()); + f.renderer.applyTerrainMeshes(2); + assert.ok(f.renderer.meshUpload.parts[0].mesh); + assert.equal(f.renderer.meshUpload.parts[0].offset, 0); + assert.equal(f.gl.uploads.length, 0); + assert.equal(f.renderer.meshUploadMilliseconds, 3); + f.renderer.applyTerrainMeshes(0.5); + assert.equal(f.gl.uploads.length, 1); + assert.equal(f.renderer.sections.get("0,0,0"), f.old); +}); + +test('preview publication keeps the section dirty for corrected lighting and reports visible geometry', t => { + const f=fixture(t),published=[]; + f.renderer.onMeshPublished=(id,result)=>published.push({id,preview:result.preview}); + f.queue(f.result(6,0,{preview:true})); f.drain(); + assert.equal(f.old.count,6); + assert.equal(f.renderer.dirty.has('0,0,0'),true); + assert.deepEqual(published,[{id:'0,0,0',preview:true}]); + f.queue(f.result(6,0,{preview:false}));f.drain(); + assert.equal(f.renderer.dirty.has('0,0,0'),false); +}); + +test('edit uploads survive unrelated terrain versions but reject newer edits before publication',t=>{ + const f=fixture(t),oldBuffer=f.old.buffer; + const edits={epoch:9,ticket:1,ready:new Map(),isCurrent(r){return r.epoch===this.epoch&&r.editJob===this.ticket;},cancel(){this.ticket++;}}; + f.renderer.edits=edits; + edits.ready.set('0,0,0',f.result(3000,0,{epoch:9,editJob:1,preview:true})); + f.renderer.applyTerrainMeshes(0.5);const stage=f.renderer.meshUpload; + f.terrain.version++; + f.renderer.applyTerrainMeshes(0.5); + assert.equal(f.renderer.meshUpload,stage,'a distant chunk update must not discard a local edit'); + edits.ticket++;f.renderer.applyTerrainMeshes(0.5); + assert.equal(f.renderer.meshUpload,null);assert.equal(f.old.buffer,oldBuffer); + edits.ready.set('0,0,0',f.result(3,0,{epoch:9,editJob:2,preview:true})); + for(let frame=0;frame<10&&(f.renderer.meshUpload||edits.ready.size);frame++)f.renderer.applyTerrainMeshes(0.5); + assert.equal(f.old.count,3);assert.equal(f.renderer.dirty.has('0,0,0'),true); + f.queue(f.result(3,0));f.drain(); + assert.equal(edits.ticket,3,'final lighting cancels any older preview'); + assert.equal(f.renderer.dirty.has('0,0,0'),false); +}); diff --git a/client/tests/texture-pack.test.js b/client/tests/texture-pack.test.js new file mode 100644 index 0000000..a9c8f14 --- /dev/null +++ b/client/tests/texture-pack.test.js @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {prepareTexturePack, textureSubset, BLOCK_FACES} from '../texture-pack.js'; +import {getBlockFaceTextures, getFaceUV} from '../block-textures.js'; +import {buildSectionMesh} from '../mesh-geometry.js'; + +const limits = {layers:256, size:2048}; +const files = new Map([['atlas.png', {}]]); +const descriptor = () => ({pixel_size:32, atlas:{path:'atlas.png', columns:40, rows:32}, + textures:Array.from({length:1269}, (_,i)=>({name:`tile_${i}`}))}); + +test('atlas supports the full catalog on a GPU with only 256 array layers', () => { + const result = prepareTexturePack(descriptor(), files, limits); + assert.deepEqual([result.width,result.height,result.layers.size],[1280,1024,1269]); + assert.equal(result.layers.get('tile_1268'),1268); + const regular = {...descriptor(),atlas:undefined}; + assert.throws(()=>prepareTexturePack(regular,files,limits),/размер/); +}); + +test('atlas bounds, verified resources, and names are validated before allocating GPU memory', () => { + assert.throws(()=>prepareTexturePack(descriptor(),new Map(),limits),/атлас/); + assert.throws(()=>prepareTexturePack(descriptor(),files,{...limits,size:1024}),/видеокарты/); + for(const atlas of [{path:'atlas.png',columns:1,rows:1},{path:'atlas.png',columns:40.5,rows:32}]) + assert.throws(()=>prepareTexturePack({...descriptor(),atlas},files,limits),/атлас/); + for(const textures of [[{name:'same'},{name:'same'}],[{}],[{name:'../escape'}]]) + assert.throws(()=>prepareTexturePack({...descriptor(),textures},files,limits),/ресурс/); +}); + +test('state faces and UV transforms survive worker transfer and reach section geometry', () => { + const d=descriptor(); + const face={texture:'tile_1268',tint:[.4,.7,.3],cutout:true,uv:[0,0,1,0,0,-1,0,1]}; + d.block_faces={sets:[Array(6).fill(face),Array(6).fill(null)], + states:{'minecraft:test[facing=east,half=top]':0},defaults:{'minecraft:test':1}}; + const {layers}=prepareTexturePack(d,files,limits); + const cloned=new Map(structuredClone([...layers])); + const state='minecraft:test[half=top,facing=east]'; + const faces=getBlockFaceTextures(state,cloned); + assert.equal(faces[0].layer,1268); + assert.deepEqual(getFaceUV(0,[.25,.5,.75],state,faces[0]),[.75,.5]); + assert.deepEqual(getBlockFaceTextures('minecraft:test',cloned),Array(6).fill(null)); + const mesh=buildSectionMesh({id:'0,0,0',blocks:new Map([['0,0,0',1]]), + materials:new Map([[1,{state,color:[255,255,255],render:[{min:[0,0,0],max:[1,1,1]}]}]]),textureLayers:cloned}); + for(let i=0;i { + for(const face of [{texture:'missing'},{texture:'tile_0',tint:[NaN,1,1]}, + {texture:'tile_0',uv:Array(8).fill(Infinity)}]){ + const d=descriptor();d.block_faces={sets:[Array(6).fill(face)],states:{},defaults:{}}; + assert.throws(()=>prepareTexturePack(d,files,limits),/гран/); + } + const d=descriptor();d.block_faces={sets:[Array(6).fill(null)],states:{'minecraft:stone':9},defaults:{}}; + assert.throws(()=>prepareTexturePack(d,files,limits),/состояние/); +}); + +test('edit snapshots send only nearby state mappings from a large texture catalog',()=>{ + const d=descriptor(); + d.block_faces={sets:[Array(6).fill({texture:'tile_0'}),Array(6).fill({texture:'tile_1'})], + states:{'minecraft:stone':0,'minecraft:dirt':1},defaults:{'minecraft:stone':0,'minecraft:dirt':1}}; + const {layers}=prepareTexturePack(d,files,limits); + const subset=textureSubset(layers,new Map([[1,{state:'minecraft:dirt'}]])); + assert.equal(subset.get(BLOCK_FACES).sets.length,1); + assert.deepEqual(Object.keys(subset.get(BLOCK_FACES).states),['minecraft:dirt']); + assert.equal(getBlockFaceTextures('minecraft:dirt',subset)[0].layer,1); + assert.equal(layers.get(BLOCK_FACES).sets.length,2); +}); diff --git a/client/tests/view-distance.test.js b/client/tests/view-distance.test.js new file mode 100644 index 0000000..85c28bc --- /dev/null +++ b/client/tests/view-distance.test.js @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { normalizeViewDistance, viewDistanceProfile, MAX_VIEW_CELLS, MAX_LIGHT_CELLS } from "../view-distance.js"; +import { applySectionView, applySectionBatch } from "../section-stream.js"; +import { SectionVoxelMap } from "../section-voxel-map.js"; +import { buildBlockLight } from "../block-light.js"; +import {actorLightBounds} from '../terrain-state.js'; + +const message = (radius, generation, center = [0, 64, 0]) => ({ + world: "test", revision: 7, generation, view_distance: radius, + view_center: center, view_min: center.map((v,i) => v - (i===1?2:radius)*16), + view_max: center.map((v,i) => v + (i===1?3:radius+1)*16-1), + unload: [], total_sections: (radius*2+1)**2*5, +}); +test("distance settings are bounded and projection, fog and memory scale together", () => { + for (const value of [null, undefined, "bad", 1, 65, 3.5, Infinity]) assert.equal(normalizeViewDistance(value), 3); + assert.equal(normalizeViewDistance("8"), 8); + const initial=viewDistanceProfile(3), maximum=viewDistanceProfile(64); + assert.equal(initial.sections, 1176); assert.equal(maximum.sections, 399384); + assert.equal(maximum.width, 2064); assert.equal(maximum.streamVoxelBytes, MAX_VIEW_CELLS*4); + assert.ok(maximum.drawRadius>initial.drawRadius); + assert.ok(maximum.farClip>maximum.drawRadius); + assert.ok(maximum.fogDistance>initial.fogDistance); +}); +test("resizing retains overlapping arrays, accepts the far ring and ignores obsolete batches", () => { + const view={world:"test",revision:7,viewGeneration:0,blocks:new SectionVoxelMap(),loadedSections:new Set()}; + applySectionView(view,message(3,1)); + const cells=new Uint32Array(4096).fill(1);view.blocks.setSection("0,4,0",cells);view.loadedSections.add("0,4,0"); + assert.deepEqual(applySectionView(view,message(8,2)),{unload:[]}); + assert.equal(view.totalSections,1445);assert.equal([...view.blocks.sectionEntries()][0][1],cells); + const batch={world:"test",revision:7,generation:2,sections:[{section:[8,4,8],palette:[0],runs:[4096,0]}]}; + assert.equal(applySectionBatch(view,batch).status,"applied"); + const shrunk=applySectionView(view,message(2,3)); + assert.deepEqual(shrunk.unload,[[8,4,8]]);assert.equal(view.totalSections,125); + assert.equal(view.blocks.sectionCount,1);assert.equal([...view.blocks.sectionEntries()][0][1],cells); + assert.equal(applySectionBatch(view,batch).status,"ignored"); + assert.equal(applySectionView(view,message(66,4)),"resync"); +}); +test("the maximum view permits the small air margin beyond the playable border", () => { + const view={world:"test",revision:7,viewGeneration:0,blocks:new SectionVoxelMap(),loadedSections:new Set()}; + assert.notEqual(applySectionView(view,message(8,1,[29_999_872,64,-29_999_872])),"resync"); + assert.equal(applySectionBatch(view,{world:"test",revision:7,generation:1,sections:[{section:[1_875_000,4,-1_874_992],palette:[0],runs:[4096,0]}]}).status,"applied"); +}); +test("lighting accepts the largest negotiated volume and still rejects larger allocations", () => { + const bounds={min:[0,0,0],max:[303,79,303]}; + const columns=new Map(); + for(let x=0;x<19;x++)for(let z=0;z<19;z++)columns.set(`${x},${z}`,new Int16Array(256).fill(100)); + const field=buildBlockLight(new SectionVoxelMap(),new Map(),bounds,columns); + assert.equal(field.data.length,MAX_LIGHT_CELLS*4);assert.equal(field.sample([130,50,130]).skyLevel,0); + assert.throws(()=>buildBlockLight(new Map(),new Map(),{min:[0,0,0],max:[304,80,304]}),RangeError); +}); + +test('buffered views fit the allocation bound while preserving the requested render distance',()=>{ + const view={world:'test',revision:7,viewGeneration:0,blocks:new SectionVoxelMap(),loadedSections:new Set()}; + const expanded={...message(9,1),view_distance:8,stream_radius:9}; + assert.deepEqual(applySectionView(view,expanded),{unload:[]}); + assert.equal(view.totalSections,1805);assert.equal(viewDistanceProfile(8).streamSections,8664); + assert.equal(applySectionView(view,message(66,2)),'resync'); +}); + +test('64 chunk radius accepts the outer buffer without allocating a giant light volume',()=>{ + const view={world:'test',revision:7,viewGeneration:0,blocks:new SectionVoxelMap(),loadedSections:new Set()}; + assert.deepEqual(applySectionView(view,{...message(65,1),view_distance:64,stream_radius:65}),{unload:[]}); + assert.equal(view.totalSections,85805); + assert.equal(applySectionBatch(view,{world:'test',revision:7,generation:1,sections:[ + {section:[64,4,-64],palette:[5],runs:[4096,0]}, + {section:[65,4,65],palette:[0],runs:[4096,0]}, + ]}).status,'applied'); + assert.equal(view.blocks.getAt(1024,64,-1024),5); + assert.equal(view.blocks.byteLength,8); + const bounds=actorLightBounds(view.viewBounds); + assert.equal((bounds.max[0]-bounds.min[0]+1)*(bounds.max[2]-bounds.min[2]+1),112**2); + assert.ok(112**2*80{ + const view={world:'test',revision:7,viewGeneration:0,blocks:new SectionVoxelMap(),loadedSections:new Set()}; + const full={...message(65,1),full_height:true,view_min:[-1040,-64,-1040],view_max:[1055,319,1055],total_sections:411864}; + assert.deepEqual(applySectionView(view,full),{unload:[]}); + const sections=[{section:[0,-4,0],palette:[1],runs:[4096,0]}, {section:[0,19,0],palette:[2],runs:[4096,0]}]; + assert.equal(applySectionBatch(view,{world:'test',revision:7,generation:1,sections}).status,'applied'); + assert.deepEqual(applySectionView(view,{...full,generation:2,view_center:[0,512,0]}),{unload:[]}); + assert.equal(view.blocks.getAt(0,-64,0),1);assert.equal(view.blocks.getAt(0,319,0),2); + assert.equal(view.totalSections,411864);assert.equal(view.viewBounds.fullHeight,true); + assert.equal(applySectionView(view,{...full,generation:3,view_min:[-1040,-48,-1040]}),'resync'); +}); diff --git a/client/tests/world-view.test.js b/client/tests/world-view.test.js new file mode 100644 index 0000000..a765de8 --- /dev/null +++ b/client/tests/world-view.test.js @@ -0,0 +1,358 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { SectionBlockMap } from "../section-block-map.js"; +import { + getViewBounds, + mergeChunkUpdate, + mergeSnapshot, + positionInView, +} from "../world-view.js"; + +function view() { + return { + world: "lobby", + revision: 7, + viewCenter: [0, 0, 0], + viewBounds: { min: [-32, -16, -32], max: [31, 31, 31] }, + blocks: new Map([ + ["0,0,0", 1], + ["31,0,0", 2], + ["-32,0,0", 3], + ]), + }; +} + +function moveEast() { + const message = { + type: "chunks", + world: "lobby", + revision: 7, + from_center: [0, 0, 0], + view_center: [16, 0, 0], + view_min: [-16, -16, -32], + view_max: [47, 31, 31], + unload: [], + sections: [], + }; + for (const y of [-1, 0, 1]) + for (const z of [-2, -1, 0, 1]) { + message.unload.push([-2, y, z]); + message.sections.push({ section: [2, y, z], blocks: [] }); + } + message.sections.find((s) => s.section.join(",") === "2,0,0").blocks = [ + { pos: [32, 0, 0], block: 4 }, + ]; + return message; +} + +test("crossing a section boundary retains overlapping blocks and map identity", () => { + const current = view(), + blocks = current.blocks, + result = mergeChunkUpdate(current, moveEast()); + assert.equal(result.status, "applied"); + assert.equal(current.blocks, blocks); + assert.equal(current.revision, 7); + assert.deepEqual(current.viewCenter, [16, 0, 0]); + assert.deepEqual( + [...current.blocks], + [ + ["0,0,0", 1], + ["31,0,0", 2], + ["32,0,0", 4], + ], + ); + assert.deepEqual(result.changes, [ + { pos: [-32, 0, 0], block: 0 }, + { pos: [32, 0, 0], block: 4 }, + ]); +}); + +test("an empty incoming section clears any stale block without touching overlap", () => { + const current = view(); + current.blocks.set("32,0,16", 99); + const result = mergeChunkUpdate(current, moveEast()); + assert.equal(result.status, "applied"); + assert.equal(current.blocks.has("32,0,16"), false); + assert.ok( + result.changes.some( + (change) => change.pos.join(",") === "32,0,16" && !change.block, + ), + ); + assert.equal(current.blocks.get("0,0,0"), 1); +}); + +test("negative coordinates use floor division when loading and unloading sections", () => { + const current = view(), + message = moveEast(); + current.viewCenter = [-16, 0, 0]; + current.blocks = new Map([ + ["-1,0,0", 1], + ["0,0,0", 2], + ["-48,0,0", 3], + ]); + message.from_center = [-16, 0, 0]; + message.view_center = [-32, 0, 0]; + message.view_min = [-64, -16, -32]; + message.view_max = [-1, 31, 31]; + message.unload = message.unload.map(([, y, z]) => [0, y, z]); + message.sections = message.sections.map(({ section: [, y, z] }) => ({ + section: [-4, y, z], + blocks: y === 0 && z === 0 ? [{ pos: [-49, 0, 0], block: 4 }] : [], + })); + const result = mergeChunkUpdate(current, message); + assert.equal(result.status, "applied"); + assert.deepEqual( + [...current.blocks], + [ + ["-1,0,0", 1], + ["-48,0,0", 3], + ["-49,0,0", 4], + ], + ); +}); + +test("same-world full snapshots only report actual changes and retain the map", () => { + const current = view(), + blocks = current.blocks, + records = [...blocks].map(([id, block]) => ({ + pos: id.split(",").map(Number), + block, + })); + assert.deepEqual(mergeSnapshot(blocks, records), []); + assert.equal(current.blocks, blocks); + records.pop(); + records.push({ pos: [32, 0, 0], block: 4 }); + assert.deepEqual(mergeSnapshot(blocks, records), [ + { pos: [-32, 0, 0], block: 0 }, + { pos: [32, 0, 0], block: 4 }, + ]); + assert.equal(blocks.get("0,0,0"), 1); +}); + +test("newer or older chunk revisions request resync without advancing revision", () => { + for (const revision of [6, 8]) { + const current = view(), + before = structuredClone(current), + message = { ...moveEast(), revision }; + assert.equal(mergeChunkUpdate(current, message).status, "resync"); + assert.deepEqual(current, before); + } +}); + +test("an out-of-order or duplicate transition cannot overwrite the current window", () => { + const current = view(), + message = moveEast(); + assert.equal(mergeChunkUpdate(current, message).status, "applied"); + const before = structuredClone(current); + assert.equal(mergeChunkUpdate(current, message).status, "resync"); + assert.deepEqual(current, before); + assert.equal( + mergeChunkUpdate(current, { ...message, world: "other" }).status, + "ignored", + ); + assert.deepEqual(current, before); +}); + +test("incomplete or malformed transitions do not partially unload a live world", () => { + const invalidMessages = [ + (m) => m.sections.pop(), + (m) => m.unload.pop(), + (m) => m.unload.push([0, 0, 0]), + (m) => (m.view_min[1] = -8), + (m) => m.sections[0].blocks.push({ pos: [0, 0, 0], block: 5 }), + (m) => m.sections[0].blocks.push({ pos: [32, -16, -32], block: -1 }), + ]; + for (const invalidate of invalidMessages) { + const current = view(), + before = structuredClone(current), + message = moveEast(); + invalidate(message); + assert.equal(mergeChunkUpdate(current, message).status, "resync"); + assert.deepEqual(current, before); + } +}); + +test("explicit view bounds include the entire bottom section with legacy fallback", () => { + const streamed = getViewBounds(moveEast()), + legacy = getViewBounds({ view_center: [16, 0, 0] }); + assert.equal(positionInView([0, -16, 0], streamed), true); + assert.equal(positionInView([0, -17, 0], streamed), false); + assert.equal(positionInView([0, -16, 0], legacy), false); + assert.equal(positionInView([0, -8, 0], legacy), true); + assert.equal(positionInView([48, 0, 0], streamed), false); +}); + +test("indexed transitions never iterate or read retained blocks", () => { + class IndexedBlocks extends SectionBlockMap { + [Symbol.iterator]() { + throw Error("A chunk transition must not scan the complete map"); + } + get(key) { + assert.ok(!["0,0,0", "31,0,0"].includes(key), "Retained block was read"); + return super.get(key); + } + } + const current = view(); + current.blocks = new IndexedBlocks(current.blocks); + current.blocks.set("-31,0,0", 8); + current.blocks.set("32,0,16", 99); + current.blocks.set("32,0,0", 3); + const result = mergeChunkUpdate(current, moveEast()); + assert.equal(result.status, "applied"); + assert.deepEqual( + [...current.blocks.entries()], + [ + ["0,0,0", 1], + ["31,0,0", 2], + ["32,0,0", 4], + ], + ); + assert.deepEqual([...current.blocks.loadedSectionKeys()].sort(), [ + "0,0,0", + "1,0,0", + "2,0,0", + ]); + assert.deepEqual( + result.changes.map(({ pos, block }) => [pos.join(","), block]).sort(), + [ + ["-31,0,0", 0], + ["-32,0,0", 0], + ["32,0,0", 4], + ["32,0,16", 0], + ], + ); +}); + +test("malformed transitions preserve the section index before any indexed access", () => { + class IndexedBlocks extends SectionBlockMap { + keysInSection() { + throw Error("Incomplete transition accessed the index"); + } + } + const current = view(); + current.blocks = new IndexedBlocks(current.blocks); + const beforeBlocks = [...current.blocks], + beforeSections = [...current.blocks.loadedSectionKeys()], + beforeCenter = current.viewCenter, + beforeBounds = current.viewBounds, + message = moveEast(); + message.sections.at(-1).blocks.push({ pos: [32, 16, 16], block: -1 }); + assert.equal(mergeChunkUpdate(current, message).status, "resync"); + assert.deepEqual([...current.blocks], beforeBlocks); + assert.deepEqual([...current.blocks.loadedSectionKeys()], beforeSections); + assert.equal(current.viewCenter, beforeCenter); + assert.equal(current.viewBounds, beforeBounds); +}); + +test("full snapshot merges maintain the section index alongside the block map", () => { + const blocks = new SectionBlockMap(view().blocks); + mergeSnapshot(blocks, [ + { pos: [0, 0, 0], block: 1 }, + { pos: [32, -1, 0], block: 4 }, + ]); + assert.deepEqual([...blocks.loadedSectionKeys()], ["0,0,0", "2,-1,0"]); + assert.deepEqual([...blocks.keysInSection("-2,0,0")], []); + assert.deepEqual([...blocks.keysInSection("2,-1,0")], ["32,-1,0"]); +}); + +test("optional section unloads omit only departing changes for indexed and ordinary maps", () => { + for (const BlockMap of [Map, SectionBlockMap]) { + const current = view(); + current.blocks = new BlockMap(current.blocks); + current.blocks.set("32,0,16", 99); + const result = mergeChunkUpdate(current, moveEast(), { + includeUnloadedChanges: false, + }); + assert.equal(result.status, "applied"); + assert.equal(current.blocks.has("-32,0,0"), false); + assert.deepEqual(result.changes, [ + { pos: [32, 0, 16], block: 0 }, + { pos: [32, 0, 0], block: 4 }, + ]); + assert.deepEqual( + [...current.blocks], + [ + ["0,0,0", 1], + ["31,0,0", 2], + ["32,0,0", 4], + ], + ); + } +}); + +test("bulk streamed merges avoid departing key access and use validated sections for insertion", () => { + class BulkBlocks extends SectionBlockMap { + set(key, value) { + assert.equal( + this.locked, + undefined, + "Incoming records reparsed through ordinary set", + ); + return super.set(key, value); + } + delete() { + throw Error("Departing blocks were deleted individually"); + } + keysInSection(section) { + assert.ok( + !section.startsWith("-2,"), + "Departing block keys were requested", + ); + return super.keysInSection(section); + } + [Symbol.iterator]() { + throw Error("Streamed merge scanned the full map"); + } + } + const current = view(), + message = moveEast(); + current.blocks = new BulkBlocks(current.blocks); + current.blocks.locked = true; + const result = mergeChunkUpdate(current, message, { + includeUnloadedChanges: false, + }); + assert.equal(result.status, "applied"); + assert.equal(current.blocks.has("-32,0,0"), false); + assert.equal(current.blocks.get("32,0,0"), 4); + assert.deepEqual(result.changes, [{ pos: [32, 0, 0], block: 4 }]); + assert.deepEqual( + [...current.blocks.loadedSectionKeys()], + ["0,0,0", "1,0,0", "2,0,0"], + ); +}); + +test("bulk merges still validate all incoming records before deleting any section", () => { + for (const invalidRecord of [ + { pos: [32, 16, 16], block: -1 }, + { pos: [31, 16, 16], block: 4 }, + { pos: [32, 16.5, 16], block: 4 }, + ]) { + const current = view(), + message = moveEast(); + current.blocks = new SectionBlockMap(current.blocks); + const before = [...current.blocks], + sectionsBefore = [...current.blocks.loadedSectionKeys()], + centerBefore = current.viewCenter; + message.sections.at(-1).blocks.push(invalidRecord); + assert.equal( + mergeChunkUpdate(current, message, { includeUnloadedChanges: false }) + .status, + "resync", + ); + assert.deepEqual([...current.blocks], before); + assert.deepEqual([...current.blocks.loadedSectionKeys()], sectionsBefore); + assert.equal(current.viewCenter, centerBefore); + } + const current = view(), + message = moveEast(); + current.blocks = new SectionBlockMap(current.blocks); + message.sections + .find((s) => s.section.join(",") === "2,0,0") + .blocks.push({ pos: [32, 0, 0], block: 0 }); + assert.equal( + mergeChunkUpdate(current, message, { includeUnloadedChanges: false }) + .status, + "resync", + ); + assert.equal(current.blocks.get("-32,0,0"), 3); +}); diff --git a/client/texture-pack.js b/client/texture-pack.js new file mode 100644 index 0000000..a282e64 --- /dev/null +++ b/client/texture-pack.js @@ -0,0 +1,70 @@ +// Declarative data only: the same resolved map is structured-cloned to mesh workers. +export const BLOCK_FACES = "@block_faces"; + +/** An edit job only needs face definitions for the materials in its neighborhood. */ +export function textureSubset(layers, materials) { + const mapping = layers.get(BLOCK_FACES); + if (!mapping) return layers; + const result = new Map(layers), sets = [], states = {}, defaults = {}, indices = new Map(); + const include = old => { + if (!Number.isInteger(old)) return undefined; + if (!indices.has(old)) { indices.set(old, sets.length); sets.push(mapping.sets[old]); } + return indices.get(old); + }; + for (const {state} of materials.values()) { + if (typeof state !== "string") continue; + const base = state.split("[")[0]; + if (Object.hasOwn(mapping.states, state)) states[state] = include(mapping.states[state]); + if (Object.hasOwn(mapping.defaults, base)) defaults[base] = include(mapping.defaults[base]); + } + result.set(BLOCK_FACES, {sets, states, defaults}); + return result; +} + +export function prepareTexturePack(descriptor, files, limits) { + const size = descriptor?.pixel_size, entries = descriptor?.textures, atlas = descriptor?.atlas; + if (!Number.isInteger(size) || size < 1 || size > 128 || !Array.isArray(entries) || !entries.length || + entries.length > (atlas ? 16384 : Math.min(256, limits.layers))) + throw Error("Некорректный размер текстурпака."); + let width = size, height = size; + if (atlas) { + if (![atlas.columns, atlas.rows].every(v => Number.isInteger(v) && v > 0) || + atlas.columns * atlas.rows < entries.length || !files.has(atlas.path)) + throw Error("Некорректный атлас текстурпака."); + width *= atlas.columns; height *= atlas.rows; + if (width > limits.size || height > limits.size) throw Error("Атлас превышает лимит видеокарты."); + } + const layers = new Map(); + for (const entry of entries) { + if (!entry || typeof entry.name !== "string" || !/^[a-z0-9_]+$/.test(entry.name) || layers.has(entry.name) || !atlas && !files.has(entry.path)) + throw Error("Некорректный ресурс текстурпака."); + layers.set(entry.name, layers.size); + } + const mapping = descriptor.block_faces; + if (mapping) { + if (!Array.isArray(mapping.sets) || mapping.sets.length > 65536 || !mapping.states || !mapping.defaults || + Object.keys(mapping.states).length > 100000) throw Error("Некорректная таблица граней."); + const sets = mapping.sets.map(faces => { + if (!Array.isArray(faces) || faces.length !== 6) throw Error("Ожидалось шесть граней."); + return faces.map(face => { + if (face === null) return null; + const tint = face.tint ?? [1, 1, 1], uv = face.uv; + if (!layers.has(face.texture) || !Array.isArray(tint) || tint.length !== 3 || + !tint.every(v => Number.isFinite(v) && v >= 0 && v <= 1) || + uv !== undefined && (!Array.isArray(uv) || uv.length !== 8 || !uv.every(v => Number.isFinite(v) && Math.abs(v) <= 256))) + throw Error("Некорректная текстура грани."); + return {layer: layers.get(face.texture), tint, cutout: face.cutout === true, ...(uv ? {uv} : {})}; + }); + }); + for (const table of [mapping.states, mapping.defaults]) + for (const [state, index] of Object.entries(table)) + if (state.length > 1024 || !Number.isInteger(index) || index < 0 || index >= sets.length) + throw Error("Некорректное состояние в таблице граней."); + layers.set(BLOCK_FACES, {sets, states: mapping.states, defaults: mapping.defaults}); + } + const overlay = descriptor.grass_overlay; + if (overlay && (!atlas || !layers.has(overlay.base) || !layers.has(overlay.overlay))) + throw Error("Некорректный слой травы."); + return {layers, width, height, atlas, size, entries, + grassOverlay: overlay ? [layers.get(overlay.base), layers.get(overlay.overlay)] : [-1, -1]}; +} diff --git a/client/view-distance.js b/client/view-distance.js new file mode 100644 index 0000000..a68f7fb --- /dev/null +++ b/client/view-distance.js @@ -0,0 +1,30 @@ +export const MIN_VIEW_DISTANCE = 2; +export const DEFAULT_VIEW_DISTANCE = 3; +export const MAX_VIEW_DISTANCE = 64; +export const WORLD_MIN_Y = -64; +export const WORLD_MAX_Y = 319; +export const VIEW_HEIGHT = WORLD_MAX_Y - WORLD_MIN_Y + 1; +export const VIEW_BUFFER = 1; +export const MAX_VIEW_WIDTH = ((MAX_VIEW_DISTANCE + VIEW_BUFFER) * 2 + 1) * 16; +export const MAX_VIEW_CELLS = MAX_VIEW_WIDTH ** 2 * VIEW_HEIGHT; +// Lighting uses local windows; raising render distance must not allocate a +// multi-gigabyte dense light volume. +export const MAX_LIGHT_CELLS = 304 ** 2 * 80; +export const MAX_VIEW_COORDINATE = 30_000_000 + (MAX_VIEW_DISTANCE + VIEW_BUFFER) * 16; + +export function normalizeViewDistance(value) { + const distance = Number(value); + return Number.isInteger(distance) && distance >= MIN_VIEW_DISTANCE && distance <= MAX_VIEW_DISTANCE + ? distance : DEFAULT_VIEW_DISTANCE; +} + +export function viewDistanceProfile(value) { + const chunks = normalizeViewDistance(value), width = (chunks * 2 + 1) * 16; + const drawRadius = Math.SQRT2 * chunks * 16 + 24; + const verticalSections = VIEW_HEIGHT / 16; + return { chunks, radius: chunks * 16, width, sections: (chunks * 2 + 1) ** 2 * verticalSections, + voxelBytes: width * width * VIEW_HEIGHT * 4, + streamWidth: width + VIEW_BUFFER * 32, streamSections: (chunks * 2 + 1 + VIEW_BUFFER * 2) ** 2 * verticalSections, + streamVoxelBytes: (width + VIEW_BUFFER * 32) ** 2 * VIEW_HEIGHT * 4, drawRadius, + farClip: Math.max(190, drawRadius + 60), fogDistance: 78 * chunks / DEFAULT_VIEW_DISTANCE }; +} diff --git a/client/world-view.js b/client/world-view.js new file mode 100644 index 0000000..403af6b --- /dev/null +++ b/client/world-view.js @@ -0,0 +1,206 @@ +import { WORLD_MIN_Y, WORLD_MAX_Y } from './view-distance.js'; +const positionKey = (pos) => pos.join(","); +const integerPosition = (pos) => + Array.isArray(pos) && pos.length === 3 && pos.every(Number.isSafeInteger); +const samePosition = (a, b) => + integerPosition(a) && + integerPosition(b) && + a.every((value, i) => value === b[i]); +const sectionOf = (pos) => pos.map((value) => Math.floor(value / 16)); + +export function getViewBounds(message) { + const center = + message.view_center || + message.spawn?.map((value) => Math.floor(value / 16) * 16); + if (!integerPosition(center)) return null; + if (integerPosition(message.view_min) && integerPosition(message.view_max)) + return { min: [...message.view_min], max: [...message.view_max], + ...(message.full_height === true && message.view_min[1] === WORLD_MIN_Y && message.view_max[1] === WORLD_MAX_Y ? {fullHeight:true} : {}) }; + // Protocol 1 servers without chunk_stream_v1 use an unaligned vertical view. + return { + min: center.map((value, i) => value - (i === 1 ? 8 : 32)), + max: center.map((value) => value + 31), + }; +} + +export function positionInView(pos, bounds) { + return ( + !bounds || + pos.every((value, i) => value >= bounds.min[i] && value <= bounds.max[i]) + ); +} + +function recordMap(records) { + const result = new Map(); + for (const record of records) { + if ( + !integerPosition(record?.pos) || + !Number.isSafeInteger(record.block) || + record.block < 0 + ) + throw Error("Invalid block record"); + if (record.block) result.set(positionKey(record.pos), record.block); + } + return result; +} + +/** Update the existing map so unchanged blocks never trigger mesh rebuilds. */ +export function mergeSnapshot(blocks, records) { + const next = recordMap(records), + changes = []; + for (const [id] of blocks) { + if (!next.has(id)) { + blocks.delete(id); + changes.push({ pos: id.split(",").map(Number), block: 0 }); + } + } + for (const [id, block] of next) { + if (blocks.get(id) !== block) { + blocks.set(id, block); + changes.push({ pos: id.split(",").map(Number), block }); + } + } + return changes; +} + +function sectionWindow(center) { + const c = sectionOf(center), + sections = new Set(); + for (let x = c[0] - 2; x <= c[0] + 1; x++) + for (let y = c[1] - 1; y <= c[1] + 1; y++) + for (let z = c[2] - 2; z <= c[2] + 1; z++) + sections.add(positionKey([x, y, z])); + return sections; +} + +function alignedCenter(center) { + return integerPosition(center) && center.every((value) => value % 16 === 0); +} + +function* entriesInSections(blocks, sections) { + if (typeof blocks.keysInSection === "function") { + for (const section of sections) + for (const key of blocks.keysInSection(section)) + yield [key, blocks.get(key), section]; + } else { + for (const [key, block] of blocks) { + const section = positionKey(sectionOf(key.split(",").map(Number))); + if (sections.has(section)) yield [key, block, section]; + } + } +} + +/** + * Validate the complete transition before mutating blocks or view metadata. + * Consumers handling section unloads separately can omit their block-zero + * records; the departing blocks are still removed from the map. + */ +export function mergeChunkUpdate( + view, + message, + { includeUnloadedChanges = true } = {}, +) { + const invalid = { status: "resync", changes: [] }; + if (message.world !== view.world) return { status: "ignored", changes: [] }; + if ( + !Number.isSafeInteger(message.revision) || + message.revision !== view.revision || + !alignedCenter(view.viewCenter) || + !samePosition(message.from_center, view.viewCenter) || + !alignedCenter(message.view_center) || + !samePosition( + message.view_min, + message.view_center.map((value, i) => value - (i === 1 ? 16 : 32)), + ) || + !samePosition( + message.view_max, + message.view_center.map((value) => value + 31), + ) || + !Array.isArray(message.unload) || + !Array.isArray(message.sections) + ) + return invalid; + const previous = sectionWindow(view.viewCenter), + next = sectionWindow(message.view_center), + departing = new Set([...previous].filter((id) => !next.has(id))), + entering = new Set([...next].filter((id) => !previous.has(id))), + unloaded = new Set(), + received = new Set(), + replacement = new Map(); + for (const pos of message.unload) { + if (!integerPosition(pos)) return invalid; + const id = positionKey(pos); + if (!departing.has(id) || unloaded.has(id)) return invalid; + unloaded.add(id); + } + for (const section of message.sections) { + if (!integerPosition(section?.section) || !Array.isArray(section.blocks)) + return invalid; + const id = positionKey(section.section), + [sx, sy, sz] = section.section; + if (!entering.has(id) || received.has(id)) return invalid; + received.add(id); + for (const record of section.blocks) { + if ( + !integerPosition(record?.pos) || + Math.floor(record.pos[0] / 16) !== sx || + Math.floor(record.pos[1] / 16) !== sy || + Math.floor(record.pos[2] / 16) !== sz || + !Number.isSafeInteger(record.block) || + record.block < 0 + ) + return invalid; + const key = positionKey(record.pos); + if (replacement.has(key)) return invalid; + replacement.set(key, { + pos: record.pos, + block: record.block, + section: id, + }); + } + } + if (unloaded.size !== departing.size || received.size !== entering.size) + return invalid; + + const changes = [], + blocks = view.blocks, + bulkUnload = + typeof blocks.keysInSection === "function" && + typeof blocks.deleteSection === "function"; + if (bulkUnload) { + for (const section of departing) { + if (includeUnloadedChanges) + for (const key of blocks.keysInSection(section)) + if (blocks.get(key) !== 0) + changes.push({ pos: key.split(",").map(Number), block: 0 }); + blocks.deleteSection(section); + } + } + for (const [id, block, section] of entriesInSections( + blocks, + bulkUnload ? entering : new Set([...departing, ...entering]), + )) { + const record = replacement.get(id), + newBlock = record?.block || 0; + if (newBlock !== block) { + if (newBlock) blocks.set(id, newBlock); + else blocks.delete(id); + if (includeUnloadedChanges || !departing.has(section)) + changes.push({ + pos: record?.pos || id.split(",").map(Number), + block: newBlock, + }); + } + replacement.delete(id); + } + for (const [id, { pos, block, section }] of replacement) { + if (!block) continue; + if (typeof blocks.setInSection === "function") + blocks.setInSection(id, block, section); + else blocks.set(id, block); + changes.push({ pos, block }); + } + view.viewCenter = [...message.view_center]; + view.viewBounds = getViewBounds(message); + return { status: "applied", changes }; +} diff --git a/crates/shacraft-compat/src/anvil.rs b/crates/shacraft-compat/src/anvil.rs index 301ee85..f66800e 100644 --- a/crates/shacraft-compat/src/anvil.rs +++ b/crates/shacraft-compat/src/anvil.rs @@ -407,6 +407,21 @@ fn world_name(dimension: &str) -> String { } pub fn import_anvil(source: &Path, destination: &Path, mode: Mode) -> Result { + import_anvil_impl(source, destination, mode, false) +} + +/// Load source sections as an immutable baseline, without per-block edit history. +/// Subsequent edits remain journaled; resetting them restores the imported map. +pub fn import_anvil_baseline(source: &Path, destination: &Path, mode: Mode) -> Result { + import_anvil_impl(source, destination, mode, true) +} + +fn import_anvil_impl( + source: &Path, + destination: &Path, + mode: Mode, + baseline: bool, +) -> Result { let stage = io::stage(destination)?; ensure!( !stage @@ -475,6 +490,16 @@ pub fn import_anvil(source: &Path, destination: &Path, mode: Mode) -> Result>>()?; + if baseline { + let cells = Box::new(std::array::from_fn(|i| registry[indices[i]])); + let nonair = cells.iter().filter(|&&block| block != 0).count(); + report.nonair_blocks += nonair as u64; + report.sections += 1; + if nonair > 0 { + store.materialize_section(&world, [x, y, z], &cells)?; + } + continue; + } let mut changes = Vec::new(); for (index, pid) in indices.into_iter().enumerate() { let block = registry[pid]; @@ -512,6 +537,9 @@ pub fn import_anvil(source: &Path, destination: &Path, mode: Mode) -> Result Result<()> { source, destination, mode, - } => import_anvil(&source, &destination, mode)?, + baseline, + } => { + if baseline { + import_anvil_baseline(&source, &destination, mode)? + } else { + import_anvil(&source, &destination, mode)? + } + } Command::ExportAnvil { store, destination, diff --git a/crates/shacraft-compat/tests/roundtrip.rs b/crates/shacraft-compat/tests/roundtrip.rs index 46c3eb2..2747b4c 100644 --- a/crates/shacraft-compat/tests/roundtrip.rs +++ b/crates/shacraft-compat/tests/roundtrip.rs @@ -1,5 +1,5 @@ use shacraft_compat::{ - Mode, export_anvil, export_schem, import_anvil, import_schem, + Mode, export_anvil, export_schem, import_anvil, import_anvil_baseline, import_schem, nbt::{self, Tag}, }; use shacraft_core::{BlockChange, WorldStore}; @@ -151,6 +151,56 @@ fn genuine_java_world_import_exact_roundtrip_edit_and_new_chunk() { assert_eq!(store.registry()[id as usize], "minecraft:stone"); } +#[test] +fn anvil_baseline_preserves_roundtrip_edits_snapshots_and_reset() { + let t = tempfile::tempdir().unwrap(); + let path = t.path().join("baseline"); + let source = fixture("java26_2-world"); + let report = import_anvil_baseline(&source, &path, Mode::Exact).unwrap(); + assert_eq!(report.nonair_blocks, 3); + assert_eq!(report.worlds["main"], 0); + let exact = t.path().join("exact"); + export_anvil(&path, None, &exact, Mode::Exact).unwrap(); + assert_tree_equal(&source, &exact); + let db = rusqlite::Connection::open(path.join("worlds.sqlite3")).unwrap(); + let history: i64 = db + .query_row("SELECT count(*) FROM operation_changes", [], |r| r.get(0)) + .unwrap(); + assert_eq!(history, 0); + drop(db); + let mut store = WorldStore::open(&path, 2).unwrap(); + let pos = [-1, -1, 31]; + let original = store.get_block("main", pos).unwrap(); + assert_ne!(original, 0); + store + .edit("main", 0, "remove", vec![BlockChange { pos, block: 0 }]) + .unwrap(); + drop(store); + assert!(export_anvil(&path, None, &t.path().join("rejected"), Mode::Exact).is_err()); + let edited = t.path().join("edited"); + export_anvil(&path, None, &edited, Mode::BestEffort).unwrap(); + let reimport = t.path().join("reimport"); + import_anvil_baseline(&edited, &reimport, Mode::Exact).unwrap(); + assert_eq!( + WorldStore::open(&reimport, 2) + .unwrap() + .get_block("main", pos) + .unwrap(), + 0 + ); + let mut store = WorldStore::open(&path, 2).unwrap(); + store.reset_world("main", 1, "reset").unwrap(); + assert_eq!(store.get_block("main", pos).unwrap(), original); + store.create_world("playable", Some("main")).unwrap(); + assert_eq!(store.get_block("playable", pos).unwrap(), original); + assert!( + store + .read_section("playable", [-1, -1, 1]) + .unwrap() + .is_some() + ); +} + #[test] fn native_new_world_export_and_schematic() { let t = tempfile::tempdir().unwrap(); diff --git a/crates/shacraft-core/src/database.rs b/crates/shacraft-core/src/database.rs index e159e02..6697825 100644 --- a/crates/shacraft-core/src/database.rs +++ b/crates/shacraft-core/src/database.rs @@ -111,6 +111,16 @@ pub(crate) fn configure(conn: &mut Connection) -> Result<()> { tx.pragma_update(None, "user_version", SCHEMA_VERSION)?; tx.commit().context("storage_commit: initialize schema")?; } + // Additive v1 extension: immutable natural terrain underneath edit overlays. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS generated_sections ( + world_id INTEGER NOT NULL REFERENCES worlds(id), + x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL, + blob_id INTEGER NOT NULL REFERENCES blobs(id), + PRIMARY KEY(world_id,x,y,z) + ) WITHOUT ROWID; + CREATE INDEX IF NOT EXISTS generated_blob_idx ON generated_sections(blob_id);", + )?; let check: String = conn.query_row("PRAGMA quick_check(1)", [], |row| row.get(0))?; if check != "ok" { bail!("storage_corrupt: {check}"); diff --git a/crates/shacraft-core/src/store.rs b/crates/shacraft-core/src/store.rs index 2893dd5..960f389 100644 --- a/crates/shacraft-core/src/store.rs +++ b/crates/shacraft-core/src/store.rs @@ -162,9 +162,14 @@ impl WorldStore { params![world.id, world.revision], )?; let id = tx.last_insert_rowid(); + tx.execute( + "INSERT INTO snapshot_sections(snapshot_id,x,y,z,blob_id) + SELECT ?1,x,y,z,blob_id FROM generated_sections WHERE world_id=?2", + params![id, world.id], + )?; if let Some(base) = world.base { tx.execute( - "INSERT INTO snapshot_sections(snapshot_id,x,y,z,blob_id) + "INSERT OR REPLACE INTO snapshot_sections(snapshot_id,x,y,z,blob_id) SELECT ?1,x,y,z,blob_id FROM snapshot_sections WHERE snapshot_id=?2", params![id, base], )?; @@ -246,6 +251,51 @@ impl WorldStore { result } + /// Read a complete stored section, including explicit all-air overrides. + /// None means no durable data exists; a procedural host may generate it. + pub fn read_section(&mut self, name: &str, pos: Pos) -> Result>> { + validate_world_name(name)?; + validate_section_pos(pos)?; + let world = lookup_world(&self.conn, name)?; + effective_blob(&self.conn, world, pos)? + .map(|id| load_decoded(&self.conn, &mut self.cache, self.registry.len(), id)) + .transpose() + } + + /// Persist the immutable procedural baseline before its first edit. This + /// materializes already-existing natural terrain and does not advance the + /// edit revision. A repeated call never overwrites a baseline or overlay. + pub fn materialize_section( + &mut self, + name: &str, + pos: Pos, + cells: &[u32; 4096], + ) -> Result { + validate_world_name(name)?; + validate_section_pos(pos)?; + ensure!( + cells.iter().all(|id| (*id as usize) < self.registry.len()), + "unknown_block in generated section" + ); + let started = Instant::now(); + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let world = lookup_world(&tx, name)?; + if effective_blob(&tx, world, pos)?.is_some() { + return Ok(false); + } + let blob = intern_blob(&tx, cells)?; + let [x, y, z] = pos; + tx.execute( + "INSERT INTO generated_sections(world_id,x,y,z,blob_id) VALUES(?1,?2,?3,?4,?5)", + params![world.id, x, y, z, blob], + )?; + tx.commit().context("storage_commit: materialize_section")?; + self.record_commit(started); + Ok(true) + } + pub fn edit( &mut self, name: &str, @@ -530,6 +580,7 @@ impl WorldStore { "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 + UNION SELECT x,y,z FROM generated_sections WHERE world_id=?1 ) 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", @@ -579,6 +630,7 @@ impl WorldStore { SELECT b.id FROM blobs b WHERE NOT EXISTS(SELECT 1 FROM world_sections w WHERE w.blob_id=b.id) AND NOT EXISTS(SELECT 1 FROM snapshot_sections s WHERE s.blob_id=b.id) + AND NOT EXISTS(SELECT 1 FROM generated_sections g WHERE g.blob_id=b.id) LIMIT ?1)", [max_blobs as i64], )?; @@ -630,6 +682,7 @@ impl WorldStore { "snapshot_count": count("snapshots"), "immutable_blob_count": count("blobs"), "overlay_section_count": count("world_sections"), + "materialized_natural_sections": count("generated_sections"), "operation_count": count("operations"), "history_change_count": count("operation_changes"), "database_bytes": file_size("worlds.sqlite3"), @@ -749,6 +802,18 @@ fn canonical_state(state: &str) -> Result { } } +fn validate_section_pos(pos: Pos) -> Result<()> { + for axis in pos { + let min = axis.checked_mul(16).context("invalid section coordinate")?; + let max = min.checked_add(15).context("invalid section coordinate")?; + ensure!( + min >= -COORD_LIMIT && max <= COORD_LIMIT, + "section outside coordinate limits" + ); + } + Ok(()) +} + fn locate(pos: Pos) -> (SectionPos, usize) { let section = pos.map(|v| v.div_euclid(16)); let [x, y, z] = pos.map(|v| v.rem_euclid(16) as usize); @@ -798,6 +863,23 @@ fn base_blob(conn: &Connection, base: Option, [x, y, z]: SectionPos) -> Res } } +fn natural_blob( + conn: &Connection, + world: World, + pos @ [x, y, z]: SectionPos, +) -> Result> { + if let Some(id) = base_blob(conn, world.base, pos)? { + return Ok(Some(id)); + } + Ok(conn + .query_row( + "SELECT blob_id FROM generated_sections WHERE world_id=?1 AND x=?2 AND y=?3 AND z=?4", + params![world.id, x, y, z], + |r| r.get(0), + ) + .optional()?) +} + fn effective_blob( conn: &Connection, world: World, @@ -812,7 +894,7 @@ fn effective_blob( .optional()?; match own { Some(id) => Ok(Some(id)), - None => base_blob(conn, world.base, pos), + None => natural_blob(conn, world, pos), } } @@ -959,7 +1041,7 @@ fn apply_changes( if !section_changed { continue; } - let baseline = if let Some(id) = base_blob(tx, world.base, pos)? { + let baseline = if let Some(id) = natural_blob(tx, world, pos)? { load_decoded(tx, cache, registry_len, id)? } else { Box::new([0; CELL_COUNT]) diff --git a/crates/shacraft-core/tests/generated.rs b/crates/shacraft-core/tests/generated.rs new file mode 100644 index 0000000..971cab6 --- /dev/null +++ b/crates/shacraft-core/tests/generated.rs @@ -0,0 +1,78 @@ +use shacraft_core::{BlockChange, WorldStore}; +#[test] +fn procedural_baseline_preserves_deletion_undo_reset_clone_and_restart() { + let dir = tempfile::tempdir().unwrap(); + let stone; + { + let mut store = WorldStore::open(dir.path(), 2).unwrap(); + stone = store.register_block("minecraft:stone").unwrap(); + store.create_world("natural", None).unwrap(); + let cells = [stone; 4096]; + assert!( + store + .materialize_section("natural", [-1, -4, 1], &cells) + .unwrap() + ); + assert_eq!(store.revision("natural").unwrap(), 0); + assert!( + !store + .materialize_section("natural", [-1, -4, 1], &[0; 4096]) + .unwrap() + ); + let pos = [-1, -64, 16]; + assert_eq!(store.get_block("natural", pos).unwrap(), stone); + store + .edit("natural", 0, "mine", vec![BlockChange { pos, block: 0 }]) + .unwrap(); + assert_eq!(store.get_block("natural", pos).unwrap(), 0); + store.create_world("copy", Some("natural")).unwrap(); + assert_eq!(store.get_block("copy", pos).unwrap(), 0); + assert_eq!(store.get_block("copy", [-2, -64, 16]).unwrap(), stone); + store.undo("natural", 1, "undo", "mine").unwrap(); + assert_eq!(store.get_block("natural", pos).unwrap(), stone); + store + .edit( + "natural", + 2, + "mine-again", + vec![BlockChange { pos, block: 0 }], + ) + .unwrap(); + store.reset_world("natural", 3, "reset").unwrap(); + assert_eq!(store.get_block("natural", pos).unwrap(), stone); + assert_eq!(store.get_block("copy", pos).unwrap(), 0); + store.collect_garbage(100).unwrap(); + } + let mut store = WorldStore::open(dir.path(), 2).unwrap(); + assert_eq!(store.get_block("natural", [-1, -64, 16]).unwrap(), stone); + assert_eq!(store.get_block("copy", [-1, -64, 16]).unwrap(), 0); + assert!(store.read_section("copy", [-1, -4, 1]).unwrap().is_some()); + assert_eq!( + store.section_positions("natural", None, 16).unwrap(), + vec![[-1, -4, 1]] + ); +} +#[test] +fn generated_input_is_checked_before_mutation_and_air_is_explicit() { + let dir = tempfile::tempdir().unwrap(); + let mut store = WorldStore::open(dir.path(), 1).unwrap(); + store.create_world("natural", None).unwrap(); + assert!( + store + .materialize_section("natural", [i32::MAX, 0, 0], &[0; 4096]) + .is_err() + ); + assert!( + store + .materialize_section("natural", [0, 0, 0], &[u32::MAX; 4096]) + .is_err() + ); + assert!(store.read_section("natural", [0, 0, 0]).unwrap().is_none()); + store + .materialize_section("natural", [0, 0, 0], &[0; 4096]) + .unwrap(); + assert_eq!( + *store.read_section("natural", [0, 0, 0]).unwrap().unwrap(), + [0; 4096] + ); +} diff --git a/crates/shacraft-physics/Cargo.toml b/crates/shacraft-physics/Cargo.toml new file mode 100644 index 0000000..3271dcd --- /dev/null +++ b/crates/shacraft-physics/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "shacraft-physics" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +crate-type = ["rlib", "cdylib"] + +[dependencies] +serde.workspace = true +serde_json.workspace = true diff --git a/crates/shacraft-physics/src/lib.rs b/crates/shacraft-physics/src/lib.rs new file mode 100644 index 0000000..c390d42 --- /dev/null +++ b/crates/shacraft-physics/src/lib.rs @@ -0,0 +1,1222 @@ +//! Shared, fixed-tick player locomotion for the server and browser. +//! +//! Distances are blocks, velocities are blocks per 50 ms tick, and angles are +//! radians in Shacraft's coordinate system (yaw zero faces negative Z). This is +//! an independent implementation; its tuning targets Minecraft Java 26.2. +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +const EPSILON: f64 = 1.0e-7; +const WIDTH: f64 = 0.6000000238418579; +const GRAVITY: f64 = 0.08; +const AIR_DRAG: f64 = 0.9800000190734863; +const HORIZONTAL_DRAG: f64 = 0.9100000262260437; +const INPUT_SCALE: f64 = 0.9800000190734863; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Pose { + #[default] + Standing, + Crouching, + Swimming, +} +impl Pose { + pub fn height(self) -> f64 { + match self { + Self::Standing => 1.7999999523162842, + Self::Crouching => 1.5, + Self::Swimming => 0.6000000238418579, + } + } + pub fn eye_height(self) -> f64 { + match self { + Self::Standing => 1.6200000047683716, + Self::Crouching => 1.2699999809265137, + Self::Swimming => 0.4000000059604645, + } + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct PhysicsBody { + pub position: [f64; 3], + pub velocity: [f64; 3], + pub on_ground: bool, + pub pose: Pose, + pub sprinting: bool, + pub flying: bool, + pub in_water: bool, + pub in_lava: bool, + pub on_climbable: bool, + pub horizontal_collision: bool, + pub vertical_collision: bool, + pub fall_distance: f64, + pub jump_cooldown: u8, + pub swimming: bool, + pub submerged: bool, +} +impl PhysicsBody { + pub fn height(&self) -> f64 { + self.pose.height() + } + pub fn width(&self) -> f64 { + WIDTH + } + pub fn eye_height(&self) -> f64 { + self.pose.eye_height() + } + fn bounds(&self) -> Aabb { + Aabb::body(self.position, self.height()) + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct Controls { + pub forward: f64, + pub strafe: f64, + pub yaw: f64, + pub pitch: f64, + pub jump: bool, + pub sprint: bool, + pub sneak: bool, + pub fly_toggle: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Aabb { + pub min: [f64; 3], + pub max: [f64; 3], +} +impl Aabb { + pub fn new(min: [f64; 3], max: [f64; 3]) -> Self { + Self { min, max } + } + pub fn unit() -> Self { + Self::new([0.; 3], [1.; 3]) + } + fn body(position: [f64; 3], height: f64) -> Self { + Self { + min: [ + position[0] - WIDTH / 2., + position[1], + position[2] - WIDTH / 2., + ], + max: [ + position[0] + WIDTH / 2., + position[1] + height, + position[2] + WIDTH / 2., + ], + } + } + fn translated(self, offset: [f64; 3]) -> Self { + Self { + min: std::array::from_fn(|i| self.min[i] + offset[i]), + max: std::array::from_fn(|i| self.max[i] + offset[i]), + } + } + fn intersects(&self, other: &Self) -> bool { + (0..3).all(|i| self.max[i] > other.min[i] + EPSILON && self.min[i] < other.max[i] - EPSILON) + } + fn expanded(self, movement: [f64; 3]) -> Self { + Self { + min: std::array::from_fn(|i| self.min[i] + movement[i].min(0.)), + max: std::array::from_fn(|i| self.max[i] + movement[i].max(0.)), + } + } + fn deflated(self, amount: f64) -> Self { + Self { + min: self.min.map(|v| v + amount), + max: self.max.map(|v| v - amount), + } + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct CollisionBlock { + pub pos: [i32; 3], + pub state: String, + pub collision: Vec, +} +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct PhysicsWorld { + pub blocks: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub struct PhysicsSettings { + pub allow_flight: bool, + pub frozen: bool, + pub movement_speed: f64, + pub flight_speed: f64, + pub step_height: f64, + pub jump_impulse: Option, + pub speed_level: u8, + pub slowness_level: u8, + pub jump_boost_level: u8, + pub levitation_level: u8, + pub slow_falling: bool, + pub dolphins_grace: bool, + pub depth_strider: u8, + pub leather_boots: bool, +} +impl Default for PhysicsSettings { + fn default() -> Self { + Self { + allow_flight: false, + frozen: false, + movement_speed: 0.10000000149011612, + flight_speed: 0.05000000074505806, + step_height: 0.6000000238418579, + jump_impulse: None, + speed_level: 0, + slowness_level: 0, + jump_boost_level: 0, + levitation_level: 0, + slow_falling: false, + dolphins_grace: false, + depth_strider: 0, + leather_boots: false, + } + } +} +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct StepRequest { + pub body: PhysicsBody, + pub input: Controls, + pub world: PhysicsWorld, + pub settings: PhysicsSettings, +} + +fn name(state: &str) -> &str { + state + .split('[') + .next() + .unwrap_or(state) + .strip_prefix("minecraft:") + .unwrap_or(state.split('[').next().unwrap_or(state)) +} +fn property<'a>(state: &'a str, key: &str) -> Option<&'a str> { + state + .split_once('[')? + .1 + .trim_end_matches(']') + .split(',') + .find_map(|part| { + let (k, v) = part.split_once('=')?; + (k == key).then_some(v) + }) +} +fn is_climbable(state: &str) -> bool { + matches!( + name(state), + "ladder" + | "vine" + | "scaffolding" + | "weeping_vines" + | "weeping_vines_plant" + | "twisting_vines" + | "twisting_vines_plant" + ) +} +fn fluid(state: &str, water: bool) -> Option { + let block = name(state); + let present = if water { + block == "water" + || block == "bubble_column" + || matches!(block, "kelp" | "kelp_plant" | "seagrass" | "tall_seagrass") + || property(state, "waterlogged") == Some("true") + } else { + block == "lava" + }; + if !present { + return None; + } + let level = property(state, "level") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + Some(if level >= 8 { + (8.0_f32 / 9.0_f32) as f64 + } else { + ((8.0_f32 - f32::from(level)) / 9.0_f32) as f64 + }) +} + +struct WorldView<'a> { + blocks: &'a [CollisionBlock], + lookup: HashMap<[i32; 3], &'a CollisionBlock>, + boxes: Vec, +} +impl<'a> WorldView<'a> { + fn new( + world: &'a PhysicsWorld, + body: &PhysicsBody, + input: &Controls, + settings: &PhysicsSettings, + ) -> Self { + let lookup: HashMap<_, _> = world.blocks.iter().map(|b| (b.pos, b)).collect(); + let mut boxes = Vec::new(); + // Context-sensitive surfaces are generated for this body rather than + // inherited from the catalog's empty-entity collision measurement. + for block in &world.blocks { + let local = match name(&block.state) { + "scaffolding" => { + if !input.sneak && body.position[1] >= block.pos[1] as f64 + 1. - EPSILON { + vec![Aabb::new([0., 14. / 16., 0.], [1.; 3])] + } else { + Vec::new() + } + } + "powder_snow" => { + if (settings.leather_boots + && !input.sneak + && body.position[1] >= block.pos[1] as f64 + 1. - EPSILON) + || body.fall_distance > 2.5 + { + vec![Aabb::new( + [0.; 3], + [1., if settings.leather_boots { 1. } else { 0.9 }, 1.], + )] + } else { + Vec::new() + } + } + _ => block.collision.clone(), + }; + let offset = block.pos.map(f64::from); + boxes.extend(local.into_iter().map(|b| b.translated(offset))); + } + Self { + blocks: &world.blocks, + lookup, + boxes, + } + } + fn get(&self, pos: [i32; 3]) -> Option<&CollisionBlock> { + self.lookup.get(&pos).copied() + } + fn at(&self, point: [f64; 3]) -> Option<&CollisionBlock> { + self.get(point.map(|v| v.floor() as i32)) + } + fn free(&self, bounds: Aabb) -> bool { + !self.boxes.iter().any(|b| b.intersects(&bounds)) + } + fn intersecting(&self, bounds: Aabb) -> impl Iterator + '_ { + let mut blocks: Vec<_> = self + .blocks + .iter() + .filter(|b| { + Aabb::unit() + .translated(b.pos.map(f64::from)) + .intersects(&bounds) + }) + .collect(); + // Source map/serialization order differs between native and browser. + // Java visits X, then Y, then Z; fluid depth accumulation and callbacks + // depend on that order when a body overlaps several different cells. + blocks.sort_unstable_by_key(|block| block.pos); + blocks.into_iter() + } + fn fluid_height(&self, block: &CollisionBlock, water: bool) -> Option { + let own = fluid(&block.state, water)?; + let above = [block.pos[0], block.pos[1] + 1, block.pos[2]]; + Some( + if self + .get(above) + .and_then(|b| fluid(&b.state, water)) + .is_some() + { + 1. + } else { + own + }, + ) + } + fn fluid_state(&self, body: &PhysicsBody, water: bool) -> (bool, bool, f64, [f64; 3]) { + let bounds = body.bounds().deflated(0.001); + let mut depth: f64 = 0.; + let mut submerged = false; + let mut current = [0.; 3]; + let mut count = 0.; + for block in self.intersecting(bounds) { + let Some(height) = self.fluid_height(block, water) else { + continue; + }; + let top = block.pos[1] as f64 + height; + if top <= bounds.min[1] { + continue; + } + depth = depth.max(top - body.position[1]); + if top > body.position[1] + body.eye_height() { + let eye = [ + body.position[0], + body.position[1] + body.eye_height(), + body.position[2], + ] + .map(|v| v.floor() as i32); + submerged |= eye == block.pos; + } + let flow = self.flow(block, water); + let scale = if depth < 0.4 { depth } else { 1. }; + for axis in 0..3 { + current[axis] += flow[axis] * scale; + } + count += 1.; + } + if current.iter().map(|v| v * v).sum::() < 1.0e-5 { + current = [0.; 3]; + } + if count > 0. { + for value in &mut current { + *value /= count; + } + } + (depth > 0., submerged, depth, current) + } + fn flow(&self, block: &CollisionBlock, water: bool) -> [f64; 3] { + let own = fluid(&block.state, water).unwrap_or(0.); + let mut flow = [0.; 3]; + for (dx, dz) in [(-1, 0), (1, 0), (0, -1), (0, 1)] { + let next = [block.pos[0] + dx, block.pos[1], block.pos[2] + dz]; + let neighbor = self.get(next); + let difference = if let Some(height) = neighbor.and_then(|b| fluid(&b.state, water)) { + own - height + } else if neighbor.is_none_or(|b| b.collision.is_empty()) { + self.get([next[0], next[1] - 1, next[2]]) + .and_then(|b| fluid(&b.state, water)) + .map_or(0., |height| own - (height - 8. / 9.)) + } else { + 0. + }; + flow[0] += dx as f64 * difference; + flow[2] += dz as f64 * difference; + } + let length = (flow[0] * flow[0] + flow[2] * flow[2]).sqrt(); + if length > 0. { + flow[0] /= length; + flow[2] /= length; + } + if property(&block.state, "level") + .and_then(|s| s.parse::().ok()) + .is_some_and(|v| v >= 8) + { + let against_wall = [(-1, 0), (1, 0), (0, -1), (0, 1)].iter().any(|&(dx, dz)| { + self.get([block.pos[0] + dx, block.pos[1], block.pos[2] + dz]) + .is_some_and(|b| !b.collision.is_empty()) + }); + if against_wall { + flow[1] = -6.; + let length = (flow.iter().map(|v| v * v).sum::()).sqrt(); + for value in &mut flow { + *value /= length; + } + } + } + flow + } +} + +fn clip_axis(bounds: Aabb, boxes: &[Aabb], axis: usize, mut movement: f64) -> f64 { + if movement.abs() < EPSILON { + return 0.; + } + for obstacle in boxes { + if !(0..3).filter(|&i| i != axis).all(|i| { + bounds.max[i] > obstacle.min[i] + EPSILON && bounds.min[i] < obstacle.max[i] - EPSILON + }) { + continue; + } + if movement > 0. && bounds.max[axis] <= obstacle.min[axis] + EPSILON { + movement = movement.min((obstacle.min[axis] - bounds.max[axis]).max(0.)); + } else if movement < 0. && bounds.min[axis] >= obstacle.max[axis] - EPSILON { + movement = movement.max((obstacle.max[axis] - bounds.min[axis]).min(0.)); + } + } + movement +} +fn collide(bounds: Aabb, movement: [f64; 3], boxes: &[Aabb]) -> [f64; 3] { + let mut adjusted = movement; + let mut bounds = bounds; + adjusted[1] = clip_axis(bounds, boxes, 1, adjusted[1]); + bounds = bounds.translated([0., adjusted[1], 0.]); + // The major horizontal component is resolved first, matching corner slides. + let axes = if movement[0].abs() < movement[2].abs() { + [2, 0] + } else { + [0, 2] + }; + for axis in axes { + adjusted[axis] = clip_axis(bounds, boxes, axis, adjusted[axis]); + let mut offset = [0.; 3]; + offset[axis] = adjusted[axis]; + bounds = bounds.translated(offset); + } + adjusted +} +fn horizontal_length(v: [f64; 3]) -> f64 { + v[0] * v[0] + v[2] * v[2] +} +fn stepped_move( + bounds: Aabb, + movement: [f64; 3], + boxes: &[Aabb], + height: f64, + grounded: bool, +) -> [f64; 3] { + let height = height as f32 as f64; + let flat = collide(bounds, movement, boxes); + if height <= 0. + || !(grounded || movement[1] < 0. && flat[1] != movement[1]) + || (flat[0] == movement[0] && flat[2] == movement[2]) + { + return flat; + } + // Java 26.2 tries the actual collider Y planes in ascending order. The + // lowest useful step leaves more headroom than an unconditional .6 lift. + let base = if movement[1] < 0. && flat[1] != movement[1] { + bounds.translated([0., flat[1], 0.]) + } else { + bounds + }; + let swept = base.expanded([movement[0], height, movement[2]]); + let mut candidates = Vec::new(); + for obstacle in boxes.iter().filter(|obstacle| obstacle.intersects(&swept)) { + for plane in [obstacle.min[1], obstacle.max[1]] { + let candidate = (plane - base.min[1]) as f32; + if candidate >= 0. && candidate != flat[1] as f32 && candidate as f64 <= height { + candidates.push(candidate); + } + } + } + candidates.sort_by(f32::total_cmp); + candidates.dedup(); + for height in candidates { + let mut step = collide(base, [movement[0], height as f64, movement[2]], boxes); + if horizontal_length(step) > horizontal_length(flat) { + step[1] += base.min[1] - bounds.min[1]; + return step; + } + } + flat +} +fn reduce_edge(mut distance: f64) -> f64 { + if distance.abs() <= 0.05 { + 0. + } else { + distance -= distance.signum() * 0.05; + distance + } +} +fn avoid_edge(bounds: Aabb, mut movement: [f64; 3], boxes: &[Aabb], step_height: f64) -> [f64; 3] { + let supported = |x, z| { + !boxes + .iter() + .all(|b| !b.intersects(&bounds.translated([x, -step_height, z]))) + }; + while movement[0] != 0. && !supported(movement[0], 0.) { + movement[0] = reduce_edge(movement[0]); + } + while movement[2] != 0. && !supported(0., movement[2]) { + movement[2] = reduce_edge(movement[2]); + } + while movement[0] != 0. && movement[2] != 0. && !supported(movement[0], movement[2]) { + movement[0] = reduce_edge(movement[0]); + movement[2] = reduce_edge(movement[2]); + } + movement +} +fn accelerate(velocity: &mut [f64; 3], forward: f64, strafe: f64, yaw: f64, amount: f64) { + let length = (forward * forward + strafe * strafe).sqrt().max(1.); + if length < EPSILON { + return; + } + let forward = forward / length * amount; + let strafe = strafe / length * amount; + let (sin, cos) = yaw.sin_cos(); + velocity[0] += sin * forward + cos * strafe; + velocity[2] += -cos * forward + sin * strafe; +} +fn clamp_finite(value: f64, min: f64, max: f64) -> f64 { + if value.is_finite() { + value.clamp(min, max) + } else { + 0. + } +} + +/// Advance one fixed 20 Hz tick. Callers supply authoritative nearby block +/// states, including water and climbable cells with empty collision geometry. +pub fn step( + body: &mut PhysicsBody, + input: &Controls, + world: &PhysicsWorld, + settings: &PhysicsSettings, +) { + if body + .position + .iter() + .chain(&body.velocity) + .any(|v| !v.is_finite()) + { + body.position = body.position.map(|v| if v.is_finite() { v } else { 0. }); + body.velocity = [0.; 3]; + } + if settings.frozen { + body.velocity = [0.; 3]; + body.sprinting = false; + return; + } + let input = Controls { + forward: clamp_finite(input.forward, -1., 1.), + strafe: clamp_finite(input.strafe, -1., 1.), + yaw: clamp_finite(input.yaw, -1.0e8, 1.0e8), + pitch: clamp_finite( + input.pitch, + -std::f64::consts::FRAC_PI_2, + std::f64::consts::FRAC_PI_2, + ), + ..input.clone() + }; + if !settings.allow_flight { + body.flying = false; + } + if input.fly_toggle && settings.allow_flight { + body.flying = !body.flying; + // The explicit flight shortcut also works while standing still. A + // resting body's small downward gravity remainder must not make the + // same tick count as landing and immediately cancel its new flight. + if body.flying && body.on_ground { + body.velocity[1] = body.velocity[1].max(0.); + body.on_ground = false; + } + } + if body.jump_cooldown > 0 { + body.jump_cooldown -= 1; + } + if !input.jump { + body.jump_cooldown = 0; + } + // Vanilla zeroes tiny residual motion before integrating the next input. + if horizontal_length(body.velocity) < 9.0e-6 { + body.velocity[0] = 0.; + body.velocity[2] = 0.; + } + if body.velocity[1].abs() < 0.003 { + body.velocity[1] = 0.; + } + let view = WorldView::new(world, body, &input, settings); + let (water, submerged, water_depth, water_flow) = view.fluid_state(body, true); + let (lava, _, lava_depth, lava_flow) = view.fluid_state(body, false); + body.in_water = water && !body.flying; + body.in_lava = lava && !body.flying; + body.submerged = submerged && !body.flying; + body.on_climbable = !body.flying + && view + .intersecting(body.bounds().deflated(EPSILON)) + .any(|b| is_climbable(&b.state)); + body.sprinting = input.sprint + && !input.sneak + && (input.forward > 0.8 || body.swimming && input.forward > 0.) + && !body.horizontal_collision; + body.swimming = + !body.flying && body.sprinting && body.in_water && (body.submerged || body.swimming); + let desired = if body.swimming { + Pose::Swimming + } else if input.sneak && !body.flying { + Pose::Crouching + } else { + Pose::Standing + }; + body.pose = if view.free(Aabb::body(body.position, desired.height())) { + desired + } else if view.free(Aabb::body(body.position, Pose::Crouching.height())) { + Pose::Crouching + } else { + Pose::Swimming + }; + let mut forward = input.forward * INPUT_SCALE; + let mut strafe = input.strafe * INPUT_SCALE; + if body.pose == Pose::Crouching && !body.flying { + forward *= 0.3; + strafe *= 0.3; + } + let was_grounded = body.on_ground; + let under = view.at([ + body.position[0], + body.position[1] - 0.5000001, + body.position[2], + ]); + let near_under = view.at([ + body.position[0], + body.position[1] - 0.2000001, + body.position[2], + ]); + let ground = near_under + .or(under) + .map(|b| name(&b.state)) + .unwrap_or("air"); + let friction = match ground { + "ice" | "packed_ice" | "frosted_ice" => 0.9800000190734863, + "blue_ice" => 0.9890000224113464, + "slime_block" => 0.800000011920929, + _ => 0.6000000238418579, + }; + let speed = (settings.movement_speed.clamp(0., 10.) + * (1. + 0.2 * f64::from(settings.speed_level)) + * (1. - 0.15 * f64::from(settings.slowness_level)).max(0.) + * if body.sprinting { + 1.300000011920929 + } else { + 1. + }) as f32 as f64; + if body.in_water || body.in_lava { + let flow = if body.in_water { water_flow } else { lava_flow }; + let strength = if body.in_water { + 0.014 + } else { + 0.0023333333333333335 + }; + let mut push = flow.map(|value| value * strength); + let length = push.iter().map(|value| value * value).sum::().sqrt(); + if body.velocity[0].abs() < 0.003 + && body.velocity[2].abs() < 0.003 + && length > 0. + && length < 0.0045 + { + for value in &mut push { + *value *= 0.0045 / length; + } + } + for (velocity, current) in body.velocity.iter_mut().zip(push) { + *velocity += current; + } + } + if !body.flying && input.jump { + let liquid_jump = body.in_water && (!was_grounded || water_depth > 0.4) + || body.in_lava && (!was_grounded || lava_depth > 0.4); + if liquid_jump { + body.velocity[1] += 0.04; + } else if (was_grounded || body.in_water && water_depth <= 0.4) && body.jump_cooldown == 0 { + let factor = if ground == "honey_block" { 0.5 } else { 1. }; + let jump = settings + .jump_impulse + .filter(|v| v.is_finite()) + .unwrap_or(0.41999998688697815 * factor) + + 0.1 * f64::from(settings.jump_boost_level); + body.velocity[1] = body.velocity[1].max(jump); + if body.sprinting { + body.velocity[0] += input.yaw.sin() * 0.2; + body.velocity[2] -= input.yaw.cos() * 0.2; + } + body.jump_cooldown = 10; + } + } + if body.flying { + let amount = settings.flight_speed.clamp(0., 2.) * if body.sprinting { 2. } else { 1. }; + accelerate(&mut body.velocity, forward, strafe, input.yaw, amount); + body.velocity[1] += ((f32::from(input.jump) - f32::from(input.sneak)) + * settings.flight_speed as f32 + * 3.0_f32) as f64; + } else if body.in_water || body.in_lava { + if input.sneak { + body.velocity[1] -= 0.04; + } + let mut acceleration = 0.019999999552965164; + if body.in_water { + let depth_strider = + f64::from(settings.depth_strider.min(3)) * if body.on_ground { 1. } else { 0.5 }; + acceleration += (speed - acceleration) * depth_strider / 3.; + if body.swimming { + let look_y = input.pitch.sin(); + let factor = if look_y < -0.2 { 0.085 } else { 0.06 }; + if look_y <= 0. + || input.jump + || view + .at([ + body.position[0], + body.position[1] + 1. - 0.1, + body.position[2], + ]) + .and_then(|b| fluid(&b.state, true)) + .is_some() + { + body.velocity[1] += (look_y - body.velocity[1]) * factor; + } + } + } + accelerate(&mut body.velocity, forward, strafe, input.yaw, acceleration); + } else { + let acceleration = if was_grounded { + if friction > 0.6 { + let friction = friction as f32; + ((speed as f32) * (0.21600002_f32 / ((friction * friction) * friction))) as f64 + } else { + speed + } + } else if body.sprinting { + 0.025999998673796654 + } else { + 0.019999999552965164 + }; + accelerate(&mut body.velocity, forward, strafe, input.yaw, acceleration); + } + if body.on_climbable { + body.fall_distance = 0.; + body.velocity[0] = body.velocity[0].clamp(-0.15, 0.15); + body.velocity[2] = body.velocity[2].clamp(-0.15, 0.15); + body.velocity[1] = body.velocity[1].max(-0.15); + if input.sneak + && body.velocity[1] < 0. + && !view + .intersecting(body.bounds()) + .any(|b| name(&b.state) == "scaffolding") + { + body.velocity[1] = 0.; + } + } + let mut stuck = None; + for block in view.intersecting(body.bounds().deflated(0.001)) { + match name(&block.state) { + "cobweb" if !body.flying => stuck = Some([0.25, 0.05, 0.25]), + "sweet_berry_bush" if stuck.is_none() && !body.flying => stuck = Some([0.8, 0.75, 0.8]), + "powder_snow" if stuck.is_none() && !body.flying => stuck = Some([0.9, 1.5, 0.9]), + _ => {} + } + } + let mut movement = body.velocity; + if let Some(factors) = stuck { + for i in 0..3 { + movement[i] *= factors[i]; + } + body.velocity = [0.; 3]; + } + if input.sneak + && !body.flying + && !body.swimming + && (was_grounded + || body.fall_distance < settings.step_height + && !view.free(body.bounds().translated([0., -settings.step_height, 0.]))) + { + movement = avoid_edge(body.bounds(), movement, &view.boxes, settings.step_height); + } + let requested = movement; + let moved = stepped_move( + body.bounds(), + requested, + &view.boxes, + if body.flying { + 0. + } else { + settings.step_height.clamp(0., 2.) + }, + was_grounded && !body.flying, + ); + for (position, distance) in body.position.iter_mut().zip(moved) { + *position += distance; + } + body.horizontal_collision = + (requested[0] - moved[0]).abs() > EPSILON || (requested[2] - moved[2]).abs() > EPSILON; + body.vertical_collision = (requested[1] - moved[1]).abs() > EPSILON; + body.on_ground = body.vertical_collision && requested[1] < 0.; + if body.horizontal_collision { + body.sprinting = false; + } + for axis in [0, 2] { + if (requested[axis] - moved[axis]).abs() > EPSILON { + body.velocity[axis] = 0.; + } + } + let landed_ground = view + .at([body.position[0], body.position[1] - 0.2, body.position[2]]) + .map(|b| name(&b.state)) + .unwrap_or(ground); + if body.vertical_collision { + let restitution = match landed_ground { + "slime_block" => 1., + block if block.ends_with("_bed") || block == "bed" => 0.75, + _ => 0., + }; + let gravity = if settings.slow_falling && body.velocity[1] <= 0. { + 0.01 + } else { + GRAVITY + }; + body.velocity[1] = if requested[1] < 0. && !input.sneak && !body.flying { + restitution_velocity(body.velocity[1], moved[1], gravity, restitution) + } else { + 0. + }; + } + if body.on_ground || body.in_water || body.on_climbable || body.flying { + body.fall_distance = 0.; + } else if moved[1] < 0. { + body.fall_distance -= moved[1]; + } + if body.on_climbable && (body.horizontal_collision || input.jump) { + body.velocity[1] = 0.2; + } + let gravity = if settings.slow_falling && body.velocity[1] <= 0. { + body.fall_distance = 0.; + 0.01 + } else { + GRAVITY + }; + if body.flying { + body.velocity[0] *= HORIZONTAL_DRAG; + body.velocity[2] *= HORIZONTAL_DRAG; + body.velocity[1] *= 0.6; + body.fall_distance = 0.; + if body.on_ground && !input.jump { + body.flying = false; + } + } else if body.in_water { + let mut drag = if body.sprinting { 0.9 } else { 0.8 }; + let depth_strider = + f64::from(settings.depth_strider.min(3)) * if was_grounded { 1. } else { 0.5 }; + drag += (0.54600006 - drag) * depth_strider / 3.; + if settings.dolphins_grace { + drag = 0.96; + } + body.velocity[0] *= drag; + body.velocity[2] *= drag; + body.velocity[1] = fluid_fall(body.velocity[1] * 0.8, gravity, body.sprinting); + if body.horizontal_collision + && view.free( + body.bounds() + .translated([body.velocity[0], 0.6, body.velocity[2]]), + ) + && water_depth < 1. + { + body.velocity[1] = 0.3; + } + } else if body.in_lava { + if lava_depth <= 0.4 { + body.velocity[0] *= 0.5; + body.velocity[2] *= 0.5; + body.velocity[1] = fluid_fall(body.velocity[1] * 0.8, gravity, body.sprinting); + } else { + for value in &mut body.velocity { + *value *= 0.5; + } + } + body.velocity[1] -= gravity / 4.; + if body.horizontal_collision + && view.free( + body.bounds() + .translated([body.velocity[0], 0.6, body.velocity[2]]), + ) + && lava_depth < 1. + { + body.velocity[1] = 0.3; + } + } else { + if settings.levitation_level > 0 { + body.velocity[1] += + (0.05 * f64::from(settings.levitation_level) - body.velocity[1]) * 0.2; + body.fall_distance = 0.; + } else { + body.velocity[1] -= gravity; + } + body.velocity[1] *= AIR_DRAG; + let horizontal_drag = if was_grounded { + ((friction as f32) * (HORIZONTAL_DRAG as f32)) as f64 + } else { + HORIZONTAL_DRAG + }; + body.velocity[0] *= horizontal_drag; + body.velocity[2] *= horizontal_drag; + } + if !body.flying { + if matches!(landed_ground, "soul_sand" | "honey_block") { + body.velocity[0] *= 0.4000000059604645; + body.velocity[2] *= 0.4000000059604645; + } + if landed_ground == "slime_block" + && body.on_ground + && !input.sneak + && body.velocity[1].abs() < 0.1 + { + let factor = 0.4 + body.velocity[1].abs() * 0.2; + body.velocity[0] *= factor; + body.velocity[2] *= factor; + } + block_effects(body, &view, &input); + } +} +/// Apply an external impulse in blocks per tick; useful for explosions, +/// combat knockback, or server-authored launch pads. Invalid components are +/// ignored rather than poisoning subsequent simulation frames. +pub fn apply_impulse(body: &mut PhysicsBody, impulse: [f64; 3]) { + for (velocity, impulse) in body.velocity.iter_mut().zip(impulse) { + if impulse.is_finite() { + *velocity += impulse.clamp(-64., 64.); + } + } + if impulse[1] > 0. { + body.on_ground = false; + } +} +fn restitution_velocity(incoming: f64, moved: f64, gravity: f64, restitution: f64) -> f64 { + if -incoming < gravity || restitution == 0. { + return 0.; + } + let fraction = (moved / incoming).clamp(0., 1.); + (fraction * gravity - incoming) * (1. + fraction * (AIR_DRAG - 1.)) * restitution +} +fn fluid_fall(vertical: f64, gravity: f64, sprinting: bool) -> f64 { + if sprinting { + return vertical; + } + if vertical <= 0. + && (vertical - 0.005).abs() >= 0.003 + && (vertical - gravity / 16.).abs() < 0.003 + { + -0.003 + } else { + vertical - gravity / 16. + } +} +fn block_effects(body: &mut PhysicsBody, world: &WorldView<'_>, input: &Controls) { + let bounds = body.bounds().deflated(0.001); + for block in world.intersecting(bounds) { + match name(&block.state) { + "bubble_column" => { + let down = property(&block.state, "drag") == Some("true"); + let above = world.get([block.pos[0], block.pos[1] + 1, block.pos[2]]); + let surface = + above.is_none_or(|b| fluid(&b.state, true).is_none() && b.collision.is_empty()); + body.velocity[1] = if down { + (body.velocity[1] - 0.03).max(if surface { -0.9 } else { -0.3 }) + } else { + (body.velocity[1] + if surface { 0.1 } else { 0.06 }).min(if surface { + 1.8 + } else { + 0.7 + }) + }; + body.fall_distance = 0.; + } + "honey_block" + if !body.on_ground + && body.velocity[1] / AIR_DRAG + GRAVITY < -0.08 + && body.position[1] <= block.pos[1] as f64 + 0.9375 => + { + let dx = (body.position[0] - (block.pos[0] as f64 + 0.5)).abs(); + let dz = (body.position[2] - (block.pos[2] as f64 + 0.5)).abs(); + if dx + EPSILON > 0.4375 + WIDTH / 2. || dz + EPSILON > 0.4375 + WIDTH / 2. { + let incoming = body.velocity[1] / AIR_DRAG + GRAVITY; + if incoming < -0.13 { + let ratio = -0.05 / incoming; + body.velocity[0] *= ratio; + body.velocity[2] *= ratio; + } + body.velocity[1] = (-0.05 - GRAVITY) * AIR_DRAG; + body.fall_distance = 0.; + } + } + "scaffolding" if input.sneak => { + body.velocity[1] = body.velocity[1].max(-0.15); + } + _ => {} + } + } +} + +/// Raw JSON bridge keeps the exact same Rust numerical implementation in the +/// browser. The host writes only the allocated input region and copies output +/// before the next call; neither pointer survives another allocation. +#[cfg(target_arch = "wasm32")] +mod wasm { + use super::*; + use std::cell::RefCell; + const MAX_INPUT: usize = 16 * 1024 * 1024; + thread_local! { + static INPUT: RefCell> = const { RefCell::new(Vec::new()) }; + static OUTPUT: RefCell> = const { RefCell::new(Vec::new()) }; + } + #[unsafe(no_mangle)] + pub extern "C" fn physics_version() -> u32 { + 1 + } + #[unsafe(no_mangle)] + pub extern "C" fn physics_input(len: u32) -> u32 { + if len as usize > MAX_INPUT { + return 0; + } + INPUT.with(|input| { + let mut input = input.borrow_mut(); + input.resize(len as usize, 0); + input.as_mut_ptr() as u32 + }) + } + #[unsafe(no_mangle)] + pub extern "C" fn physics_run() -> u32 { + let request = INPUT.with(|input| serde_json::from_slice::(&input.borrow())); + let (status, result) = match request { + Ok(mut request) => { + step( + &mut request.body, + &request.input, + &request.world, + &request.settings, + ); + (0, serde_json::to_vec(&request.body)) + } + Err(error) => ( + 1, + serde_json::to_vec(&serde_json::json!({"error": error.to_string()})), + ), + }; + OUTPUT.with(|output| { + *output.borrow_mut() = + result.unwrap_or_else(|_| b"{\"error\":\"serialization failed\"}".to_vec()) + }); + status + } + #[unsafe(no_mangle)] + pub extern "C" fn physics_output() -> u32 { + OUTPUT.with(|output| output.borrow().as_ptr() as u32) + } + #[unsafe(no_mangle)] + pub extern "C" fn physics_output_len() -> u32 { + OUTPUT.with(|output| output.borrow().len() as u32) + } +} + +#[cfg(test)] +mod reference_callbacks { + use super::*; + fn fixture() -> serde_json::Value { + serde_json::from_str(include_str!("../tests/fixtures/java26.2.json")).unwrap() + } + #[test] + fn actual_java26_2_collision_measurements() { + let fixture = fixture(); + for case in fixture["collision_cases"].as_array().unwrap() { + let position: [f64; 3] = serde_json::from_value(case["position"].clone()).unwrap(); + let requested: [f64; 3] = serde_json::from_value(case["requested"].clone()).unwrap(); + let boxes: Vec = serde_json::from_value(case["boxes"].clone()).unwrap(); + let actual = stepped_move( + Aabb::body(position, Pose::Standing.height()), + requested, + &boxes, + case["step_height"].as_f64().unwrap(), + case["on_ground"].as_bool().unwrap(), + ); + for axis in 0..3 { + assert!( + (actual[axis] - case["result"][axis].as_f64().unwrap()).abs() < 1.0e-7, + "{} axis {axis}: {:?} expected {}", + case["name"], + actual, + case["result"] + ); + } + } + } + #[test] + fn actual_java26_2_restitution_measurements() { + let fixture = fixture(); + for case in fixture["callback_measurements"]["vertical_collision_restitution"] + .as_array() + .unwrap() + { + let incoming = case["incoming_y"].as_f64().unwrap(); + let moved = case["moved_fraction"].as_f64().unwrap() * incoming; + let factor = match case["surface"].as_str().unwrap() { + "slime_block" => 1., + "white_bed" => 0.75, + _ => 0., + }; + let expected = case["velocity"][1].as_f64().unwrap(); + assert!( + (restitution_velocity(incoming, moved, GRAVITY, factor) - expected).abs() < 1.0e-12 + ); + } + } + #[test] + fn actual_java26_2_honey_measurements() { + let fixture = fixture(); + let world = PhysicsWorld { + blocks: vec![CollisionBlock { + pos: [0; 3], + state: "minecraft:honey_block".into(), + collision: vec![], + }], + }; + for case in fixture["callback_measurements"]["honey_slide_callback"] + .as_array() + .unwrap() + { + let mut body = PhysicsBody { + position: [1.238, 0., 0.5], + velocity: [0.1, case["incoming_y"].as_f64().unwrap(), 0.2], + ..Default::default() + }; + let view = WorldView::new( + &world, + &body, + &Controls::default(), + &PhysicsSettings::default(), + ); + block_effects(&mut body, &view, &Controls::default()); + for axis in 0..3 { + assert!( + (body.velocity[axis] - case["velocity"][axis].as_f64().unwrap()).abs() + < 1.0e-12 + ); + } + } + } + #[test] + fn actual_java26_2_bubble_measurements() { + let fixture = fixture(); + for case in fixture["callback_measurements"]["bubble_column_callback"] + .as_array() + .unwrap() + { + let mut world = PhysicsWorld { + blocks: vec![CollisionBlock { + pos: [0; 3], + state: format!( + "minecraft:bubble_column[drag={}]", + case["down"].as_bool().unwrap() + ), + collision: vec![], + }], + }; + if !case["above"].as_bool().unwrap() { + world.blocks.push(CollisionBlock { + pos: [0, 1, 0], + state: "minecraft:water[level=0]".into(), + collision: vec![], + }); + } + let mut body = PhysicsBody { + position: [0.5, 0., 0.5], + velocity: [0.1, case["incoming_y"].as_f64().unwrap(), 0.2], + ..Default::default() + }; + let view = WorldView::new( + &world, + &body, + &Controls::default(), + &PhysicsSettings::default(), + ); + block_effects(&mut body, &view, &Controls::default()); + for axis in 0..3 { + assert!( + (body.velocity[axis] - case["velocity"][axis].as_f64().unwrap()).abs() + < 1.0e-12 + ); + } + } + } +} diff --git a/crates/shacraft-physics/tests/fixtures/java26.2.json b/crates/shacraft-physics/tests/fixtures/java26.2.json new file mode 100644 index 0000000..be25ee6 --- /dev/null +++ b/crates/shacraft-physics/tests/fixtures/java26.2.json @@ -0,0 +1,7814 @@ +{ + "version": "26.2", + "surfaces": { + "stone": { + "friction": 0.6000000238418579, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 0.0 + }, + "ice": { + "friction": 0.9800000190734863, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 0.0 + }, + "packed_ice": { + "friction": 0.9800000190734863, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 0.0 + }, + "blue_ice": { + "friction": 0.9890000224113464, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 0.0 + }, + "frosted_ice": { + "friction": 0.9800000190734863, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 0.0 + }, + "slime_block": { + "friction": 0.800000011920929, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 1.0 + }, + "honey_block": { + "friction": 0.6000000238418579, + "speed_factor": 0.4000000059604645, + "jump_factor": 0.5, + "bounce_restitution": 0.0 + }, + "soul_sand": { + "friction": 0.6000000238418579, + "speed_factor": 0.4000000059604645, + "jump_factor": 1.0, + "bounce_restitution": 0.0 + }, + "soul_soil": { + "friction": 0.6000000238418579, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 0.0 + }, + "white_bed": { + "friction": 0.6000000238418579, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 0.75 + }, + "water": { + "friction": 0.6000000238418579, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 0.0 + }, + "lava": { + "friction": 0.6000000238418579, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 0.0 + }, + "cobweb": { + "friction": 0.6000000238418579, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 0.0 + }, + "powder_snow": { + "friction": 0.6000000238418579, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 0.0 + }, + "ladder": { + "friction": 0.6000000238418579, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 0.0 + }, + "scaffolding": { + "friction": 0.6000000238418579, + "speed_factor": 1.0, + "jump_factor": 1.0, + "bounce_restitution": 0.0 + } + }, + "player_attributes": { + "minecraft:air_drag_modifier": 1.0, + "minecraft:armor": 0.0, + "minecraft:armor_toughness": 0.0, + "minecraft:attack_damage": 1.0, + "minecraft:attack_knockback": 0.0, + "minecraft:attack_speed": 4.0, + "minecraft:below_name_distance": 10.0, + "minecraft:block_break_speed": 1.0, + "minecraft:block_interaction_range": 4.5, + "minecraft:bounciness": 0.0, + "minecraft:burning_time": 1.0, + "minecraft:camera_distance": 4.0, + "minecraft:explosion_knockback_resistance": 0.0, + "minecraft:entity_interaction_range": 3.0, + "minecraft:fall_damage_multiplier": 1.0, + "minecraft:friction_modifier": 1.0, + "minecraft:gravity": 0.08, + "minecraft:jump_strength": 0.41999998688697815, + "minecraft:knockback_resistance": 0.0, + "minecraft:luck": 0.0, + "minecraft:max_absorption": 0.0, + "minecraft:max_health": 20.0, + "minecraft:mining_efficiency": 0.0, + "minecraft:movement_efficiency": 0.0, + "minecraft:movement_speed": 0.10000000149011612, + "minecraft:name_tag_distance": 64.0, + "minecraft:oxygen_bonus": 0.0, + "minecraft:safe_fall_distance": 3.0, + "minecraft:scale": 1.0, + "minecraft:sneaking_speed": 0.3, + "minecraft:step_height": 0.6, + "minecraft:submerged_mining_speed": 0.2, + "minecraft:sweeping_damage_ratio": 0.0, + "minecraft:water_movement_efficiency": 0.0, + "minecraft:waypoint_transmit_range": 60000000.0, + "minecraft:waypoint_receive_range": 60000000.0 + }, + "sprint_attribute_modifier": 0.30000001192092896, + "poses": { + "standing": { + "width": 0.6000000238418579, + "height": 1.7999999523162842, + "eye_height": 1.6200000047683716 + }, + "crouching": { + "width": 0.6000000238418579, + "height": 1.5, + "eye_height": 1.2699999809265137 + }, + "swimming": { + "width": 0.6000000238418579, + "height": 0.6000000238418579, + "eye_height": 0.4000000059604645 + }, + "fall_flying": { + "width": 0.6000000238418579, + "height": 0.6000000238418579, + "eye_height": 0.4000000059604645 + }, + "sleeping": { + "width": 0.20000000298023224, + "height": 0.20000000298023224, + "eye_height": 0.20000000298023224 + } + }, + "fluid_levels": { + "water": [ + { + "level": 0, + "height": 0.8888888955116272, + "source": true + }, + { + "level": 1, + "height": 0.7777777910232544, + "source": false + }, + { + "level": 2, + "height": 0.6666666865348816, + "source": false + }, + { + "level": 3, + "height": 0.5555555820465088, + "source": false + }, + { + "level": 4, + "height": 0.4444444477558136, + "source": false + }, + { + "level": 5, + "height": 0.3333333432674408, + "source": false + }, + { + "level": 6, + "height": 0.2222222238779068, + "source": false + }, + { + "level": 7, + "height": 0.1111111119389534, + "source": false + }, + { + "level": 8, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 9, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 10, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 11, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 12, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 13, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 14, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 15, + "height": 0.8888888955116272, + "source": false + } + ], + "lava": [ + { + "level": 0, + "height": 0.8888888955116272, + "source": true + }, + { + "level": 1, + "height": 0.7777777910232544, + "source": false + }, + { + "level": 2, + "height": 0.6666666865348816, + "source": false + }, + { + "level": 3, + "height": 0.5555555820465088, + "source": false + }, + { + "level": 4, + "height": 0.4444444477558136, + "source": false + }, + { + "level": 5, + "height": 0.3333333432674408, + "source": false + }, + { + "level": 6, + "height": 0.2222222238779068, + "source": false + }, + { + "level": 7, + "height": 0.1111111119389534, + "source": false + }, + { + "level": 8, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 9, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 10, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 11, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 12, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 13, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 14, + "height": 0.8888888955116272, + "source": false + }, + { + "level": 15, + "height": 0.8888888955116272, + "source": false + } + ] + }, + "travel_kernel_trajectories": { + "stone_walk_stop": { + "surface": "stone", + "sprint": false, + "medium": "air", + "fluid_depth": 0.0, + "flying": false, + "jump_first_tick": false, + "input_ticks": 20, + "initial_position": [ + 0.0, + 0.0, + 0.0 + ], + "initial_velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "samples": [ + { + "tick": 1, + "position": [ + 0.0, + 0.0, + 0.09800000336766246 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.053508008053839436 + ], + "on_ground": true + }, + { + "tick": 2, + "position": [ + 0.0, + 0.0, + 0.24950801478916435 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.08272338384467842 + ], + "on_ground": true + }, + { + "tick": 3, + "position": [ + 0.0, + 0.0, + 0.4302314020015052 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.09867498087929642 + ], + "on_ground": true + }, + { + "tick": 4, + "position": [ + 0.0, + 0.0, + 0.6269063862484641 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.10738455387183765 + ], + "on_ground": true + }, + { + "tick": 5, + "position": [ + 0.0, + 0.0, + 0.8322909434879642 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11213998127812054 + ], + "on_ground": true + }, + { + "tick": 6, + "position": [ + 0.0, + 0.0, + 1.0424309281337472 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11473644494353707 + ], + "on_ground": true + }, + { + "tick": 7, + "position": [ + 0.0, + 0.0, + 1.2551673764449467 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11615411426952052 + ], + "on_ground": true + }, + { + "tick": 8, + "position": [ + 0.0, + 0.0, + 1.4693214940821298 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11692816181141515 + ], + "on_ground": true + }, + { + "tick": 9, + "position": [ + 0.0, + 0.0, + 1.6842496592612073 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11735079181837918 + ], + "on_ground": true + }, + { + "tick": 10, + "position": [ + 0.0, + 0.0, + 1.899600454447249 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11758154782898447 + ], + "on_ground": true + }, + { + "tick": 11, + "position": [ + 0.0, + 0.0, + 2.115182005643896 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11770754062540936 + ], + "on_ground": true + }, + { + "tick": 12, + "position": [ + 0.0, + 0.0, + 2.330889549636968 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11777633270024773 + ], + "on_ground": true + }, + { + "tick": 13, + "position": [ + 0.0, + 0.0, + 2.5466658857048783 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11781389317747222 + ], + "on_ground": true + }, + { + "tick": 14, + "position": [ + 0.0, + 0.0, + 2.762479782250013 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11783440120041885 + ], + "on_ground": true + }, + { + "tick": 15, + "position": [ + 0.0, + 0.0, + 2.9783141868180945 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11784559858224833 + ], + "on_ground": true + }, + { + "tick": 16, + "position": [ + 0.0, + 0.0, + 3.194159788768005 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11785171235343735 + ], + "on_ground": true + }, + { + "tick": 17, + "position": [ + 0.0, + 0.0, + 3.410011504489105 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11785505047289427 + ], + "on_ground": true + }, + { + "tick": 18, + "position": [ + 0.0, + 0.0, + 3.625866558329662 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11785687308632946 + ], + "on_ground": true + }, + { + "tick": 19, + "position": [ + 0.0, + 0.0, + 3.841723434783654 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11785786823338068 + ], + "on_ground": true + }, + { + "tick": 20, + "position": [ + 0.0, + 0.0, + 4.057581306384697 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11785841158373374 + ], + "on_ground": true + }, + { + "tick": 21, + "position": [ + 0.0, + 0.0, + 4.17543971796843 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.06435070019922154 + ], + "on_ground": true + }, + { + "tick": 22, + "position": [ + 0.0, + 0.0, + 4.239790418167652 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.035135486389854025 + ], + "on_ground": true + }, + { + "tick": 23, + "position": [ + 0.0, + 0.0, + 4.274925904557506 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.019183977797129728 + ], + "on_ground": true + }, + { + "tick": 24, + "position": [ + 0.0, + 0.0, + 4.294109882354635 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.010474453093868082 + ], + "on_ground": true + }, + { + "tick": 25, + "position": [ + 0.0, + 0.0, + 4.304584335448504 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.005719052053534896 + ], + "on_ground": true + }, + { + "tick": 26, + "position": [ + 0.0, + 0.0, + 4.310303387502039 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0031226027839285713 + ], + "on_ground": true + }, + { + "tick": 27, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.001704941318058414 + ], + "on_ground": true + }, + { + "tick": 28, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 29, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 30, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 31, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 32, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 33, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 34, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 35, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 36, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 37, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 38, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 39, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 40, + "position": [ + 0.0, + 0.0, + 4.3134259902859675 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + } + ] + }, + "stone_sprint_stop": { + "surface": "stone", + "sprint": true, + "medium": "air", + "fluid_depth": 0.0, + "flying": false, + "jump_first_tick": false, + "input_ticks": 20, + "initial_position": [ + 0.0, + 0.0, + 0.0 + ], + "initial_velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "samples": [ + { + "tick": 1, + "position": [ + 0.0, + 0.0, + 0.12740001240968724 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0695604148553142 + ], + "on_ground": true + }, + { + "tick": 2, + "position": [ + 0.0, + 0.0, + 0.32436043967468864 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.10754040577779149 + ], + "on_ground": true + }, + { + "tick": 3, + "position": [ + 0.0, + 0.0, + 0.5593008578621674 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.12827748323013013 + ], + "on_ground": true + }, + { + "tick": 4, + "position": [ + 0.0, + 0.0, + 0.8149783535019848 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.13959992883423883 + ], + "on_ground": true + }, + { + "tick": 5, + "position": [ + 0.0, + 0.0, + 1.081978294745911 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.14578198485214422 + ], + "on_ground": true + }, + { + "tick": 6, + "position": [ + 0.0, + 0.0, + 1.3551602920077424 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1491573878299825 + ], + "on_ground": true + }, + { + "tick": 7, + "position": [ + 0.0, + 0.0, + 1.6317176922474121 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.15100035806994805 + ], + "on_ground": true + }, + { + "tick": 8, + "position": [ + 0.0, + 0.0, + 1.9101180627270473 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1520066199378492 + ], + "on_ground": true + }, + { + "tick": 9, + "position": [ + 0.0, + 0.0, + 2.1895246950745837 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1525560389815397 + ], + "on_ground": true + }, + { + "tick": 10, + "position": [ + 0.0, + 0.0, + 2.469480746465811 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1528560218142385 + ], + "on_ground": true + }, + { + "tick": 11, + "position": [ + 0.0, + 0.0, + 2.7497367806897364 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.15301981245991675 + ], + "on_ground": true + }, + { + "tick": 12, + "position": [ + 0.0, + 0.0, + 3.0301566055593403 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.15310924216284458 + ], + "on_ground": true + }, + { + "tick": 13, + "position": [ + 0.0, + 0.0, + 3.310665860131872 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.15315807078631474 + ], + "on_ground": true + }, + { + "tick": 14, + "position": [ + 0.0, + 0.0, + 3.591223943327874 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1531847312178261 + ], + "on_ground": true + }, + { + "tick": 15, + "position": [ + 0.0, + 0.0, + 3.8718086869553874 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.15319928781512213 + ], + "on_ground": true + }, + { + "tick": 16, + "position": [ + 0.0, + 0.0, + 4.152407987180196 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1532072357181689 + ], + "on_ground": true + }, + { + "tick": 17, + "position": [ + 0.0, + 0.0, + 4.433015235308052 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1532115752737365 + ], + "on_ground": true + }, + { + "tick": 18, + "position": [ + 0.0, + 0.0, + 4.713626822991476 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.15321394467135163 + ], + "on_ground": true + }, + { + "tick": 19, + "position": [ + 0.0, + 0.0, + 4.9942407800725155 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.15321523836259976 + ], + "on_ground": true + }, + { + "tick": 20, + "position": [ + 0.0, + 0.0, + 5.274856030844802 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.15321594471810326 + ], + "on_ground": true + }, + { + "tick": 21, + "position": [ + 0.0, + 0.0, + 5.428071975562905 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.08365591553293879 + ], + "on_ground": true + }, + { + "tick": 22, + "position": [ + 0.0, + 0.0, + 5.511727891095844 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0456761351863877 + ], + "on_ground": true + }, + { + "tick": 23, + "position": [ + 0.0, + 0.0, + 5.5574040262822315 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.02493917270851812 + ], + "on_ground": true + }, + { + "tick": 24, + "position": [ + 0.0, + 0.0, + 5.58234319899075 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.013616789880476819 + ], + "on_ground": true + }, + { + "tick": 25, + "position": [ + 0.0, + 0.0, + 5.595959988871226 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.007434768138308198 + ], + "on_ground": true + }, + { + "tick": 26, + "position": [ + 0.0, + 0.0, + 5.603394757009535 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.00405938387502438 + ], + "on_ground": true + }, + { + "tick": 27, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.002216423853206766 + ], + "on_ground": true + }, + { + "tick": 28, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 29, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 30, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 31, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 32, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 33, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 34, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 35, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 36, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 37, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 38, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 39, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 40, + "position": [ + 0.0, + 0.0, + 5.607454140884559 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + } + ] + }, + "stone_jump": { + "surface": "stone", + "sprint": false, + "medium": "air", + "fluid_depth": 0.0, + "flying": false, + "jump_first_tick": true, + "input_ticks": 0, + "initial_position": [ + 0.0, + 0.0, + 0.0 + ], + "initial_velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "samples": [ + { + "tick": 1, + "position": [ + 0.0, + 0.41999998688697815, + 0.0 + ], + "velocity": [ + 0.0, + 0.33319999363422365, + 0.0 + ], + "on_ground": false + }, + { + "tick": 2, + "position": [ + 0.0, + 0.7531999805212017, + 0.0 + ], + "velocity": [ + 0.0, + 0.24813599859094576, + 0.0 + ], + "on_ground": false + }, + { + "tick": 3, + "position": [ + 0.0, + 1.0013359791121474, + 0.0 + ], + "velocity": [ + 0.0, + 0.16477328182606651, + 0.0 + ], + "on_ground": false + }, + { + "tick": 4, + "position": [ + 0.0, + 1.166109260938214, + 0.0 + ], + "velocity": [ + 0.0, + 0.08307781780646721, + 0.0 + ], + "on_ground": false + }, + { + "tick": 5, + "position": [ + 0.0, + 1.2491870787446813, + 0.0 + ], + "velocity": [ + 0.0, + 0.0030162615090425808, + 0.0 + ], + "on_ground": false + }, + { + "tick": 6, + "position": [ + 0.0, + 1.2522033402537238, + 0.0 + ], + "velocity": [ + 0.0, + -0.07544406518948656, + 0.0 + ], + "on_ground": false + }, + { + "tick": 7, + "position": [ + 0.0, + 1.1767592750642373, + 0.0 + ], + "velocity": [ + 0.0, + -0.15233518685055708, + 0.0 + ], + "on_ground": false + }, + { + "tick": 8, + "position": [ + 0.0, + 1.0244240882136801, + 0.0 + ], + "velocity": [ + 0.0, + -0.22768848754498797, + 0.0 + ], + "on_ground": false + }, + { + "tick": 9, + "position": [ + 0.0, + 0.7967356006686922, + 0.0 + ], + "velocity": [ + 0.0, + -0.30153472366278034, + 0.0 + ], + "on_ground": false + }, + { + "tick": 10, + "position": [ + 0.0, + 0.49520087700591187, + 0.0 + ], + "velocity": [ + 0.0, + -0.3739040364667221, + 0.0 + ], + "on_ground": false + }, + { + "tick": 11, + "position": [ + 0.0, + 0.12129684053918977, + 0.0 + ], + "velocity": [ + 0.0, + -0.4448259643949201, + 0.0 + ], + "on_ground": false + }, + { + "tick": 12, + "position": [ + 0.0, + 0.0, + 0.0 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 13, + "position": [ + 0.0, + 0.0, + 0.0 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 14, + "position": [ + 0.0, + 0.0, + 0.0 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 15, + "position": [ + 0.0, + 0.0, + 0.0 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + }, + { + "tick": 16, + "position": [ + 0.0, + 0.0, + 0.0 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "on_ground": true + } + ] + }, + "stone_sprint_jump": { + "surface": "stone", + "sprint": true, + "medium": "air", + "fluid_depth": 0.0, + "flying": false, + "jump_first_tick": true, + "input_ticks": 15, + "initial_position": [ + 0.0, + 0.0, + 0.0 + ], + "initial_velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "samples": [ + { + "tick": 1, + "position": [ + 0.0, + 0.41999998688697815, + 0.32740001240968725 + ], + "velocity": [ + 0.0, + 0.33319999363422365, + 0.17876042753918261 + ], + "on_ground": false + }, + { + "tick": 2, + "position": [ + 0.0, + 0.7531999805212017, + 0.5316404391451012 + ], + "velocity": [ + 0.0, + 0.24813599859094576, + 0.18585879368564506 + ], + "on_ground": false + }, + { + "tick": 3, + "position": [ + 0.0, + 1.0013359791121474, + 0.7429792320269776 + ], + "velocity": [ + 0.0, + 0.16477328182606651, + 0.19231830706508796 + ], + "on_ground": false + }, + { + "tick": 4, + "position": [ + 0.0, + 1.166109260938214, + 0.9607775382882968 + ], + "velocity": [ + 0.0, + 0.08307781780646721, + 0.19819646440978847 + ], + "on_ground": false + }, + { + "tick": 5, + "position": [ + 0.0, + 1.2491870787446813, + 1.1844540018943166 + ], + "velocity": [ + 0.0, + 0.0030162615090425808, + 0.20354558774762674 + ], + "on_ground": false + }, + { + "tick": 6, + "position": [ + 0.0, + 1.2522033402537238, + 1.4134795888381746 + ], + "velocity": [ + 0.0, + -0.07544406518948656, + 0.2084132901253459 + ], + "on_ground": false + }, + { + "tick": 7, + "position": [ + 0.0, + 1.1767592750642373, + 1.6473728781597519 + ], + "velocity": [ + 0.0, + -0.15233518685055708, + 0.21284289941673093 + ], + "on_ground": false + }, + { + "tick": 8, + "position": [ + 0.0, + 1.0244240882136801, + 1.885695776772714 + ], + "velocity": [ + 0.0, + -0.22768848754498797, + 0.21687384398806242 + ], + "on_ground": false + }, + { + "tick": 9, + "position": [ + 0.0, + 0.7967356006686922, + 2.128049619957008 + ], + "velocity": [ + 0.0, + -0.30153472366278034, + 0.2205420036536898 + ], + "on_ground": false + }, + { + "tick": 10, + "position": [ + 0.0, + 0.49520087700591187, + 2.374071622806929 + ], + "velocity": [ + 0.0, + -0.3739040364667221, + 0.22388002904561205 + ], + "on_ground": false + }, + { + "tick": 11, + "position": [ + 0.0, + 0.12129684053918977, + 2.6234316510487723 + ], + "velocity": [ + 0.0, + -0.4448259643949201, + 0.2269176322398045 + ], + "on_ground": false + }, + { + "tick": 12, + "position": [ + 0.0, + 0.0, + 2.875829282484808 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.22968185122618393 + ], + "on_ground": true + }, + { + "tick": 13, + "position": [ + 0.0, + 0.0, + 3.232911146120679 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1949667201910825 + ], + "on_ground": true + }, + { + "tick": 14, + "position": [ + 0.0, + 0.0, + 3.5552778787214487 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.17601225644430638 + ], + "on_ground": true + }, + { + "tick": 15, + "position": [ + 0.0, + 0.0, + 3.8586901475754423 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.16566311803648698 + ], + "on_ground": true + }, + { + "tick": 16, + "position": [ + 0.0, + 0.0, + 4.024353265611929 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.09045207295416784 + ], + "on_ground": true + }, + { + "tick": 17, + "position": [ + 0.0, + 0.0, + 4.114805338566097 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.04938683756938659 + ], + "on_ground": true + }, + { + "tick": 18, + "position": [ + 0.0, + 0.0, + 4.164192176135484 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.02696521644496582 + ], + "on_ground": true + }, + { + "tick": 19, + "position": [ + 0.0, + 0.0, + 4.1911573925804495 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.014723009889067624 + ], + "on_ground": true + }, + { + "tick": 20, + "position": [ + 0.0, + 0.0, + 4.205880402469517 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.008038764333154523 + ], + "on_ground": true + } + ] + }, + "ice_walk_stop": { + "surface": "ice", + "sprint": false, + "medium": "air", + "fluid_depth": 0.0, + "flying": false, + "jump_first_tick": false, + "input_ticks": 20, + "initial_position": [ + 0.0, + 0.0, + 0.0 + ], + "initial_velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "samples": [ + { + "tick": 1, + "position": [ + 0.0, + 0.0, + 0.022490629097454473 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.02005714406293894 + ], + "on_ground": true + }, + { + "tick": 2, + "position": [ + 0.0, + 0.0, + 0.06503840225784789 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.03794410606023668 + ], + "on_ground": true + }, + { + "tick": 3, + "position": [ + 0.0, + 0.0, + 0.12547313741553906 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.05389569959163861 + ], + "on_ground": true + }, + { + "tick": 4, + "position": [ + 0.0, + 0.0, + 0.20185946610463215 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.06812133143619138 + ], + "on_ground": true + }, + { + "tick": 5, + "position": [ + 0.0, + 0.0, + 0.292471426638278 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0808077505690746 + ], + "on_ground": true + }, + { + "tick": 6, + "position": [ + 0.0, + 0.0, + 0.39576980630480707 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.09212149973493779 + ], + "on_ground": true + }, + { + "tick": 7, + "position": [ + 0.0, + 0.0, + 0.5103819351371993 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.10221110176111484 + ], + "on_ground": true + }, + { + "tick": 8, + "position": [ + 0.0, + 0.0, + 0.6350836659957686 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1112090093118493 + ], + "on_ground": true + }, + { + "tick": 9, + "position": [ + 0.0, + 0.0, + 0.7687833044050724 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11923334367920202 + ], + "on_ground": true + }, + { + "tick": 10, + "position": [ + 0.0, + 0.0, + 0.9105072771817289 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1263894454368626 + ], + "on_ground": true + }, + { + "tick": 11, + "position": [ + 0.0, + 0.0, + 1.059387351716046 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.13277125731328954 + ], + "on_ground": true + }, + { + "tick": 12, + "position": [ + 0.0, + 0.0, + 1.21464923812679 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.13846255743804048 + ], + "on_ground": true + }, + { + "tick": 13, + "position": [ + 0.0, + 0.0, + 1.3756024246622849 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.14353805915090595 + ], + "on_ground": true + }, + { + "tick": 14, + "position": [ + 0.0, + 0.0, + 1.5416311129106453 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1480643918117455 + ], + "on_ground": true + }, + { + "tick": 15, + "position": [ + 0.0, + 0.0, + 1.7121861338198452 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1521009754867446 + ], + "on_ground": true + }, + { + "tick": 16, + "position": [ + 0.0, + 0.0, + 1.8867777384040443 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.15570080099365882 + ], + "on_ground": true + }, + { + "tick": 17, + "position": [ + 0.0, + 0.0, + 2.0649691684951574 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1589111255461985 + ], + "on_ground": true + }, + { + "tick": 18, + "position": [ + 0.0, + 0.0, + 2.2463709231388105 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.16177409312972268 + ], + "on_ground": true + }, + { + "tick": 19, + "position": [ + 0.0, + 0.0, + 2.430635645365988 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.16432728775231187 + ], + "on_ground": true + }, + { + "tick": 20, + "position": [ + 0.0, + 0.0, + 2.617453562215754 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.16660422683409987 + ], + "on_ground": true + }, + { + "tick": 21, + "position": [ + 0.0, + 0.0, + 2.784057789049854 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.14857765714896376 + ], + "on_ground": true + }, + { + "tick": 22, + "position": [ + 0.0, + 0.0, + 2.9326354461988178 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1325015614751302 + ], + "on_ground": true + }, + { + "tick": 23, + "position": [ + 0.0, + 0.0, + 3.065137007673948 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11816489861423392 + ], + "on_ground": true + }, + { + "tick": 24, + "position": [ + 0.0, + 0.0, + 3.183301906288182 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.10537946201587176 + ], + "on_ground": true + }, + { + "tick": 25, + "position": [ + 0.0, + 0.0, + 3.288681368304054 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.09397740906974292 + ], + "on_ground": true + }, + { + "tick": 26, + "position": [ + 0.0, + 0.0, + 3.3826587773737966 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.08380905772826588 + ], + "on_ground": true + }, + { + "tick": 27, + "position": [ + 0.0, + 0.0, + 3.4664678351020624 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.07474092153452702 + ], + "on_ground": true + }, + { + "tick": 28, + "position": [ + 0.0, + 0.0, + 3.5412087566365895 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.06665395726011476 + ], + "on_ground": true + }, + { + "tick": 29, + "position": [ + 0.0, + 0.0, + 3.6078627138967043 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0594420021484596 + ], + "on_ground": true + }, + { + "tick": 30, + "position": [ + 0.0, + 0.0, + 3.6673047160451637 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.05301038024837285 + ], + "on_ground": true + }, + { + "tick": 31, + "position": [ + 0.0, + 0.0, + 3.7203150962935365 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.04727465954223247 + ], + "on_ground": true + }, + { + "tick": 32, + "position": [ + 0.0, + 0.0, + 3.767589755835769 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.042159543552842016 + ], + "on_ground": true + }, + { + "tick": 33, + "position": [ + 0.0, + 0.0, + 3.8097492993886113 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.03759788287837655 + ], + "on_ground": true + }, + { + "tick": 34, + "position": [ + 0.0, + 0.0, + 3.847347182266988 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.033529793679201926 + ], + "on_ground": true + }, + { + "tick": 35, + "position": [ + 0.0, + 0.0, + 3.88087697594619 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.029901871544379725 + ], + "on_ground": true + }, + { + "tick": 36, + "position": [ + 0.0, + 0.0, + 3.9107788474905694 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.02666649041778022 + ], + "on_ground": true + }, + { + "tick": 37, + "position": [ + 0.0, + 0.0, + 3.9374453379083496 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.023781177380357687 + ], + "on_ground": true + }, + { + "tick": 38, + "position": [ + 0.0, + 0.0, + 3.961226515288707 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.02120805508095479 + ], + "on_ground": true + }, + { + "tick": 39, + "position": [ + 0.0, + 0.0, + 3.9824345703696618 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.018913344496068313 + ], + "on_ground": true + }, + { + "tick": 40, + "position": [ + 0.0, + 0.0, + 4.00134791486573 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.01686692149098536 + ], + "on_ground": true + } + ] + }, + "blue_ice_walk_stop": { + "surface": "blue_ice", + "sprint": false, + "medium": "air", + "fluid_depth": 0.0, + "flying": false, + "jump_first_tick": false, + "input_ticks": 20, + "initial_position": [ + 0.0, + 0.0, + 0.0 + ], + "initial_velocity": [ + 0.0, + -0.0784000015258789, + 0.0 + ], + "samples": [ + { + "tick": 1, + "position": [ + 0.0, + 0.0, + 0.02188220029444743 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.019693761928400874 + ], + "on_ground": true + }, + { + "tick": 2, + "position": [ + 0.0, + 0.0, + 0.06345816251729573 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.03741795116319856 + ], + "on_ground": true + }, + { + "tick": 3, + "position": [ + 0.0, + 0.0, + 0.12275831397494172 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.05336954462579032 + ], + "on_ground": true + }, + { + "tick": 4, + "position": [ + 0.0, + 0.0, + 0.19801005889517947 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.06772581958003394 + ], + "on_ground": true + }, + { + "tick": 5, + "position": [ + 0.0, + 0.0, + 0.28761807876966083 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.08064632379456121 + ], + "on_ground": true + }, + { + "tick": 6, + "position": [ + 0.0, + 0.0, + 0.3901466028586695 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.09227464866920225 + ], + "on_ground": true + }, + { + "tick": 7, + "position": [ + 0.0, + 0.0, + 0.5043034518223192 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.10274002503107534 + ], + "on_ground": true + }, + { + "tick": 8, + "position": [ + 0.0, + 0.0, + 0.628925677147842 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.11215875933514534 + ], + "on_ground": true + }, + { + "tick": 9, + "position": [ + 0.0, + 0.0, + 0.7629666367774348 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.12063552623039606 + ], + "on_ground": true + }, + { + "tick": 10, + "position": [ + 0.0, + 0.0, + 0.9054843633022783 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1282645318564883 + ], + "on_ground": true + }, + { + "tick": 11, + "position": [ + 0.0, + 0.0, + 1.055631095453214 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.13513056079914523 + ], + "on_ground": true + }, + { + "tick": 12, + "position": [ + 0.0, + 0.0, + 1.2126438565468067 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.14130991833955248 + ], + "on_ground": true + }, + { + "tick": 13, + "position": [ + 0.0, + 0.0, + 1.3758359751808067 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.14687127846941697 + ], + "on_ground": true + }, + { + "tick": 14, + "position": [ + 0.0, + 0.0, + 1.5445894539446712 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.15187644709605838 + ], + "on_ground": true + }, + { + "tick": 15, + "position": [ + 0.0, + 0.0, + 1.718348101335177 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.15638104891937638 + ], + "on_ground": true + }, + { + "tick": 16, + "position": [ + 0.0, + 0.0, + 1.8966113505490008 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1604351456142675 + ], + "on_ground": true + }, + { + "tick": 17, + "position": [ + 0.0, + 0.0, + 2.078928696457716 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.16408379218863242 + ], + "on_ground": true + }, + { + "tick": 18, + "position": [ + 0.0, + 0.0, + 2.2648946889407955 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.16736753770003107 + ], + "on_ground": true + }, + { + "tick": 19, + "position": [ + 0.0, + 0.0, + 2.454144426935274 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.17032287589567632 + ], + "on_ground": true + }, + { + "tick": 20, + "position": [ + 0.0, + 0.0, + 2.646349503125398 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.17298265078393177 + ], + "on_ground": true + }, + { + "tick": 21, + "position": [ + 0.0, + 0.0, + 2.81933215390933 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.15568265971621228 + ], + "on_ground": true + }, + { + "tick": 22, + "position": [ + 0.0, + 0.0, + 2.9750148136255423 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.140112840371419 + ], + "on_ground": true + }, + { + "tick": 23, + "position": [ + 0.0, + 0.0, + 3.1151276539969612 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1261001583139215 + ], + "on_ground": true + }, + { + "tick": 24, + "position": [ + 0.0, + 0.0, + 3.241227812310883 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.1134888842781585 + ], + "on_ground": true + }, + { + "tick": 25, + "position": [ + 0.0, + 0.0, + 3.3547166965890414 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.10213886347896302 + ], + "on_ground": true + }, + { + "tick": 26, + "position": [ + 0.0, + 0.0, + 3.4568555600680044 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.09192395800812365 + ], + "on_ground": true + }, + { + "tick": 27, + "position": [ + 0.0, + 0.0, + 3.548779518076128 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.08273064500683115 + ], + "on_ground": true + }, + { + "tick": 28, + "position": [ + 0.0, + 0.0, + 3.631510163082959 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.07445675503486757 + ], + "on_ground": true + }, + { + "tick": 29, + "position": [ + 0.0, + 0.0, + 3.7059669181178267 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0670103366154648 + ], + "on_ground": true + }, + { + "tick": 30, + "position": [ + 0.0, + 0.0, + 3.7729772547332914 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.06030863433700658 + ], + "on_ground": true + }, + { + "tick": 31, + "position": [ + 0.0, + 0.0, + 3.833285889070298 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.0542771691547567 + ], + "on_ground": true + }, + { + "tick": 32, + "position": [ + 0.0, + 0.0, + 3.8875630582250547 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.04884891067159087 + ], + "on_ground": true + }, + { + "tick": 33, + "position": [ + 0.0, + 0.0, + 3.9364119688966457 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.043963532198914294 + ], + "on_ground": true + }, + { + "tick": 34, + "position": [ + 0.0, + 0.0, + 3.98037550109556 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.03956674031892037 + ], + "on_ground": true + }, + { + "tick": 35, + "position": [ + 0.0, + 0.0, + 4.019942241414481 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.035609671497312964 + ], + "on_ground": true + }, + { + "tick": 36, + "position": [ + 0.0, + 0.0, + 4.055551912911794 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.03204834904077697 + ], + "on_ground": true + }, + { + "tick": 37, + "position": [ + 0.0, + 0.0, + 4.087600261952571 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.028843194364120233 + ], + "on_ground": true + }, + { + "tick": 38, + "position": [ + 0.0, + 0.0, + 4.116443456316691 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.025958587135577707 + ], + "on_ground": true + }, + { + "tick": 39, + "position": [ + 0.0, + 0.0, + 4.142402043452269 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.02336246941197402 + ], + "on_ground": true + }, + { + "tick": 40, + "position": [ + 0.0, + 0.0, + 4.165764512864243 + ], + "velocity": [ + 0.0, + -0.0784000015258789, + 0.02102598936431965 + ], + "on_ground": true + } + ] + }, + "water_move_stop": { + "surface": "stone", + "sprint": false, + "medium": "water", + "fluid_depth": 1.0, + "flying": false, + "jump_first_tick": false, + "input_ticks": 20, + "initial_position": [ + 0.0, + 10.0, + 0.0 + ], + "initial_velocity": [ + 0.0, + 0.0, + 0.0 + ], + "samples": [ + { + "tick": 1, + "position": [ + 0.0, + 10.0, + 0.01959999994337558 + ], + "velocity": [ + 0.0, + -0.005, + 0.01568000018835067 + ], + "on_ground": false + }, + { + "tick": 2, + "position": [ + 0.0, + 9.995, + 0.054880000075101826 + ], + "velocity": [ + 0.0, + -0.009000000059604645, + 0.028224000525951372 + ], + "on_ground": false + }, + { + "tick": 3, + "position": [ + 0.0, + 9.985999999940395, + 0.10270400054442877 + ], + "velocity": [ + 0.0, + -0.012200000154972078, + 0.038259200945568075 + ], + "on_ground": false + }, + { + "tick": 4, + "position": [ + 0.0, + 9.973799999785422, + 0.16056320143337244 + ], + "velocity": [ + 0.0, + -0.014760000269412998, + 0.04628736140089035 + ], + "on_ground": false + }, + { + "tick": 5, + "position": [ + 0.0, + 9.959039999516008, + 0.22645056277763836 + ], + "velocity": [ + 0.0, + -0.016808000391483315, + 0.052709889860851296 + ], + "on_ground": false + }, + { + "tick": 6, + "position": [ + 0.0, + 9.942231999124525, + 0.29876045258186523 + ], + "velocity": [ + 0.0, + -0.018446400513553632, + 0.05784791270538255 + ], + "on_ground": false + }, + { + "tick": 7, + "position": [ + 0.0, + 9.923785598610971, + 0.3762083652306234 + ], + "velocity": [ + 0.0, + -0.019757120630741136, + 0.06195833104225758 + ], + "on_ground": false + }, + { + "tick": 8, + "position": [ + 0.0, + 9.90402847798023, + 0.45776669621625654 + ], + "velocity": [ + 0.0, + -0.02080569674011614, + 0.06524666576075759 + ], + "on_ground": false + }, + { + "tick": 9, + "position": [ + 0.0, + 9.883222781240114, + 0.5426133619203897 + ], + "velocity": [ + 0.0, + -0.021644557640116146, + 0.06787733357475761 + ], + "on_ground": false + }, + { + "tick": 10, + "position": [ + 0.0, + 9.861578223599999, + 0.6300906954385228 + ], + "velocity": [ + 0.0, + -0.02231564637011615, + 0.06998186785731764 + ], + "on_ground": false + }, + { + "tick": 11, + "position": [ + 0.0, + 9.839262577229883, + 0.719672563239216 + ], + "velocity": [ + 0.0, + -0.022852517362116156, + 0.07166549530845366 + ], + "on_ground": false + }, + { + "tick": 12, + "position": [ + 0.0, + 9.816410059867767, + 0.8109380584910453 + ], + "velocity": [ + 0.0, + -0.02328201416211616, + 0.07301239728943287 + ], + "on_ground": false + }, + { + "tick": 13, + "position": [ + 0.0, + 9.79312804570565, + 0.9035504557238537 + ], + "velocity": [ + 0.0, + -0.023625611607236165, + 0.07408991889027257 + ], + "on_ground": false + }, + { + "tick": 14, + "position": [ + 0.0, + 9.769502434098413, + 0.9972403745575019 + ], + "velocity": [ + 0.0, + -0.02390048956742817, + 0.07495193618378938 + ], + "on_ground": false + }, + { + "tick": 15, + "position": [ + 0.0, + 9.745601944530986, + 1.091792310684667 + ], + "velocity": [ + 0.0, + -0.024120391938858576, + 0.07564155002887889 + ], + "on_ground": false + }, + { + "tick": 16, + "position": [ + 0.0, + 9.721481552592127, + 1.1870338606569215 + ], + "velocity": [ + 0.0, + -0.02429631383862434, + 0.07619324111317133 + ], + "on_ground": false + }, + { + "tick": 17, + "position": [ + 0.0, + 9.697185238753503, + 1.2828271017134685 + ], + "velocity": [ + 0.0, + -0.024437051360534104, + 0.07663459398718195 + ], + "on_ground": false + }, + { + "tick": 18, + "position": [ + 0.0, + 9.672748187392969, + 1.379061695644026 + ], + "velocity": [ + 0.0, + -0.024549641379739636, + 0.07698767629165178 + ], + "on_ground": false + }, + { + "tick": 19, + "position": [ + 0.0, + 9.64819854601323, + 1.4756493718790533 + ], + "velocity": [ + 0.0, + -0.02463971339644624, + 0.07727014213943671 + ], + "on_ground": false + }, + { + "tick": 20, + "position": [ + 0.0, + 9.623558832616784, + 1.5725195139618655 + ], + "velocity": [ + 0.0, + -0.024711771010885265, + 0.07749611482103191 + ], + "on_ground": false + }, + { + "tick": 21, + "position": [ + 0.0, + 9.598847061605898, + 1.6500156287828975 + ], + "velocity": [ + 0.0, + -0.024769417103295478, + 0.06199689278065121 + ], + "on_ground": false + }, + { + "tick": 22, + "position": [ + 0.0, + 9.574077644502603, + 1.7120125215635487 + ], + "velocity": [ + 0.0, + -0.024815533977910847, + 0.04959751496358152 + ], + "on_ground": false + }, + { + "tick": 23, + "position": [ + 0.0, + 9.549262110524692, + 1.76161003652713 + ], + "velocity": [ + 0.0, + -0.024852427478152896, + 0.03967801256211367 + ], + "on_ground": false + }, + { + "tick": 24, + "position": [ + 0.0, + 9.524409683046539, + 1.8012880490892438 + ], + "velocity": [ + 0.0, + -0.02488194227878634, + 0.0317424105226897 + ], + "on_ground": false + }, + { + "tick": 25, + "position": [ + 0.0, + 9.499527740767752, + 1.8330304596119336 + ], + "velocity": [ + 0.0, + -0.024905554119644936, + 0.025393928796550783 + ], + "on_ground": false + }, + { + "tick": 26, + "position": [ + 0.0, + 9.474622186648107, + 1.8584243884084843 + ], + "velocity": [ + 0.0, + -0.024924443592613293, + 0.02031514333995985 + ], + "on_ground": false + }, + { + "tick": 27, + "position": [ + 0.0, + 9.449697743055493, + 1.878739531748444 + ], + "velocity": [ + 0.0, + -0.024939555171213157, + 0.01625211491414326 + ], + "on_ground": false + }, + { + "tick": 28, + "position": [ + 0.0, + 9.42475818788428, + 1.8949916466625873 + ], + "velocity": [ + 0.0, + -0.024951644434273192, + 0.013001692125054915 + ], + "on_ground": false + }, + { + "tick": 29, + "position": [ + 0.0, + 9.399806543450007, + 1.9079933387876422 + ], + "velocity": [ + 0.0, + -0.024961315844865335, + 0.01040135385503618 + ], + "on_ground": false + }, + { + "tick": 30, + "position": [ + 0.0, + 9.37484522760514, + 1.9183946926426785 + ], + "velocity": [ + 0.0, + -0.024969052973454343, + 0.008321083208022743 + ], + "on_ground": false + }, + { + "tick": 31, + "position": [ + 0.0, + 9.349876174631687, + 1.9267157758507012 + ], + "velocity": [ + 0.0, + -0.02497524267641778, + 0.006656866665613236 + ], + "on_ground": false + }, + { + "tick": 32, + "position": [ + 0.0, + 9.32490093195527, + 1.9333726425163145 + ], + "velocity": [ + 0.0, + -0.02498019443886232, + 0.005325493411846623 + ], + "on_ground": false + }, + { + "tick": 33, + "position": [ + 0.0, + 9.299920737516407, + 1.938698135928161 + ], + "velocity": [ + 0.0, + -0.02498415584887698, + 0.004260394792962127 + ], + "on_ground": false + }, + { + "tick": 34, + "position": [ + 0.0, + 9.27493658166753, + 1.942958530721123 + ], + "velocity": [ + 0.0, + -0.024987324976935933, + 0.0034083158851575656 + ], + "on_ground": false + }, + { + "tick": 35, + "position": [ + 0.0, + 9.249949256690595, + 1.9463668466062807 + ], + "velocity": [ + 0.0, + -0.024989860279420874, + 0.002726652748756344 + ], + "on_ground": false + }, + { + "tick": 36, + "position": [ + 0.0, + 9.224959396411174, + 1.9463668466062807 + ], + "velocity": [ + 0.0, + -0.02499188852143905, + 0.0 + ], + "on_ground": false + }, + { + "tick": 37, + "position": [ + 0.0, + 9.199967507889735, + 1.9463668466062807 + ], + "velocity": [ + 0.0, + -0.024993511115077766, + 0.0 + ], + "on_ground": false + }, + { + "tick": 38, + "position": [ + 0.0, + 9.174973996774657, + 1.9463668466062807 + ], + "velocity": [ + 0.0, + -0.024994809190008085, + 0.0 + ], + "on_ground": false + }, + { + "tick": 39, + "position": [ + 0.0, + 9.149979187584648, + 1.9463668466062807 + ], + "velocity": [ + 0.0, + -0.024995847649967814, + 0.0 + ], + "on_ground": false + }, + { + "tick": 40, + "position": [ + 0.0, + 9.12498333993468, + 1.9463668466062807 + ], + "velocity": [ + 0.0, + -0.024996678417947976, + 0.0 + ], + "on_ground": false + } + ] + }, + "water_sprint_stop": { + "surface": "stone", + "sprint": true, + "medium": "water", + "fluid_depth": 1.0, + "flying": false, + "jump_first_tick": false, + "input_ticks": 20, + "initial_position": [ + 0.0, + 10.0, + 0.0 + ], + "initial_velocity": [ + 0.0, + 0.0, + 0.0 + ], + "samples": [ + { + "tick": 1, + "position": [ + 0.0, + 10.0, + 0.01959999994337558 + ], + "velocity": [ + 0.0, + 0.0, + 0.017639999481737608 + ], + "on_ground": false + }, + { + "tick": 2, + "position": [ + 0.0, + 10.0, + 0.05683999936848877 + ], + "velocity": [ + 0.0, + 0.0, + 0.033515998594731096 + ], + "on_ground": false + }, + { + "tick": 3, + "position": [ + 0.0, + 10.0, + 0.10995599790659544 + ], + "velocity": [ + 0.0, + 0.0, + 0.04780439741791192 + ], + "on_ground": false + }, + { + "tick": 4, + "position": [ + 0.0, + 10.0, + 0.17736039526788294 + ], + "velocity": [ + 0.0, + 0.0, + 0.06066395601811269 + ], + "on_ground": false + }, + { + "tick": 5, + "position": [ + 0.0, + 10.0, + 0.2576243512293712 + ], + "velocity": [ + 0.0, + 0.0, + 0.07223755845169762 + ], + "on_ground": false + }, + { + "tick": 6, + "position": [ + 0.0, + 10.0, + 0.3494619096244444 + ], + "velocity": [ + 0.0, + 0.0, + 0.08265380036598786 + ], + "on_ground": false + }, + { + "tick": 7, + "position": [ + 0.0, + 10.0, + 0.45171570993380783 + ], + "velocity": [ + 0.0, + 0.0, + 0.09202841784050653 + ], + "on_ground": false + }, + { + "tick": 8, + "position": [ + 0.0, + 10.0, + 0.5633441277176899 + ], + "velocity": [ + 0.0, + 0.0, + 0.10046557334406502 + ], + "on_ground": false + }, + { + "tick": 9, + "position": [ + 0.0, + 10.0, + 0.6834097010051305 + ], + "velocity": [ + 0.0, + 0.0, + 0.1080590130961102 + ], + "on_ground": false + }, + { + "tick": 10, + "position": [ + 0.0, + 10.0, + 0.8110687140446163 + ], + "velocity": [ + 0.0, + 0.0, + 0.11489310869190916 + ], + "on_ground": false + }, + { + "tick": 11, + "position": [ + 0.0, + 10.0, + 0.9455618226799011 + ], + "velocity": [ + 0.0, + 0.0, + 0.12104379456519068 + ], + "on_ground": false + }, + { + "tick": 12, + "position": [ + 0.0, + 10.0, + 1.0862056171884673 + ], + "velocity": [ + 0.0, + 0.0, + 0.12657941170450027 + ], + "on_ground": false + }, + { + "tick": 13, + "position": [ + 0.0, + 10.0, + 1.2323850288363432 + ], + "velocity": [ + 0.0, + 0.0, + 0.1315614669978995 + ], + "on_ground": false + }, + { + "tick": 14, + "position": [ + 0.0, + 10.0, + 1.3835464957776182 + ], + "velocity": [ + 0.0, + 0.0, + 0.13604531664317734 + ], + "on_ground": false + }, + { + "tick": 15, + "position": [ + 0.0, + 10.0, + 1.5391918123641712 + ], + "velocity": [ + 0.0, + 0.0, + 0.1400807812170241 + ], + "on_ground": false + }, + { + "tick": 16, + "position": [ + 0.0, + 10.0, + 1.698872593524571 + ], + "velocity": [ + 0.0, + 0.0, + 0.14371269923727323 + ], + "on_ground": false + }, + { + "tick": 17, + "position": [ + 0.0, + 10.0, + 1.8621852927052198 + ], + "velocity": [ + 0.0, + 0.0, + 0.14698142536890577 + ], + "on_ground": false + }, + { + "tick": 18, + "position": [ + 0.0, + 10.0, + 2.028766718017501 + ], + "velocity": [ + 0.0, + 0.0, + 0.14992327880944253 + ], + "on_ground": false + }, + { + "tick": 19, + "position": [ + 0.0, + 10.0, + 2.198289996770319 + ], + "velocity": [ + 0.0, + 0.0, + 0.15257094683578637 + ], + "on_ground": false + }, + { + "tick": 20, + "position": [ + 0.0, + 10.0, + 2.3704609435494812 + ], + "velocity": [ + 0.0, + 0.0, + 0.1549538479963705 + ], + "on_ground": false + }, + { + "tick": 21, + "position": [ + 0.0, + 10.0, + 2.5254147915458516 + ], + "velocity": [ + 0.0, + 0.0, + 0.1394584595023458 + ], + "on_ground": false + }, + { + "tick": 22, + "position": [ + 0.0, + 10.0, + 2.6648732510481974 + ], + "velocity": [ + 0.0, + 0.0, + 0.12551261022716245 + ], + "on_ground": false + }, + { + "tick": 23, + "position": [ + 0.0, + 10.0, + 2.79038586127536 + ], + "velocity": [ + 0.0, + 0.0, + 0.11296134621199239 + ], + "on_ground": false + }, + { + "tick": 24, + "position": [ + 0.0, + 10.0, + 2.9033472074873523 + ], + "velocity": [ + 0.0, + 0.0, + 0.10166520889758478 + ], + "on_ground": false + }, + { + "tick": 25, + "position": [ + 0.0, + 10.0, + 3.005012416384937 + ], + "velocity": [ + 0.0, + 0.0, + 0.09149868558393884 + ], + "on_ground": false + }, + { + "tick": 26, + "position": [ + 0.0, + 10.0, + 3.096511101968876 + ], + "velocity": [ + 0.0, + 0.0, + 0.0823488148440463 + ], + "on_ground": false + }, + { + "tick": 27, + "position": [ + 0.0, + 10.0, + 3.178859916812922 + ], + "velocity": [ + 0.0, + 0.0, + 0.07411393139629292 + ], + "on_ground": false + }, + { + "tick": 28, + "position": [ + 0.0, + 10.0, + 3.252973848209215 + ], + "velocity": [ + 0.0, + 0.0, + 0.06670253648964981 + ], + "on_ground": false + }, + { + "tick": 29, + "position": [ + 0.0, + 10.0, + 3.319676384698865 + ], + "velocity": [ + 0.0, + 0.0, + 0.06003228125037244 + ], + "on_ground": false + }, + { + "tick": 30, + "position": [ + 0.0, + 10.0, + 3.3797086659492375 + ], + "velocity": [ + 0.0, + 0.0, + 0.05402905169405407 + ], + "on_ground": false + }, + { + "tick": 31, + "position": [ + 0.0, + 10.0, + 3.4337377176432917 + ], + "velocity": [ + 0.0, + 0.0, + 0.048626145236495694 + ], + "on_ground": false + }, + { + "tick": 32, + "position": [ + 0.0, + 10.0, + 3.4823638628797875 + ], + "velocity": [ + 0.0, + 0.0, + 0.04376352955350848 + ], + "on_ground": false + }, + { + "tick": 33, + "position": [ + 0.0, + 10.0, + 3.526127392433296 + ], + "velocity": [ + 0.0, + 0.0, + 0.039387175554753774 + ], + "on_ground": false + }, + { + "tick": 34, + "position": [ + 0.0, + 10.0, + 3.5655145679880498 + ], + "velocity": [ + 0.0, + 0.0, + 0.035448457060214954 + ], + "on_ground": false + }, + { + "tick": 35, + "position": [ + 0.0, + 10.0, + 3.600963025048265 + ], + "velocity": [ + 0.0, + 0.0, + 0.03190361050903638 + ], + "on_ground": false + }, + { + "tick": 36, + "position": [ + 0.0, + 10.0, + 3.6328666355573014 + ], + "velocity": [ + 0.0, + 0.0, + 0.028713248697491395 + ], + "on_ground": false + }, + { + "tick": 37, + "position": [ + 0.0, + 10.0, + 3.6615798842547926 + ], + "velocity": [ + 0.0, + 0.0, + 0.02584192314316506 + ], + "on_ground": false + }, + { + "tick": 38, + "position": [ + 0.0, + 10.0, + 3.687421807397958 + ], + "velocity": [ + 0.0, + 0.0, + 0.023257730212729092 + ], + "on_ground": false + }, + { + "tick": 39, + "position": [ + 0.0, + 10.0, + 3.710679537610687 + ], + "velocity": [ + 0.0, + 0.0, + 0.020931956636948683 + ], + "on_ground": false + }, + { + "tick": 40, + "position": [ + 0.0, + 10.0, + 3.7316114942476357 + ], + "velocity": [ + 0.0, + 0.0, + 0.018838760474197077 + ], + "on_ground": false + } + ] + }, + "water_jump": { + "surface": "stone", + "sprint": false, + "medium": "water", + "fluid_depth": 1.0, + "flying": false, + "jump_first_tick": true, + "input_ticks": 0, + "initial_position": [ + 0.0, + 10.0, + 0.0 + ], + "initial_velocity": [ + 0.0, + 0.0, + 0.0 + ], + "samples": [ + { + "tick": 1, + "position": [ + 0.0, + 10.03999999910593, + 0.0 + ], + "velocity": [ + 0.0, + 0.02699999976158141, + 0.0 + ], + "on_ground": false + }, + { + "tick": 2, + "position": [ + 0.0, + 10.066999998867512, + 0.0 + ], + "velocity": [ + 0.0, + 0.016600000131130204, + 0.0 + ], + "on_ground": false + }, + { + "tick": 3, + "position": [ + 0.0, + 10.083599998998643, + 0.0 + ], + "velocity": [ + 0.0, + 0.008280000302791586, + 0.0 + ], + "on_ground": false + }, + { + "tick": 4, + "position": [ + 0.0, + 10.091879999301435, + 0.0 + ], + "velocity": [ + 0.0, + 0.0016240003409385643, + 0.0 + ], + "on_ground": false + }, + { + "tick": 5, + "position": [ + 0.0, + 10.091879999301435, + 0.0 + ], + "velocity": [ + 0.0, + -0.005, + 0.0 + ], + "on_ground": false + }, + { + "tick": 6, + "position": [ + 0.0, + 10.086879999301434, + 0.0 + ], + "velocity": [ + 0.0, + -0.009000000059604645, + 0.0 + ], + "on_ground": false + }, + { + "tick": 7, + "position": [ + 0.0, + 10.07787999924183, + 0.0 + ], + "velocity": [ + 0.0, + -0.012200000154972078, + 0.0 + ], + "on_ground": false + }, + { + "tick": 8, + "position": [ + 0.0, + 10.065679999086857, + 0.0 + ], + "velocity": [ + 0.0, + -0.014760000269412998, + 0.0 + ], + "on_ground": false + }, + { + "tick": 9, + "position": [ + 0.0, + 10.050919998817443, + 0.0 + ], + "velocity": [ + 0.0, + -0.016808000391483315, + 0.0 + ], + "on_ground": false + }, + { + "tick": 10, + "position": [ + 0.0, + 10.03411199842596, + 0.0 + ], + "velocity": [ + 0.0, + -0.018446400513553632, + 0.0 + ], + "on_ground": false + }, + { + "tick": 11, + "position": [ + 0.0, + 10.015665597912406, + 0.0 + ], + "velocity": [ + 0.0, + -0.019757120630741136, + 0.0 + ], + "on_ground": false + }, + { + "tick": 12, + "position": [ + 0.0, + 9.995908477281665, + 0.0 + ], + "velocity": [ + 0.0, + -0.02080569674011614, + 0.0 + ], + "on_ground": false + } + ] + }, + "lava_deep_move_stop": { + "surface": "stone", + "sprint": false, + "medium": "lava", + "fluid_depth": 1.0, + "flying": false, + "jump_first_tick": false, + "input_ticks": 20, + "initial_position": [ + 0.0, + 10.0, + 0.0 + ], + "initial_velocity": [ + 0.0, + 0.0, + 0.0 + ], + "samples": [ + { + "tick": 1, + "position": [ + 0.0, + 10.0, + 0.01959999994337558 + ], + "velocity": [ + 0.0, + -0.02, + 0.00979999997168779 + ], + "on_ground": false + }, + { + "tick": 2, + "position": [ + 0.0, + 9.98, + 0.04899999985843895 + ], + "velocity": [ + 0.0, + -0.03, + 0.014699999957531684 + ], + "on_ground": false + }, + { + "tick": 3, + "position": [ + 0.0, + 9.950000000000001, + 0.08329999975934621 + ], + "velocity": [ + 0.0, + -0.035, + 0.01714999995045363 + ], + "on_ground": false + }, + { + "tick": 4, + "position": [ + 0.0, + 9.915000000000001, + 0.12004999965317542 + ], + "velocity": [ + 0.0, + -0.037500000000000006, + 0.018374999946914605 + ], + "on_ground": false + }, + { + "tick": 5, + "position": [ + 0.0, + 9.877500000000001, + 0.1580249995434656 + ], + "velocity": [ + 0.0, + -0.03875000000000001, + 0.018987499945145092 + ], + "on_ground": false + }, + { + "tick": 6, + "position": [ + 0.0, + 9.838750000000001, + 0.19661249943198628 + ], + "velocity": [ + 0.0, + -0.03937500000000001, + 0.019293749944260336 + ], + "on_ground": false + }, + { + "tick": 7, + "position": [ + 0.0, + 9.799375000000001, + 0.2355062493196222 + ], + "velocity": [ + 0.0, + -0.0396875, + 0.019446874943817957 + ], + "on_ground": false + }, + { + "tick": 8, + "position": [ + 0.0, + 9.759687500000002, + 0.2745531242068157 + ], + "velocity": [ + 0.0, + -0.03984375, + 0.019523437443596768 + ], + "on_ground": false + }, + { + "tick": 9, + "position": [ + 0.0, + 9.719843750000003, + 0.31367656159378804 + ], + "velocity": [ + 0.0, + -0.039921874999999996, + 0.019561718693486174 + ], + "on_ground": false + }, + { + "tick": 10, + "position": [ + 0.0, + 9.679921875000003, + 0.3528382802306498 + ], + "velocity": [ + 0.0, + -0.0399609375, + 0.019580859318430878 + ], + "on_ground": false + }, + { + "tick": 11, + "position": [ + 0.0, + 9.639960937500003, + 0.39201913949245626 + ], + "velocity": [ + 0.0, + -0.03998046875, + 0.01959042963090323 + ], + "on_ground": false + }, + { + "tick": 12, + "position": [ + 0.0, + 9.599980468750003, + 0.4312095690667351 + ], + "velocity": [ + 0.0, + -0.039990234375, + 0.019595214787139402 + ], + "on_ground": false + }, + { + "tick": 13, + "position": [ + 0.0, + 9.559990234375002, + 0.47040478379725004 + ], + "velocity": [ + 0.0, + -0.0399951171875, + 0.01959760736525749 + ], + "on_ground": false + }, + { + "tick": 14, + "position": [ + 0.0, + 9.519995117187502, + 0.5096023911058831 + ], + "velocity": [ + 0.0, + -0.039997558593749996, + 0.019598803654316536 + ], + "on_ground": false + }, + { + "tick": 15, + "position": [ + 0.0, + 9.479997558593752, + 0.5488011947035752 + ], + "velocity": [ + 0.0, + -0.039998779296874995, + 0.019599401798846058 + ], + "on_ground": false + }, + { + "tick": 16, + "position": [ + 0.0, + 9.439998779296877, + 0.5880005964457968 + ], + "velocity": [ + 0.0, + -0.039999389648437494, + 0.01959970087111082 + ], + "on_ground": false + }, + { + "tick": 17, + "position": [ + 0.0, + 9.399999389648439, + 0.6272002972602833 + ], + "velocity": [ + 0.0, + -0.03999969482421875, + 0.0195998504072432 + ], + "on_ground": false + }, + { + "tick": 18, + "position": [ + 0.0, + 9.35999969482422, + 0.666400147610902 + ], + "velocity": [ + 0.0, + -0.03999984741210938, + 0.01959992517530939 + ], + "on_ground": false + }, + { + "tick": 19, + "position": [ + 0.0, + 9.31999984741211, + 0.705600072729587 + ], + "velocity": [ + 0.0, + -0.039999923706054694, + 0.019599962559342484 + ], + "on_ground": false + }, + { + "tick": 20, + "position": [ + 0.0, + 9.279999923706054, + 0.7448000352323051 + ], + "velocity": [ + 0.0, + -0.039999961853027344, + 0.019599981251359033 + ], + "on_ground": false + }, + { + "tick": 21, + "position": [ + 0.0, + 9.239999961853027, + 0.7644000164836642 + ], + "velocity": [ + 0.0, + -0.039999980926513676, + 0.009799990625679517 + ], + "on_ground": false + }, + { + "tick": 22, + "position": [ + 0.0, + 9.199999980926513, + 0.7742000071093437 + ], + "velocity": [ + 0.0, + -0.039999990463256835, + 0.004899995312839758 + ], + "on_ground": false + }, + { + "tick": 23, + "position": [ + 0.0, + 9.159999990463257, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.03999999523162842, + 0.002449997656419879 + ], + "on_ground": false + }, + { + "tick": 24, + "position": [ + 0.0, + 9.119999995231629, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.039999997615814215, + 0.0 + ], + "on_ground": false + }, + { + "tick": 25, + "position": [ + 0.0, + 9.079999997615815, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.03999999880790711, + 0.0 + ], + "on_ground": false + }, + { + "tick": 26, + "position": [ + 0.0, + 9.039999998807907, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.03999999940395356, + 0.0 + ], + "on_ground": false + }, + { + "tick": 27, + "position": [ + 0.0, + 8.999999999403954, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.03999999970197678, + 0.0 + ], + "on_ground": false + }, + { + "tick": 28, + "position": [ + 0.0, + 8.959999999701978, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.039999999850988385, + 0.0 + ], + "on_ground": false + }, + { + "tick": 29, + "position": [ + 0.0, + 8.91999999985099, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.03999999992549419, + 0.0 + ], + "on_ground": false + }, + { + "tick": 30, + "position": [ + 0.0, + 8.879999999925497, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.0399999999627471, + 0.0 + ], + "on_ground": false + }, + { + "tick": 31, + "position": [ + 0.0, + 8.83999999996275, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.039999999981373546, + 0.0 + ], + "on_ground": false + }, + { + "tick": 32, + "position": [ + 0.0, + 8.799999999981376, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.03999999999068678, + 0.0 + ], + "on_ground": false + }, + { + "tick": 33, + "position": [ + 0.0, + 8.759999999990688, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.039999999995343385, + 0.0 + ], + "on_ground": false + }, + { + "tick": 34, + "position": [ + 0.0, + 8.719999999995345, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.03999999999767169, + 0.0 + ], + "on_ground": false + }, + { + "tick": 35, + "position": [ + 0.0, + 8.679999999997673, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.03999999999883584, + 0.0 + ], + "on_ground": false + }, + { + "tick": 36, + "position": [ + 0.0, + 8.639999999998837, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.03999999999941792, + 0.0 + ], + "on_ground": false + }, + { + "tick": 37, + "position": [ + 0.0, + 8.599999999999419, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.03999999999970896, + 0.0 + ], + "on_ground": false + }, + { + "tick": 38, + "position": [ + 0.0, + 8.55999999999971, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.039999999999854485, + 0.0 + ], + "on_ground": false + }, + { + "tick": 39, + "position": [ + 0.0, + 8.519999999999854, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.039999999999927247, + 0.0 + ], + "on_ground": false + }, + { + "tick": 40, + "position": [ + 0.0, + 8.479999999999926, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.03999999999996362, + 0.0 + ], + "on_ground": false + } + ] + }, + "lava_shallow_move_stop": { + "surface": "stone", + "sprint": false, + "medium": "lava", + "fluid_depth": 0.2, + "flying": false, + "jump_first_tick": false, + "input_ticks": 20, + "initial_position": [ + 0.0, + 10.0, + 0.0 + ], + "initial_velocity": [ + 0.0, + 0.0, + 0.0 + ], + "samples": [ + { + "tick": 1, + "position": [ + 0.0, + 10.0, + 0.01959999994337558 + ], + "velocity": [ + 0.0, + -0.025, + 0.00979999997168779 + ], + "on_ground": false + }, + { + "tick": 2, + "position": [ + 0.0, + 9.975, + 0.04899999985843895 + ], + "velocity": [ + 0.0, + -0.04500000029802323, + 0.014699999957531684 + ], + "on_ground": false + }, + { + "tick": 3, + "position": [ + 0.0, + 9.929999999701977, + 0.08329999975934621 + ], + "velocity": [ + 0.0, + -0.06100000077486038, + 0.01714999995045363 + ], + "on_ground": false + }, + { + "tick": 4, + "position": [ + 0.0, + 9.868999998927116, + 0.12004999965317542 + ], + "velocity": [ + 0.0, + -0.07380000134706498, + 0.018374999946914605 + ], + "on_ground": false + }, + { + "tick": 5, + "position": [ + 0.0, + 9.79519999758005, + 0.1580249995434656 + ], + "velocity": [ + 0.0, + -0.08404000195741657, + 0.018987499945145092 + ], + "on_ground": false + }, + { + "tick": 6, + "position": [ + 0.0, + 9.711159995622634, + 0.19661249943198628 + ], + "velocity": [ + 0.0, + -0.09223200256776816, + 0.019293749944260336 + ], + "on_ground": false + }, + { + "tick": 7, + "position": [ + 0.0, + 9.618927993054866, + 0.2355062493196222 + ], + "velocity": [ + 0.0, + -0.09878560315370569, + 0.019446874943817957 + ], + "on_ground": false + }, + { + "tick": 8, + "position": [ + 0.0, + 9.52014238990116, + 0.2745531242068157 + ], + "velocity": [ + 0.0, + -0.10402848370058071, + 0.019523437443596768 + ], + "on_ground": false + }, + { + "tick": 9, + "position": [ + 0.0, + 9.416113906200579, + 0.31367656159378804 + ], + "velocity": [ + 0.0, + -0.10822278820058073, + 0.019561718693486174 + ], + "on_ground": false + }, + { + "tick": 10, + "position": [ + 0.0, + 9.307891117999999, + 0.3528382802306498 + ], + "velocity": [ + 0.0, + -0.11157823185058076, + 0.019580859318430878 + ], + "on_ground": false + }, + { + "tick": 11, + "position": [ + 0.0, + 9.196312886149418, + 0.39201913949245626 + ], + "velocity": [ + 0.0, + -0.1142625868105808, + 0.01959042963090323 + ], + "on_ground": false + }, + { + "tick": 12, + "position": [ + 0.0, + 9.082050299338837, + 0.4312095690667351 + ], + "velocity": [ + 0.0, + -0.11641007081058083, + 0.019595214787139402 + ], + "on_ground": false + }, + { + "tick": 13, + "position": [ + 0.0, + 8.965640228528256, + 0.47040478379725004 + ], + "velocity": [ + 0.0, + -0.11812805803618086, + 0.01959760736525749 + ], + "on_ground": false + }, + { + "tick": 14, + "position": [ + 0.0, + 8.847512170492076, + 0.5096023911058831 + ], + "velocity": [ + 0.0, + -0.11950244783714088, + 0.019598803654316536 + ], + "on_ground": false + }, + { + "tick": 15, + "position": [ + 0.0, + 8.728009722654935, + 0.5488011947035752 + ], + "velocity": [ + 0.0, + -0.12060195969429291, + 0.019599401798846058 + ], + "on_ground": false + }, + { + "tick": 16, + "position": [ + 0.0, + 8.607407762960642, + 0.5880005964457968 + ], + "velocity": [ + 0.0, + -0.12148156919312172, + 0.01959970087111082 + ], + "on_ground": false + }, + { + "tick": 17, + "position": [ + 0.0, + 8.48592619376752, + 0.6272002972602833 + ], + "velocity": [ + 0.0, + -0.12218525680267055, + 0.0195998504072432 + ], + "on_ground": false + }, + { + "tick": 18, + "position": [ + 0.0, + 8.36374093696485, + 0.666400147610902 + ], + "velocity": [ + 0.0, + -0.12274820689869821, + 0.01959992517530939 + ], + "on_ground": false + }, + { + "tick": 19, + "position": [ + 0.0, + 8.240992730066152, + 0.705600072729587 + ], + "velocity": [ + 0.0, + -0.12319856698223124, + 0.019599962559342484 + ], + "on_ground": false + }, + { + "tick": 20, + "position": [ + 0.0, + 8.117794163083921, + 0.7448000352323051 + ], + "velocity": [ + 0.0, + -0.12355885505442636, + 0.019599981251359033 + ], + "on_ground": false + }, + { + "tick": 21, + "position": [ + 0.0, + 7.994235308029495, + 0.7644000164836642 + ], + "velocity": [ + 0.0, + -0.12384708551647743, + 0.009799990625679517 + ], + "on_ground": false + }, + { + "tick": 22, + "position": [ + 0.0, + 7.870388222513018, + 0.7742000071093437 + ], + "velocity": [ + 0.0, + -0.12407766988955425, + 0.004899995312839758 + ], + "on_ground": false + }, + { + "tick": 23, + "position": [ + 0.0, + 7.746310552623464, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.1242621373907645, + 0.002449997656419879 + ], + "on_ground": false + }, + { + "tick": 24, + "position": [ + 0.0, + 7.622048415232699, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12440971139393171, + 0.0 + ], + "on_ground": false + }, + { + "tick": 25, + "position": [ + 0.0, + 7.497638703838768, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12452777059822472, + 0.0 + ], + "on_ground": false + }, + { + "tick": 26, + "position": [ + 0.0, + 7.373110933240543, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12462221796306648, + 0.0 + ], + "on_ground": false + }, + { + "tick": 27, + "position": [ + 0.0, + 7.248488715277476, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.1246977758560658, + 0.0 + ], + "on_ground": false + }, + { + "tick": 28, + "position": [ + 0.0, + 7.1237909394214105, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12475822217136598, + 0.0 + ], + "on_ground": false + }, + { + "tick": 29, + "position": [ + 0.0, + 6.999032717250045, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.1248065792243267, + 0.0 + ], + "on_ground": false + }, + { + "tick": 30, + "position": [ + 0.0, + 6.874226138025718, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12484526486727172, + 0.0 + ], + "on_ground": false + }, + { + "tick": 31, + "position": [ + 0.0, + 6.749380873158446, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12487621338208892, + 0.0 + ], + "on_ground": false + }, + { + "tick": 32, + "position": [ + 0.0, + 6.624504659776357, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12490097219431161, + 0.0 + ], + "on_ground": false + }, + { + "tick": 33, + "position": [ + 0.0, + 6.499603687582045, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12492077924438491, + 0.0 + ], + "on_ground": false + }, + { + "tick": 34, + "position": [ + 0.0, + 6.37468290833766, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12493662488467967, + 0.0 + ], + "on_ground": false + }, + { + "tick": 35, + "position": [ + 0.0, + 6.24974628345298, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12494930139710438, + 0.0 + ], + "on_ground": false + }, + { + "tick": 36, + "position": [ + 0.0, + 6.124796982055876, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12495944260719526, + 0.0 + ], + "on_ground": false + }, + { + "tick": 37, + "position": [ + 0.0, + 5.999837539448681, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12496755557538886, + 0.0 + ], + "on_ground": false + }, + { + "tick": 38, + "position": [ + 0.0, + 5.874869983873292, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12497404595004044, + 0.0 + ], + "on_ground": false + }, + { + "tick": 39, + "position": [ + 0.0, + 5.7498959379232515, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.12497923824983909, + 0.0 + ], + "on_ground": false + }, + { + "tick": 40, + "position": [ + 0.0, + 5.624916699673412, + 0.7791000024221835 + ], + "velocity": [ + 0.0, + -0.1249833920897399, + 0.0 + ], + "on_ground": false + } + ] + }, + "creative_fly_move_stop": { + "surface": "stone", + "sprint": false, + "medium": "air", + "fluid_depth": 0.0, + "flying": true, + "jump_first_tick": false, + "input_ticks": 20, + "initial_position": [ + 0.0, + 10.0, + 0.0 + ], + "initial_velocity": [ + 0.0, + 0.0, + 0.0 + ], + "samples": [ + { + "tick": 1, + "position": [ + 0.0, + 10.0, + 0.04900000168383123 + ], + "velocity": [ + 0.0, + 0.0, + 0.044590002817362605 + ], + "on_ground": false + }, + { + "tick": 2, + "position": [ + 0.0, + 10.0, + 0.14259000618502507 + ], + "velocity": [ + 0.0, + 0.0, + 0.08516690655058194 + ], + "on_ground": false + }, + { + "tick": 3, + "position": [ + 0.0, + 10.0, + 0.2767569144194383 + ], + "velocity": [ + 0.0, + 0.0, + 0.1220918900119832 + ], + "on_ground": false + }, + { + "tick": 4, + "position": [ + 0.0, + 10.0, + 0.4478488061152527 + ], + "velocity": [ + 0.0, + 0.0, + 0.15569362593025457 + ], + "on_ground": false + }, + { + "tick": 5, + "position": [ + 0.0, + 10.0, + 0.6525424337293385 + ], + "velocity": [ + 0.0, + 0.0, + 0.1862712064971221 + ], + "on_ground": false + }, + { + "tick": 6, + "position": [ + 0.0, + 10.0, + 0.8878136419102918 + ], + "velocity": [ + 0.0, + 0.0, + 0.2140968056149005 + ], + "on_ground": false + }, + { + "tick": 7, + "position": [ + 0.0, + 10.0, + 1.1509104492090234 + ], + "velocity": [ + 0.0, + 0.0, + 0.23941810154183427 + ], + "on_ground": false + }, + { + "tick": 8, + "position": [ + 0.0, + 10.0, + 1.4393285524346888 + ], + "velocity": [ + 0.0, + 0.0, + 0.2624604814994214 + ], + "on_ground": false + }, + { + "tick": 9, + "position": [ + 0.0, + 10.0, + 1.7507890356179414 + ], + "velocity": [ + 0.0, + 0.0, + 0.2834290478651362 + ], + "on_ground": false + }, + { + "tick": 10, + "position": [ + 0.0, + 10.0, + 2.0832180851669087 + ], + "velocity": [ + 0.0, + 0.0, + 0.30251044380785913 + ], + "on_ground": false + }, + { + "tick": 11, + "position": [ + 0.0, + 10.0, + 2.434728530658599 + ], + "velocity": [ + 0.0, + 0.0, + 0.3198745146161665 + ], + "on_ground": false + }, + { + "tick": 12, + "position": [ + 0.0, + 10.0, + 2.803603046958597 + ], + "velocity": [ + 0.0, + 0.0, + 0.3356758195071171 + ], + "on_ground": false + }, + { + "tick": 13, + "position": [ + 0.0, + 10.0, + 3.188278868149545 + ], + "velocity": [ + 0.0, + 0.0, + 0.3500550073722879 + ], + "on_ground": false + }, + { + "tick": 14, + "position": [ + 0.0, + 10.0, + 3.587333877205664 + ], + "velocity": [ + 0.0, + 0.0, + 0.3631400687067025 + ], + "on_ground": false + }, + { + "tick": 15, + "position": [ + 0.0, + 10.0, + 3.999473947596198 + ], + "velocity": [ + 0.0, + 0.0, + 0.3750474748641892 + ], + "on_ground": false + }, + { + "tick": 16, + "position": [ + 0.0, + 10.0, + 4.4235214241442185 + ], + "velocity": [ + 0.0, + 0.0, + 0.38588321477978627 + ], + "on_ground": false + }, + { + "tick": 17, + "position": [ + 0.0, + 10.0, + 4.858404640607836 + ], + "velocity": [ + 0.0, + 0.0, + 0.39574373838715815 + ], + "on_ground": false + }, + { + "tick": 18, + "position": [ + 0.0, + 10.0, + 5.3031483806788255 + ], + "velocity": [ + 0.0, + 0.0, + 0.4047168151284691 + ], + "on_ground": false + }, + { + "tick": 19, + "position": [ + 0.0, + 10.0, + 5.756865197491126 + ], + "velocity": [ + 0.0, + 0.0, + 0.41288231519839036 + ], + "on_ground": false + }, + { + "tick": 20, + "position": [ + 0.0, + 10.0, + 6.218747514373348 + ], + "velocity": [ + 0.0, + 0.0, + 0.4203129204761675 + ], + "on_ground": false + }, + { + "tick": 21, + "position": [ + 0.0, + 10.0, + 6.639060434849515 + ], + "velocity": [ + 0.0, + 0.0, + 0.3824847686564574 + ], + "on_ground": false + }, + { + "tick": 22, + "position": [ + 0.0, + 10.0, + 7.021545203505973 + ], + "velocity": [ + 0.0, + 0.0, + 0.3480611495084385 + ], + "on_ground": false + }, + { + "tick": 23, + "position": [ + 0.0, + 10.0, + 7.369606353014412 + ], + "velocity": [ + 0.0, + 0.0, + 0.316735655180946 + ], + "on_ground": false + }, + { + "tick": 24, + "position": [ + 0.0, + 10.0, + 7.686342008195358 + ], + "velocity": [ + 0.0, + 0.0, + 0.288229454521384 + ], + "on_ground": false + }, + { + "tick": 25, + "position": [ + 0.0, + 10.0, + 7.974571462716742 + ], + "velocity": [ + 0.0, + 0.0, + 0.2622888111735777 + ], + "on_ground": false + }, + { + "tick": 26, + "position": [ + 0.0, + 10.0, + 8.23686027389032 + ], + "velocity": [ + 0.0, + 0.0, + 0.23868282504675356 + ], + "on_ground": false + }, + { + "tick": 27, + "position": [ + 0.0, + 10.0, + 8.475543098937074 + ], + "velocity": [ + 0.0, + 0.0, + 0.21720137705225195 + ], + "on_ground": false + }, + { + "tick": 28, + "position": [ + 0.0, + 10.0, + 8.692744475989326 + ], + "velocity": [ + 0.0, + 0.0, + 0.19765325881388207 + ], + "on_ground": false + }, + { + "tick": 29, + "position": [ + 0.0, + 10.0, + 8.890397734803207 + ], + "velocity": [ + 0.0, + 0.0, + 0.17986447070429568 + ], + "on_ground": false + }, + { + "tick": 30, + "position": [ + 0.0, + 10.0, + 9.070262205507502 + ], + "velocity": [ + 0.0, + 0.0, + 0.16367667305804254 + ], + "on_ground": false + }, + { + "tick": 31, + "position": [ + 0.0, + 10.0, + 9.233938878565544 + ], + "velocity": [ + 0.0, + 0.0, + 0.1489457767754103 + ], + "on_ground": false + }, + { + "tick": 32, + "position": [ + 0.0, + 10.0, + 9.382884655340954 + ], + "velocity": [ + 0.0, + 0.0, + 0.13554066077188182 + ], + "on_ground": false + }, + { + "tick": 33, + "position": [ + 0.0, + 10.0, + 9.518425316112836 + ], + "velocity": [ + 0.0, + 0.0, + 0.12334200485710775 + ], + "on_ground": false + }, + { + "tick": 34, + "position": [ + 0.0, + 10.0, + 9.641767320969944 + ], + "velocity": [ + 0.0, + 0.0, + 0.11224122765474086 + ], + "on_ground": false + }, + { + "tick": 35, + "position": [ + 0.0, + 10.0, + 9.754008548624684 + ], + "velocity": [ + 0.0, + 0.0, + 0.10213952010945752 + ], + "on_ground": false + }, + { + "tick": 36, + "position": [ + 0.0, + 10.0, + 9.856148068734141 + ], + "velocity": [ + 0.0, + 0.0, + 0.09294696597832186 + ], + "on_ground": false + }, + { + "tick": 37, + "position": [ + 0.0, + 10.0, + 9.949095034712464 + ], + "velocity": [ + 0.0, + 0.0, + 0.08458174147790408 + ], + "on_ground": false + }, + { + "tick": 38, + "position": [ + 0.0, + 10.0, + 10.033676776190369 + ], + "velocity": [ + 0.0, + 0.0, + 0.07696938696313715 + ], + "on_ground": false + }, + { + "tick": 39, + "position": [ + 0.0, + 10.0, + 10.110646163153506 + ], + "velocity": [ + 0.0, + 0.0, + 0.07004214415505731 + ], + "on_ground": false + }, + { + "tick": 40, + "position": [ + 0.0, + 10.0, + 10.180688307308563 + ], + "velocity": [ + 0.0, + 0.0, + 0.06373835301803049 + ], + "on_ground": false + } + ] + }, + "creative_fly_sprint_stop": { + "surface": "stone", + "sprint": true, + "medium": "air", + "fluid_depth": 0.0, + "flying": true, + "jump_first_tick": false, + "input_ticks": 20, + "initial_position": [ + 0.0, + 10.0, + 0.0 + ], + "initial_velocity": [ + 0.0, + 0.0, + 0.0 + ], + "samples": [ + { + "tick": 1, + "position": [ + 0.0, + 10.0, + 0.09800000336766246 + ], + "velocity": [ + 0.0, + 0.0, + 0.08918000563472521 + ], + "on_ground": false + }, + { + "tick": 2, + "position": [ + 0.0, + 10.0, + 0.28518001237005014 + ], + "velocity": [ + 0.0, + 0.0, + 0.17033381310116388 + ], + "on_ground": false + }, + { + "tick": 3, + "position": [ + 0.0, + 10.0, + 0.5535138288388766 + ], + "velocity": [ + 0.0, + 0.0, + 0.2441837800239664 + ], + "on_ground": false + }, + { + "tick": 4, + "position": [ + 0.0, + 10.0, + 0.8956976122305054 + ], + "velocity": [ + 0.0, + 0.0, + 0.31138725186050914 + ], + "on_ground": false + }, + { + "tick": 5, + "position": [ + 0.0, + 10.0, + 1.305084867458677 + ], + "velocity": [ + 0.0, + 0.0, + 0.3725424129942442 + ], + "on_ground": false + }, + { + "tick": 6, + "position": [ + 0.0, + 10.0, + 1.7756272838205835 + ], + "velocity": [ + 0.0, + 0.0, + 0.428193611229801 + ], + "on_ground": false + }, + { + "tick": 7, + "position": [ + 0.0, + 10.0, + 2.301820898418047 + ], + "velocity": [ + 0.0, + 0.0, + 0.47883620308366853 + ], + "on_ground": false + }, + { + "tick": 8, + "position": [ + 0.0, + 10.0, + 2.8786571048693776 + ], + "velocity": [ + 0.0, + 0.0, + 0.5249209629988428 + ], + "on_ground": false + }, + { + "tick": 9, + "position": [ + 0.0, + 10.0, + 3.501578071235883 + ], + "velocity": [ + 0.0, + 0.0, + 0.5668580957302723 + ], + "on_ground": false + }, + { + "tick": 10, + "position": [ + 0.0, + 10.0, + 4.166436170333817 + ], + "velocity": [ + 0.0, + 0.0, + 0.6050208876157183 + ], + "on_ground": false + }, + { + "tick": 11, + "position": [ + 0.0, + 10.0, + 4.869457061317198 + ], + "velocity": [ + 0.0, + 0.0, + 0.639749029232333 + ], + "on_ground": false + }, + { + "tick": 12, + "position": [ + 0.0, + 10.0, + 5.607206093917194 + ], + "velocity": [ + 0.0, + 0.0, + 0.6713516390142342 + ], + "on_ground": false + }, + { + "tick": 13, + "position": [ + 0.0, + 10.0, + 6.37655773629909 + ], + "velocity": [ + 0.0, + 0.0, + 0.7001100147445758 + ], + "on_ground": false + }, + { + "tick": 14, + "position": [ + 0.0, + 10.0, + 7.174667754411328 + ], + "velocity": [ + 0.0, + 0.0, + 0.726280137413405 + ], + "on_ground": false + }, + { + "tick": 15, + "position": [ + 0.0, + 10.0, + 7.998947895192396 + ], + "velocity": [ + 0.0, + 0.0, + 0.7500949497283784 + ], + "on_ground": false + }, + { + "tick": 16, + "position": [ + 0.0, + 10.0, + 8.847042848288437 + ], + "velocity": [ + 0.0, + 0.0, + 0.7717664295595725 + ], + "on_ground": false + }, + { + "tick": 17, + "position": [ + 0.0, + 10.0, + 9.716809281215673 + ], + "velocity": [ + 0.0, + 0.0, + 0.7914874767743163 + ], + "on_ground": false + }, + { + "tick": 18, + "position": [ + 0.0, + 10.0, + 10.606296761357651 + ], + "velocity": [ + 0.0, + 0.0, + 0.8094336302569382 + ], + "on_ground": false + }, + { + "tick": 19, + "position": [ + 0.0, + 10.0, + 11.513730394982252 + ], + "velocity": [ + 0.0, + 0.0, + 0.8257646303967807 + ], + "on_ground": false + }, + { + "tick": 20, + "position": [ + 0.0, + 10.0, + 12.437495028746696 + ], + "velocity": [ + 0.0, + 0.0, + 0.840625840952335 + ], + "on_ground": false + }, + { + "tick": 21, + "position": [ + 0.0, + 10.0, + 13.27812086969903 + ], + "velocity": [ + 0.0, + 0.0, + 0.7649695373129148 + ], + "on_ground": false + }, + { + "tick": 22, + "position": [ + 0.0, + 10.0, + 14.043090407011945 + ], + "velocity": [ + 0.0, + 0.0, + 0.696122299016877 + ], + "on_ground": false + }, + { + "tick": 23, + "position": [ + 0.0, + 10.0, + 14.739212706028823 + ], + "velocity": [ + 0.0, + 0.0, + 0.633471310361892 + ], + "on_ground": false + }, + { + "tick": 24, + "position": [ + 0.0, + 10.0, + 15.372684016390716 + ], + "velocity": [ + 0.0, + 0.0, + 0.576458909042768 + ], + "on_ground": false + }, + { + "tick": 25, + "position": [ + 0.0, + 10.0, + 15.949142925433485 + ], + "velocity": [ + 0.0, + 0.0, + 0.5245776223471554 + ], + "on_ground": false + }, + { + "tick": 26, + "position": [ + 0.0, + 10.0, + 16.47372054778064 + ], + "velocity": [ + 0.0, + 0.0, + 0.4773656500935071 + ], + "on_ground": false + }, + { + "tick": 27, + "position": [ + 0.0, + 10.0, + 16.951086197874147 + ], + "velocity": [ + 0.0, + 0.0, + 0.4344027541045039 + ], + "on_ground": false + }, + { + "tick": 28, + "position": [ + 0.0, + 10.0, + 17.38548895197865 + ], + "velocity": [ + 0.0, + 0.0, + 0.39530651762776414 + ], + "on_ground": false + }, + { + "tick": 29, + "position": [ + 0.0, + 10.0, + 17.780795469606414 + ], + "velocity": [ + 0.0, + 0.0, + 0.35972894140859135 + ], + "on_ground": false + }, + { + "tick": 30, + "position": [ + 0.0, + 10.0, + 18.140524411015004 + ], + "velocity": [ + 0.0, + 0.0, + 0.3273533461160851 + ], + "on_ground": false + }, + { + "tick": 31, + "position": [ + 0.0, + 10.0, + 18.467877757131088 + ], + "velocity": [ + 0.0, + 0.0, + 0.2978915535508206 + ], + "on_ground": false + }, + { + "tick": 32, + "position": [ + 0.0, + 10.0, + 18.765769310681907 + ], + "velocity": [ + 0.0, + 0.0, + 0.27108132154376363 + ], + "on_ground": false + }, + { + "tick": 33, + "position": [ + 0.0, + 10.0, + 19.036850632225672 + ], + "velocity": [ + 0.0, + 0.0, + 0.2466840097142155 + ], + "on_ground": false + }, + { + "tick": 34, + "position": [ + 0.0, + 10.0, + 19.283534641939887 + ], + "velocity": [ + 0.0, + 0.0, + 0.22448245530948172 + ], + "on_ground": false + }, + { + "tick": 35, + "position": [ + 0.0, + 10.0, + 19.50801709724937 + ], + "velocity": [ + 0.0, + 0.0, + 0.20427904021891505 + ], + "on_ground": false + }, + { + "tick": 36, + "position": [ + 0.0, + 10.0, + 19.712296137468282 + ], + "velocity": [ + 0.0, + 0.0, + 0.18589393195664372 + ], + "on_ground": false + }, + { + "tick": 37, + "position": [ + 0.0, + 10.0, + 19.898190069424928 + ], + "velocity": [ + 0.0, + 0.0, + 0.16916348295580816 + ], + "on_ground": false + }, + { + "tick": 38, + "position": [ + 0.0, + 10.0, + 20.067353552380737 + ], + "velocity": [ + 0.0, + 0.0, + 0.1539387739262743 + ], + "on_ground": false + }, + { + "tick": 39, + "position": [ + 0.0, + 10.0, + 20.22129232630701 + ], + "velocity": [ + 0.0, + 0.0, + 0.14008428831011463 + ], + "on_ground": false + }, + { + "tick": 40, + "position": [ + 0.0, + 10.0, + 20.361376614617125 + ], + "velocity": [ + 0.0, + 0.0, + 0.12747670603606098 + ], + "on_ground": false + } + ] + } + }, + "callback_measurements": { + "vertical_collision_restitution": [ + { + "surface": "stone", + "incoming_y": -0.0784000015258789, + "moved_fraction": 0.0, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "stone", + "incoming_y": -0.0784000015258789, + "moved_fraction": 0.25, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "stone", + "incoming_y": -0.0784000015258789, + "moved_fraction": 0.9, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "stone", + "incoming_y": -0.3, + "moved_fraction": 0.0, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "stone", + "incoming_y": -0.3, + "moved_fraction": 0.25, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "stone", + "incoming_y": -0.3, + "moved_fraction": 0.9, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "stone", + "incoming_y": -1.0, + "moved_fraction": 0.0, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "stone", + "incoming_y": -1.0, + "moved_fraction": 0.25, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "stone", + "incoming_y": -1.0, + "moved_fraction": 0.9, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "slime_block", + "incoming_y": -0.0784000015258789, + "moved_fraction": 0.0, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "slime_block", + "incoming_y": -0.0784000015258789, + "moved_fraction": 0.25, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "slime_block", + "incoming_y": -0.0784000015258789, + "moved_fraction": 0.9, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "slime_block", + "incoming_y": -0.3, + "moved_fraction": 0.0, + "velocity": [ + 0.1, + 0.3, + 0.2 + ] + }, + { + "surface": "slime_block", + "incoming_y": -0.3, + "moved_fraction": 0.25, + "velocity": [ + 0.1, + 0.31840000152587894, + 0.2 + ] + }, + { + "surface": "slime_block", + "incoming_y": -0.3, + "moved_fraction": 0.9, + "velocity": [ + 0.1, + 0.36530400638580324, + 0.2 + ] + }, + { + "surface": "slime_block", + "incoming_y": -1.0, + "moved_fraction": 0.0, + "velocity": [ + 0.1, + 1.0, + 0.2 + ] + }, + { + "surface": "slime_block", + "incoming_y": -1.0, + "moved_fraction": 0.25, + "velocity": [ + 0.1, + 1.014900004863739, + 0.2 + ] + }, + { + "surface": "slime_block", + "incoming_y": -1.0, + "moved_fraction": 0.9, + "velocity": [ + 0.1, + 1.0527040184020997, + 0.2 + ] + }, + { + "surface": "white_bed", + "incoming_y": -0.0784000015258789, + "moved_fraction": 0.0, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "white_bed", + "incoming_y": -0.0784000015258789, + "moved_fraction": 0.25, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "white_bed", + "incoming_y": -0.0784000015258789, + "moved_fraction": 0.9, + "velocity": [ + 0.1, + 0.0, + 0.2 + ] + }, + { + "surface": "white_bed", + "incoming_y": -0.3, + "moved_fraction": 0.0, + "velocity": [ + 0.1, + 0.22499999999999998, + 0.2 + ] + }, + { + "surface": "white_bed", + "incoming_y": -0.3, + "moved_fraction": 0.25, + "velocity": [ + 0.1, + 0.2388000011444092, + 0.2 + ] + }, + { + "surface": "white_bed", + "incoming_y": -0.3, + "moved_fraction": 0.9, + "velocity": [ + 0.1, + 0.2739780047893524, + 0.2 + ] + }, + { + "surface": "white_bed", + "incoming_y": -1.0, + "moved_fraction": 0.0, + "velocity": [ + 0.1, + 0.75, + 0.2 + ] + }, + { + "surface": "white_bed", + "incoming_y": -1.0, + "moved_fraction": 0.25, + "velocity": [ + 0.1, + 0.7611750036478043, + 0.2 + ] + }, + { + "surface": "white_bed", + "incoming_y": -1.0, + "moved_fraction": 0.9, + "velocity": [ + 0.1, + 0.7895280138015748, + 0.2 + ] + } + ], + "honey_slide_callback": [ + { + "incoming_y": -0.16, + "velocity": [ + 0.1, + -0.12740000247955321, + 0.2 + ] + }, + { + "incoming_y": -0.3, + "velocity": [ + 0.022111913940015954, + -0.12740000247955321, + 0.04422382788003191 + ] + }, + { + "incoming_y": -1.0, + "velocity": [ + 0.005316840390061075, + -0.12740000247955321, + 0.01063368078012215 + ] + } + ], + "slime_step_callback": [ + { + "incoming_y": 0.0, + "velocity": [ + 0.04000000000000001, + 0.0, + 0.08000000000000002 + ] + }, + { + "incoming_y": -0.0784000015258789, + "velocity": [ + 0.04156800003051758, + -0.0784000015258789, + 0.08313600006103516 + ] + }, + { + "incoming_y": 0.05, + "velocity": [ + 0.04100000000000001, + 0.05, + 0.08200000000000002 + ] + }, + { + "incoming_y": 0.2, + "velocity": [ + 0.1, + 0.2, + 0.2 + ] + } + ], + "bubble_column_callback": [ + { + "above": false, + "down": false, + "incoming_y": -1.0, + "velocity": [ + 0.1, + -0.94, + 0.2 + ] + }, + { + "above": false, + "down": false, + "incoming_y": 0.0, + "velocity": [ + 0.1, + 0.06, + 0.2 + ] + }, + { + "above": false, + "down": false, + "incoming_y": 1.0, + "velocity": [ + 0.1, + 0.7, + 0.2 + ] + }, + { + "above": false, + "down": true, + "incoming_y": -1.0, + "velocity": [ + 0.1, + -0.3, + 0.2 + ] + }, + { + "above": false, + "down": true, + "incoming_y": 0.0, + "velocity": [ + 0.1, + -0.03, + 0.2 + ] + }, + { + "above": false, + "down": true, + "incoming_y": 1.0, + "velocity": [ + 0.1, + 0.97, + 0.2 + ] + }, + { + "above": true, + "down": false, + "incoming_y": -1.0, + "velocity": [ + 0.1, + -0.9, + 0.2 + ] + }, + { + "above": true, + "down": false, + "incoming_y": 0.0, + "velocity": [ + 0.1, + 0.1, + 0.2 + ] + }, + { + "above": true, + "down": false, + "incoming_y": 1.0, + "velocity": [ + 0.1, + 1.1, + 0.2 + ] + }, + { + "above": true, + "down": true, + "incoming_y": -1.0, + "velocity": [ + 0.1, + -0.9, + 0.2 + ] + }, + { + "above": true, + "down": true, + "incoming_y": 0.0, + "velocity": [ + 0.1, + -0.03, + 0.2 + ] + }, + { + "above": true, + "down": true, + "incoming_y": 1.0, + "velocity": [ + 0.1, + 0.97, + 0.2 + ] + } + ] + }, + "collision_cases": [ + { + "name": "half_slab", + "on_ground": true, + "position": [ + 0.0, + 0.0, + 0.0 + ], + "requested": [ + 0.8, + -0.0784, + 0.0 + ], + "result": [ + 0.8, + 0.5, + 0.0 + ], + "step_height": 0.6000000238418579, + "boxes": [ + { + "min": [ + -10.0, + -1.0, + -10.0 + ], + "max": [ + 10.0, + 0.0, + 10.0 + ] + }, + { + "min": [ + 0.8, + 0.0, + -1.0 + ], + "max": [ + 1.8, + 0.5, + 1.0 + ] + } + ] + }, + { + "name": "half_slab_low_ceiling", + "on_ground": true, + "position": [ + 0.0, + 0.0, + 0.0 + ], + "requested": [ + 0.8, + -0.0784, + 0.0 + ], + "result": [ + 0.8, + 0.5, + 0.0 + ], + "step_height": 0.6000000238418579, + "boxes": [ + { + "min": [ + -10.0, + -1.0, + -10.0 + ], + "max": [ + 10.0, + 0.0, + 10.0 + ] + }, + { + "min": [ + 0.8, + 0.0, + -1.0 + ], + "max": [ + 1.8, + 0.5, + 1.0 + ] + }, + { + "min": [ + -1.0, + 2.3, + -1.0 + ], + "max": [ + 2.0, + 2.4, + 1.0 + ] + } + ] + }, + { + "name": "full_block", + "on_ground": true, + "position": [ + 0.0, + 0.0, + 0.0 + ], + "requested": [ + 0.8, + -0.0784, + 0.0 + ], + "result": [ + 0.4999999880790711, + 0.0, + 0.0 + ], + "step_height": 0.6000000238418579, + "boxes": [ + { + "min": [ + -10.0, + -1.0, + -10.0 + ], + "max": [ + 10.0, + 0.0, + 10.0 + ] + }, + { + "min": [ + 0.8, + 0.0, + -1.0 + ], + "max": [ + 1.8, + 1.0, + 1.0 + ] + } + ] + }, + { + "name": "thin_step", + "on_ground": true, + "position": [ + 0.0, + 0.0, + 0.0 + ], + "requested": [ + 0.8, + -0.0784, + 0.0 + ], + "result": [ + 0.8, + 0.0625, + 0.0 + ], + "step_height": 0.6000000238418579, + "boxes": [ + { + "min": [ + -10.0, + -1.0, + -10.0 + ], + "max": [ + 10.0, + 0.0, + 10.0 + ] + }, + { + "min": [ + 0.8, + 0.0, + -1.0 + ], + "max": [ + 1.8, + 0.0625, + 1.0 + ] + } + ] + }, + { + "name": "lowest_improving_step", + "on_ground": true, + "position": [ + 0.0, + 0.0, + 0.0 + ], + "requested": [ + 0.8, + -0.0784, + 0.0 + ], + "result": [ + 0.4999999880790711, + 0.125, + 0.0 + ], + "step_height": 0.6000000238418579, + "boxes": [ + { + "min": [ + -10.0, + -1.0, + -10.0 + ], + "max": [ + 10.0, + 0.0, + 10.0 + ] + }, + { + "min": [ + 0.4, + 0.0, + -1.0 + ], + "max": [ + 1.4, + 0.125, + 1.0 + ] + }, + { + "min": [ + 0.8, + 0.0, + -1.0 + ], + "max": [ + 1.8, + 0.5, + 1.0 + ] + } + ] + }, + { + "name": "descending_into_step", + "on_ground": false, + "position": [ + 0.0, + 0.2, + 0.0 + ], + "requested": [ + 0.8, + -0.4, + 0.0 + ], + "result": [ + 0.8, + 0.3, + 0.0 + ], + "step_height": 0.6000000238418579, + "boxes": [ + { + "min": [ + -10.0, + -1.0, + -10.0 + ], + "max": [ + 10.0, + 0.0, + 10.0 + ] + }, + { + "min": [ + 0.8, + 0.0, + -1.0 + ], + "max": [ + 1.8, + 0.5, + 1.0 + ] + } + ] + }, + { + "name": "corner_major_z", + "on_ground": true, + "position": [ + 0.0, + 0.0, + 0.0 + ], + "requested": [ + 0.8, + -0.0784, + 0.9 + ], + "result": [ + 0.8, + 0.5, + 0.4999999880790711 + ], + "step_height": 0.6000000238418579, + "boxes": [ + { + "min": [ + -10.0, + -1.0, + -10.0 + ], + "max": [ + 10.0, + 0.0, + 10.0 + ] + }, + { + "min": [ + 0.8, + 0.0, + -1.0 + ], + "max": [ + 1.8, + 0.5, + 1.0 + ] + }, + { + "min": [ + -1.0, + 0.0, + 0.8 + ], + "max": [ + 2.0, + 2.0, + 1.8 + ] + } + ] + }, + { + "name": "airborne_no_step", + "on_ground": false, + "position": [ + 0.0, + 0.0, + 0.0 + ], + "requested": [ + 0.8, + 0.1, + 0.0 + ], + "result": [ + 0.4999999880790711, + 0.1, + 0.0 + ], + "step_height": 0.6000000238418579, + "boxes": [ + { + "min": [ + -10.0, + -1.0, + -10.0 + ], + "max": [ + 10.0, + 0.0, + 10.0 + ] + }, + { + "min": [ + 0.8, + 0.0, + -1.0 + ], + "max": [ + 1.8, + 0.5, + 1.0 + ] + } + ] + } + ], + "fluid_current_cases": [ + { + "incoming_x": 0.0, + "current_x": 0.001, + "strength": 0.014, + "velocity": [ + 0.0, + 0.0, + 0.0 + ] + }, + { + "incoming_x": 0.0, + "current_x": 0.01, + "strength": 0.014, + "velocity": [ + 0.0045000000000000005, + 0.0, + 0.0 + ] + }, + { + "incoming_x": 0.0, + "current_x": 1.0, + "strength": 0.014, + "velocity": [ + 0.014, + 0.0, + 0.0 + ] + }, + { + "incoming_x": 0.01, + "current_x": 0.001, + "strength": 0.014, + "velocity": [ + 0.01, + 0.0, + 0.0 + ] + }, + { + "incoming_x": 0.01, + "current_x": 0.01, + "strength": 0.014, + "velocity": [ + 0.01014, + 0.0, + 0.0 + ] + }, + { + "incoming_x": 0.01, + "current_x": 1.0, + "strength": 0.014, + "velocity": [ + 0.024, + 0.0, + 0.0 + ] + } + ], + "measurement_scope": "Public block and attribute APIs; original Player.travel, jumpFromGround and Entity.collideWithShapes execute in an isolated flat-plane harness. Player constructors and server constructors are bypassed. The harness supplies inputs, threshold preparation, the measured sprint attribute modifier, constant medium/depth and flat-plane movement bookkeeping. Collision cases call original Entity.collide including step selection on explicit boxes. Collision restitution, honey slide, slime step, bubble column and fluid current application are invoked separately. This is not an original full game tick, multiplayer, fluid-world sampling, or automatic callback-dispatch measurement. The water sprint case intentionally leaves swimming pose false to isolate travelInWater.", + "provenance": { + "source_url": "https://piston-data.mojang.com/v1/objects/823e2250d24b3ddac457a60c92a6a941943fcd6a/server.jar", + "source_sha1": "823e2250d24b3ddac457a60c92a6a941943fcd6a", + "source_sha256": "cdacdfb25898de5e4b4b0e5ddcc2722f77067e46605709c2d886c000ebb63ec5", + "executable_sha256": "183c0499c5f855570ee487dd38e141a53f0121f83a0b07a3bac2d8b6698823e8", + "probe_sha256": "d672bfc1fd65df0e3de9091bc0cc1ea21494e36a3c69f367e405de20f903d106", + "command": "python3 scripts/measure_physics.py --java /path/to/java25/bin/java --output crates/shacraft-physics/tests/fixtures/java26.2.json", + "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)" + } +} diff --git a/crates/shacraft-physics/tests/locomotion.rs b/crates/shacraft-physics/tests/locomotion.rs new file mode 100644 index 0000000..d6fb30b --- /dev/null +++ b/crates/shacraft-physics/tests/locomotion.rs @@ -0,0 +1,624 @@ +use shacraft_physics::{ + Aabb, CollisionBlock, Controls, PhysicsBody, PhysicsSettings, PhysicsWorld, Pose, step, +}; + +fn block(pos: [i32; 3], state: &str, collision: Vec) -> CollisionBlock { + CollisionBlock { + pos, + state: format!("minecraft:{state}"), + collision, + } +} +fn floor(state: &str) -> PhysicsWorld { + let mut world = PhysicsWorld::default(); + for x in -12..=12 { + for z in -60..=60 { + world + .blocks + .push(block([x, -1, z], state, vec![Aabb::unit()])); + } + } + world +} +fn player() -> PhysicsBody { + PhysicsBody { + position: [0.5, 0., 0.5], + velocity: [0., -0.0784000015258789, 0.], + on_ground: true, + ..Default::default() + } +} +fn run(body: &mut PhysicsBody, input: &Controls, world: &PhysicsWorld, ticks: usize) { + for _ in 0..ticks { + step(body, input, world, &PhysicsSettings::default()); + } +} + +#[test] +fn original_java26_2_travel_kernel_trajectories() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("fixtures/java26.2.json")).unwrap(); + for (case, trajectory) in fixture["travel_kernel_trajectories"].as_object().unwrap() { + let medium = trajectory["medium"].as_str().unwrap_or("air"); + // A fixed shallow-fluid depth is a kernel-only harness condition; + // the integrated world correctly changes depth as a player falls. + if case == "lava_shallow_move_stop" { + continue; + } + let world = if medium == "air" { + floor(trajectory["surface"].as_str().unwrap()) + } else { + let mut world = liquid_world(&format!("{medium}[level=0]")); + for block in &mut world.blocks { + block.pos[1] += 10; + } + world + }; + let mut body = PhysicsBody { + position: serde_json::from_value(trajectory["initial_position"].clone()).unwrap(), + velocity: serde_json::from_value(trajectory["initial_velocity"].clone()).unwrap(), + flying: trajectory["flying"].as_bool().unwrap_or(false), + on_ground: medium == "air" && !trajectory["flying"].as_bool().unwrap_or(false), + ..Default::default() + }; + let input_ticks = trajectory["input_ticks"].as_u64().unwrap(); + for sample in trajectory["samples"].as_array().unwrap() { + let tick = sample["tick"].as_u64().unwrap(); + // Kernel fixture keeps sprint asserted after input release; the + // integrated player controller ends swimming at release instead. + if medium == "water" && trajectory["sprint"].as_bool().unwrap() && tick > input_ticks { + break; + } + let input = Controls { + forward: if tick <= input_ticks { 1. } else { 0. }, + sprint: trajectory["sprint"].as_bool().unwrap(), + jump: tick == 1 && trajectory["jump_first_tick"].as_bool().unwrap(), + ..Default::default() + }; + step( + &mut body, + &input, + &world, + &PhysicsSettings { + allow_flight: true, + ..Default::default() + }, + ); + // Java yaw zero faces +Z; Shacraft yaw zero faces -Z. + for (field, actual) in [("position", body.position), ("velocity", body.velocity)] { + for axis in 0..3 { + let expected = + sample[field][axis].as_f64().unwrap() * if axis == 2 { -1. } else { 1. }; + assert!( + (actual[axis] - expected).abs() < 2.0e-6, + "{case} tick {tick}: {field}[{axis}] actual {} expected {expected}", + actual[axis] + ); + } + } + assert_eq!( + body.on_ground, + sample["on_ground"].as_bool().unwrap(), + "{case} tick {tick}" + ); + } + } +} + +#[test] +fn full_cube_stops_player_but_half_slab_is_stepped_without_jump() { + let mut wall = floor("stone"); + wall.blocks + .push(block([0, 0, -1], "stone", vec![Aabb::unit()])); + let mut blocked = player(); + run( + &mut blocked, + &Controls { + forward: 1., + ..Default::default() + }, + &wall, + 20, + ); + assert!((blocked.position[2] - blocked.width() / 2.).abs() < 1.0e-6); + assert_eq!(blocked.position[1], 0.); + wall.blocks.last_mut().unwrap().collision[0].max[1] = 0.5; + let mut climbed = player(); + run( + &mut climbed, + &Controls { + forward: 1., + ..Default::default() + }, + &wall, + 5, + ); + assert!(climbed.position[2] < 0. && climbed.position[1] >= 0.5); +} + +#[test] +fn step_up_preserves_clearance_under_low_ceiling() { + let mut world = floor("stone"); + world.blocks.push(block( + [0, 0, -1], + "stone_slab[type=bottom]", + vec![Aabb::new([0.; 3], [1., 0.5, 1.])], + )); + world + .blocks + .push(block([0, 2, -1], "stone", vec![Aabb::unit()])); + let mut body = player(); + run( + &mut body, + &Controls { + forward: 1., + ..Default::default() + }, + &world, + 12, + ); + assert!( + body.position[2] >= body.width() / 2. - 1.0e-6, + "standing body must not step into a ceiling" + ); +} + +#[test] +fn sneaking_keeps_support_at_straight_and_diagonal_edges() { + let world = PhysicsWorld { + blocks: vec![block([0, -1, 0], "stone", vec![Aabb::unit()])], + }; + for strafe in [0., 1.] { + let mut body = player(); + run( + &mut body, + &Controls { + forward: 1., + strafe, + sneak: true, + ..Default::default() + }, + &world, + 120, + ); + assert_eq!(body.position[1], 0.); + assert!(body.position[2] > -body.width() / 2.); + assert!(body.position[0] < 1. + body.width() / 2.); + assert_eq!(body.pose, Pose::Crouching); + } + let mut walking = player(); + run( + &mut walking, + &Controls { + forward: 1., + ..Default::default() + }, + &world, + 20, + ); + assert!(walking.position[1] < -1.); +} + +#[test] +fn crawl_pose_persists_until_there_is_headroom() { + let mut world = floor("stone"); + world + .blocks + .push(block([0, 1, 0], "stone", vec![Aabb::unit()])); + let mut body = PhysicsBody { + pose: Pose::Swimming, + ..player() + }; + step( + &mut body, + &Controls::default(), + &world, + &PhysicsSettings::default(), + ); + assert_eq!(body.pose, Pose::Swimming); + world.blocks.pop(); + step( + &mut body, + &Controls::default(), + &world, + &PhysicsSettings::default(), + ); + assert_eq!(body.pose, Pose::Standing); +} + +#[test] +fn diagonal_input_is_normalized_and_air_control_is_weaker() { + let world = floor("stone"); + let mut straight = player(); + let mut diagonal = player(); + run( + &mut straight, + &Controls { + forward: 1., + ..Default::default() + }, + &world, + 20, + ); + run( + &mut diagonal, + &Controls { + forward: 1., + strafe: 1., + ..Default::default() + }, + &world, + 20, + ); + let a = (straight.position[0] - 0.5).hypot(straight.position[2] - 0.5); + let b = (diagonal.position[0] - 0.5).hypot(diagonal.position[2] - 0.5); + assert!( + (b / a - 1. / 0.98).abs() < 1.0e-6, + "vanilla normalizes a diagonal after multiplying input by .98" + ); + let mut air = PhysicsBody { + position: [0., 20., 0.], + ..Default::default() + }; + step( + &mut air, + &Controls { + forward: 1., + ..Default::default() + }, + &world, + &PhysicsSettings::default(), + ); + assert!((air.position[2] + 0.019600000381469584).abs() < 1.0e-6); +} + +#[test] +fn held_jump_has_ten_tick_cooldown_and_release_rearms() { + let world = floor("stone"); + let mut body = player(); + let jump = Controls { + jump: true, + ..Default::default() + }; + step(&mut body, &jump, &world, &PhysicsSettings::default()); + assert_eq!(body.jump_cooldown, 10); + body.position[1] = 0.; + body.velocity = [0., -0.08, 0.]; + body.on_ground = true; + step(&mut body, &jump, &world, &PhysicsSettings::default()); + assert_eq!(body.position[1], 0.); + step( + &mut body, + &Controls::default(), + &world, + &PhysicsSettings::default(), + ); + step(&mut body, &jump, &world, &PhysicsSettings::default()); + assert!(body.position[1] > 0.4); +} + +fn liquid_world(state: &str) -> PhysicsWorld { + let mut world = PhysicsWorld::default(); + for x in -3..=3 { + for z in -12..=3 { + for y in -4..=4 { + world.blocks.push(block([x, y, z], state, vec![])); + } + } + } + world +} +#[test] +fn water_swimming_lava_and_waterlogged_cells_apply_distinct_motion() { + let mut water = PhysicsBody { + position: [0.5, 0., 0.5], + ..Default::default() + }; + let mut lava = water.clone(); + let input = Controls { + forward: 1., + ..Default::default() + }; + run(&mut water, &input, &liquid_world("water[level=0]"), 20); + run(&mut lava, &input, &liquid_world("lava[level=0]"), 20); + assert!(water.in_water && !water.in_lava && lava.in_lava); + assert!(water.position[2] < lava.position[2]); + assert!(water.velocity[1] > lava.velocity[1]); + let mut swimmer = PhysicsBody { + position: [0.5, 0., 0.5], + ..Default::default() + }; + run( + &mut swimmer, + &Controls { + sprint: true, + forward: 1., + pitch: 0.6, + ..Default::default() + }, + &liquid_world("water[level=0]"), + 10, + ); + assert!(swimmer.swimming && swimmer.pose == Pose::Swimming); + assert!(swimmer.position[1] > 0.); + let mut logged = PhysicsBody { + position: [0.5, 0., 0.5], + ..Default::default() + }; + run( + &mut logged, + &input, + &liquid_world("oak_sign[waterlogged=true]"), + 2, + ); + assert!(logged.in_water); +} +#[test] +fn climbable_clamps_fall_and_jump_climbs() { + let world = liquid_world("ladder[facing=north]"); + let mut body = PhysicsBody { + position: [0.5, 2., 0.5], + velocity: [0., -2., 0.], + ..Default::default() + }; + step( + &mut body, + &Controls::default(), + &world, + &PhysicsSettings::default(), + ); + assert!(body.on_climbable && body.position[1] > 1.84); + let height = body.position[1]; + run( + &mut body, + &Controls { + jump: true, + ..Default::default() + }, + &world, + 5, + ); + assert!(body.position[1] > height); + let height = body.position[1]; + body.velocity[1] = -0.1; + run( + &mut body, + &Controls { + sneak: true, + ..Default::default() + }, + &world, + 5, + ); + assert!((body.position[1] - height).abs() < 1.0e-6); +} +#[test] +fn slime_bounces_without_sneak_and_honey_limits_jump() { + let world = floor("slime_block"); + let mut body = PhysicsBody { + position: [0.5, 0.1, 0.5], + velocity: [0., -0.9, 0.], + ..Default::default() + }; + step( + &mut body, + &Controls::default(), + &world, + &PhysicsSettings::default(), + ); + assert!(body.velocity[1] > 0.7); + let mut sneaking = PhysicsBody { + position: [0.5, 0.1, 0.5], + velocity: [0., -0.9, 0.], + ..Default::default() + }; + step( + &mut sneaking, + &Controls { + sneak: true, + ..Default::default() + }, + &world, + &PhysicsSettings::default(), + ); + assert!(sneaking.velocity[1] < 0.); + let mut honey = player(); + step( + &mut honey, + &Controls { + jump: true, + ..Default::default() + }, + &floor("honey_block"), + &PhysicsSettings::default(), + ); + assert!((honey.position[1] - 0.21).abs() < 1.0e-6); +} +#[test] +fn cobweb_slows_falling_and_bubble_column_lifts() { + let mut web = PhysicsBody { + position: [0.5, 2., 0.5], + velocity: [0., -1., 0.], + ..Default::default() + }; + step( + &mut web, + &Controls::default(), + &liquid_world("cobweb"), + &PhysicsSettings::default(), + ); + assert!((web.position[1] - 1.95).abs() < 1.0e-6); + let mut bubble = PhysicsBody { + position: [0.5, 0., 0.5], + ..Default::default() + }; + run( + &mut bubble, + &Controls::default(), + &liquid_world("bubble_column[drag=false]"), + 10, + ); + assert!(bubble.position[1] > 0.5 && bubble.velocity[1] > 0.); +} +#[test] +fn powder_snow_and_scaffolding_have_entity_sensitive_support() { + for material in ["powder_snow", "scaffolding"] { + let world = PhysicsWorld { + blocks: vec![block([0, -1, 0], material, vec![Aabb::unit()])], + }; + let mut body = player(); + let settings = PhysicsSettings { + leather_boots: true, + ..Default::default() + }; + step(&mut body, &Controls::default(), &world, &settings); + assert_eq!(body.position[1], 0., "{material}"); + run( + &mut body, + &Controls { + sneak: true, + ..Default::default() + }, + &world, + 5, + ); + assert!(body.position[1] < 0., "{material}"); + } +} +#[test] +fn flight_is_authorized_and_frozen_body_does_not_move() { + let mut denied = player(); + step( + &mut denied, + &Controls { + fly_toggle: true, + ..Default::default() + }, + &PhysicsWorld::default(), + &PhysicsSettings::default(), + ); + assert!(!denied.flying); + let mut flight = player(); + let settings = PhysicsSettings { + allow_flight: true, + ..Default::default() + }; + step( + &mut flight, + &Controls { + fly_toggle: true, + jump: true, + ..Default::default() + }, + &PhysicsWorld::default(), + &settings, + ); + assert!(flight.flying && flight.position[1] > 0.); + let position = flight.position; + step( + &mut flight, + &Controls { + jump: true, + forward: 1., + ..Default::default() + }, + &PhysicsWorld::default(), + &PhysicsSettings { + frozen: true, + ..settings + }, + ); + assert_eq!(flight.position, position); + assert_eq!(flight.velocity, [0.; 3]); +} + +#[test] +fn fluid_and_callback_results_do_not_depend_on_block_serialization_order() { + let mut world = PhysicsWorld { + blocks: vec![ + block([0, 0, 0], "water[level=5]", vec![]), + block([1, 0, 0], "water[level=7]", vec![]), + block([0, 0, 1], "water[level=2]", vec![]), + block([1, 0, 1], "water[level=6]", vec![]), + block([-1, 0, 0], "water[level=0]", vec![]), + ], + }; + let start = PhysicsBody { + position: [0.95, 0., 0.95], + ..Default::default() + }; + let mut forward = start.clone(); + step( + &mut forward, + &Controls::default(), + &world, + &PhysicsSettings::default(), + ); + world.blocks.reverse(); + let mut reversed = start; + step( + &mut reversed, + &Controls::default(), + &world, + &PhysicsSettings::default(), + ); + assert_eq!(forward.position, reversed.position); + assert_eq!(forward.velocity, reversed.velocity); + assert!( + forward.velocity[0].abs() + forward.velocity[2].abs() > 0., + "fixture must exercise current pushing" + ); +} + +#[test] +fn shallow_grounded_water_jump_uses_the_normal_jump_impulse() { + let mut world = floor("stone"); + world + .blocks + .push(block([0, 0, 0], "water[level=7]", vec![])); + let mut body = player(); + step( + &mut body, + &Controls { + jump: true, + ..Default::default() + }, + &world, + &PhysicsSettings::default(), + ); + assert!((body.position[1] - 0.41999998688697815).abs() < 1.0e-12); + assert_eq!(body.jump_cooldown, 10); +} + +#[test] +fn ground_flight_shortcut_hovers_and_landing_still_cancels_flight() { + let world = floor("stone"); + let settings = PhysicsSettings { + allow_flight: true, + ..Default::default() + }; + let mut body = player(); + step(&mut body, &Controls::default(), &world, &settings); + assert!(body.on_ground && body.velocity[1] < 0.); + step( + &mut body, + &Controls { + fly_toggle: true, + ..Default::default() + }, + &world, + &settings, + ); + assert!(body.flying && !body.on_ground); + for _ in 0..5 { + step(&mut body, &Controls::default(), &world, &settings); + assert!(body.flying && !body.on_ground); + assert_eq!(body.position[1], 0.); + assert_eq!(body.velocity[1], 0.); + } + body.position[1] = 0.1; + body.velocity[1] = -0.2; + step(&mut body, &Controls::default(), &world, &settings); + assert!(body.on_ground && !body.flying); + assert_eq!(body.position[1], 0.); +} diff --git a/crates/shacraft-server/Cargo.toml b/crates/shacraft-server/Cargo.toml index 6b0af34..4fe97b3 100644 --- a/crates/shacraft-server/Cargo.toml +++ b/crates/shacraft-server/Cargo.toml @@ -11,6 +11,7 @@ serde.workspace = true serde_json.workspace = true shacraft-core.workspace = true shacraft-content = { path = "../shacraft-content" } +shacraft-physics = { path = "../shacraft-physics" } axum = { version = "0.8.9", features = ["ws"] } tokio = { version = "1", features = ["full"] } tower-http = { version = "0.6", features = ["fs", "limit", "trace"] } diff --git a/crates/shacraft-server/src/control.rs b/crates/shacraft-server/src/control.rs index 834c040..56e4a6c 100644 --- a/crates/shacraft-server/src/control.rs +++ b/crates/shacraft-server/src/control.rs @@ -11,6 +11,7 @@ impl Game { .map(|w| { let mut v = json!(w); v["mode"] = json!(self.config(&w.name).mode); + v["terrain"] = json!(self.config(&w.name).terrain); v }) .collect::>() @@ -67,11 +68,53 @@ impl Game { .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::>().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::(),"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()}), + 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::>().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::(),"limits":{"players":32,"command_queue":64,"messages_per_client":16,"bytes_per_client":8388608,"build_plans":16,"plan_cells":32768},"terrain":self.terrain.stats(),"stream":{"batches":self.stream_batches,"sections":self.stream_sections,"last_ms":self.last_stream_ms,"waiting_players":self.players.values().filter(|p|p.waiting_terrain).count()},"tick_samples_ms":self.tick_samples,"storage":self.store.stats(),"catalog":self.catalog.summary(),"wasm":self.jump.metrics()}), + ) + } + "diagnostics.snapshot" => Ok( + json!({"tick":self.tick,"tick_ms":self.last_tick_ms,"stream_ms":self.last_stream_ms,"terrain":self.terrain.stats(),"players":self.players.values().map(|p|json!({"id":p.id,"world":p.world,"position":p.position,"velocity":p.body.velocity,"waiting_terrain":p.waiting_terrain,"view":p.view,"generation":p.view_generation,"sent_sections":p.sent_sections.len(),"view_distance":p.view_distance,"stream_radius":p.stream_radius(),"pending_inputs":p.pending_inputs.len(),"processed_seq":p.processed_seq})).collect::>()}), + ), + "player.teleport" => { + let id = string(&p, "id")?; + let position: [f64; 3] = serde_json::from_value(p["position"].clone())?; + ensure!( + position + .iter() + .all(|v| v.is_finite() && v.abs() <= crate::terrain::BORDER) + && (-128.0..=1024.0).contains(&position[1]), + "position outside playable bounds" + ); + let player = self.players.get_mut(id).context("unknown player")?; + player.position = position; + player.velocity = 0.; + player.grounded = false; + player.input = Input::default(); + player.reset_motion(); + if p["flying"] == true { + player.body.flying = true; + } + self.snapshot(id, false)?; + Ok(json!({"id":id,"position":position})) + } + "terrain.inspect" => { + let world = string(&p, "world")?; + let position: Pos = serde_json::from_value(p["position"].clone())?; + let generator = self + .generators + .get(world) + .context("world is not procedural")?; + Ok( + json!({"world":world,"config":generator.config,"position":position,"height":generator.height(position[0],position[2]),"biome":generator.biome(position[0],position[2]),"min_y":crate::terrain::MIN_Y,"max_y":crate::terrain::MAX_Y,"border":crate::terrain::BORDER}), ) } "world.create" => { let world = string(&p, "world")?; + if let Some(config) = p.get("terrain") { + self.create_overworld(world, serde_json::from_value(config.clone())?)?; + return Ok( + json!({"world":world,"revision":0,"terrain":self.config(world).terrain}), + ); + } 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); @@ -84,13 +127,14 @@ impl Game { }; self.meta.worlds.insert(world.into(), conf); self.save_meta()?; + self.open_generators()?; 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 blocks = self.read_world_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, @@ -105,9 +149,11 @@ impl Game { .context("expected_revision required")?; let op = string(&p, "operation_id")?; let changes: Vec = serde_json::from_value(p["changes"].clone())?; + self.materialize_changes(world, &changes)?; let result = self.store.edit(world, rev, op, changes.clone())?; + self.terrain_changed(world, &changes); if !result.replayed { - self.broadcast(world,json!({"type":"blocks","revision":result.revision,"changes":changes,"materials":self.materials(&changes)})); + self.broadcast_blocks(world, result.revision, &changes); } Ok(json!(result)) } @@ -248,7 +294,7 @@ impl Game { .context("min_players too large")?; } if p.get("elimination_y").is_some() { - conf.elimination_y = number(&p, "elimination_y")?; + conf.elimination_y = f64::from(number(&p, "elimination_y")?); } if let Some(value) = p.get("floor_block") { let name = value @@ -274,14 +320,17 @@ impl Game { ensure!(plan.expires > Instant::now(), "plan expired"); let world = plan.world.clone(); let changes = plan.changes.clone(); + let revision = plan.revision; + self.materialize_changes(&world, &changes)?; let result = self.store.edit( &world, - plan.revision, + 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)})); + self.terrain_changed(&world, &changes); + self.broadcast_blocks(&world, result.revision, &changes); } Ok(json!(result)) } @@ -291,7 +340,7 @@ impl Game { 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 blocks = self.read_world_region(world, min, max)?; let colors = blocks .iter() .map(|b| { @@ -470,8 +519,8 @@ impl Game { ) } } -fn entity_position(v: &Value) -> Result<[f32; 3]> { - let p: [f32; 3] = serde_json::from_value(v.clone())?; +fn entity_position(v: &Value) -> Result<[f64; 3]> { + let p: [f64; 3] = serde_json::from_value(v.clone())?; ensure!( p.iter().all(|n| n.is_finite() && n.abs() <= 32700.), "position outside playable limits +/-32700" diff --git a/crates/shacraft-server/src/game.rs b/crates/shacraft-server/src/game.rs index 3e89609..b2e4af6 100644 --- a/crates/shacraft-server/src/game.rs +++ b/crates/shacraft-server/src/game.rs @@ -3,8 +3,11 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shacraft_content::{Catalog, StateDefinition}; use shacraft_core::{BlockChange, Pos, WorldStore}; +use shacraft_physics::{ + Aabb as PhysicsAabb, CollisionBlock, Controls, PhysicsBody, PhysicsSettings, PhysicsWorld, +}; use std::{ - collections::{BTreeMap, HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet, VecDeque}, path::Path, sync::{ Arc, @@ -47,15 +50,16 @@ impl Outbox { #[derive(Clone, Serialize, Deserialize)] #[serde(default)] pub struct WorldConfig { - pub spawn: [f32; 3], + pub spawn: [f64; 3], pub mode: String, pub countdown_seconds: u32, pub round_seconds: u32, pub min_players: usize, - pub elimination_y: f32, - pub spawn_points: Vec<[f32; 3]>, - pub spectator_spawn: [f32; 3], + pub elimination_y: f64, + pub spawn_points: Vec<[f64; 3]>, + pub spectator_spawn: [f64; 3], pub floor_block: String, + pub terrain: Option, } impl Default for WorldConfig { fn default() -> Self { @@ -69,6 +73,7 @@ impl Default for WorldConfig { spawn_points: Vec::new(), spectator_spawn: [13., 2., 0.], floor_block: "minecraft:snow_block".into(), + terrain: None, } } } @@ -107,8 +112,14 @@ impl WorldConfig { .chain(&self.spawn_points) { ensure!( - point.iter().all(|n| n.is_finite() && n.abs() <= 32700.), - "spawn position outside playable limits +/-32700" + point.iter().all(|n| n.is_finite() + && n.abs() + <= if self.terrain.is_some() { + crate::terrain::BORDER + } else { + 32700. + }), + "spawn position outside playable world border" ); ensure!( point[1] > self.elimination_y + 0.5, @@ -118,11 +129,14 @@ impl WorldConfig { for (i, a) in self.spawn_points.iter().enumerate() { for b in self.spawn_points.iter().skip(i + 1) { ensure!( - (0..3).map(|axis| (a[axis] - b[axis]).powi(2)).sum::() >= 0.64, + (0..3).map(|axis| (a[axis] - b[axis]).powi(2)).sum::() >= 0.64, "spawn_points are too close together" ); } } + if let Some(terrain) = &self.terrain { + terrain.validate()?; + } let floor = catalog .state(&self.floor_block) .context("unknown floor_block")?; @@ -149,23 +163,117 @@ struct Input { forward: f32, strafe: f32, jump: bool, + sprint: bool, + sneak: bool, + fly_toggle: bool, +} +impl Input { + fn controls(&self) -> Controls { + Controls { + forward: f64::from(self.forward), + strafe: f64::from(self.strafe), + yaw: f64::from(self.yaw), + pitch: f64::from(self.pitch), + jump: self.jump, + sprint: self.sprint, + sneak: self.sneak, + fly_toggle: self.fly_toggle, + } + } } struct Player { id: String, name: String, world: String, - position: [f32; 3], + position: [f64; 3], velocity: f32, grounded: bool, + body: PhysicsBody, + physics_settings: PhysicsSettings, + prediction: bool, + pending_inputs: VecDeque, + received_seq: u64, + processed_seq: u64, + motion_epoch: u64, + // Latest received view angles stay independent of queued movement replay. + look: [f32; 2], input: Input, out: Outbox, last_input: Instant, last_action: Instant, last_chat: Instant, last_snapshot: Instant, + last_view: Instant, + chunk_stream: bool, + section_stream: bool, + view_distance: i32, + buffered_view: bool, + full_height: bool, + last_distance_change: Instant, + sent_sections: HashSet, + pending_sections: VecDeque, + view_generation: u64, + welcomed: bool, + last_batch: Instant, + waiting_terrain: bool, view: [i32; 3], alive: bool, } +impl Player { + fn stream_radius(&self) -> i32 { + self.view_distance + i32::from(self.buffered_view) + } +} + +impl Player { + fn reset_motion(&mut self) { + self.body = PhysicsBody { + position: self.position, + velocity: [0., f64::from(self.velocity) / 20., 0.], + on_ground: self.grounded, + ..PhysicsBody::default() + }; + self.pending_inputs.clear(); + self.processed_seq = self.received_seq; + self.motion_epoch += 1; + } + fn synchronize_pose(&mut self) { + if self.body.position != self.position { + self.reset_motion(); + } + self.body.on_ground = self.grounded; + if (self.body.velocity[1] * 20.) as f32 != self.velocity { + self.body.velocity[1] = f64::from(self.velocity) / 20.; + } + } +} +#[derive(Serialize)] +struct ViewSection { + section: Pos, + blocks: Vec, +} + +fn view_bounds(center: Pos) -> (Pos, Pos) { + ( + [center[0] - 32, center[1] - 16, center[2] - 32], + [center[0] + 31, center[1] + 31, center[2] + 31], + ) +} + +fn view_sections(center: Pos) -> Vec { + let (min, max) = view_bounds(center); + let min = min.map(|v| v.div_euclid(16)); + let max = max.map(|v| v.div_euclid(16)); + let mut sections = Vec::with_capacity(48); + for x in min[0]..=max[0] { + for y in min[1]..=max[1] { + for z in min[2]..=max[2] { + sections.push([x, y, z]); + } + } + } + sections +} #[derive(Clone)] struct Match { phase: String, @@ -195,6 +303,13 @@ pub struct Game { last_tick_ms: f64, slow_clients: u64, pub jump: crate::packages::JumpModule, + terrain: crate::terrain::TerrainService, + generators: HashMap>, + sky_cache: HashMap<(String, i32, i32), Vec>, + stream_batches: u64, + stream_sections: u64, + last_stream_ms: f64, + tick_samples: VecDeque, } impl Game { pub fn open( @@ -240,11 +355,27 @@ impl Game { last_tick_ms: 0., slow_clients: 0, jump, + terrain: crate::terrain::TerrainService::new(), + generators: HashMap::new(), + sky_cache: HashMap::new(), + stream_batches: 0, + stream_sections: 0, + last_stream_ms: 0., + tick_samples: VecDeque::with_capacity(600), }; for conf in game.meta.worlds.values() { conf.validate(&game.catalog)?; } game.seed()?; + game.open_generators()?; + if !game + .store + .list_worlds()? + .iter() + .any(|world| world.name == "overworld") + { + game.create_overworld("overworld", crate::terrain::TerrainConfig::default())?; + } for world in game.meta.active_matches.clone() { let revision = game.store.revision(&world)?; game.store.reset_world( @@ -490,6 +621,10 @@ impl Game { self.step(); self.last_tick_ms = start.elapsed().as_secs_f64() * 1000.; self.max_tick_ms = self.max_tick_ms.max(self.last_tick_ms); + if self.tick_samples.len() == 600 { + self.tick_samples.pop_front(); + } + self.tick_samples.push_back(self.last_tick_ms); next += Duration::from_millis(50); if next < Instant::now() { next = Instant::now() + Duration::from_millis(50); @@ -520,10 +655,19 @@ impl Game { self.meta.worlds.get(world).cloned().unwrap_or_default() } fn players_json(&self, world: &str) -> Value { - json!(self.players.values().filter(|p|p.world==world).map(|p|json!({"id":p.id,"name":p.name,"position":p.position,"yaw":p.input.yaw,"pitch":p.input.pitch,"alive":p.alive})).collect::>()) + json!(self.players.values().filter(|p|p.world==world).map(|p|json!({"id":p.id,"name":p.name,"position":p.body.position,"yaw":p.look[0],"pitch":p.look[1],"alive":p.alive,"velocity":p.body.velocity,"pose":p.body.pose,"height":p.body.height(),"eye_height":p.body.eye_height(),"sprinting":p.body.sprinting,"flying":p.body.flying})).collect::>()) + } + fn motion_json(&self, p: &Player) -> Value { + let mut settings = p.physics_settings.clone(); + settings.frozen |= p.waiting_terrain; + json!({"body":p.body,"ack":p.processed_seq,"tick":self.tick, + "epoch":p.motion_epoch,"settings":settings,"waiting_terrain":p.waiting_terrain}) } fn materials(&self, blocks: &[BlockChange]) -> Vec { - let ids: HashSet<_> = blocks.iter().map(|b| b.block).collect(); + self.materials_for_ids(blocks.iter().map(|b| b.block)) + } + fn materials_for_ids(&self, ids: impl Iterator) -> Vec { + let ids: HashSet<_> = ids.collect(); let mut budget = 0; ids.into_iter() .filter_map(|id| self.material(id)) @@ -545,18 +689,98 @@ impl Game { Some(v) } fn snapshot(&mut self, id: &str, welcome: bool) -> Result<()> { + let player = self.players.get(id).context("not joined")?; + let allow_flight = self.config(&player.world).mode == "creative" || !player.alive; + let frozen = self + .matches + .get(&player.world) + .is_some_and(|m| m.phase == "countdown"); + if let Some(player) = self.players.get_mut(id) { + player.synchronize_pose(); + player.physics_settings.allow_flight = allow_flight; + player.physics_settings.frozen = frozen; + player.physics_settings.jump_impulse = None; + if !allow_flight { + player.body.flying = false; + } + if !player.alive { + player.body.flying = true; + } + } let p = self.players.get(id).context("not joined")?; let world = p.world.clone(); let center = p.position.map(|v| (v.floor() as i32).div_euclid(16) * 16); - let blocks = self.store.read_region( - &world, - [center[0] - 32, center[1] - 8, center[2] - 32], - [center[0] + 31, center[1] + 31, center[2] + 31], - )?; - let value = json!({"type":if welcome{"welcome"}else{"snapshot"},"id":id,"world":world,"revision":self.store.revision(&world)?,"spawn":p.position,"blocks":blocks,"materials":self.materials(&blocks),"players":self.players_json(&world),"entities":self.entity_list(&world),"manifest_hash":self.manifest["hash"],"view_center":center}); + if p.section_stream { + return self.begin_section_view(id, center, true); + } + let (min, max) = if p.chunk_stream { + view_bounds(center) + } else { + ( + [center[0] - 32, center[1] - 8, center[2] - 32], + [center[0] + 31, center[1] + 31, center[2] + 31], + ) + }; + let blocks = self.read_world_region(&world, min, max)?; + let p = &self.players[id]; + let mut value = json!({"type":if welcome{"welcome"}else{"snapshot"},"id":id,"world":world,"revision":self.store.revision(&world)?,"spawn":p.position,"blocks":blocks,"materials":self.materials(&blocks),"players":self.players_json(&world),"entities":self.entity_list(&world),"manifest_hash":self.manifest["hash"],"view_center":center}); + if p.chunk_stream { + value["features"] = json!(["chunk_stream_v1"]); + value["view_min"] = json!(min); + value["view_max"] = json!(max); + } + if p.prediction { + value["motion"] = self.motion_json(p); + let mut features = if p.chunk_stream { + vec!["chunk_stream_v1"] + } else { + vec![] + }; + features.push("movement_prediction_v1"); + value["features"] = json!(features); + } if let Some(p) = self.players.get_mut(id) { p.view = center; p.last_snapshot = Instant::now(); + p.last_view = p.last_snapshot; + } + self.send(id, value); + Ok(()) + } + fn stream_view(&mut self, id: &str, center: Pos) -> Result<()> { + let p = self.players.get(id).context("not joined")?; + let world = p.world.clone(); + let from_center = p.view; + let old_sections = view_sections(from_center); + let new_sections = view_sections(center); + let old: HashSet<_> = old_sections.iter().copied().collect(); + let new: HashSet<_> = new_sections.iter().copied().collect(); + let unload: Vec<_> = old_sections + .into_iter() + .filter(|section| !new.contains(section)) + .collect(); + let mut sections = Vec::new(); + for section in new_sections + .into_iter() + .filter(|section| !old.contains(section)) + { + let min = section.map(|v| v * 16); + let max = min.map(|v| v + 15); + sections.push(ViewSection { + section, + blocks: self.store.read_region(&world, min, max)?, + }); + } + let materials = self.materials_for_ids( + sections + .iter() + .flat_map(|section| section.blocks.iter().map(|block| block.block)), + ); + let (min, max) = view_bounds(center); + let value = json!({"type":"chunks","world":world,"revision":self.store.revision(&world)?,"from_center":from_center,"view_center":center,"view_min":min,"view_max":max,"unload":unload,"sections":sections,"materials":materials}); + if let Some(p) = self.players.get_mut(id) { + p.view = center; + p.last_view = Instant::now(); } self.send(id, value); Ok(()) @@ -574,12 +798,30 @@ impl Game { self.config(world).mode != "template", "template is not a playable world" ); + ensure!( + !self.generators.contains_key(world) + || p["features"] + .as_array() + .is_some_and(|f| f.iter().any(|v| v == "chunk_stream_v2")), + "This world requires chunk_stream_v2; refresh the client" + ); let name = p["name"].as_str().unwrap_or("Игрок").trim(); ensure!( !name.is_empty() && name.chars().count() <= 32 && !name.chars().any(char::is_control), "invalid player name" ); let id = Uuid::new_v4().to_string(); + let view_distance = if let Some(value) = p.get("view_distance") { + let radius = value.as_i64().context("view_distance must be an integer")?; + ensure!( + (i64::from(terrain::MIN_VIEW_RADIUS)..=i64::from(terrain::MAX_VIEW_RADIUS)) + .contains(&radius), + "view_distance must be 2..64 chunks" + ); + radius as i32 + } else { + terrain::VIEW_RADIUS + }; let now = Instant::now(); self.players.insert( id.clone(), @@ -594,12 +836,50 @@ impl Game { }, velocity: 0., grounded: false, + body: PhysicsBody::default(), + physics_settings: PhysicsSettings { + allow_flight: self.config(world).mode == "creative", + ..PhysicsSettings::default() + }, + prediction: p["features"].as_array().is_some_and(|features| { + features + .iter() + .any(|feature| feature.as_str() == Some("movement_prediction_v1")) + }), + pending_inputs: VecDeque::new(), + received_seq: 0, + processed_seq: 0, + motion_epoch: 0, + look: [0.; 2], input: Input::default(), out, last_input: now, last_action: now - Duration::from_secs(1), last_chat: now - Duration::from_secs(1), last_snapshot: now, + last_view: now, + section_stream: p["features"] + .as_array() + .is_some_and(|f| f.iter().any(|v| v == "chunk_stream_v2")), + sent_sections: HashSet::new(), + pending_sections: VecDeque::new(), + view_distance, + full_height: p["features"] + .as_array() + .is_some_and(|f| f.iter().any(|v| v == "full_height_v1")), + buffered_view: p["features"] + .as_array() + .is_some_and(|f| f.iter().any(|v| v == "view_buffer_v1")), + last_distance_change: now - Duration::from_secs(1), + view_generation: 0, + welcomed: false, + last_batch: now - Duration::from_secs(1), + waiting_terrain: false, + chunk_stream: p["features"].as_array().is_some_and(|features| { + features + .iter() + .any(|feature| feature.as_str() == Some("chunk_stream_v1")) + }), view: [0; 3], alive: !self.matches.contains_key(world), }, @@ -612,6 +892,32 @@ impl Game { let player = self.players.get(id).context("not joined")?; let world = player.world.clone(); match typ { + "view_distance" => { + ensure!( + player.section_stream, + "view distance requires chunk_stream_v2" + ); + let distance = p["chunks"].as_i64().context("chunks must be an integer")?; + ensure!( + (i64::from(terrain::MIN_VIEW_RADIUS)..=i64::from(terrain::MAX_VIEW_RADIUS)) + .contains(&distance), + "view distance must be 2..64 chunks" + ); + if player.view_distance == distance as i32 { + return Ok(()); + } + ensure!( + player.last_distance_change.elapsed() >= Duration::from_millis(500), + "view distance change rate limited" + ); + let center = player + .position + .map(|v| (v.floor() as i32).div_euclid(16) * 16); + let player = self.players.get_mut(id).unwrap(); + player.view_distance = distance as i32; + player.last_distance_change = Instant::now(); + self.begin_section_view(id, center, false)?; + } "input" => { let seq = p["seq"].as_u64().context("input sequence required")?; let yaw = number(&p, "yaw")?; @@ -626,18 +932,52 @@ impl Game { "invalid movement input" ); let player = self.players.get_mut(id).unwrap(); - if seq > player.input.seq { - player.input = Input { + if seq > player.received_seq { + let input = Input { seq, yaw, pitch, forward, strafe, jump: p["jump"].as_bool().unwrap_or(false), + sprint: p["sprint"].as_bool().unwrap_or(false), + sneak: p["sneak"].as_bool().unwrap_or(false), + fly_toggle: p["fly_toggle"].as_bool().unwrap_or(false), }; + if player.prediction { + ensure!( + player.pending_inputs.len() < 32, + "movement command queue full" + ); + player.pending_inputs.push_back(input); + } else { + player.input = input; + } + player.received_seq = seq; + player.look = [yaw, pitch]; player.last_input = Instant::now(); } } + "look" => { + let yaw = number(&p, "yaw")?; + let pitch = number(&p, "pitch")?; + ensure!(pitch.abs() <= 1.6 && yaw.abs() < 1e7, "invalid look input"); + let player = self.players.get_mut(id).unwrap(); + player.look = [yaw, pitch]; + } + "input_reset" => { + let player = self.players.get_mut(id).unwrap(); + player.pending_inputs.clear(); + player.processed_seq = player.received_seq; + player.input = Input { + seq: player.processed_seq, + yaw: player.input.yaw, + pitch: player.input.pitch, + ..Input::default() + }; + player.motion_epoch += 1; + player.last_input = Instant::now(); + } "break" | "place" => { ensure!( player.last_action.elapsed() >= Duration::from_millis(110), @@ -661,7 +1001,7 @@ impl Game { if conf.mode == "spleef" { ensure!(typ=="break"&&self.matches.get(&world).is_some_and(|m|m.phase=="active"&&m.participants.contains(id)),"Spleef allows breaking the configured floor only during a match"); ensure!( - self.store.get_block(&world, pos)? == self.id(&conf.floor_block)?, + self.world_block(&world, pos)? == self.id(&conf.floor_block)?, "arena boundary is protected" ); } @@ -676,8 +1016,12 @@ impl Game { .context("invalid block id")? as u32 }; if block != 0 { + let boxes = self + .definition(block) + .map(|s| s.collision.clone()) + .unwrap_or_else(|| vec![shacraft_content::Aabb::new([0.; 3], [1.; 3])]); ensure!( - self.store.get_block(&world, pos)? == 0, + self.world_block(&world, pos)? == 0, "placement cell occupied" ); ensure!( @@ -685,15 +1029,28 @@ impl Game { .players .values() .filter(|q| q.world == world) - .any(|q| intersects( - q.position, - [pos[0] as f32, pos[1] as f32, pos[2] as f32], - [pos[0] as f32 + 1., pos[1] as f32 + 1., pos[2] as f32 + 1.] - )), + .any(|q| boxes.iter().any(|b| { + let half = q.body.width() / 2.; + let min = [ + q.body.position[0] - half, + q.body.position[1], + q.body.position[2] - half, + ]; + let max = [ + q.body.position[0] + half, + q.body.position[1] + q.body.height(), + q.body.position[2] + half, + ]; + (0..3).all(|i| { + min[i] < f64::from(pos[i]) + f64::from(b.max[i]) - 1e-7 + && max[i] > f64::from(pos[i]) + f64::from(b.min[i]) + 1e-7 + }) + })), "placement intersects a player" ); } let changes = vec![BlockChange { pos, block }]; + self.materialize_changes(&world, &changes)?; let rev = self.store.revision(&world)?; let result = self.store.edit( &world, @@ -701,8 +1058,9 @@ impl Game { &format!("play-{}", Uuid::new_v4()), changes.clone(), )?; + self.terrain_changed(&world, &changes); self.players.get_mut(id).unwrap().last_action = Instant::now(); - self.broadcast(&world,json!({"type":"blocks","revision":result.revision,"changes":changes,"materials":self.materials(&changes)})); + self.broadcast_blocks(&world, result.revision, &changes); } "switch_world" => { ensure!( @@ -713,6 +1071,10 @@ impl Game { self.store.revision(target)?; let conf = self.config(target); ensure!(conf.mode != "template", "template is not playable"); + ensure!( + conf.terrain.is_none() || player.section_stream, + "procedural worlds require chunk_stream_v2; refresh the browser client" + ); if let Some(m) = self.matches.get_mut(&world) { m.participants.remove(id); } @@ -725,8 +1087,10 @@ impl Game { conf.spawn }; q.velocity = 0.; + q.grounded = false; q.input = Input::default(); q.alive = !spectator; + q.reset_motion(); self.snapshot(id, false)?; } "resync" => { @@ -748,7 +1112,10 @@ impl Game { let q = self.players.get_mut(id).unwrap(); q.position = spawn; q.velocity = 0.; + q.grounded = false; + q.input = Input::default(); q.alive = true; + q.reset_motion(); } "start_match" => { self.start_match(&world)?; @@ -774,6 +1141,7 @@ impl Game { } Ok(()) } + #[cfg(test)] fn collision_boxes( &mut self, world: &str, @@ -812,36 +1180,88 @@ impl Game { .get(id as usize) .and_then(|s| self.catalog.state(s)) } + fn physics_world(&mut self, world: &str, body: &PhysicsBody) -> Result { + // Include the swept motion plus neighbors needed by shapes, fluids and stepping. + let min = std::array::from_fn(|axis| { + (body.position[axis] + body.velocity[axis].min(0.) - 3.).floor() as i32 + }); + let max = std::array::from_fn(|axis| { + (body.position[axis] + + body.velocity[axis].max(0.) + + if axis == 1 { body.height() + 3. } else { 3. }) + .ceil() as i32 + }); + let blocks = self + .read_world_region(world, min, max)? + .into_iter() + .map(|block| { + let definition = self.definition(block.block); + CollisionBlock { + pos: block.pos, + state: self + .store + .registry() + .get(block.block as usize) + .cloned() + .unwrap_or_default(), + collision: definition + .map(|d| { + d.collision + .iter() + .map(|b| PhysicsAabb { + min: b.min.map(f64::from), + max: b.max.map(f64::from), + }) + .collect() + }) + .unwrap_or_else(|| vec![PhysicsAabb::unit()]), + } + }) + .collect(); + Ok(PhysicsWorld { blocks }) + } fn step(&mut self) { self.tick += 1; + self.pump_terrain(); let ids: Vec<_> = self.players.keys().cloned().collect(); for id in ids { let Some(mut p) = self.players.remove(&id) else { continue; }; + p.synchronize_pose(); if p.last_input.elapsed() > Duration::from_secs(1) { - p.input.forward = 0.; - p.input.strafe = 0.; - p.input.jump = false; + p.pending_inputs.clear(); + p.processed_seq = p.received_seq; + p.input = Input { + seq: p.processed_seq, + yaw: p.input.yaw, + pitch: p.input.pitch, + ..Input::default() + }; + } + if p.prediction { + if let Some(input) = p.pending_inputs.pop_front() { + p.processed_seq = input.seq; + p.input = input; + } + } else { + p.processed_seq = p.input.seq; } let conf = self.config(&p.world); - let frozen = self + p.physics_settings.frozen = self .matches .get(&p.world) .is_some_and(|m| m.phase == "countdown"); - let speed = if frozen { 0. } else { 5. }; - let len = (p.input.forward * p.input.forward + p.input.strafe * p.input.strafe) - .sqrt() - .max(1.); - let dx = (p.input.yaw.sin() * p.input.forward + p.input.yaw.cos() * p.input.strafe) - * speed - * 0.05 - / len; - let dz = (-p.input.yaw.cos() * p.input.forward + p.input.yaw.sin() * p.input.strafe) - * speed - * 0.05 - / len; - if p.input.jump && p.grounded && !frozen { + p.physics_settings.allow_flight = conf.mode == "creative" || !p.alive; + p.physics_settings.jump_impulse = None; + if !p.alive { + p.body.flying = true; + } + if p.input.jump + && p.body.on_ground + && p.body.jump_cooldown <= 1 + && !p.physics_settings.frozen + { let under = [ p.position[0].floor() as i32, (p.position[1] - 0.2).floor() as i32, @@ -853,62 +1273,49 @@ impl Game { .ok() .and_then(|id| self.store.registry().get(id as usize)) .is_some_and(|state| self.jump.handles(state)); - p.velocity = if trampoline { - self.jump.call().unwrap_or(7.) - } else { - 7. - }; - p.grounded = false; + if trampoline { + p.physics_settings.jump_impulse = + Some(f64::from(self.jump.call().unwrap_or(7.)) / 20.); + } } - p.velocity = (p.velocity - 20. * 0.05).max(-30.); - let movement = [dx, p.velocity * 0.05, dz]; - p.grounded = false; - let center = p.position.map(|v| v.floor() as i32); - let boxes = match self.collision_boxes( - &p.world, - [center[0] - 3, center[1] - 5, center[2] - 3], - [center[0] + 3, center[1] + 5, center[2] + 3], - ) { - Ok(boxes) => boxes, - Err(e) => { - p.out - .send(json!({"type":"error","message":format!("world read failed: {e}")})); + let physics_world = match self.physics_world(&p.world, &p.body) { + Ok(world) => { + p.waiting_terrain = false; + world + } + Err(error) if error.to_string().contains("terrain pending") => { + p.waiting_terrain = true; + // Freeze the body and discard acknowledged motion instead of + // falling through sections that have not been generated yet. + p.body.velocity = [0.; 3]; + p.velocity = 0.; + p.pending_inputs.clear(); + p.processed_seq = p.received_seq; + p.motion_epoch += 1; + self.players.insert(id, p); + continue; + } + Err(error) => { + p.out.send( + json!({"type":"error","message":format!("world read failed: {error}")}), + ); self.players.insert(id, p); continue; } }; - for axis in [0, 2, 1] { - if movement[axis].abs() < 1e-6 { - continue; - } - let steps = (movement[axis].abs() / 0.15).ceil().max(1.) as usize; - for _ in 0..steps { - let mut candidate = p.position; - candidate[axis] += movement[axis] / steps as f32; - if boxes.iter().any(|b| intersects(candidate, b.min, b.max)) { - let mut low = 0.; - let mut high = 1.; - for _ in 0..10 { - let t = (low + high) * 0.5; - let mut at = p.position; - at[axis] += (candidate[axis] - p.position[axis]) * t; - if boxes.iter().any(|b| intersects(at, b.min, b.max)) { - high = t; - } else { - low = t; - } - } - p.position[axis] += (candidate[axis] - p.position[axis]) * low; - if axis == 1 { - p.grounded = movement[1] < 0.; - p.velocity = 0.; - } - break; - } - p.position = candidate; - } - } - if p.position[1] < conf.elimination_y || p.position.iter().any(|v| v.abs() > 32700.) { + shacraft_physics::step( + &mut p.body, + &p.input.controls(), + &physics_world, + &p.physics_settings, + ); + p.input.fly_toggle = false; + p.position = p.body.position; + p.velocity = (p.body.velocity[1] * 20.) as f32; + p.grounded = p.body.on_ground; + if p.position[1] < conf.elimination_y + || p.position.iter().any(|v| v.abs() > crate::terrain::BORDER) + { let active = self .matches .get(&p.world) @@ -920,12 +1327,21 @@ impl Game { p.position = conf.spawn; } p.velocity = 0.; + p.grounded = false; + p.input = Input::default(); + p.reset_motion(); } self.players.insert(id.clone(), p); let p = &self.players[&id]; let center = p.position.map(|v| (v.floor() as i32).div_euclid(16) * 16); - if center != p.view && p.last_snapshot.elapsed() > Duration::from_secs(2) { - let _ = self.snapshot(&id, false); + if center != p.view { + if p.section_stream { + let _ = self.begin_section_view(&id, center, false); + } else if p.chunk_stream && p.last_view.elapsed() >= Duration::from_millis(250) { + let _ = self.stream_view(&id, center); + } else if !p.chunk_stream && p.last_snapshot.elapsed() > Duration::from_secs(2) { + let _ = self.snapshot(&id, false); + } } } self.advance_matches(); @@ -937,13 +1353,15 @@ impl Game { .players .values() .filter(|p| p.world == world) - .map(|p| (p.id.clone(), p.input.seq)) + .map(|p| (p.id.clone(), p.processed_seq)) .collect(); for (id, ack) in ids { - self.send( - &id, - json!({"type":"state","tick":self.tick,"ack":ack,"players":players,"match":m}), - ); + let mut message = + json!({"type":"state","tick":self.tick,"ack":ack,"players":players,"match":m}); + if let Some(player) = self.players.get(&id).filter(|p| p.prediction) { + message["motion"] = self.motion_json(player); + } + self.send(&id, message); } } self.plans.retain(|_, p| p.expires > Instant::now()); @@ -951,17 +1369,22 @@ impl Game { fn raycast(&mut self, id: &str) -> Result> { let p = self.players.get(id).context("not joined")?; let world = p.world.clone(); - let eye = [p.position[0], p.position[1] + 1.62, p.position[2]]; - let dir = [ - p.input.yaw.sin() * p.input.pitch.cos(), - p.input.pitch.sin(), - -p.input.yaw.cos() * p.input.pitch.cos(), + let eye = [ + p.position[0], + p.position[1] + p.body.eye_height(), + p.position[2], ]; + let dir = [ + p.look[0].sin() * p.look[1].cos(), + p.look[1].sin(), + -p.look[0].cos() * p.look[1].cos(), + ]; + let dir = dir.map(f64::from); let end = std::array::from_fn::<_, 3, _>(|i| eye[i] + dir[i] * 6.); let min = std::array::from_fn(|i| eye[i].min(end[i]).floor() as i32 - 3); let max = std::array::from_fn(|i| eye[i].max(end[i]).ceil() as i32 + 3); - let mut nearest: Option<(f32, Pos, Pos)> = None; - for change in self.store.read_region(&world, min, max)? { + let mut nearest: Option<(f64, Pos, Pos)> = None; + for change in self.read_world_region(&world, min, max)? { let cell = change.pos; let boxes = self .definition(change.block) @@ -971,8 +1394,8 @@ impl Game { if let Some((t, normal)) = ray_box( eye, dir, - std::array::from_fn(|i| cell[i] as f32 + b.min[i]), - std::array::from_fn(|i| cell[i] as f32 + b.max[i]), + std::array::from_fn(|i| f64::from(cell[i]) + f64::from(b.min[i])), + std::array::from_fn(|i| f64::from(cell[i]) + f64::from(b.max[i])), ) && nearest.is_none_or(|old| t < old.0) { nearest = Some((t, cell, normal)); @@ -1011,7 +1434,7 @@ impl Game { self.store .reset_world(world, rev, &format!("match-reset-{}", Uuid::new_v4()))?; for (i, id) in ids.iter().enumerate() { - let angle = i as f32 / ids.len() as f32 * std::f32::consts::TAU; + let angle = i as f64 / ids.len() as f64 * std::f64::consts::TAU; let p = self.players.get_mut(id).unwrap(); p.position = conf.spawn_points.get(i).copied().unwrap_or([ angle.sin() * 7., @@ -1020,6 +1443,9 @@ impl Game { ]); p.alive = true; p.velocity = 0.; + p.grounded = false; + p.input = Input::default(); + p.reset_motion(); } self.matches.insert( world.into(), @@ -1071,6 +1497,9 @@ impl Game { p.position = conf.spawn; p.velocity = 0.; p.alive = true; + p.grounded = false; + p.input = Input::default(); + p.reset_motion(); } self.meta.active_matches.remove(&world); let _ = self.save_meta(); @@ -1088,6 +1517,8 @@ impl Game { } } pub(super) fn resnapshot(&mut self, world: &str) { + self.terrain.clear_world(world); + self.sky_cache.retain(|(w, _, _), _| w != world); let ids: Vec<_> = self .players .values() @@ -1107,6 +1538,7 @@ impl Game { .collect() } } +#[cfg(test)] fn intersects(p: [f32; 3], min: [f32; 3], max: [f32; 3]) -> bool { let a = [p[0] - 0.3, p[1] + 0.001, p[2] - 0.3]; let b = [p[0] + 0.3, p[1] + 1.799, p[2] + 0.3]; @@ -1123,10 +1555,11 @@ fn string<'a>(p: &'a Value, key: &str) -> Result<&'a str> { p[key].as_str().context(format!("{key} string required")) } include!("control.rs"); +include!("world_streaming.rs"); -fn ray_box(eye: [f32; 3], dir: [f32; 3], min: [f32; 3], max: [f32; 3]) -> Option<(f32, Pos)> { - let mut near = 0f32; - let mut far = 6f32; +fn ray_box(eye: [f64; 3], dir: [f64; 3], min: [f64; 3], max: [f64; 3]) -> Option<(f64, Pos)> { + let mut near = 0f64; + let mut far = 6f64; let mut normal = [0; 3]; for i in 0..3 { if dir[i].abs() < 1e-7 { diff --git a/crates/shacraft-server/src/main.rs b/crates/shacraft-server/src/main.rs index 6392d84..6dc1b75 100644 --- a/crates/shacraft-server/src/main.rs +++ b/crates/shacraft-server/src/main.rs @@ -1,6 +1,7 @@ mod game; mod packages; mod render; +mod terrain; use anyhow::{Context, Result}; use axum::{ Json, Router, @@ -116,7 +117,19 @@ async fn main() -> Result<()> { .route("/api/control", post(control)) .route("/packages/{id}/{version}/{*path}", get(resource)) .route("/ws", get(websocket)) - .fallback_service(ServeDir::new(&args.client)) + .fallback_service( + Router::new() + .fallback_service(ServeDir::new(&args.client)) + .layer(axum::middleware::map_response( + |mut response: Response| async move { + response.headers_mut().insert( + header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-cache"), + ); + response + }, + )), + ) .layer(DefaultBodyLimit::max(2 * 1024 * 1024)) .layer(tower::limit::ConcurrencyLimitLayer::new(16)) .with_state(app); diff --git a/crates/shacraft-server/src/terrain.rs b/crates/shacraft-server/src/terrain.rs new file mode 100644 index 0000000..76c8262 --- /dev/null +++ b/crates/shacraft-server/src/terrain.rs @@ -0,0 +1,775 @@ +//! Original deterministic terrain. Unedited sections are reproducible cache data. +use anyhow::{Result, ensure}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use shacraft_core::Pos; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + sync::{Arc, Mutex, mpsc}, + time::Instant, +}; + +pub const MIN_Y: i32 = -64; +pub const MAX_Y: i32 = 319; +pub const BORDER: f64 = 29_999_872.; +pub const VIEW_RADIUS: i32 = 3; +pub const MIN_VIEW_RADIUS: i32 = 2; +pub const MAX_VIEW_RADIUS: i32 = 64; +pub const VERTICAL_RADIUS: i32 = 2; +pub type Cells = [u32; 4096]; +pub type TerrainKey = (String, Pos); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct TerrainConfig { + pub seed: u64, + pub version: u32, +} +impl Default for TerrainConfig { + fn default() -> Self { + Self { + seed: 20260914, + version: 1, + } + } +} +impl TerrainConfig { + pub fn validate(&self) -> Result<()> { + ensure!(self.version == 1, "unsupported terrain generator version"); + Ok(()) + } +} +#[derive(Clone)] +pub struct Palette { + pub grass: u32, + pub dirt: u32, + pub stone: u32, + pub deep: u32, + pub bedrock: u32, + pub sand: u32, + pub water: u32, + pub lava: u32, + pub log: u32, + pub leaves: u32, + pub snow: u32, + pub coal: u32, + pub iron: u32, + pub copper: u32, + pub gold: u32, + pub diamond: u32, + pub deep_iron: u32, + pub deep_diamond: u32, +} +#[derive(Clone)] +pub struct Generator { + pub config: TerrainConfig, + pub palette: Palette, +} +#[derive(Clone, Copy)] +struct Column { + height: i32, + desert: bool, + mountain: bool, +} +#[derive(Clone, Copy)] +struct Tree { + x: i32, + z: i32, + ground: i32, + height: i32, +} + +fn hash(seed: u64, x: i32, y: i32, z: i32) -> u64 { + let mut h = seed + ^ (x as i64 as u64).wrapping_mul(0x9e3779b185ebca87) + ^ (y as i64 as u64).wrapping_mul(0xc2b2ae3d27d4eb4f) + ^ (z as i64 as u64).wrapping_mul(0x165667b19e3779f9); + h = (h ^ (h >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + h = (h ^ (h >> 27)).wrapping_mul(0x94d049bb133111eb); + h ^ (h >> 31) +} +fn smooth(v: f64) -> f64 { + v * v * v * (v * (v * 6. - 15.) + 10.) +} +fn lerp(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t +} +fn noise(seed: u64, x: f64, y: f64, z: f64) -> f64 { + let [ix, iy, iz] = [x.floor() as i32, y.floor() as i32, z.floor() as i32]; + let [fx, fy, fz] = [ + smooth(x - x.floor()), + smooth(y - y.floor()), + smooth(z - z.floor()), + ]; + let n = |a, b, c| ((hash(seed, a, b, c) >> 11) as f64 / ((1u64 << 53) as f64)) * 2. - 1.; + lerp( + lerp( + lerp(n(ix, iy, iz), n(ix + 1, iy, iz), fx), + lerp(n(ix, iy + 1, iz), n(ix + 1, iy + 1, iz), fx), + fy, + ), + lerp( + lerp(n(ix, iy, iz + 1), n(ix + 1, iy, iz + 1), fx), + lerp(n(ix, iy + 1, iz + 1), n(ix + 1, iy + 1, iz + 1), fx), + fy, + ), + fz, + ) +} +impl Generator { + fn column(&self, x: i32, z: i32) -> Column { + let s = self.config.seed; + let nx = f64::from(x); + let nz = f64::from(z); + let continent = noise(s, nx / 420., 0., nz / 420.); + let hills = noise(s ^ 17, nx / 105., 0., nz / 105.) * 15. + + noise(s ^ 29, nx / 36., 0., nz / 36.) * 4.; + let ridge = (1. - noise(s ^ 71, nx / 240., 0., nz / 240.).abs()).powi(3); + let mountains = ((continent - 0.12) * 2.8).clamp(0., 1.) * ridge * 96.; + let mut height = 68. + continent * 35. + hills + mountains; + let river = noise(s ^ 131, nx / 210., 0., nz / 210.).abs(); + if river < 0.045 && mountains < 14. { + height = lerp(58., height, (river / 0.045).powi(2)); + } + // A dry, softly blended spawn clearing; the rest of the world is unbounded noise. + let distance = nx.hypot(nz - 8.); + if distance < 40. { + height = lerp(76., height, smooth((distance / 40.).clamp(0., 1.))); + } + Column { + height: (height.round() as i32).clamp(28, 210), + desert: noise(s ^ 911, nx / 330., 0., nz / 330.) > 0.27 && mountains < 30., + mountain: mountains > 38., + } + } + pub fn height(&self, x: i32, z: i32) -> i32 { + self.column(x, z).height + } + pub fn biome(&self, x: i32, z: i32) -> &'static str { + let c = self.column(x, z); + if c.height < 61 { + "ocean" + } else if c.mountain { + "mountains" + } else if c.desert { + "desert" + } else if noise( + self.config.seed ^ 351, + f64::from(x) / 180., + 0., + f64::from(z) / 180., + ) > -0.15 + { + "forest" + } else { + "plains" + } + } + fn trees(&self, sx: i32, sz: i32) -> Vec { + let mut trees = Vec::new(); + for gx in (sx * 16 - 3).div_euclid(8)..=(sx * 16 + 18).div_euclid(8) { + for gz in (sz * 16 - 3).div_euclid(8)..=(sz * 16 + 18).div_euclid(8) { + let h = hash(self.config.seed ^ 771, gx, 0, gz); + let x = gx * 8 + 2 + (h % 4) as i32; + let z = gz * 8 + 2 + ((h >> 8) % 4) as i32; + let c = self.column(x, z); + let forest = self.biome(x, z) == "forest"; + if c.height <= 63 + || c.desert + || c.mountain + || h % 100 >= if forest { 58 } else { 12 } + || f64::from(x).hypot(f64::from(z - 8)) < 12. + { + continue; + } + trees.push(Tree { + x, + z, + ground: c.height, + height: 4 + ((h >> 20) % 3) as i32, + }); + } + } + trees + } + fn ground_block(&self, x: i32, y: i32, z: i32, c: Column) -> u32 { + let p = &self.palette; + let seed = self.config.seed; + if !(MIN_Y..=MAX_Y).contains(&y) { + return 0; + } + if y == MIN_Y || y < MIN_Y + 5 && hash(seed ^ 1, x, y, z) % 5 > (y - MIN_Y) as u64 { + return p.bedrock; + } + if y > c.height { + return if y <= 62 { p.water } else { 0 }; + } + if y < c.height - 4 && y > MIN_Y + 5 { + let n = noise( + seed ^ 801, + f64::from(x) / 34., + f64::from(y) / 24., + f64::from(z) / 34., + ); + let tunnel = noise( + seed ^ 807, + f64::from(x) / 28., + f64::from(y) / 18., + f64::from(z) / 28., + ) + .abs() + < 0.065 + && noise( + seed ^ 809, + f64::from(x) / 65., + f64::from(y) / 37., + f64::from(z) / 65., + ) + .abs() + < 0.18; + if n > 0.39 || tunnel { + return if y < -54 { + p.lava + } else if y < 18 && n > 0.69 { + p.water + } else { + 0 + }; + } + } + // A naturally open ravine near spawn is a repeatable route underground. + if (26..=35).contains(&x) + && (-24..=30).contains(&z) + && y > -26 + && y <= c.height + && f64::from(x - 30).abs() + < 2.4 + noise(seed ^ 999, 0., f64::from(y) / 14., f64::from(z) / 18.) * 1.5 + { + return 0; + } + if y == c.height { + return if c.desert || c.height <= 63 { + p.sand + } else if c.height > 145 { + p.snow + } else { + p.grass + }; + } + if y >= c.height - 3 { + return if c.desert || c.height <= 63 { + p.sand + } else { + p.dirt + }; + } + let ore = hash( + seed ^ 331, + x.div_euclid(2), + y.div_euclid(2), + z.div_euclid(2), + ) % 1000; + let edge = !hash(seed ^ 333, x, y, z).is_multiple_of(5); + if edge { + if y < 0 && ore < 5 { + return p.deep_diamond; + } + if y < 18 && ore < 5 { + return p.diamond; + } + if y < 28 && (5..12).contains(&ore) { + return p.gold; + } + if y < 66 && (12..35).contains(&ore) { + return if y < 0 { p.deep_iron } else { p.iron }; + } + if y > 0 && y < 88 && (35..55).contains(&ore) { + return p.copper; + } + if y > 0 && (55..86).contains(&ore) { + return p.coal; + } + } + if y < 0 { p.deep } else { p.stone } + } + pub fn generate(&self, section: Pos) -> Cells { + let [sx, sy, sz] = section; + let mut cells = [0; 4096]; + if sy * 16 > MAX_Y || sy * 16 + 15 < MIN_Y { + return cells; + } + for z in 0..16 { + for x in 0..16 { + let wx = sx * 16 + x; + let wz = sz * 16 + z; + let c = self.column(wx, wz); + for y in 0..16 { + cells[(x + 16 * z + 256 * y) as usize] = + self.ground_block(wx, sy * 16 + y, wz, c); + } + } + } + for tree in self.trees(sx, sz) { + for y in tree.ground + 1..=tree.ground + tree.height + 2 { + for z in tree.z - 2..=tree.z + 2 { + for x in tree.x - 2..=tree.x + 2 { + if x.div_euclid(16) != sx + || y.div_euclid(16) != sy + || z.div_euclid(16) != sz + { + continue; + } + let trunk = x == tree.x && z == tree.z && y <= tree.ground + tree.height; + let leaf = y >= tree.ground + tree.height - 2 + && (x - tree.x).abs() + (z - tree.z).abs() + <= if y > tree.ground + tree.height { 2 } else { 3 }; + let i = (x.rem_euclid(16) + 16 * z.rem_euclid(16) + 256 * y.rem_euclid(16)) + as usize; + if trunk { + cells[i] = self.palette.log; + } else if leaf && cells[i] == 0 { + cells[i] = self.palette.leaves; + } + } + } + } + } + cells + } + /// Highest opaque natural cell. Used even when its section is not streamed. + pub fn sky_heights(&self, sx: i32, sz: i32) -> [i16; 256] { + let mut heights = [-65; 256]; + for z in 0..16 { + for x in 0..16 { + let wx = sx * 16 + x; + let wz = sz * 16 + z; + let c = self.column(wx, wz); + let mut top = c.height; + while top > MIN_Y { + let block = self.ground_block(wx, top, wz, c); + if block != 0 && block != self.palette.water && block != self.palette.lava { + break; + } + top -= 1; + } + heights[(x + 16 * z) as usize] = top as i16; + } + } + for t in self.trees(sx, sz) { + if t.x.div_euclid(16) == sx && t.z.div_euclid(16) == sz { + heights[(t.x.rem_euclid(16) + 16 * t.z.rem_euclid(16)) as usize] = + (t.ground + t.height) as i16; + } + } + heights + } +} + +struct Job { + key: TerrainKey, + generator: Arc, +} +struct Finished { + key: TerrainKey, + cells: Arc, + milliseconds: f64, +} +struct Cached { + cells: Arc, + used: u64, +} +/// Fixed CPU workers and bounded pending jobs. Old natural sections can be +/// discarded at any time; only edited sections require durable storage. +pub struct TerrainService { + tx: Option>, + rx: mpsc::Receiver, + workers: Vec>, + cache: HashMap, + lru: VecDeque<(TerrainKey, u64)>, + pending: HashSet, + clock: u64, + pub generated: u64, + pub discarded: u64, + read_interests: HashMap, + pub last_ms: f64, + pub max_ms: f64, + pub hits: u64, + pub misses: u64, + pub evictions: u64, +} +impl TerrainService { + pub fn new() -> Self { + let (tx, jobs) = mpsc::sync_channel::(32); + let jobs = Arc::new(Mutex::new(jobs)); + let (finished, rx) = mpsc::sync_channel(32); + let mut workers = Vec::new(); + for i in 0..2 { + let jobs = jobs.clone(); + let finished = finished.clone(); + workers.push( + std::thread::Builder::new() + .name(format!("terrain-{i}")) + .spawn(move || { + loop { + let job = match jobs.lock().unwrap().recv() { + Ok(job) => job, + Err(_) => break, + }; + let start = Instant::now(); + let cells = Arc::new(job.generator.generate(job.key.1)); + if finished + .send(Finished { + key: job.key, + cells, + milliseconds: start.elapsed().as_secs_f64() * 1000., + }) + .is_err() + { + break; + } + } + }) + .expect("terrain worker thread"), + ); + } + Self { + tx: Some(tx), + rx, + workers, + cache: HashMap::new(), + lru: VecDeque::new(), + pending: HashSet::new(), + read_interests: HashMap::new(), + clock: 0, + generated: 0, + discarded: 0, + last_ms: 0., + max_ms: 0., + hits: 0, + misses: 0, + evictions: 0, + } + } + /// A control read may target terrain with no player nearby. Keep its + /// completed jobs long enough for the caller to retry, without unbounded pins. + pub fn retain_for_read(&mut self, key: TerrainKey) { + if self.read_interests.len() >= 512 + && !self.read_interests.contains_key(&key) + && let Some(old) = self + .read_interests + .iter() + .min_by_key(|(_, t)| **t) + .map(|(k, _)| k.clone()) + { + self.read_interests.remove(&old); + } + self.read_interests + .insert(key, Instant::now() + std::time::Duration::from_secs(10)); + } + pub fn request(&mut self, key: TerrainKey, generator: Arc) -> bool { + if self.cache.contains_key(&key) || self.pending.contains(&key) { + return true; + } + if self.pending.len() >= 32 { + return false; + } + if self + .tx + .as_ref() + .unwrap() + .try_send(Job { + key: key.clone(), + generator, + }) + .is_err() + { + return false; + } + self.pending.insert(key); + true + } + pub fn poll(&mut self, wanted: &HashSet) { + if self.lru.len() > self.cache.len() * 4 + 128 { + self.lru.retain(|(key, stamp)| { + self.cache + .get(key) + .is_some_and(|entry| entry.used == *stamp) + }); + } + self.read_interests + .retain(|_, until| *until > Instant::now()); + while let Ok(done) = self.rx.try_recv() { + self.pending.remove(&done.key); + self.generated += 1; + self.last_ms = done.milliseconds; + self.max_ms = self.max_ms.max(done.milliseconds); + if wanted.contains(&done.key) || self.read_interests.contains_key(&done.key) { + self.insert(done.key, done.cells); + } else { + self.discarded += 1; + } + } + } + pub fn get(&mut self, key: &TerrainKey) -> Option> { + self.clock += 1; + if let Some(entry) = self.cache.get_mut(key) { + entry.used = self.clock; + self.lru.push_back((key.clone(), self.clock)); + self.hits += 1; + Some(entry.cells.clone()) + } else { + self.misses += 1; + None + } + } + pub fn contains(&self, key: &TerrainKey) -> bool { + self.cache.contains_key(key) + } + pub fn insert(&mut self, key: TerrainKey, cells: Arc) { + self.clock += 1; + while self.cache.len() >= 8192 && !self.cache.contains_key(&key) { + let Some((old, stamp)) = self.lru.pop_front() else { + break; + }; + if self + .cache + .get(&old) + .is_some_and(|entry| entry.used == stamp) + { + self.cache.remove(&old); + self.evictions += 1; + } + } + self.lru.push_back((key.clone(), self.clock)); + self.cache.insert( + key, + Cached { + cells, + used: self.clock, + }, + ); + } + pub fn clear_world(&mut self, world: &str) { + self.cache.retain(|(w, _), _| w != world); + } + pub fn invalidate(&mut self, world: &str, sections: &HashSet) { + self.cache + .retain(|(w, pos), _| w != world || !sections.contains(pos)); + } + pub fn stats(&self) -> Value { + json!({"workers":2,"pending":self.pending.len(),"queue_limit":32,"cache_sections":self.cache.len(),"cache_limit":8192,"cache_bytes":self.cache.len()*16384,"generated_sections":self.generated,"discarded_results":self.discarded,"cache_hits":self.hits,"cache_misses":self.misses,"cache_evictions":self.evictions,"last_generate_ms":self.last_ms,"max_generate_ms":self.max_ms}) + } +} +impl Drop for TerrainService { + fn drop(&mut self) { + self.tx.take(); + // Drain completed work while joining: workers may be backpressured. + while self.workers.iter().any(|w| !w.is_finished()) { + while self.rx.try_recv().is_ok() {} + std::thread::yield_now(); + } + for worker in self.workers.drain(..) { + let _ = worker.join(); + } + } +} + +/// Keep one prefetched ring resident while crossing a single horizontal section. +pub fn view_needs_move(previous: Pos, next: Pos, buffered: bool) -> bool { + (0..3).any(|axis| { + (previous[axis] - next[axis]).abs() > if buffered && axis != 1 { 16 } else { 0 } + }) +} +#[cfg(test)] +pub fn bounds(center: Pos, radius: i32) -> (Pos, Pos) { + view_bounds(center, radius, false) +} +pub fn view_bounds(center: Pos, radius: i32, full_height: bool) -> (Pos, Pos) { + ( + [ + center[0] - radius * 16, + if full_height { + MIN_Y + } else { + center[1] - VERTICAL_RADIUS * 16 + }, + center[2] - radius * 16, + ], + [ + center[0] + (radius + 1) * 16 - 1, + if full_height { + MAX_Y + } else { + center[1] + (VERTICAL_RADIUS + 1) * 16 - 1 + }, + center[2] + (radius + 1) * 16 - 1, + ], + ) +} +pub fn sections(center: Pos, radius: i32) -> Vec { + view_sections(center, radius, false) +} +pub fn view_sections(center: Pos, radius: i32, full_height: bool) -> Vec { + let c = center.map(|v| v.div_euclid(16)); + let (min, max) = view_bounds(center, radius, full_height); + let mut result = Vec::new(); + for x in c[0] - radius..=c[0] + radius { + for z in c[2] - radius..=c[2] + radius { + for y in min[1].div_euclid(16)..=max[1].div_euclid(16) { + result.push([x, y, z]); + } + } + } + result.sort_by_key(|p| (p[0] - c[0]).pow(2) + (p[2] - c[2]).pow(2) + (p[1] - c[1]).pow(2) * 2); + result +} +/// Palette + runs avoids JSON objects and repeated world coordinates per voxel. +pub fn pack(cells: &Cells) -> Value { + if cells.iter().all(|id| *id == cells[0]) { + return json!({"palette":[cells[0]],"runs":[4096,0]}); + } + let mut palette = Vec::new(); + let mut indices = HashMap::new(); + let mut runs = Vec::::new(); + let mut previous = u32::MAX; + let mut count = 0; + for &block in cells { + let next = indices.len() as u32; + let index = *indices.entry(block).or_insert_with(|| { + palette.push(block); + next + }); + if index == previous { + count += 1; + } else { + if count > 0 { + runs.extend([count, previous]); + } + previous = index; + count = 1; + } + } + runs.extend([count, previous]); + json!({"palette":palette,"runs":runs}) +} + +#[cfg(test)] +mod tests { + use super::*; + fn generator(seed: u64) -> Generator { + Generator { + config: TerrainConfig { seed, version: 1 }, + palette: Palette { + grass: 1, + dirt: 2, + stone: 3, + deep: 4, + bedrock: 5, + sand: 6, + water: 7, + lava: 8, + log: 9, + leaves: 10, + snow: 11, + coal: 12, + iron: 13, + copper: 14, + gold: 15, + diamond: 16, + deep_iron: 17, + deep_diamond: 18, + }, + } + } + #[test] + fn generation_is_order_independent_and_seeded_at_negative_and_distant_coordinates() { + let g = generator(20260914); + let other = generator(921); + for pos in [[-1, 3, -1], [1, -2, 0], [1_800_000, 4, -1_800_000]] { + let first = g.generate(pos); + let _ = g.generate([pos[0] + 1, pos[1], pos[2]]); + assert_eq!(first, g.generate(pos)); + } + assert_ne!(g.generate([19, 3, -7]), other.generate([19, 3, -7])); + } + #[test] + fn world_has_deep_caves_ores_surface_water_and_an_unbroken_bedrock_bottom() { + let g = generator(20260914); + let bottom = g.generate([0, -4, 0]); + assert!(bottom[..256].iter().all(|id| *id == g.palette.bedrock)); + let mut caves = 0; + let mut deep = 0; + let mut ores = 0; + for sy in -3..2 { + for sx in -2..=2 { + for sz in -2..=2 { + for id in g.generate([sx, sy, sz]) { + if id == 0 { + caves += 1; + } + if id == g.palette.deep { + deep += 1; + } + if (12..=18).contains(&id) { + ores += 1; + } + } + } + } + } + assert!(caves > 1000); + assert!(deep > 10000); + assert!(ores > 1000); + assert!(g.generate([0, 20, 0]).iter().all(|id| *id == 0)); + assert!(g.height(0, 8) > 62); + assert!((-1000..1000).step_by(40).any(|x| g.height(x, 100) < 62)); + } + #[test] + fn natural_sky_height_agrees_with_the_highest_opaque_generated_cell_including_ravine() { + let g = generator(20260914); + for [sx, sz] in [[0, 0], [1, 0], [-1, -1]] { + let heights = g.sky_heights(sx, sz); + let sections: Vec<_> = (MIN_Y / 16..=MAX_Y / 16) + .map(|sy| g.generate([sx, sy, sz])) + .collect(); + for z in 0..16 { + for x in 0..16 { + let top = (MIN_Y..=MAX_Y) + .rev() + .find(|y| { + let sy = (y.div_euclid(16) - MIN_Y / 16) as usize; + let id = sections[sy][(x + 16 * z + 256 * y.rem_euclid(16)) as usize]; + id != 0 + && id != g.palette.water + && id != g.palette.lava + && id != g.palette.leaves + }) + .unwrap(); + assert_eq!( + i32::from(heights[(x + 16 * z) as usize]), + top, + "{sx}/{sz}/{x}/{z}" + ); + } + } + } + } + #[test] + fn view_and_wire_payloads_are_bounded_and_near_sections_are_first() { + let view = sections([-16, 64, 32], VIEW_RADIUS); + assert_eq!(view.len(), 245); + assert_eq!(view[0], [-1, 4, 2]); + assert!(view.len() * 4096 <= 1_048_576); + assert_eq!(pack(&[3; 4096]), json!({"palette":[3],"runs":[4096,0]})); + let cells = std::array::from_fn(|i| (i % 3) as u32); + let value = pack(&cells); + let runs = value["runs"].as_array().unwrap(); + assert_eq!( + runs.iter() + .step_by(2) + .map(|v| v.as_u64().unwrap()) + .sum::(), + 4096 + ); + } +} diff --git a/crates/shacraft-server/src/tests.rs b/crates/shacraft-server/src/tests.rs index 1c303b1..736b452 100644 --- a/crates/shacraft-server/src/tests.rs +++ b/crates/shacraft-server/src/tests.rs @@ -51,9 +51,20 @@ fn fixture() -> (TempDir, Game) { (dir, game) } fn connect(game: &mut Game, world: &str, name: &str) -> TestClient { + connect_with_features(game, world, name, &[]) +} +fn connect_with_features( + game: &mut Game, + world: &str, + name: &str, + features: &[&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 mut p = json!({"type":"join","protocol":1,"name":name,"world":world,"manifest_hash":game.manifest["hash"]}); + if !features.is_empty() { + p["features"] = json!(features); + } let id = game .join( p, @@ -83,6 +94,854 @@ fn entity_spawn(game: &mut Game, kind: &str) -> Result { 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()})) } +fn physics_fixture() -> (TempDir, Game, TestClient) { + let (dir, mut game) = fixture(); + game.store.create_world("physics", None).unwrap(); + game.meta.worlds.insert( + "physics".into(), + WorldConfig { + spawn: [0.5, 1., 0.5], + elimination_y: -100., + ..WorldConfig::default() + }, + ); + let stone = game.id("minecraft:stone").unwrap(); + let mut floor = Vec::new(); + for x in -4..=4 { + for z in -30..=4 { + floor.push(BlockChange { + pos: [x, 0, z], + block: stone, + }); + } + } + edit(&mut game, "physics", floor); + let mut client = connect_with_features( + &mut game, + "physics", + "Walker", + &["chunk_stream_v1", "movement_prediction_v1"], + ); + client.drain(); + ticks(&mut game, &mut [&mut client], 2); + (dir, game, client) +} + +fn movement(seq: u64) -> Value { + json!({"type":"input","seq":seq,"yaw":0.,"pitch":0.,"forward":1.,"strafe":0.}) +} + +fn settle_sections(game: &mut Game, client: &mut TestClient) -> Vec { + let deadline = Instant::now() + Duration::from_secs(15); + let mut messages = client.drain(); + while game.players[&client.id].sent_sections.len() + < ((game.players[&client.id].view_distance * 2 + 1).pow(2) * 5) as usize + { + assert!( + Instant::now() < deadline, + "terrain did not settle: {}", + game.query("diagnostics.snapshot", json!({})).unwrap() + ); + game.players.get_mut(&client.id).unwrap().last_batch = + Instant::now() - Duration::from_secs(1); + game.step(); + messages.extend(client.drain()); + std::thread::sleep(Duration::from_millis(1)); + } + messages +} + +#[test] +fn render_distance_changes_only_the_requesting_view_and_preserves_motion() { + let (_dir, mut game) = fixture(); + game.store.create_world("distance-test", None).unwrap(); + let mut first = + connect_with_features(&mut game, "distance-test", "First", &["chunk_stream_v2"]); + game.query( + "player.teleport", + json!({"id":first.id,"position":[0.,80.,8.],"flying":true}), + ) + .unwrap(); + settle_sections(&mut game, &mut first); + let position = game.players[&first.id].position; + let epoch = game.players[&first.id].motion_epoch; + let mut second = + connect_with_features(&mut game, "distance-test", "Second", &["chunk_stream_v2"]); + second.drain(); + game.client(&first.id, json!({"type":"view_distance","chunks":6})) + .unwrap(); + let view = first + .drain() + .into_iter() + .find(|v| v["type"] == "view") + .unwrap(); + assert_eq!(view["total_sections"], 845); + assert_eq!(view["unload"], json!([])); + assert_eq!(game.players[&first.id].sent_sections.len(), 245); + assert_eq!(game.players[&second.id].view_distance, 3); + assert!(second.drain().is_empty()); + game.players.remove(&second.id); + for value in [json!(1), json!(65), json!(3.5), json!("6"), Value::Null] { + assert!( + game.client(&first.id, json!({"type":"view_distance","chunks":value})) + .is_err() + ); + assert_eq!(game.players[&first.id].view_distance, 6); + } + assert_eq!(game.players[&first.id].position, position); + assert_eq!(game.players[&first.id].motion_epoch, epoch); + settle_sections(&mut game, &mut first); + game.players + .get_mut(&first.id) + .unwrap() + .last_distance_change = Instant::now() - Duration::from_secs(1); + game.client(&first.id, json!({"type":"view_distance","chunks":2})) + .unwrap(); + let view = first + .drain() + .into_iter() + .find(|v| v["type"] == "view") + .unwrap(); + assert_eq!(view["total_sections"], 125); + assert_eq!(view["unload"].as_array().unwrap().len(), 720); + assert_eq!(game.players[&first.id].sent_sections.len(), 125); + assert_eq!(game.store.revision("distance-test").unwrap(), 0); + assert!( + game.section_data("overworld", [1_875_000, 4, 0]) + .unwrap() + .unwrap() + .iter() + .all(|id| *id == 0) + ); +} + +#[test] +fn full_height_stream_retains_ground_and_roof_during_vertical_flight() { + let (_dir, mut game) = fixture(); + game.store.create_world("height-test", None).unwrap(); + let mut client = connect_with_features( + &mut game, + "height-test", + "Fly", + &["chunk_stream_v2", "view_buffer_v1", "full_height_v1"], + ); + let welcome = client + .drain() + .into_iter() + .find(|v| v["type"] == "welcome") + .unwrap(); + assert_eq!(welcome["view_min"][1], -64); + assert_eq!(welcome["view_max"][1], 319); + assert_eq!(welcome["total_sections"], 1944); + let p = game.players.get_mut(&client.id).unwrap(); + p.sent_sections.extend([[0, -4, 0], [0, 19, 0]]); + let center = [p.view[0], 512, p.view[2]]; + game.begin_section_view(&client.id, center, false).unwrap(); + let view = client + .drain() + .into_iter() + .find(|v| v["type"] == "view") + .unwrap(); + assert_eq!(view["view_min"][1], -64); + assert_eq!(view["view_max"][1], 319); + assert_eq!(view["unload"], json!([])); + assert!(game.players[&client.id].sent_sections.contains(&[0, -4, 0])); + assert!(game.players[&client.id].sent_sections.contains(&[0, 19, 0])); + assert_eq!(terrain::view_sections([0, 96, 0], 65, true).len(), 411864); +} + +#[test] +fn sixty_four_chunk_view_queues_the_full_buffer_and_delivers_a_retained_outer_ring() { + let (_dir, mut game) = fixture(); + game.store.create_world("distance-64", None).unwrap(); + let mut client = connect_with_features( + &mut game, + "distance-64", + "Wide", + &["chunk_stream_v2", "view_buffer_v1"], + ); + game.query( + "player.teleport", + json!({"id":client.id,"position":[0.,80.,0.],"flying":true}), + ) + .unwrap(); + game.client(&client.id, json!({"type":"view_distance","chunks":64})) + .unwrap(); + let view = client + .drain() + .into_iter() + .rev() + .find(|m| m["type"] == "view") + .unwrap(); + assert_eq!(view["view_distance"], 64); + assert_eq!(view["stream_radius"], 65); + assert_eq!(view["total_sections"], 85805); + assert_eq!(view["view_min"][0], -1040); + assert_eq!(view["view_max"][0], 1055); + let center = game.players[&client.id].view; + let outer = [64, center[1] / 16, -64]; + assert!(game.players[&client.id].pending_sections.contains(&outer)); + assert_eq!( + game.players[&client.id].pending_sections.front().unwrap()[0], + 0 + ); + let gold = game.id("minecraft:gold_block").unwrap(); + game.store + .materialize_section("distance-64", outer, &[gold; 4096]) + .unwrap(); + // Model an otherwise retained view with one outer section still missing. + let player = game.players.get_mut(&client.id).unwrap(); + player.sent_sections.extend( + terrain::sections(center, 65) + .into_iter() + .filter(|pos| *pos != outer), + ); + game.begin_section_view(&client.id, center, false).unwrap(); + client.drain(); + assert_eq!( + game.players[&client.id].pending_sections, + VecDeque::from([outer]) + ); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + assert!(Instant::now() < deadline, "outer section was not delivered"); + game.players.get_mut(&client.id).unwrap().last_batch = + Instant::now() - Duration::from_secs(1); + game.pump_terrain(); + if let Some(batch) = client.drain().into_iter().find(|m| m["type"] == "sections") { + assert_eq!(batch["sections"][0]["section"], json!(outer)); + assert_eq!(batch["sections"][0]["palette"], json!([gold])); + assert_eq!(batch["loaded_sections"], 85805); + break; + } + } + assert!(game.players[&client.id].pending_sections.is_empty()); + assert_eq!(game.store.revision("distance-64").unwrap(), 0); +} + +#[test] +fn procedural_stream_is_bounded_retains_overlap_and_never_advances_edit_revision() { + let (_dir, mut game) = fixture(); + let mut legacy = connect(&mut game, "lobby", "Legacy"); + legacy.drain(); + game.players.get_mut(&legacy.id).unwrap().last_snapshot = + Instant::now() - Duration::from_secs(2); + assert!( + game.client( + &legacy.id, + json!({"type":"switch_world","world":"overworld"}) + ) + .is_err() + ); + assert_eq!(game.players[&legacy.id].world, "lobby"); + game.players.remove(&legacy.id); + let revision = game.store.revision("overworld").unwrap(); + let mut client = connect_with_features( + &mut game, + "overworld", + "Explorer", + &["chunk_stream_v2", "movement_prediction_v1"], + ); + let welcome = client + .drain() + .into_iter() + .find(|m| m["type"] == "welcome") + .unwrap(); + assert_eq!(welcome["blocks"], json!([])); + assert_eq!(welcome["total_sections"], 245); + let first = game.players[&client.id].position; + game.client(&client.id, movement(1)).unwrap(); + game.step(); + // A cold collision read either holds the authoritative body or uses a + // completed solid section; it can never fall through ungenerated ground. + assert!(game.players[&client.id].position[1] >= first[1] - 0.01); + let messages = settle_sections(&mut game, &mut client); + let batches: Vec<_> = messages + .iter() + .filter(|m| m["type"] == "sections") + .collect(); + assert!(!batches.is_empty()); + for batch in batches { + assert!(batch["sections"].as_array().unwrap().len() <= 32); + assert!(batch.to_string().len() < 512 * 1024); + } + assert_eq!(game.store.revision("overworld").unwrap(), revision); + let before = game.players[&client.id].sent_sections.clone(); + let center = game.players[&client.id].view; + game.begin_section_view(&client.id, [center[0] + 16, center[1], center[2]], false) + .unwrap(); + let retained = &game.players[&client.id].sent_sections; + assert_eq!(retained.len(), 210); + assert!(retained.is_subset(&before)); + let update = client.drain(); + assert!( + update + .iter() + .any(|m| m["type"] == "view" && m["unload"].as_array().unwrap().len() == 35) + ); + assert!(!update.iter().any(|m| m["type"] == "snapshot")); +} + +#[test] +fn remote_procedural_reads_finish_without_players_and_air_edits_survive_restart() { + let (dir, mut game) = fixture(); + let min = [1_000_000, -20, -1_000_000]; + let max = [min[0] + 17, min[1] + 3, min[2] + 3]; + let query = json!({"world":"overworld","min":min,"max":max}); + let deadline = Instant::now() + Duration::from_secs(10); + let read = loop { + match game.query("world.read", query.clone()) { + Ok(value) => break value, + Err(e) => assert!(e.to_string().contains("terrain pending"), "{e}"), + } + assert!(Instant::now() < deadline); + game.step(); + std::thread::sleep(Duration::from_millis(1)); + }; + let natural = &read["blocks"][0]; + assert!(natural.is_object()); + let pos: Pos = serde_json::from_value(natural["pos"].clone()).unwrap(); + let original = natural["block"].as_u64().unwrap() as u32; + let revision = game.store.revision("overworld").unwrap(); + game.query("world.edit",json!({"world":"overworld","expected_revision":revision,"operation_id":"mine-natural","changes":[{"pos":pos,"block":0}]})).unwrap(); + assert_eq!(game.world_block("overworld", pos).unwrap(), 0); + game.terrain.clear_world("overworld"); + assert_eq!(game.world_block("overworld", pos).unwrap(), 0); + drop(game); + let mut game = open_game(dir.path()); + assert_eq!(game.world_block("overworld", pos).unwrap(), 0); + let revision = game.store.revision("overworld").unwrap(); + game.query("world.undo",json!({"world":"overworld","expected_revision":revision,"operation_id":"undo-natural","target_operation":"mine-natural"})).unwrap(); + assert_eq!(game.world_block("overworld", pos).unwrap(), original); + let before = game.store.stats()["materialized_natural_sections"].clone(); + assert!( + game.materialize_changes( + "overworld", + &[BlockChange { + pos: [100, 320, 0], + block: original + }] + ) + .is_err() + ); + assert!( + game.materialize_changes( + "overworld", + &[BlockChange { + pos: [100, 70, 0], + block: u32::MAX + }] + ) + .is_err() + ); + assert_eq!(game.store.stats()["materialized_natural_sections"], before); +} + +#[test] +fn distant_player_coordinates_keep_sub_block_precision_and_wait_for_cold_terrain() { + let (_dir, mut game) = fixture(); + let mut client = connect_with_features( + &mut game, + "overworld", + "Far", + &["chunk_stream_v2", "movement_prediction_v1"], + ); + client.drain(); + let position = [29_999_000.125, 250.25, -29_999_000.875]; + game.query( + "player.teleport", + json!({"id":client.id,"position":position,"flying":true}), + ) + .unwrap(); + assert_eq!(game.players[&client.id].body.position, position); + let snapshot = client + .drain() + .into_iter() + .find(|m| m["type"] == "snapshot") + .unwrap(); + assert_eq!(snapshot["spawn"], json!(position)); + let _ = settle_sections(&mut game, &mut client); + assert_eq!(game.players[&client.id].position, position); + game.client(&client.id, movement(1)).unwrap(); + ticks(&mut game, &mut [&mut client], 1); + let next = game.players[&client.id].position; + assert!(next[2] < position[2] && next[2] > position[2] - 1.); + assert_eq!(next[0], position[0]); + assert_eq!(next[1], position[1]); +} + +#[test] +fn movement_queue_acknowledges_processed_commands_and_bounds_packet_bursts() { + let (_dir, mut game, mut client) = physics_fixture(); + for seq in 1..=32 { + game.client(&client.id, movement(seq)).unwrap(); + } + assert!(game.client(&client.id, movement(33)).is_err()); + assert_eq!(game.players[&client.id].processed_seq, 0); + let before = game.players[&client.id].body.position; + game.step(); + let state = client + .drain() + .into_iter() + .find(|m| m["type"] == "state") + .unwrap(); + assert_eq!(state["ack"], 1); + assert_eq!(state["motion"]["ack"], 1); + assert_eq!(game.players[&client.id].pending_inputs.len(), 31); + assert!((before[2] - game.players[&client.id].body.position[2] - 0.098).abs() < 1e-6); + game.client(&client.id, movement(1)).unwrap(); + assert_eq!( + game.players[&client.id].pending_inputs.len(), + 31, + "duplicate must not move twice" + ); + game.client(&client.id, movement(33)).unwrap(); + ticks(&mut game, &mut [&mut client], 32); + assert_eq!(game.players[&client.id].processed_seq, 33); +} + +#[test] +fn server_movement_preserves_shared_double_precision_state_over_ticks() { + let (_dir, mut game, mut client) = physics_fixture(); + let mut expected = game.players[&client.id].body.clone(); + for seq in 1..=80 { + let mut input = movement(seq); + input["forward"] = json!(if seq <= 55 { 1. } else { 0. }); + input["sprint"] = json!(seq > 20 && seq < 50); + input["jump"] = json!(seq == 25); + input["sneak"] = json!(seq > 55); + game.client(&client.id, input).unwrap(); + let p = &game.players[&client.id]; + let controls = p.pending_inputs.front().unwrap().controls(); + let settings = p.physics_settings.clone(); + let world = game.physics_world("physics", &expected).unwrap(); + shacraft_physics::step(&mut expected, &controls, &world, &settings); + ticks(&mut game, &mut [&mut client], 1); + let actual = &game.players[&client.id].body; + for axis in 0..3 { + assert!( + (actual.position[axis] - expected.position[axis]).abs() < 1e-12, + "tick {seq}, axis {axis} was rounded by the adapter" + ); + assert!((actual.velocity[axis] - expected.velocity[axis]).abs() < 1e-12); + } + } +} + +#[test] +fn input_reset_cancels_backlog_and_respawn_invalidates_prediction_epoch() { + let (_dir, mut game, mut client) = physics_fixture(); + let epoch = game.players[&client.id].motion_epoch; + for seq in 1..=8 { + game.client(&client.id, movement(seq)).unwrap(); + } + game.client(&client.id, json!({"type":"input_reset"})) + .unwrap(); + let p = &game.players[&client.id]; + assert!(p.pending_inputs.is_empty()); + assert_eq!(p.processed_seq, 8); + assert_eq!(p.motion_epoch, epoch + 1); + ticks(&mut game, &mut [&mut client], 2); + assert_eq!(game.players[&client.id].body.position[2], 0.5); + game.client(&client.id, movement(9)).unwrap(); + ticks(&mut game, &mut [&mut client], 1); + game.client(&client.id, movement(10)).unwrap(); + game.client(&client.id, json!({"type":"respawn"})).unwrap(); + let p = &game.players[&client.id]; + assert_eq!(p.body.position, [0.5, 1., 0.5]); + assert_eq!(p.body.velocity, [0.; 3]); + assert_eq!(p.motion_epoch, epoch + 2); + assert_eq!(p.processed_seq, 10); + assert!(p.pending_inputs.is_empty()); +} + +#[test] +fn flight_is_authorized_by_world_and_revoked_in_switch_snapshot() { + let (_dir, mut game, mut client) = physics_fixture(); + let mut input = movement(1); + input["forward"] = json!(0.); + input["fly_toggle"] = json!(true); + input["jump"] = json!(true); + input["position"] = json!([5000., 5000., 5000.]); + game.client(&client.id, input).unwrap(); + ticks(&mut game, &mut [&mut client], 2); + assert!(game.players[&client.id].body.flying); + assert!(game.players[&client.id].body.position[1] > 1.); + assert!(game.players[&client.id].body.position[1] < 2.); + game.players.get_mut(&client.id).unwrap().last_snapshot = + Instant::now() - Duration::from_secs(2); + game.client(&client.id, json!({"type":"switch_world","world":"arena_1"})) + .unwrap(); + let snapshot = client + .drain() + .into_iter() + .find(|m| m["type"] == "snapshot") + .unwrap(); + assert_eq!(snapshot["motion"]["settings"]["allow_flight"], false); + assert_eq!(snapshot["motion"]["body"]["flying"], false); + let mut input = movement(2); + input["fly_toggle"] = json!(true); + game.client(&client.id, input).unwrap(); + ticks(&mut game, &mut [&mut client], 1); + assert!(!game.players[&client.id].body.flying); +} + +#[test] +fn raycast_uses_pose_eye_height_and_look_does_not_consume_movement() { + let (_dir, mut game, mut client) = physics_fixture(); + let stone = game.id("minecraft:stone").unwrap(); + edit( + &mut game, + "physics", + vec![BlockChange { + pos: [0, 1, -2], + block: stone, + }], + ); + let p = game.players.get_mut(&client.id).unwrap(); + p.body.pose = shacraft_physics::Pose::Swimming; + assert_eq!(game.raycast(&client.id).unwrap().unwrap().0, [0, 1, -2]); + game.players.get_mut(&client.id).unwrap().body.pose = shacraft_physics::Pose::Standing; + assert!(game.raycast(&client.id).unwrap().is_none()); + game.client(&client.id, movement(1)).unwrap(); + game.client(&client.id, json!({"type":"look","yaw":0.,"pitch":-0.5})) + .unwrap(); + assert!(game.raycast(&client.id).unwrap().is_some()); + assert_eq!(game.players[&client.id].processed_seq, 0); + assert_eq!(game.players[&client.id].pending_inputs.len(), 1); + ticks(&mut game, &mut [&mut client], 1); + assert_eq!(game.players[&client.id].processed_seq, 1); + assert_eq!( + game.players[&client.id].look, + [0., -0.5], + "queued movement must not roll back the action ray" + ); + assert!(game.raycast(&client.id).unwrap().is_some()); +} + +fn streaming_fixture() -> (TempDir, Game, TestClient) { + let (dir, mut game) = fixture(); + game.store.create_world("stream", None).unwrap(); + game.meta.worlds.insert( + "stream".into(), + WorldConfig { + spawn: [0.5, 2., 0.5], + elimination_y: -32700., + ..WorldConfig::default() + }, + ); + let client = connect_with_features(&mut game, "stream", "Explorer", &["chunk_stream_v1"]); + (dir, game, client) +} + +fn move_view(game: &mut Game, client: &mut TestClient, position: [f32; 3]) -> Vec { + let player = game.players.get_mut(&client.id).unwrap(); + player.position = position.map(f64::from); + player.velocity = 0.; + player.input = Input::default(); + player.last_view = Instant::now() - Duration::from_secs(1); + game.step(); + client.drain() +} + +fn chunk_message(messages: &[Value]) -> &Value { + assert!(messages.iter().all(|message| message["type"] != "snapshot")); + let chunks: Vec<_> = messages + .iter() + .filter(|message| message["type"] == "chunks") + .collect(); + assert_eq!(chunks.len(), 1, "one incremental view update: {messages:?}"); + chunks[0] +} + +fn received_sections(message: &Value, field: &str) -> HashSet { + message[field] + .as_array() + .unwrap() + .iter() + .map(|value| { + serde_json::from_value(if field == "sections" { + value["section"].clone() + } else { + value.clone() + }) + .unwrap() + }) + .collect() +} + +#[test] +fn chunk_stream_welcome_and_resync_use_complete_aligned_sections() { + let (_dir, mut game, mut client) = streaming_fixture(); + let welcome = client.drain().remove(0); + assert_eq!(welcome["type"], "welcome"); + assert_eq!(welcome["features"], json!(["chunk_stream_v1"])); + assert_eq!(welcome["view_center"], json!([0, 0, 0])); + assert_eq!(welcome["view_min"], json!([-32, -16, -32])); + assert_eq!(welcome["view_max"], json!([31, 31, 31])); + + let stone = game.id("minecraft:stone").unwrap(); + edit( + &mut game, + "stream", + vec![ + BlockChange { + pos: [-32, -16, -32], + block: stone, + }, + BlockChange { + pos: [31, 31, 31], + block: stone, + }, + BlockChange { + pos: [-32, -17, -32], + block: stone, + }, + BlockChange { + pos: [32, 31, 31], + block: stone, + }, + ], + ); + game.players.get_mut(&client.id).unwrap().last_snapshot = + Instant::now() - Duration::from_secs(1); + game.client(&client.id, json!({"type":"resync"})).unwrap(); + let snapshot = client.drain().remove(0); + assert_eq!(snapshot["type"], "snapshot"); + assert_eq!(snapshot["view_min"], welcome["view_min"]); + assert_eq!(snapshot["view_max"], welcome["view_max"]); + let positions: HashSet = snapshot["blocks"] + .as_array() + .unwrap() + .iter() + .map(|block| serde_json::from_value(block["pos"].clone()).unwrap()) + .collect(); + assert_eq!(positions, HashSet::from([[-32, -16, -32], [31, 31, 31]])); + assert_eq!(snapshot["revision"], game.store.revision("stream").unwrap()); + assert!(game.client(&client.id, json!({"type":"resync"})).is_err()); +} + +#[test] +fn chunk_stream_x_move_sends_only_entering_sections_and_materials() { + let (_dir, mut game, mut client) = streaming_fixture(); + client.drain(); + let stone = game.id("minecraft:stone").unwrap(); + let ore = game.id("minecraft:diamond_ore").unwrap(); + edit( + &mut game, + "stream", + vec![ + BlockChange { + pos: [0, 0, 0], + block: stone, + }, + BlockChange { + pos: [-32, 0, 0], + block: stone, + }, + BlockChange { + pos: [32, 0, 0], + block: ore, + }, + ], + ); + let last_snapshot = game.players[&client.id].last_snapshot; + let messages = move_view(&mut game, &mut client, [16.5, 2., 0.5]); + let chunks = chunk_message(&messages); + assert_eq!(chunks["world"], "stream"); + assert_eq!(chunks["from_center"], json!([0, 0, 0])); + assert_eq!(chunks["view_center"], json!([16, 0, 0])); + assert_eq!(chunks["view_min"], json!([-16, -16, -32])); + assert_eq!(chunks["view_max"], json!([47, 31, 31])); + let entering = received_sections(chunks, "sections"); + let leaving = received_sections(chunks, "unload"); + assert_eq!(entering.len(), 12); + assert!(entering.iter().all(|section| section[0] == 2)); + assert_eq!(leaving.len(), 12); + assert!(leaving.iter().all(|section| section[0] == -2)); + let blocks: Vec<_> = chunks["sections"] + .as_array() + .unwrap() + .iter() + .flat_map(|section| section["blocks"].as_array().unwrap()) + .collect(); + assert_eq!(blocks, vec![&json!({"pos":[32,0,0],"block":ore})]); + assert_eq!(chunks["materials"].as_array().unwrap().len(), 1); + assert_eq!(chunks["materials"][0]["id"], ore); + assert_eq!(chunks["revision"], game.store.revision("stream").unwrap()); + assert!(chunks.get("spawn").is_none() && chunks.get("players").is_none()); + assert_eq!(game.players[&client.id].last_snapshot, last_snapshot); +} + +#[test] +fn chunk_stream_negative_diagonal_vertical_and_disjoint_moves_are_exact() { + let (_dir, mut game, mut client) = streaming_fixture(); + client.drain(); + let mut previous = [0, 0, 0]; + for (center, expected_count) in [ + ([-16, 0, 0], 12), + ([0, 16, 16], 30), + ([0, -16, 16], 32), + ([160, 48, -160], 48), + ] { + let position = center.map(|coordinate| coordinate as f32 + 0.5); + let messages = move_view(&mut game, &mut client, position); + let chunks = chunk_message(&messages); + let before: HashSet<_> = view_sections(previous).into_iter().collect(); + let after: HashSet<_> = view_sections(center).into_iter().collect(); + assert_eq!(chunks["from_center"], json!(previous)); + assert_eq!(chunks["view_center"], json!(center)); + assert_eq!( + received_sections(chunks, "sections"), + after.difference(&before).copied().collect() + ); + assert_eq!( + received_sections(chunks, "unload"), + before.difference(&after).copied().collect() + ); + assert_eq!(chunks["sections"].as_array().unwrap().len(), expected_count); + assert!( + chunks["sections"] + .as_array() + .unwrap() + .iter() + .all(|section| section["blocks"] == json!([])) + ); + previous = center; + } +} + +#[test] +fn chunk_stream_reentering_section_reads_latest_edit_and_deletion() { + let (_dir, mut game, mut client) = streaming_fixture(); + client.drain(); + let stone = game.id("minecraft:stone").unwrap(); + let ore = game.id("minecraft:gold_ore").unwrap(); + edit( + &mut game, + "stream", + vec![ + BlockChange { + pos: [-32, 0, 0], + block: stone, + }, + BlockChange { + pos: [-31, 0, 0], + block: stone, + }, + ], + ); + move_view(&mut game, &mut client, [16.5, 2., 0.5]); + edit( + &mut game, + "stream", + vec![ + BlockChange { + pos: [-32, 0, 0], + block: ore, + }, + BlockChange { + pos: [-31, 0, 0], + block: 0, + }, + ], + ); + let messages = move_view(&mut game, &mut client, [0.5, 2., 0.5]); + let chunks = chunk_message(&messages); + let blocks: Vec<_> = chunks["sections"] + .as_array() + .unwrap() + .iter() + .flat_map(|section| section["blocks"].as_array().unwrap()) + .collect(); + assert_eq!(blocks, vec![&json!({"pos":[-32,0,0],"block":ore})]); + assert_eq!(chunks["revision"], game.store.revision("stream").unwrap()); +} + +#[test] +fn chunk_stream_keeps_view_when_center_unchanged_and_throttles_only_view_updates() { + let (_dir, mut game, mut client) = streaming_fixture(); + client.drain(); + let messages = move_view(&mut game, &mut client, [15.5, 2., 15.5]); + assert!(messages.iter().all(|message| message["type"] == "state")); + let player = game.players.get_mut(&client.id).unwrap(); + player.position = [16.5, 2., 15.5]; + player.last_view = Instant::now(); + player.last_snapshot = Instant::now() - Duration::from_secs(3); + game.step(); + assert!( + client + .drain() + .iter() + .all(|message| message["type"] == "state") + ); + game.players.get_mut(&client.id).unwrap().last_view = + Instant::now() - Duration::from_millis(251); + game.step(); + chunk_message(&client.drain()); + game.client(&client.id, json!({"type":"resync"})).unwrap(); + assert_eq!(client.drain()[0]["type"], "snapshot"); +} + +#[test] +fn chunk_stream_world_switch_starts_with_a_full_snapshot() { + let (_dir, mut game, mut client) = streaming_fixture(); + client.drain(); + move_view(&mut game, &mut client, [48.5, 18., 48.5]); + game.players.get_mut(&client.id).unwrap().last_snapshot = + Instant::now() - Duration::from_secs(3); + game.client(&client.id, json!({"type":"switch_world","world":"lobby"})) + .unwrap(); + let messages = client.drain(); + assert_eq!(messages.len(), 1); + let snapshot = &messages[0]; + assert_eq!(snapshot["type"], "snapshot"); + assert_eq!(snapshot["world"], "lobby"); + assert_eq!(snapshot["view_center"], json!([0, 0, 0])); + assert_eq!(snapshot["view_min"], json!([-32, -16, -32])); + assert_eq!(snapshot["view_max"], json!([31, 31, 31])); + assert!(snapshot["blocks"].as_array().unwrap().len() > 100); + assert_eq!(game.players[&client.id].view, [0, 0, 0]); +} + +#[test] +fn unnegotiated_clients_keep_legacy_snapshot_bounds_and_interval() { + let (_dir, mut game, mut streaming) = streaming_fixture(); + streaming.drain(); + let stone = game.id("minecraft:stone").unwrap(); + edit( + &mut game, + "stream", + vec![ + BlockChange { + pos: [0, -8, 0], + block: stone, + }, + BlockChange { + pos: [0, -9, 0], + block: stone, + }, + ], + ); + for features in [vec![], vec!["unknown_future_feature"]] { + let mut client = connect_with_features(&mut game, "stream", "Legacy", &features); + let welcome = client.drain().remove(0); + assert!(welcome.get("features").is_none()); + assert!(welcome.get("view_min").is_none()); + assert_eq!(welcome["blocks"], json!([{"pos":[0,-8,0],"block":stone}])); + let messages = move_view(&mut game, &mut client, [16.5, 2., 0.5]); + assert!(messages.iter().all(|message| message["type"] == "state")); + game.players.get_mut(&client.id).unwrap().last_snapshot = + Instant::now() - Duration::from_secs(3); + game.step(); + let messages = client.drain(); + assert!(messages.iter().any(|message| message["type"] == "snapshot")); + assert!(messages.iter().all(|message| message["type"] != "chunks")); + game.players.remove(&client.id); + streaming.drain(); + } +} + #[test] fn official_collision_shapes_drive_world_physics() { let (_dir, mut game) = fixture(); @@ -121,6 +980,7 @@ fn authoritative_actions_reject_forged_targets_and_sync_real_edits() { let player = game.players.get_mut(&a.id).unwrap(); player.position = [0.5, 1., 8.5]; player.input.pitch = -1.2; + player.look[1] = -1.2; let before = game.store.revision("lobby").unwrap(); assert!( game.client(&a.id, json!({"type":"break","pos":[20,0,20]})) @@ -441,6 +1301,7 @@ fn configured_spleef_rules_control_full_round_and_survive_restart() { let player = game.players.get_mut(&a.id).unwrap(); player.position = [0.5, 1., 2.5]; player.input.pitch = -1.2; + player.look[1] = -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); @@ -552,3 +1413,59 @@ fn interrupted_creation_of_managed_world_can_finish_its_seed() { assert_eq!(game.store.revision("lobby").unwrap(), 1); assert_ne!(game.store.get_block("lobby", [0, 0, 0]).unwrap(), 0); } + +#[test] +fn buffered_views_prefetch_a_ring_and_do_not_unload_on_single_chunk_crossings() { + let (_dir, mut game) = fixture(); + game.store.create_world("buffer-test", None).unwrap(); + let mut client = connect_with_features( + &mut game, + "buffer-test", + "Buffered", + &["chunk_stream_v2", "view_buffer_v1"], + ); + let welcome = client + .drain() + .into_iter() + .find(|m| m["type"] == "welcome") + .unwrap(); + assert_eq!(welcome["view_distance"], 3); + assert_eq!(welcome["stream_radius"], 4); + assert_eq!(welcome["total_sections"], 405); + let initial = game.players[&client.id].view; + let generation = game.players[&client.id].view_generation; + let original: HashSet<_> = terrain::sections(initial, 4).into_iter().collect(); + game.players.get_mut(&client.id).unwrap().sent_sections = original.clone(); + // Move the streaming interest directly, avoiding unrelated physics/spawn. + for dx in [16, 0, -16, 0, 16] { + game.players.get_mut(&client.id).unwrap().position[0] = f64::from(initial[0] + dx); + game.pump_terrain(); + assert_eq!(game.players[&client.id].view, initial); + assert_eq!(game.players[&client.id].view_generation, generation); + assert_eq!(game.players[&client.id].sent_sections, original); + assert!(!client.drain().iter().any(|m| m["type"] == "view")); + } + game.players.get_mut(&client.id).unwrap().position[0] = f64::from(initial[0] + 32); + game.pump_terrain(); + let view = client + .drain() + .into_iter() + .find(|m| m["type"] == "view") + .unwrap(); + assert_eq!(view["view_center"][0], initial[0] + 32); + assert_eq!(view["total_sections"], 405); + assert_eq!(view["unload"].as_array().unwrap().len(), 90); + for pos in view["unload"].as_array().unwrap() { + let x = pos[0].as_i64().unwrap() * 16 + 15; + assert!( + i64::from(initial[0] + 32) - x > 3 * 16, + "unloads must lie beyond the requested radius" + ); + } + let new_center: Pos = serde_json::from_value(view["view_center"].clone()).unwrap(); + let (min, max) = terrain::bounds(new_center, 4); + for dx in [-16, 0, 16, 31] { + let x = new_center[0] + dx; + assert!(min[0] <= x - 3 * 16 && max[0] >= x + 3 * 16); + } +} diff --git a/crates/shacraft-server/src/world_streaming.rs b/crates/shacraft-server/src/world_streaming.rs new file mode 100644 index 0000000..7f4cdfd --- /dev/null +++ b/crates/shacraft-server/src/world_streaming.rs @@ -0,0 +1,516 @@ +use crate::terrain::{self, Generator, TerrainConfig, TerrainKey}; + +impl Game { + fn broadcast_blocks(&mut self, world: &str, revision: u64, changes: &[BlockChange]) { + let mut columns = Vec::new(); + if self.generators.contains_key(world) { + let positions: HashSet<_> = changes + .iter() + .map(|c| (c.pos[0].div_euclid(16), c.pos[2].div_euclid(16))) + .collect(); + for (x, z) in positions { + if let Ok(heights) = self.sky_column(world, x, z) { + columns.push(json!({"column":[x,z],"heights":heights})); + } + } + } + self.broadcast(world,json!({"type":"blocks","world":world,"revision":revision,"changes":changes,"materials":self.materials(changes),"columns":columns})); + } + fn generator_palette(&self) -> Result { + Ok(terrain::Palette { + grass: self.id("minecraft:grass_block")?, + dirt: self.id("minecraft:dirt")?, + stone: self.id("minecraft:stone")?, + deep: self.id("minecraft:deepslate")?, + bedrock: self.id("minecraft:bedrock")?, + sand: self.id("minecraft:sand")?, + water: self.id("minecraft:water")?, + lava: self.id("minecraft:lava")?, + log: self.id("minecraft:oak_log")?, + leaves: self.id("minecraft:oak_leaves")?, + snow: self.id("minecraft:snow_block")?, + coal: self.id("minecraft:coal_ore")?, + iron: self.id("minecraft:iron_ore")?, + copper: self.id("minecraft:copper_ore")?, + gold: self.id("minecraft:gold_ore")?, + diamond: self.id("minecraft:diamond_ore")?, + deep_iron: self.id("minecraft:deepslate_iron_ore")?, + deep_diamond: self.id("minecraft:deepslate_diamond_ore")?, + }) + } + fn open_generators(&mut self) -> Result<()> { + let palette = self.generator_palette()?; + for (name, conf) in &self.meta.worlds { + if let Some(config) = &conf.terrain { + config.validate()?; + self.generators.insert( + name.clone(), + Arc::new(Generator { + config: config.clone(), + palette: palette.clone(), + }), + ); + } + } + Ok(()) + } + fn create_overworld(&mut self, name: &str, config: TerrainConfig) -> Result<()> { + config.validate()?; + let generator = Arc::new(Generator { + config: config.clone(), + palette: self.generator_palette()?, + }); + self.store.create_world(name, None)?; + self.meta.worlds.insert( + name.into(), + WorldConfig { + spawn: [0., f64::from(generator.height(0, 8) + 1), 8.], + elimination_y: -128., + terrain: Some(config), + ..WorldConfig::default() + }, + ); + self.save_meta()?; + self.generators.insert(name.into(), generator); + Ok(()) + } + fn section_data(&mut self, world: &str, pos: Pos) -> Result>> { + let key = (world.to_owned(), pos); + if let Some(cells) = self.terrain.get(&key) { + return Ok(Some(cells)); + } + if pos.iter().any(|v| { + i64::from(*v) * 16 < -i64::from(shacraft_core::COORD_LIMIT) + || i64::from(*v) * 16 + 15 > i64::from(shacraft_core::COORD_LIMIT) + }) { + let cells = Arc::new([0; 4096]); + self.terrain.insert(key, cells.clone()); + return Ok(Some(cells)); + } + if let Some(cells) = self.store.read_section(world, pos)? { + let cells: Arc = Arc::from(cells); + self.terrain.insert(key, cells.clone()); + return Ok(Some(cells)); + } + if let Some(generator) = self.generators.get(world) { + if pos[1] * 16 > terrain::MAX_Y || pos[1] * 16 + 15 < terrain::MIN_Y { + let cells = Arc::new([0; 4096]); + self.terrain.insert(key, cells.clone()); + return Ok(Some(cells)); + } + self.terrain.request(key, generator.clone()); + Ok(None) + } else { + let cells = Arc::new([0; 4096]); + self.terrain.insert(key, cells.clone()); + Ok(Some(cells)) + } + } + fn read_world_region(&mut self, world: &str, min: Pos, max: Pos) -> Result> { + if !self.generators.contains_key(world) { + return self.store.read_region(world, min, max); + } + let mut volume = 1i64; + ensure!( + min.iter() + .chain(max.iter()) + .all(|v| i64::from(*v).abs() <= i64::from(shacraft_core::COORD_LIMIT)), + "coordinate outside storage bounds" + ); + for i in 0..3 { + ensure!(min[i] <= max[i], "invalid region"); + volume = volume + .checked_mul(i64::from(max[i]) - i64::from(min[i]) + 1) + .context("region overflow")?; + ensure!( + volume <= shacraft_core::MAX_READ_VOLUME as i64, + "region too large" + ); + } + let mut sections = Vec::new(); + let mut pending = false; + for sy in min[1].div_euclid(16)..=max[1].div_euclid(16) { + for sz in min[2].div_euclid(16)..=max[2].div_euclid(16) { + for sx in min[0].div_euclid(16)..=max[0].div_euclid(16) { + let pos = [sx, sy, sz]; + if let Some(cells) = self.section_data(world, pos)? { + sections.push((pos, cells)); + } else { + pending = true; + self.terrain.retain_for_read((world.into(), pos)); + } + } + } + } + ensure!(!pending, "terrain pending; retry when generated"); + let mut result = Vec::new(); + for ([sx, sy, sz], cells) in sections { + for y in min[1].max(sy * 16)..=max[1].min(sy * 16 + 15) { + for z in min[2].max(sz * 16)..=max[2].min(sz * 16 + 15) { + for x in min[0].max(sx * 16)..=max[0].min(sx * 16 + 15) { + let block = cells[(x.rem_euclid(16) + + 16 * z.rem_euclid(16) + + 256 * y.rem_euclid(16)) + as usize]; + if block != 0 { + result.push(BlockChange { + pos: [x, y, z], + block, + }); + } + } + } + } + } + Ok(result) + } + fn world_block(&mut self, world: &str, pos: Pos) -> Result { + if !self.generators.contains_key(world) { + return self.store.get_block(world, pos); + } + let cells = self + .section_data(world, pos.map(|v| v.div_euclid(16)))? + .context("terrain pending")?; + Ok( + cells[(pos[0].rem_euclid(16) + 16 * pos[2].rem_euclid(16) + 256 * pos[1].rem_euclid(16)) + as usize], + ) + } + fn materialize_changes(&mut self, world: &str, changes: &[BlockChange]) -> Result<()> { + if !self.generators.contains_key(world) { + return Ok(()); + } + ensure!( + changes.len() <= shacraft_core::MAX_EDIT_CELLS, + "edit exceeds cell limit" + ); + let mut unique = HashSet::new(); + for change in changes { + ensure!( + change + .pos + .iter() + .all(|v| f64::from(*v).abs() <= terrain::BORDER), + "outside playable bounds" + ); + ensure!( + (change.block as usize) < self.store.registry().len(), + "unknown block" + ); + ensure!(unique.insert(change.pos), "duplicate position"); + } + let positions: HashSet<_> = changes + .iter() + .map(|c| c.pos.map(|v| v.div_euclid(16))) + .collect(); + let mut sections = Vec::new(); + for pos in positions { + ensure!( + pos[1] >= terrain::MIN_Y / 16 && pos[1] <= terrain::MAX_Y / 16, + "outside build height -64..319" + ); + self.terrain.retain_for_read((world.into(), pos)); + let cells = self + .section_data(world, pos)? + .context("terrain pending; retry when loaded")?; + sections.push((pos, cells)); + } + for (pos, cells) in sections { + self.store.materialize_section(world, pos, &cells)?; + } + Ok(()) + } + fn terrain_changed(&mut self, world: &str, changes: &[BlockChange]) { + let sections = changes + .iter() + .map(|c| c.pos.map(|v| v.div_euclid(16))) + .collect(); + self.terrain.invalidate(world, §ions); + self.sky_cache.retain(|(w, sx, sz), _| { + w != world + || !changes + .iter() + .any(|c| c.pos[0].div_euclid(16) == *sx && c.pos[2].div_euclid(16) == *sz) + }); + } + fn sky_column(&mut self, world: &str, sx: i32, sz: i32) -> Result> { + let key = (world.to_owned(), sx, sz); + if let Some(heights) = self.sky_cache.get(&key) { + return Ok(heights.clone()); + } + let Some(generator) = self.generators.get(world).cloned() else { + return Ok(vec![-32768; 256]); + }; + let mut heights = generator.sky_heights(sx, sz); + // Only edited columns have durable sections. Resolve their actual top + // against the seed, including air overrides left by mining a roof. + let mut stored = HashMap::new(); + for sy in terrain::MIN_Y.div_euclid(16)..=terrain::MAX_Y.div_euclid(16) { + if let Some(cells) = self.store.read_section(world, [sx, sy, sz])? { + stored.insert(sy, cells); + } + } + if !stored.is_empty() { + let highest = stored.keys().max().copied().unwrap() * 16 + 15; + let natural_max = i32::from(*heights.iter().max().unwrap()); + let mut generated = HashMap::new(); + for z in 0..16 { + for x in 0..16 { + let mut top = highest.max(natural_max); + loop { + let sy = top.div_euclid(16); + let index = (x + 16 * z + 256 * top.rem_euclid(16)) as usize; + let block = if let Some(cells) = stored.get(&sy) { + cells[index] + } else { + let cells = generated + .entry(sy) + .or_insert_with(|| generator.generate([sx, sy, sz])); + cells[index] + }; + let opaque = self.definition(block).is_some_and(|d| { + d.opacity >= 1. + && !d.state.contains("leaves") + && !d.state.contains("water") + && (!d.state.contains("glass") || d.state.contains("tinted_glass")) + && d.render + .iter() + .any(|b| b.min == [0.; 3] && b.max == [1.; 3]) + }); + if opaque || top <= terrain::MIN_Y { + break; + } + top -= 1; + } + heights[(x + 16 * z) as usize] = top as i16; + } + } + } + if self.sky_cache.len() >= 2048 { + self.sky_cache.clear(); + } + self.sky_cache.insert(key, heights.to_vec()); + Ok(heights.to_vec()) + } + fn begin_section_view(&mut self, id: &str, center: Pos, welcome: bool) -> Result<()> { + let p = self.players.get_mut(id).context("not joined")?; + let ordered = terrain::view_sections(center, p.stream_radius(), p.full_height); + let new: HashSet<_> = ordered.iter().copied().collect(); + let unload: Vec<_> = p + .sent_sections + .iter() + .filter(|pos| !new.contains(*pos)) + .copied() + .collect(); + p.sent_sections.retain(|pos| new.contains(pos)); + if welcome { + p.sent_sections.clear(); + } + p.pending_sections = ordered + .into_iter() + .filter(|pos| !p.sent_sections.contains(pos)) + .collect(); + p.view = center; + p.view_generation += 1; + p.last_view = Instant::now(); + let world = p.world.clone(); + let generation = p.view_generation; + let radius = p.stream_radius(); + let (min, max) = terrain::view_bounds(center, radius, p.full_height); + let mut message = json!({"type":"view","world":world,"revision":self.store.revision(&world)?,"generation":generation,"view_center":center,"view_min":min,"view_max":max,"unload":unload,"total_sections":new.len()}); + message["view_distance"] = json!(p.view_distance); + message["stream_radius"] = json!(radius); + message["full_height"] = json!(p.full_height); + if welcome { + let p = &self.players[id]; + message["type"] = json!(if p.welcomed { "snapshot" } else { "welcome" }); + message["id"] = json!(id); + message["spawn"] = json!(p.body.position); + message["features"] = json!([ + "chunk_stream_v2", + "movement_prediction_v1", + "view_distance_v1", + "view_buffer_v1", + "full_height_v1" + ]); + message["max_view_distance"] = json!(terrain::MAX_VIEW_RADIUS); + message["blocks"] = json!([]); + message["materials"] = json!([]); + message["players"] = self.players_json(&world); + message["entities"] = json!(self.entity_list(&world)); + message["motion"] = self.motion_json(p); + message["manifest_hash"] = self.manifest["hash"].clone(); + message["terrain"] = json!(self.config(&world).terrain); + message["world_bounds"] = + json!({"min_y":terrain::MIN_Y,"max_y":terrain::MAX_Y,"border":terrain::BORDER}); + let p = self.players.get_mut(id).unwrap(); + p.welcomed = true; + p.last_snapshot = Instant::now(); + } + self.send(id, message); + Ok(()) + } + fn pump_terrain(&mut self) { + let start = Instant::now(); + let moved: Vec<_> = self + .players + .values() + .filter(|p| p.section_stream) + .filter_map(|p| { + let center = p.position.map(|v| (v.floor() as i32).div_euclid(16) * 16); + terrain::view_needs_move(p.view, center, p.buffered_view) + .then_some((p.id.clone(), center)) + }) + .collect(); + for (id, center) in moved { + let _ = self.begin_section_view(&id, center, false); + } + let mut wanted = HashSet::::new(); + let mut priorities = HashMap::::new(); + // Shared requests deduplicate between players. The squared distance + // order puts collision sections before the outer visual/preload ring. + for p in self.players.values() { + if !self.generators.contains_key(&p.world) && !p.section_stream { + continue; + } + let center = p.position.map(|v| (v.floor() as i32).div_euclid(16) * 16); + // Queue order is computed once per view, not by sorting up to + // 411,864 sections every simulation tick. Only a bounded lookahead + // and the current collision neighborhood need server residency. + let upcoming = if p.section_stream { + p.pending_sections + .iter() + .take(if p.full_height { 256 } else { 128 }) + .copied() + .collect::>() + } else { + terrain::sections(center, p.stream_radius()) + }; + for pos in terrain::sections(center, 2).into_iter().chain(upcoming) { + let key = (p.world.clone(), pos); + wanted.insert(key.clone()); + { + let c = center.map(|v| v / 16); + let distance = (pos[0] - c[0]).pow(2) + + (pos[2] - c[2]).pow(2) + + (pos[1] - c[1]).pow(2) * 2; + // Delivered terrain can leave the server cache. Rebuild it + // only for collision or another client's unsent view. + if !p.sent_sections.contains(&pos) || distance <= 6 { + priorities + .entry(key) + .and_modify(|priority| *priority = (*priority).min(distance)) + .or_insert(distance); + } + } + } + } + self.terrain.poll(&wanted); + let mut ordered: Vec<_> = priorities + .into_iter() + .map(|(key, distance)| (distance, key)) + .collect(); + ordered.sort_by_key(|(distance, _)| *distance); + // Scheduling a large view can itself exhaust the budget on a slower + // CPU. Give loading its own bounded slice so new sections still progress. + let load_start = Instant::now(); + for (_, key) in ordered { + if self.terrain.contains(&key) { + continue; + } + if load_start.elapsed().as_secs_f64() > 0.004 { + break; + } + let _ = self.section_data(&key.0, key.1); + } + let ids: Vec<_> = self + .players + .values() + .filter(|p| p.section_stream && p.last_batch.elapsed() >= Duration::from_millis(50)) + .map(|p| p.id.clone()) + .collect(); + for id in ids { + let p = &self.players[&id]; + let world = p.world.clone(); + let generation = p.view_generation; + if p.out.bytes.load(Ordering::Relaxed) > 512 * 1024 { + continue; + } + let requested: Vec<_> = p + .pending_sections + .iter() + .copied() + .take(if p.full_height { 128 } else { 32 }) + .collect(); + let considered = requested.len(); + let mut packed = Vec::new(); + let mut delivered = Vec::new(); + let mut material_ids = HashSet::new(); + let mut columns = HashMap::new(); + let mut dense_sections = 0; + let mut estimated_bytes = 0; + for pos in requested { + if start.elapsed().as_secs_f64() > 0.010 && !packed.is_empty() { + break; + } + let Some(cells) = self.terrain.get(&(world.clone(), pos)) else { + continue; + }; + let uniform = cells.iter().all(|id| *id == cells[0]); + if !uniform && dense_sections >= 32 { + break; + } + let mut value = terrain::pack(&cells); + let bytes = 128 + + 12 * (value["palette"].as_array().unwrap().len() + + value["runs"].as_array().unwrap().len()); + if !packed.is_empty() && estimated_bytes + bytes > 256 * 1024 { + break; + } + estimated_bytes += bytes; + if uniform { + if cells[0] != 0 { + material_ids.insert(cells[0]); + } + } else { + dense_sections += 1; + material_ids.extend(cells.iter().copied().filter(|id| *id != 0)); + } + value["section"] = json!(pos); + packed.push(value); + delivered.push(pos); + if self.generators.contains_key(&world) + && !columns.contains_key(&(pos[0], pos[2])) + && let Ok(heights) = self.sky_column(&world, pos[0], pos[2]) + { + columns.insert( + (pos[0], pos[2]), + json!({"column":[pos[0],pos[2]],"heights":heights}), + ); + } + } + if packed.is_empty() { + continue; + } + let p = self.players.get_mut(&id).unwrap(); + let delivered: HashSet<_> = delivered.into_iter().collect(); + let remaining: Vec<_> = p + .pending_sections + .drain(..considered) + .filter(|pos| !delivered.contains(pos)) + .collect(); + for pos in remaining.into_iter().rev() { + p.pending_sections.push_front(pos); + } + p.sent_sections.extend(delivered); + p.last_batch = Instant::now(); + let loaded = p.sent_sections.len(); + let total = p.sent_sections.len() + p.pending_sections.len(); + let value = json!({"type":"sections","world":world,"revision":self.store.revision(&world).unwrap_or(0),"generation":generation,"sections":packed,"columns":columns.into_values().collect::>(),"materials":self.materials_for_ids(material_ids.into_iter()),"loaded_sections":loaded,"total_sections":total}); + self.stream_batches += 1; + self.stream_sections += value["sections"].as_array().unwrap().len() as u64; + self.send(&id, value); + } + self.last_stream_ms = start.elapsed().as_secs_f64() * 1000.; + } +} diff --git a/docs/CLIENT.md b/docs/CLIENT.md index 72b54c9..f87e401 100644 --- a/docs/CLIENT.md +++ b/docs/CLIENT.md @@ -4,21 +4,47 @@ The client lives in `client/` and is served by `shacraft-server` at `/`. It uses ## Controls -- Click the world to capture the mouse. If the browser blocks pointer lock, hold the mouse button and drag to look around. +- Click the world to capture the mouse. A drag or rejected request never disables the next capture attempt. If the browser blocks pointer lock, select the explicit drag-control mode in the game menu; holding a mouse button turns the camera and short clicks edit blocks in that mode. The canvas is focusable, and F3 reports capture status and browser errors. - WASD or arrow keys move; Space jumps. Movement follows the camera; +Y is up, and yaw 0 looks along −Z. The player's position specifies their feet. +- Hold Ctrl to sprint and Shift to crouch or descend in creative flight. Crouching reduces the collider and eye height and prevents walking off supported edges. Double-tap Space within 300 ms, or press F, to toggle flight when the world permits it; Space ascends. The same controls handle swimming and ladders. - Left-click removes the selected block, right-click places a block against the selected face, and middle-click copies the selected material into the current hotbar slot. - 1–9 or the mouse wheel selects a hotbar slot. E opens the library of all states; search uses English Minecraft identifiers. Selecting a state replaces the current slot. - T or Enter opens chat, Enter sends, and Esc closes it. +- The game menu includes **Render distance**, from 2 to 64 chunks (up to 1,024 blocks) around the player, with Apply and a loading counter. It remembers the choice locally; increasing it also extends fog, the projection range, and actual streaming bounds. Larger views load progressively with nearby sections first. - The world-name button opens the instance selector. F3 opens diagnostics. The menu contains the player name, return-to-spawn action, and Spleef start action. - Esc releases the mouse. Losing focus or opening a panel sends zero input so the player does not keep moving. ## Rendering and synchronization -Geometry is built from each material's local box shapes. Meshes are divided into 16³ sections; shared faces between full opaque cubes are culled. A block change rebuilds its section and adjacent sections. The shader uses directional lighting, fog, a pixelated surface pattern, and an original texture from a verified package. The sky and sun are also drawn with WebGL. Transparent materials use a separate pass with sections sorted by distance; transparent surface ordering within each section is simplified. A package with the declarative style `effect: bounce` (including the trampoline and custom blocks) supplies a texture, a verified GLSL highlight function, and an original sound; the response is tied to the player's upward movement received from the server. Entity shapes come from the catalog; players have separate multipart avatars. These are original, simplified visuals rather than an exact reproduction of Minecraft. +Geometry is built from each material's local box shapes. Meshes are divided into 16³ sections; shared faces between full opaque cubes are culled. A block change rebuilds affected sections, including diagonal neighbors whose corner lighting changes. Transparent materials use a separate pass with sections sorted by distance; transparent surface ordering within each section is simplified. A package with the declarative style `effect: bounce` (including the trampoline and custom blocks) supplies a texture, a verified GLSL highlight function, and an original sound; the response is tied to the player's upward movement received from the server. Entity shapes come from the catalog; players have separate multipart avatars. These are original, simplified visuals rather than an exact reproduction of Minecraft. -The client sends input at most 20 times per second, never declares its own position, and does not edit blocks optimistically. The server computes movement, collisions, and edits, while the client smooths received positions between frames. This introduces a small movement delay but keeps the displayed position aligned with the authoritative simulation. The camera responds to the mouse locally. +Static terrain uses separate persistent module workers for whole-view light and local terrain meshing. Protocol v2 uses dense `SectionVoxelMap` arrays and transfers copied section buffers, retaining main-thread collision ownership. The legacy path uses indexed `SectionBlockMap` records. Later updates send block deltas, complete entering sections, section unloads, sky occlusion columns and changed material definitions. Nearby meshes compute their own bounded light tiles and do not wait for whole-view light. Eight cached tiles cover 2×2 chunk columns each, with an 18-block halo and the full active vertical range. Spatial invalidation and per-section tickets prevent unrelated distant batches or view-edge changes from cancelling useful mesh work. Mesh vertices are section-local; camera-relative drawing preserves precision at distant coordinates. The controller permits one mesh request in flight and at most two completed meshes waiting for upload, prioritizing received nearby dirty sections. See [world streaming](WORLD_STREAMING.md) for storage, wire format and bounds. -`welcome` and `snapshot` replace all visible geometry. A full `registry` is optional: snapshots contain at most 256 definitions of materials in use, and the client gradually fetches the remaining shapes through `/api/catalog?ids=...&limit=128`. Each response rebuilds only affected sections; a neutral cube is displayed until its shape arrives. `blocks` events are applied only in revision order; data outside the latest 64×40×64 window is discarded while the revision still advances. A gap triggers `resync`; old revisions are ignored. After a disconnect, the client retries after 1, 2, 4, 8, 16, then 20 seconds. Connection failures, incompatible protocols, resource errors, and actions rejected by the server are shown to the user. +The main thread uploads completed section buffers in `bufferSubData` steps of at most 64 KiB, targeting a 2 ms upload budget per frame. It keeps the previous visible mesh until both opaque and translucent replacement buffers are complete, then replaces their GPU handles while retaining the section object. This avoids displaying partially uploaded geometry. GPU allocation and an individual driver call cannot be interrupted, so the budget is a scheduling target rather than a guaranteed frame duration. Dynamic avatars/entities, their light samples, draw submission and UI remain on the main thread. If the terrain worker cannot start or later fails, the renderer retains visible meshes and uses synchronous terrain meshing with the former `BlockLightController`; diagnostics identify this fallback explicitly. + +The default shader has **Moonlight** and **Daylight** modes. The game starts at night, with a dark sky, stars and weak cool moonlight; the lighting button in the game menu switches to daylight and remembers the choice locally. Daylight combines warm direct sunlight with cool sky illumination and muted ground bounce. Texture colors are decoded from sRGB, lit in linear space and encoded for display; texture sampling stays nearest-neighbor. One fixed world-space light direction controls the sun/moon and shadows. The sky, distance fog and water reflections follow the selected mode. Emissive materials also illuminate nearby surfaces and actors through separate propagated block/sky light fields, each with canonical levels 0–15. Skylight controls natural illumination, reflection and fog inside roofed spaces; source light remains independent of the time of day. See [block lighting](LIGHTING.md) for measured rules, worker integration and boundaries. + +Sun shadows use a 2048² depth map (bounded by GPU support), a light-space grid that snaps to texels, and a stable 3×3 PCF filter. Coverage fades near the 48-block radius. Opaque terrain, cutout texture silhouettes, players and entities cast shadows; translucent glass and water do not cast solid shadows. Shadow geometry updates with world edits and actor movement. Geometry-aware corner ambient occlusion also considers slabs, stairs and shapes extending beyond their owning cells, while leaving unobstructed flat planes clean. Shader sources live in `lighting-shaders.js`, shadow projection in `shadow-frame.js`, and AO sampling in `ambient-occlusion.js`. F3 shows the active lighting and shadow-map size. If a depth framebuffer is unavailable, the client keeps daylight and AO without sun shadows. + +Open `/tests/renderer-smoke.html` for a deterministic lighting fixture with overview, corner, sun-facing and enclosed-room cameras, a shadow toggle and a removable pillar. The room includes a torch/sea-lantern/off control, glass window and removable partition, plus canonical block/sky readings. Its DOM diagnostics retain WebGL errors and mesh counters for browser verification. Unit tests cover corner occlusion, partial shapes, chunk-boundary invalidation, shadow projection, propagation, measured Java light transitions and asynchronous result ordering; the fixture checks the actual GPU programs and rendering path. + +The client runs the same Rust movement solver as the server, compiled into `client/physics.wasm`. It loads the module before joining and advertises `movement_prediction_v1` only after its ABI has been verified. Input is sampled at fixed 50 ms steps independently of display FPS. A stalled frame contributes at most five catch-up commands, and hidden pages reset their accumulator. The client sends controls and sequence numbers, never its own position, and does not edit blocks optimistically. + +The server's per-player `motion` message supplies the authoritative body, processed input sequence, tick, movement settings, and reset epoch. The client removes acknowledged commands, then replays remaining commands from that body using the shared solver and its current collision neighbourhood. A maximum of 120 commands is retained; an overflow suspends prediction until the missing history has been acknowledged. Out-of-order ticks and decreasing acknowledgements are ignored. Respawn, world changes, reconnects, and input-reset epochs clear old commands. A teleport also clears interpolation and pending commands. + +Render frames interpolate toward one disposable predicted next step, so local movement and mouse look respond before the round trip to the server. Small authoritative corrections decay visually without changing the simulated position or velocity. Crouching and swimming change the camera eye height. The local collision query includes the swept body volume, extended collision boxes, climbable blocks, and fluids even when their collision shape is empty. If a required chunk or material definition has not arrived, prediction waits for authoritative motion instead of treating unknown space as air. Block and chunk changes invalidate the render preview. A missing or incompatible WebAssembly module leaves the client in a visible server-only fallback mode. + +Opening a panel, losing focus, or hiding the page sends `input_reset` when prediction is negotiated. The server clears queued movement and acknowledges the discarded commands with a new epoch, while gravity continues. A `look` packet immediately before a block action updates the server's selection ray without queuing another movement step. On legacy servers the client uses the previous zero-input packet and smooths server positions. Server-side package jump hooks remain authoritative; their impulses can produce a small correction on the first predicted jump. + +Remote player yaw has a separate server target and displayed angle. Each render frame smooths position and yaw with `1 - exp(-19 * dt)`, using the shortest angular arc for yaw. New packets replace the target without snapping the displayed pose. Initial appearances and world changes initialize the pose immediately. This avoids 20 Hz rotation steps and long spins across the angle wrap. + +The client prefers `chunk_stream_v2`, `view_buffer_v1` and `full_height_v1`, with a default 9×9×24 data window of 16³ sections (configurable from 7×7×24 to 131×131×24), typed cell arrays, palette/RLE batches and explicit readiness. Uniform sections use one uint32 value, including in worker transfers; editing expands only the affected section. The entire world height, Y=−64 through 319, remains resident during vertical flight. This includes one loaded chunk ring beyond the selected radius. Horizontal loading anchors move after two chunk crossings, keeping overlap and avoiding immediate unloads on a single crossing. Distance fog blends the visible edge before buffer unloads. A view generation rejects late batches from previous windows; revisions still order actual edits. Same-world resync keeps existing visible geometry while replacing authoritative sections. The v1 path remains available for older nonprocedural servers: an aligned 64×48×64 window with indexed block records. See [section protocol v2](WORLD_STREAMING.md#section-protocol-v2). + +F3 includes movement recordings, automatic routes, a rolling frame/CPU chart and JSON export. Automatic scenarios deliberately continue through the diagnostics panel; Stop/Esc or hiding the tab stops them. Manual play still resets held input when a panel opens. Diagnostics is a nonmodal overlay: clicking the world, resuming play and starting a recording leave it open. F3 or its close button hides it. Focusing its controls pauses manual movement; clicking the canvas restores control. Trace limits, timing definitions and measured local results are in [movement diagnostics](WORLD_STREAMING.md#diagnostics). + +A full `registry` is optional: snapshots contain at most 256 definitions of materials in use, and the client gradually fetches the remaining shapes through `/api/catalog?ids=...&limit=128`. Newly received material definitions also refresh retained sections that were displaying neutral fallback cubes. `blocks` events are applied only in revision order; data outside the current view bounds is discarded while the revision still advances. A `chunks` message must match the current world, revision, and previous center, with exactly the expected entering and departing sections; it cannot advance the world revision. A revision gap or an inconsistent transition triggers `resync` before any chunk data is changed. Servers without the negotiated feature can still send full snapshots using the legacy 64×40×64 window. After a disconnect, the client retries after 1, 2, 4, 8, 16, then 20 seconds. Connection failures, incompatible protocols, resource errors, and actions rejected by the server are shown to the user. + +Dynamic actors already contain camera-relative vertices. Both the color and shadow passes explicitly reset their own program's section offset before drawing players and entities. They must not inherit the last terrain mesh's offset. Regression checks cover mesh-order changes, empty terrain frames, negative/distant coordinates and camera-origin crossings. The renderer fixture includes paired actor cameras at X=15.99 and X=16.01 for a visual boundary check and reports actual WebGL errors. ## Packages @@ -28,6 +54,18 @@ The Control API token is never sent to the client. Packages do not execute arbit ## Debugging and checks -F3 displays real values: world, revision, visible block and entity counts, players, WebGL, FPS, triangles, tick, acknowledged input, coordinates, packages, and available server metrics. `#world-canvas` exposes `data-world`, `data-revision`, `data-blocks`, `data-entities`, `data-webgl`, and `data-connected`; these update once per second for automated browser checks. Secrets and control commands are not exposed through the DOM. +F3 displays real values: world, revision, visible block and entity counts, players, WebGL, FPS, triangles, section mesh counts, mesh rebuilds, geometry resets, full snapshots, chunk updates, tick, acknowledged input, physics mode, pose, pending input count, correction distance, coordinates, packages, the active block texture pack and its image count, and available server metrics. `#world-canvas` exposes `data-world`, `data-revision`, `data-blocks`, `data-entities`, `data-webgl`, `data-connected`, `data-texture-pack`, `data-texture-count`, `data-texture-size`, `data-full-snapshots`, `data-chunk-updates`, `data-mesh-resets`, `data-mesh-rebuilds`, `data-section-meshes`, and `data-view-center`; these update once per second for automated browser checks. Movement checks also use `data-physics` (`predicted`, `waiting-world`, or `server`), `data-player-position`, `data-player-pose`, `data-prediction-pending`, and `data-prediction-correction`. Secrets and control commands are not exposed through the DOM. See [local block texture packs](PACKAGES.md#local-block-texture-packs) for the package descriptor and preparation command. -Run the pure math checks with `cd client && npm test`. They cover camera axes and projection, negative coordinates, the nearest face, reach limits, and exact hits on non-full-block shapes. Visual and end-to-end checks use a real server separately from these unit tests. +Terrain diagnostics include frame-time p95/maximum over the most recent 240 frame intervals, the number of dirty sections, worker/fallback mode, the last packet-preparation time, worker section-build time and current frame's geometry-upload time. The canvas exposes `data-terrain-mode` (`worker` or `main-thread-fallback`), `data-terrain-prepare-ms`, `data-mesh-build-ms`, and `data-mesh-upload-ms`. Lighting diagnostics include `data-block-lighting`, `data-light-build-ms`, `data-light-sources`, `data-block-light`, and `data-sky-light`. Worker build time is background CPU time; it is distinct from the frame and upload measurements. + +F3 also shows the geometry worker mode (`data-terrain-meshing`), first nonempty terrain publication (`data-first-terrain-ms`), completed/received sections in the camera's 3×3×3 neighborhood (`data-near-meshes` / `data-near-loaded`), and local light tile work (`data-local-light-cells` / `data-local-light-ms`). These distinguish nearby rendering progress from background completion of the full draw distance. + +Run the client checks with `cd client && npm test`. They cover camera axes and projection, negative coordinates, the nearest face, reach limits, exact hits on non-full-block shapes, partial-pack fallback, per-face texture selection, cutout/tint metadata, horizontal log UV orientation, retained block maps and meshes during streaming, empty incoming sections, exact view bounds, same-world snapshot differences, and rejection of inconsistent or out-of-order chunk transitions. Physics checks cover FPS-independent timing, bounded catch-up, acknowledgement replay, stale packets, reset epochs, teleports, missing collision data, history overflow, and disposable render predictions. They also instantiate the shipped WebAssembly through the browser loader, compare its per-tick positions and velocities with independently measured Java 26.2 movement fixtures, and replay delayed authoritative updates through the actual solver. Rebuild the asset with `scripts/build_physics.sh` after changing the Rust crate. Visual and end-to-end checks use a real server separately from these unit tests. + +Confirmed small block edits use a separate edit-mesh worker, independent of whole-view lighting. It receives only the section and its sampling neighborhood, builds current geometry/culling/AO with the previous light field, and gets upload priority. Lighting is corrected by the regular terrain worker afterwards. At most one edit job is in flight and two completed buffers are queued. Section tickets reject superseded edits, unloads and world resets; unrelated chunk updates do not discard a valid local edit. Large bulk edits keep the regular terrain path. If the edit worker fails, edits still use the ordinary terrain queue. + +F3 separates the last local edit’s server acknowledgement, confirmed-to-mesh and total time. `data-edit-timings` contains `acknowledgementMs`, `geometryMs` and `totalMs`; `data-edit-prepare-ms`, `data-edit-mesh-ms` and `data-edit-worker` expose the small worker path. Timings end at mesh publication in the render loop, not GPU completion or monitor scanout. `block-edit` events are included in recorded traces. Server authority, edit validation and collision updates are unchanged. + +Terrain tests cover indexed world changes, persistent worker state, transferred deltas, stale generations, material/texture changes, pure geometry output and staged GPU uploads. Fake-GL upload tests check byte offsets and the 64 KiB step limit, preservation of old opaque/translucent meshes until completion, partial-upload disposal, and yielding after an expensive allocation. They also check independent edit uploads against newer edits and unrelated terrain versions. Local edit geometry is compared byte-for-byte with whole-world geometry at section boundaries and distant coordinates, including glass and lamps; live voxel/light buffers must survive worker transfer. They do not establish real GPU frame-time guarantees. + +Open `/tests/terrain-streaming-smoke.html` for an isolated browser comparison between worker and main-thread meshing. It warms a synthetic 64×64 view before a 12-second run at eight blocks per second, crossing six section boundaries without modifying a server world. Results include frame and JavaScript draw timings, the duration of each chunk-update stage, retained-mesh checks and WebGL errors. Keep the tab visible and compare modes in the same browser at the same canvas size. CPU draw timing measures JavaScript and graphics submission, not GPU completion. diff --git a/docs/LIGHTING.md b/docs/LIGHTING.md new file mode 100644 index 0000000..88c7579 --- /dev/null +++ b/docs/LIGHTING.md @@ -0,0 +1,129 @@ +# Block and sky lighting + +Light-emitting block states illuminate their surroundings. The renderer computes +two independent integer fields, **block light** and **sky light**, each ranging +from 0 to 15. These fields determine the brightness of terrain, transparent +surfaces and moving avatars/entities. Emissive surfaces themselves remain bright. + +## Rules and reference data + +Emission comes from the server catalog's state-specific `light` value. An ordinary +torch emits 14, a sea lantern 15, and an unlit lamp emits 0. State changes, +installation and removal trigger a new calculation. Overlapping lights select +the strongest level; they do not add their levels together. + +Block light travels to the six adjacent cells and loses at least one level at +each transition. Target material dampening and the combined source/target +occlusion faces can reduce or stop transmission. The solver uses actual face +rectangle unions, including complementary slab and stair shapes, rather than +treating every collision box as an opaque cube. Light can travel around an open +doorway with attenuation along that path. + +Open vertical sky has level 15. Unobstructed downward travel through clear cells +keeps 15; other propagation loses at least one level. Roofs, material dampening +and face occlusion interrupt direct skylight. A sealed room therefore has sky +level 0 even during the daytime scene, while a light source inside it +continues to illuminate the room. + +`client/light-properties.json` contains measured Java 26.2 dampening and effective +light-occlusion shapes for all **32,366 states**, with 60 deduplicated shapes. +The loader resolves either the catalog's `minecraft_id` or state strings, +including partial properties with original defaults. These IDs are independent +of Shacraft's runtime material IDs. Examples include transparent glass (0), +tinted glass (15), water/leaves/ordinary ice (1), and packed/blue ice (15). +Metadata is about light transmission, which can differ from collision geometry. + +The data and 78 directional crossing measurements come from the pinned original +executable through an independently authored probe. The propagation tests agree +with those crossings. This is evidence for the measured properties and local +transitions, not execution of an entire original Minecraft world-light engine. + +```sh +python3 scripts/measure_lighting.py --java /path/to/java25/bin/java +node --test client/tests/light-properties.test.js client/tests/block-light.test.js +``` + +The probe checks the official bundle and extracted executable hashes. No original +runtime or proprietary source code is distributed. The compact runtime metadata +is about 150 KB before HTTP compression. + +## Rendering and updates + +`terrain-controller.js` maintains one persistent `terrain-worker.js` instance. +The worker owns the section-indexed block map, material light properties, texture +metadata and bounded light fields. A normal streamed view has 196,608 cells; +the field buffers total about 1.18 MB. Initial blocks are packed in short main-thread +time slices and transferred as `Int32Array` records `(x, y, z, block)`. Subsequent +messages carry numeric block deltas, section unloads and changed definitions, +instead of repeatedly cloning the full loaded block map. + +`terrain-state.js` computes fields through `block-light.js`, compares old/new +light values around section neighborhoods, and builds affected static geometry +through `mesh-geometry.js`. Propagation, light comparison, AO sampling, face culling +and mesh generation therefore all run in the worker. Vertex samples blend +accessible neighbors outside each face and avoid sampling through solid corners. +The worker keeps its field arrays and transfers copies for moving actors to +sample on the main thread. Epoch/version checks reject obsolete results; dirty +section notifications from skipped intermediate fields are retained so a later +result cannot leave an old visible mesh with stale lighting. + +Material definitions also invalidate geometry independently of light values. +The worker compares transmitted material signatures, finds loaded sections using +changed definitions, and refreshes those sections and their AO/culling neighbors. +This replaces a retained placeholder when its real opaque material arrives even +if the light field is unchanged. Repeated identical definitions do not trigger +extra remeshing; pending material changes are combined across coalesced updates. + +The main thread receives transferable opaque/translucent vertex arrays. It +uploads them in steps of at most 64 KiB with a 2 ms scheduling budget per frame, +retaining the old visible section until both replacements are complete. The +section object keeps its identity when its GPU handles are replaced. There is +one mesh request in flight and at most two completed results waiting for upload. +GPU allocation and individual driver calls cannot be preempted; the scheduling +budget does not guarantee that every frame finishes in 2 ms. + +The browser's world map is also indexed by section, allowing validated chunk +transitions to update entering/departing blocks without reparsing the entire +retained volume. If the terrain worker is unavailable or fails at runtime, the +renderer keeps existing visible meshes and falls back to main-thread terrain +meshing plus the former `BlockLightController`. That controller can use its own +light worker, or calculate light on the main thread when workers are unavailable. +Fallback mode can therefore pause rendering during heavy calculations. + +Canonical levels remain scalar. Warm torch light, cool sea-lantern light and +other source tints are a Shacraft visual treatment of those levels. They are not +a claim that vanilla Java lighting stores RGB light. The shader converts the +field to linear irradiance and combines it with the existing sun shadows and +ambient occlusion. At block/sky level 0 only a small visibility floor remains; +the daytime sky is not reflected or fogged brightly through a sealed room. + +F3 shows block/sky levels at the camera, source count and build status, plus +worker/fallback mode, pending geometry, packet-preparation time, worker mesh-build +time, geometry-upload time and recent frame-time p95/maximum. Canvas attributes +`data-terrain-mode`, `data-terrain-prepare-ms`, `data-mesh-build-ms`, and +`data-mesh-upload-ms` distinguish background work from main-thread upload work. +The enclosed room in `/tests/renderer-smoke.html` supports source on/off/color changes +and removal/restoration of a partition, with explicit canonical readings. + +The game opens at night by default. The lighting button in the game menu switches +between day and night and remembers the choice in this browser. Night uses a +dark sky, stars, moonlight and matching fog/water reflections. This changes sky +illumination in the shader only: propagated block/sky levels, lamp strength and +terrain meshes remain unchanged. The setting is local; there is no synchronized +server day/night cycle yet. The renderer fixture keeps its daytime default. + +## Boundaries + +Lighting is computed for the client's loaded view, with an exposed sky boundary +above that volume and closed unknown side/bottom boundaries. It does not yet +receive a full-world authoritative sky heightmap, so a roof above the loaded +vertical range can require more world context. Rebuilding a field after an edit +has a short worker/update delay. Colored tints and display brightness are visual +choices; global illumination and ray-traced point-light shadows are not used. + +The fields are currently renderer state. This change does not add light-dependent +mob spawning, crop growth, redstone updates, or other absent gameplay systems. + +## Procedural world columns + +In the v2 world stream, the server supplies full-height opaque column maxima. These seed the top of the local skylight volume and preserve dark caves when the roof is outside the vertical view. Roof edits update the column; unloading a horizontal column releases its data. Sky rendering follows the camera sky exposure so the unloaded underground horizon stays dark. The active light field remains bounded to the streamed window; this is not global propagation over the entire world. See [world streaming](WORLD_STREAMING.md). diff --git a/docs/PACKAGES.md b/docs/PACKAGES.md index b4030a9..9b0a21f 100644 --- a/docs/PACKAGES.md +++ b/docs/PACKAGES.md @@ -40,4 +40,37 @@ This is a limited, working extension API, with no claim of arbitrary Forge/Fabri The base package provides a pixel texture; the trampoline provides its own texture, a GLSL color function, style settings, and a short synthesized sound. The custom WebGL2 renderer applies them. The core and server contain no graphics engine. Packages do not load arbitrary privileged JavaScript. +### Local block texture packs + +A `client-style` resource may declare a `texture_pack` with a display `name`, a `pixel_size` from 1 to 128, and `textures: [{name, path}]`. Each image path must name a resource from the same package that has already passed size and SHA-256 verification, and every image must be square with the declared pixel size. The client decodes the PNGs into a WebGL2 texture array and uses nearest-neighbor sampling. The local study uses actual 32×32 images. Known block faces select images by Minecraft texture name; missing faces keep the core's procedural material. Grass-top and oak-leaf masks receive a green tint, and transparent pixels are cut out. This is a partial block-texture layer, not support for arbitrary resource-pack models, animations, entities, or shaders. + +Prepare the local 24-texture study using the standard-library helper: + +```sh +python3 scripts/prepare_texture_pack.py \ + --source artifacts/pixel-pack-32/pack \ + --output artifacts/pixel-pack-32/shacraft-packages +target/release/shacraft-server \ + --data artifacts/mvp-demo \ + --packages artifacts/pixel-pack-32/shacraft-packages \ + --listen 127.0.0.1:4000 +``` + +The helper copies the selected images verbatim and bundles the required base and trampoline packages. All 24 default images must exist and match the declared dimensions. The default is 32×32; `--pixel-size 64` packages real 64×64 inputs (16, 32, 64 and 128 are supported). It validates dimensions without resampling, writes the matching `pixel_size`, and updates resource sizes and hashes. Use `--textures stone dirt ...` to select a different subset, and `--name`, `--id`, `--version`, and `--license` to describe another pack. `--overwrite` rebuilds only a directory marked as a previous output of this helper. It never downloads source art. Preparing a pack does not grant distribution rights; the reference-derived study and its bundle remain under ignored `artifacts/` and are not included in the repository. Restart a running server with the generated `--packages` directory and reload the game to activate it. F3 shows the active pack name and texture count. + When modifying a package, recalculate the size and SHA-256 of each changed resource, increase the version, and update exact dependencies. `scripts/catalog_assets.py` reproducibly generates the bundled original resources and manifests. The format is designed for a future launcher: it can consume the same manifest and cache by `(id,version,sha256)`; integration with a specific launcher is outside this repository's scope. + +### Local full-catalog atlas + +For local compatibility testing, `scripts/prepare_vanilla_texture_pack.py` accepts an existing Java client JAR, its matching generated block-state report, and a new output directory. It does not download assets. Pillow is required. + +```sh +python3 scripts/prepare_vanilla_texture_pack.py \ + --jar artifacts/pixel-pack-32/source-cache/minecraft-26.2-client.jar \ + --states artifacts/catalog-cache/generated/reports/blocks.json \ + --output artifacts/vanilla-local/packages +``` + +The resulting package declares `atlas: {path, columns, rows}` alongside the texture names. All tiles have `pixel_size` dimensions; integer texel sampling prevents bleeding and avoids the GPU's array-layer limit. The atlas and style are verified package resources. Optional `block_faces: {sets, states, defaults}` maps canonical states to six-face sets in east/west/up/down/south/north order. Each face supplies a texture name, optional tint/cutout flag, and optional eight-number UV transform (two affine rows over local x/y/z/1). Validated mappings are transferred to terrain and edit workers with the texture map. + +The importer resolves model inheritance, variants, rotations, and face materials onto the engine's existing geometry. It selects a representative face for multipart geometry and uses particle materials for entity-rendered blocks. It does not import full models, block-entity rendering, biome tint maps, or animation playback. Animated sprites use the first declared frame; native 16-pixel sprites are doubled without changing their colors in 32-pixel atlas cells. These are local testing assets with Mojang/Microsoft ownership, not open-source repository assets or a redistributable default pack. Keep the output under ignored `artifacts/`. Select its directory with the server's `--packages` option; selecting the previous package directory restores the previous textures. diff --git a/docs/PHYSICS.md b/docs/PHYSICS.md new file mode 100644 index 0000000..5aaca66 --- /dev/null +++ b/docs/PHYSICS.md @@ -0,0 +1,187 @@ +# Player physics + +Shacraft uses one Rust player-movement implementation on the authoritative server +and in the browser through WebAssembly. It targets the movement of Minecraft +Java 26.2, with measured reference cases from the original executable. The +measurements establish specific numerical agreements; they do not establish +complete Minecraft compatibility. + +## Playing + +- **WASD / arrow keys:** walk; **Ctrl + forward:** sprint. +- **Space:** jump, swim upward, or ascend while flying. +- **Shift:** crouch and avoid walking off edges; descend in water or flight. +- **Double-tap Space / F:** toggle flight when the server permits it. Creative + worlds permit flight; ordinary players in other world modes cannot enable it. +- **F3:** inspect the active physics mode, pose, unacknowledged input count, + and the latest position correction. + +Walking accelerates and retains momentum. Ground friction, air control, the +sprint jump impulse, held-jump cooldown, gravity and drag all operate on fixed +50 ms ticks. Crouching changes the collision box and eye height. A player who +cannot stand or crouch fits into the swimming-sized crawling pose when possible. + +Collision handling uses the catalog's block-state boxes, resolves vertical +movement before horizontal movement, and chooses step heights from obstacle +surfaces. Slabs, stairs, ceilings, corners and sneak-edge support participate in +the same solver. Scaffolding and powder snow have additional player-dependent +collision rules. + +The solver also handles ice variants, slime and bed bounces, honey slowing and +wall sliding, soul sand, climbable blocks, cobwebs, berry bushes, powder snow, +water, lava, waterlogged cells, bubble columns, swimming and creative flight. +Water/lava behavior includes acceleration, drag, buoyancy inputs, fluid levels +and currents derived from the provided nearby block states. + +`PhysicsSettings` exposes movement and flight speed, step height, launch-pad +jump overrides, leather boots and coefficients for speed, slowness, jump boost, +levitation, slow falling, Dolphin's Grace and Depth Strider. These are solver +inputs for server integrations; their presence does not add an inventory, +equipment or potion gameplay system. `apply_impulse` accepts an external +velocity change for future combat, explosions or scripted launch effects. + +## Shared simulation and networking + +The implementation is in +[`crates/shacraft-physics`](../crates/shacraft-physics/src/lib.rs). +Positions use blocks and velocities use blocks per tick. Angles are radians; +Shacraft yaw zero faces negative Z. Reference tests convert Minecraft's positive +Z convention explicitly. + +The server supplies authoritative nearby block states, shape boxes and settings. +The browser runs the same compiled solver immediately for local input. Each +server motion update includes a sequence acknowledgement, tick and motion epoch. +The client restores the acknowledged state and replays pending inputs. Teleports +and world changes reset prediction; render smoothing affects the displayed +position without feeding a smoothed position back into physics. + +[`client/player-physics.js`](../client/player-physics.js) samples the swept +collision neighborhood, keeps a bounded input history and interpolates display +frames between fixed simulation ticks. It suspends prediction when that +neighborhood extends into unknown chunks instead of treating missing data as +air. A failed or incompatible WebAssembly load falls back to server movement. +Server support is negotiated with `movement_prediction_v1` so older clients can +continue using authoritative updates. + +## Reference evidence + +[`scripts/physics_reference.java`](../scripts/physics_reference.java) runs +against the pinned official 26.2 server executable already used for catalog +extraction. It does not start a Minecraft server. Registry initialization is +real; player and world constructors are bypassed for an isolated measurement +harness. Only factual measurements and the independently authored harness are +stored in the repository. + +The committed +[`java26.2.json`](../crates/shacraft-physics/tests/fixtures/java26.2.json) +contains: + +- Friction, speed, jump and bounce factors for 16 surfaces; default player + attributes; five pose dimensions; water/lava heights for all 16 level values. +- Thirteen trajectories from original `Player.travel`, `jumpFromGround` and + `Entity.collideWithShapes` calls: walking, sprinting, jumping, ice movement, + water, lava and creative flight. +- Eight original `Entity.collide` measurements, including low-ceiling steps, + thin steps, descending into a step and choosing the lowest useful step. +- Separate original collision restitution, honey slide, slime step, bubble + column and current-application measurements. + +The trajectory harness supplies inputs, small-velocity threshold preparation, +the measured sprint attribute modifier, constant medium/depth and flat-plane +position/collision bookkeeping. It deliberately does not claim to execute the +entire original game tick. Callback samples measure the callbacks separately, +not their complete automatic dispatch in a running world. The water sprint +kernel keeps the swimming pose disabled to isolate water travel. + +Some details differ from older Minecraft physics descriptions: the sprint +attribute modifier is the float-derived `0.30000001192092896`, the player's +horizontal small-velocity threshold applies to the vector's squared length, +bed restitution is `0.75`, and bounce velocity includes the fraction of motion +completed before collision. The measured standing jump reaches +`1.2522033402537238` blocks above its starting position. + +Native trajectory tests compare position and velocity on every tested tick with +an absolute tolerance of `2e-6`. They exercise twelve of the thirteen kernels: +the artificial constant shallow-lava depth is excluded because an integrated +world changes immersion as the player falls. Water sprint comparison stops when +input is released, because the integrated controller ends sprinting while the +isolated kernel keeps its sprint flag set. Separate step cases use `1e-7`, and +restitution, honey and bubble callback tests use `1e-12`. + +Browser tests execute the shipped WebAssembly through the actual JavaScript ABI, +compare the eight air/ground/flight reference trajectories with `2e-6` tolerance, +and exercise replay under delayed acknowledgements. Additional tests cover +movement, collision, fluids, body poses, prediction resets, missing chunks and +input ordering. These tolerances describe the tested cases, not an error bound +for every possible world, angle or interaction. + +The HTTP/WebSocket integration check starts a real release server on an isolated +port with a temporary database and loads the WebAssembly it serves. It compares +consecutive authoritative states against the same consumed command in the +browser solver with `1e-10` tolerance. It also checks burst acknowledgements, +duplicate commands, crouching, jumping, flight permissions, reset epochs, +continuous movement across chunk boundaries, and a client without prediction +support. Only explicit world changes may produce full snapshots during that +check. The report records both binary hashes and the largest observed error. + +## Building and verification + +After changing the Rust solver, rebuild the browser artifact before opening the +game or running browser physics tests: + +```sh +rustup target add wasm32-unknown-unknown +bash scripts/build_physics.sh +cargo test -p shacraft-physics +cd client +npm test +``` + +The complete workspace checks remain `cargo test --workspace`, +`cargo fmt --all -- --check` and +`cargo clippy --workspace --all-targets -- -D warnings` from the repository root. + +Run the isolated real-network check from the repository root after rebuilding +both the native server and browser module: + +```sh +cargo build -p shacraft-server --release +bash scripts/build_physics.sh +node scripts/check_player_physics.mjs \ + --port 4013 \ + --binary target/release/shacraft-server \ + --output artifacts/physics/network-report.json +``` + +The harness refuses a port already serving HTTP and does not use the running +demo server or its database. + +To reproduce measurements with an existing Java 25 JDK and the catalog cache: + +```sh +python3 scripts/measure_physics.py \ + --java /path/to/java25/bin/java \ + --output crates/shacraft-physics/tests/fixtures/java26.2.json +``` + +If the cache is absent, prepare it using the catalog generation instructions +before running the probe. The measurement runner verifies the pinned official +bundle hash and verifies that the extracted executable matches that bundle. +The fixture records the source URL, source and executable hashes, Java version, +probe hash and reproduction command. Runtime binaries, diagnostic bytecode +output and logs remain in ignored `artifacts/`. + +## Remaining scope + +This change implements player locomotion, not Minecraft's complete simulation. +It does not add fluid spreading or scheduled fluid/block updates, pistons and +moving block machinery, boats/minecarts, entity pushing, an elytra model, or the +full combat/damage/knockback system. The external impulse API is a building block +for those systems rather than their implementation. + +Collision accuracy also depends on the catalog's measured boxes and on the +states supplied by the server. Additional entity-dependent shapes, moving +obstacles, complete fluid flow rules, every status-effect interaction and +arbitrary input-angle trajectories need further reference cases. The browser +and server share the same numerical implementation, but prediction can still +be corrected when world edits or authoritative settings arrive after an input. diff --git a/docs/PHYSICS_PLAN.md b/docs/PHYSICS_PLAN.md new file mode 100644 index 0000000..cc78dac --- /dev/null +++ b/docs/PHYSICS_PLAN.md @@ -0,0 +1,22 @@ +# Java 26.2 physics implementation + +The target is measured Java 26.2 player movement, using one original fixed-tick +Rust implementation on the server and in the browser through WebAssembly. +Existing texture, chunk-streaming, and remote-rotation improvements must remain. + +1. Measure movement attributes, block friction and speed/jump factors, poses, + and available reference trajectories from the pinned official executable. +2. Build the shared movement solver: velocity, acceleration, friction, gravity, + jumping, sprinting, sneak-edge protection, swept collisions, step-up, poses, + climbing, fluids, special surfaces, and permitted creative flight. +3. Integrate server-authoritative commands, processed acknowledgments, and + bounded client prediction/replay. Preserve package jump hooks and arena rules. +4. Verify original reference facts, trajectory behavior, native/WASM parity, + packet ordering, latency recovery, and the running browser with the local pack. +5. Record measured compatibility and explicit remaining mechanics. Movement + parity must not be confused with complete vanilla AI, redstone, or world-fluid + simulation. Additional interactions must be described by their actual tests. + +Progress and final evidence belong in `docs/PHYSICS.md` and ignored +`artifacts/physics/`. Do not describe simulated reference formulas as measurements +from the official executable. diff --git a/docs/SERVER.md b/docs/SERVER.md index 1478808..9c7cb83 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -17,9 +17,9 @@ The first launch creates a lobby, the `gallery` world, an immutable Spleef base, ## State ownership -One dedicated thread owns WorldStore, physics, and game state. HTTP and WebSocket handlers enqueue bounded commands. Movement runs at 20 ticks per second: speed is 5 blocks/s, gravity is 20 blocks/s², and the normal jump impulse is 7 blocks/s. The player's AABB is 0.6×1.8 blocks; the position specifies the center of their feet. +Y points up, yaw increases to the right, and pitch increases upward; the viewing direction is `[sin(yaw)*cos(pitch),sin(pitch),-cos(yaw)*cos(pitch)]`. +One dedicated thread owns WorldStore, physics, and game state. HTTP and WebSocket handlers enqueue bounded commands. The shared Rust/WASM movement solver runs at 20 ticks per second with measured Java 26.2 acceleration, friction, gravity, jump, fluid and collision behavior. Position specifies the center of the player's feet. Standing, crouching and swimming/crawling use different body and eye heights. +Y points up, yaw increases to the right, and pitch increases upward; the viewing direction is `[sin(yaw)*cos(pitch),sin(pitch),-cos(yaw)*cos(pitch)]`. See [player physics](PHYSICS.md) for reference evidence and limits. -Gameplay positions and spawn points are limited to ±32,700 on each axis because this client's physics uses `f32`. Storage and the converter retain their wider ±30,000,000 contract; this does not guarantee gameplay physics at distant coordinates. A player who leaves the gameplay range returns to spawn, or is eliminated during an active match. +Player positions, spawn points, physics and action rays use double precision. Procedural worlds support horizontal gameplay through ±29,999,872 and build layers Y=−64…319; section-local rendering preserves distant geometry. Nonprocedural arena configuration and entity administration retain their ±32,700 bounds. Storage and conversion retain their ±30,000,000 contract. See [procedural worlds and diagnostics](WORLD_STREAMING.md) for the bounded CPU worker pool, durable natural baselines and streaming limits. Input expresses movement intent rather than position. The server checks the 6-block reach, the nearest shape intersection, the placement cell, intersections with players, and arena rules. Stale input is cleared after one second. Gameplay physics reads a bounded region of neighboring blocks for each tick; independent worlds without players need no array of loaded sections. @@ -35,15 +35,27 @@ The token is not secret from the local machine's administrator. The manifest has ## WebSocket, snapshots, and edits +The current client prefers **`chunk_stream_v2`** with **`view_buffer_v1`**, documented in [section protocol v2](WORLD_STREAMING.md#section-protocol-v2). It receives an incremental view (405 sections by default, configurable from 245 to 1,805) and palette/RLE batches with generation IDs. The loaded radius includes one extra chunk ring; horizontal view anchors move only after a two-chunk displacement. Older v2 clients without the buffer feature retain 245 sections by default, configurable from 125 to 1,445. Procedural worlds require v2. The descriptions below of full snapshots and `chunks` apply to the retained v1/legacy paths. + Send the first message to `/ws` within 10 seconds: ```json -{"type":"join","protocol":1,"manifest_hash":"from /api/manifest","name":"Player","world":"lobby"} +{"type":"join","protocol":1,"manifest_hash":"from /api/manifest","name":"Player","world":"lobby","features":["chunk_stream_v1","movement_prediction_v1"]} ``` -`welcome` contains `id`, `world`, `revision`, `blocks:[{pos,block}]`, definitions of the `materials` in use, `players`, `entities`, `spawn`, `view_center`, and `manifest_hash`. A snapshot covers `[centerX-32, centerY-8, centerZ-32]…[centerX+31, centerY+31, centerZ+31]`, with inclusive upper bounds. When the view center moves, the server sends a new `snapshot`; the client replaces its geometry completely. Snapshots do not include the full string registry. `materials` is limited to 256 entries and 128 KiB; the client requests the remaining definitions sequentially through `/api/catalog?ids=1,2,...&limit=128`. This allows players to join worlds with large palettes without overflowing the outbound queue. +`welcome` contains `id`, `world`, `revision`, `blocks:[{pos,block}]`, definitions of the `materials` in use, `players`, `entities`, `spawn`, `view_center`, and `manifest_hash`. The view center uses world coordinates rounded down to multiples of 16, including negative coordinates. A client requesting `chunk_stream_v1` receives that feature in `features`, plus inclusive `view_min` and `view_max` bounds. Its full snapshot covers `[centerX-32, centerY-16, centerZ-32]…[centerX+31, centerY+31, centerZ+31]`: 4×3×4 complete 16³ sections. -The client sends `input` with `seq,yaw,pitch,forward,strafe,jump`; `break` with `pos`; `place` with `pos,block` or `state`; and `switch_world`, `resync`, `respawn`, `start_match`, `chat`, and `ping`. A `state` response contains authoritative positions, `tick`, the acknowledged input `ack`, and match state. Blocks arrive in `blocks` messages with a new revision. If a revision is missing, the client requests a snapshot. Subscription and snapshot creation are serialized with edits on the same thread, so an edit made between those steps cannot be lost. +As the negotiated client's center moves, the server sends `chunks` updates at most once every 250 ms. Each contains `world`, the current `revision`, `from_center`, `view_center`, `view_min`, `view_max`, `unload:[[sectionX,sectionY,sectionZ]]`, `sections:[{section:[sectionX,sectionY,sectionZ],blocks:[{pos,block}]}]`, and `materials`. Section addresses are integer section coordinates; block positions and view centers are world coordinates. Only newly visible sections are read and sent, including empty ones, while retained sections keep their existing client geometry. Unloaded sections are removed. A jump with no view overlap sends every entering section through the same message. Chunk messages do not change the spawn, players, or world revision: they contain a view of the current revision. Initial joins, world switches, and explicit resyncs still send a complete snapshot. + +Clients that omit `chunk_stream_v1` retain protocol 1's original bounds, `[centerX-32, centerY-8, centerZ-32]…[centerX+31, centerY+31, centerZ+31]`, and receive replacement `snapshot` messages on movement at most once every 2 seconds. Unknown feature names are ignored. + +Snapshots do not include the full string registry. `materials` is limited to 256 entries and 128 KiB; chunk updates include only definitions used by entering blocks. The client requests any remaining definitions sequentially through `/api/catalog?ids=1,2,...&limit=128`. This allows players to join worlds with large palettes without overflowing the outbound queue. + +The client sends `input` with `seq,yaw,pitch,forward,strafe,jump,sprint,sneak,fly_toggle`; `break` with `pos`; `place` with `pos,block` or `state`; and `switch_world`, `resync`, `respawn`, `start_match`, `chat`, and `ping`. The three added movement flags default to false for older clients. A `state` response contains authoritative positions, `tick`, the processed input `ack`, and match state. Public players also contain velocity in blocks per tick, pose, height, eye height, sprinting and flying. Blocks arrive in `blocks` messages with a new revision. If a revision is missing, the client requests a snapshot. Subscription and snapshot creation are serialized with edits on the same thread, so an edit made between those steps cannot be lost. + +Clients negotiating `movement_prediction_v1` send one movement command every 50 ms. At most 32 commands are queued, and one is consumed per server tick; packet bursts never create additional simulation ticks. Duplicate sequence numbers are ignored. When the queue is empty the last controls remain held, with flight toggles consumed once. Stale controls clear after one second. `welcome`, `snapshot` and `state` include `motion:{body,ack,tick,epoch,settings}`. The body is the complete shared solver state, including velocity, pose, contact flags and jump cooldown. Settings are chosen by the server, including creative flight permission and arena countdown freeze. The client restores this state and replays unacknowledged commands through the shipped `/physics.wasm` module. + +`input_reset` clears queued and held controls, acknowledges discarded commands and advances the motion epoch. Respawn and world/arena teleports also advance the epoch so prediction discards obsolete input. `look` with `yaw,pitch` updates the action ray without consuming a movement sequence; the browser uses it immediately before a block action. Final client positions, velocities and flight permission are never accepted as authority. Clients without the movement feature keep the earlier latest-input behavior and authoritative state updates. Control edits retain the core contract: an expected revision, a unique `operation_id`, a durable acknowledgment, replay of the same request without a second write, and rejection on conflict. A build plan covers at most 32,768 cells and is retained for 5 minutes, with at most 16 plans stored; preview does not change the world. Plans are ephemeral and disappear after a restart; accepted edits remain on disk. `camera.capture` returns a PNG of the server's isometric projection and the exact revision; it is not a frame from the player's WebGL camera. @@ -67,7 +79,7 @@ The core idempotency journal covers block edits, undo/reset, and build commits. ## Limits and observability - 32 WebSocket sessions, including those waiting to join; 16 concurrently served HTTP requests; a 64-command queue. -- WebSocket input messages up to 64 KiB, with at most 80 messages/s per connection; block actions no more often than every 110 ms, chat every 700 ms, explicit resync every 500 ms, world switches every second, and automatic snapshots on section changes every 2 seconds. +- WebSocket input messages up to 64 KiB, with at most 80 messages/s per connection; block actions no more often than every 110 ms, chat every 700 ms, explicit resync every 500 ms, and world switches every second. Negotiated chunk updates run at most every 250 ms on section changes; legacy automatic snapshots retain their 2-second interval. Movement streaming uses a separate timer and does not delay explicit resyncs or world switches. - Control API JSON up to 2 MiB; standard edits up to 32,768 cells; reads up to 262,144 cells and 6 MiB of response data. Exceeding the byte limit requires a smaller region. - Each client's outbound queue holds up to 16 messages and 8 MiB. A slow connection is closed if its queue overflows or sending times out. The aggregate queue bound depends on the number of clients; these bytes are separate from the section cache. - Server metadata up to 8 MiB, at most 4096 entities, and up to 16 KiB of properties per entity. Gameplay snapshots contain compact representations without arbitrary property JSON; full properties are available through `entity.list` with offset and limit (default 64, maximum 128). diff --git a/docs/STATUS.md b/docs/STATUS.md index 8d3ef00..ccd2f32 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Project status -Updated 2026-09-14. The agreed local MVP profile is implemented and verified. The original plan was committed before implementation as `ddfcef2`; claims from the previous cloud environment were not used as evidence. The verified implementation is commit `b6ba064`, published at [emil28092005/shacraft-core](https://github.com/emil28092005/shacraft-core). Its GitHub Actions run passed. Subsequent documentation changes do not change the runtime. +Updated 2026-09-17. The agreed local MVP profile is implemented and verified. The original plan was committed before implementation as `ddfcef2`; claims from the previous cloud environment were not used as evidence. The earlier published baseline is commit `b6ba064` at [emil28092005/shacraft-core](https://github.com/emil28092005/shacraft-core), whose GitHub Actions run passed. The current implementation adds shared Rust/WASM movement, block lighting, worker meshing, procedural worlds, movement diagnostics, atlas texture packs, and full-height streaming at up to 64 chunks of render distance. The README includes an in-game screenshot of an imported minigames lobby. ## Implemented @@ -13,7 +13,11 @@ Updated 2026-09-14. The agreed local MVP profile is implemented and verified. Th - Spleef: two independent arenas sharing a map, countdown, elimination, winner/draw, reset, spectators, persistent rules, and recovery after interruption. A lobby and a gallery of 1,197 samples. - Anvil and Sponge v3 import/export: typed NBT, original preservation, atomic output publication, loss/conversion reports, and transfer of block and entity edits made in Shacraft. -## Verified +## Current checks + +On 2026-09-17, `bash scripts/verify.sh` passed formatting, a physics WASM rebuild, workspace Clippy with warnings denied, 130 Rust tests, 190 JavaScript tests, storage crash recovery, and real HTTP/WebSocket scenarios for gameplay, chunk transitions, and movement prediction. Native and WASM movement agreed within the test tolerance. The browser also accepted the 64-chunk, full-height view, built all 27 nearest sections, and continued loading distant terrain. These checks do not establish a performance guarantee at the maximum distance. + +## Verified baseline 84 Rust tests, 6 JavaScript tests, formatting, and Clippy without warnings; 14 groups of real HTTP/WebSocket scenarios, a separate MCP SDK session, SIGKILL, and recovery. Exports were read by the official Java 26.2 codecs and an independent NBT reader. The client was inspected in a real browser: rendering, state search, gallery navigation, material loading, reconnection, and resource caching were confirmed. @@ -24,7 +28,7 @@ Final release benchmark: 100 idle map instances used 50.50 MiB RSS; 10 moving cl - This is an independent minigame platform with a Minecraft catalog, not a complete vanilla simulation: no full AI, redstone, inventories, or fluid simulation. Context-dependent shapes are marked; models do not reproduce the vanilla assets. - `exact` conservatively refuses export after edits; `best-effort` reflects changes and produces a report. Anvil and `.schem` conversion does not translate all biome/block-entity semantics; source data is archived. Version, size, and context limits are documented in [interop.md](interop.md). - Block history is stored on disk without automatic deletion. Undo accepts only the current target edit. Preview plans are ephemeral and expire after five minutes. Entity/rule metadata has its own revision; its API does not claim the core's idempotency journal. -- The playable range with f32 physics is ±32700; storage and conversion support the core's ±30 million coordinates. Several bounded structures control memory use; there is no single hard RSS cap. +- Procedural gameplay uses double precision through ±29,999,872 with Y=−64…319 building layers. The menu selects a visible radius of 2–64 chunks, default 3. Including a preloaded ring, the default data window is 144×384×144; the maximum is 2,096×384×2,096. The full world height remains loaded during vertical flight. Horizontal unload hysteresis retains overlap across single chunk crossings. Nearby terrain uses local lighting in an independent worker; distant data and meshes load progressively. Uniform sections stay compact, but large views still incur substantial loading time and memory costs. Distant-terrain LOD is not implemented, and there is no single hard RSS cap. See [current world implementation and local measurements](WORLD_STREAMING.md). - Linux x86_64 and local workloads have been tested. A long production soak, power-loss testing, all operating systems, and an exported-world playtest in a running Minecraft game have not been claimed as passed. - Launcher integration, accounts, production deployment, and existing servers are future integration work. The source repository is public; the game server has not been deployed to a remote machine. diff --git a/docs/WORLD_STREAMING.md b/docs/WORLD_STREAMING.md new file mode 100644 index 0000000..fa09e51 --- /dev/null +++ b/docs/WORLD_STREAMING.md @@ -0,0 +1,278 @@ +# Procedural worlds and movement diagnostics + +The server creates `overworld` on startup if that name is unused. Open +`http://localhost:4000/?world=overworld`, or select it in the world menu. +Existing worlds, packages, edits and arenas remain available. An existing world +named `overworld` is never automatically converted. + +## World dimensions and generation + +- Playable horizontal coordinates: **−29,999,872 through +29,999,872**. +- Building layers: **Y = −64 through 319**, inclusive: **384 layers**. +- Default seed: `20260914`; generator version: `1`. +- Original deterministic terrain: continents, hills, mountains, rivers, + oceans, forests, plains, desert areas, snow, trees, caves, tunnels, aquifers, + deep stone, ores, low lava pockets and a bedrock bottom. +- A spawn clearing and a narrow ravine near X=30 give access to the surface and + underground. Fluids are generated blocks; they do not yet simulate flow. + +The height range follows the modern Java Overworld convention described in +[Mojang's Caves & Cliffs Part II announcement](https://www.minecraft.net/fr-ca/article/caves---cliffs--part-ii-out-today-java). +Terrain generation is independently authored. Matching Minecraft seed output, +all vanilla biomes, structures and gameplay systems is outside this implementation. + +The version and seed are durable in `server.sqlite3`. Generation depends on +absolute integer coordinates, so request order and section boundaries do not +change trees, caves or terrain. Keep generator version 1 stable for saved worlds; +future algorithms need a new version and an explicit migration strategy. + +Player simulation, action rays and spawn coordinates use double precision. +Meshes store section-local float positions; draw matrices and dynamic actors use +an origin near the camera. Small geometry and movement steps remain representable +near the horizontal border. Arena/entity administration retains its earlier +configuration limits where documented. + +## CPU work, streaming and storage + +The server uses **two CPU generation threads** with **32 pending jobs** at most. +The simulation thread owns storage and gameplay. Requests are shared between +players and ordered by distance, with extra weight on vertical distance. Old +view requests are dropped; completed jobs outside current interests are discarded. +Control reads retain bounded, temporary interests so remote reads can complete +without a nearby player. Generation does not require a GPU. + +**Game menu → Render distance** selects a radius from 2 to 64 chunks +(32–1,024 blocks around the player), default 3. Each player chooses their own +radius; changes apply without reconnecting and persist locally. The current +client negotiates an extra loaded chunk ring beyond that visible radius. Its +default data window is **9×9×24 sections**, or **144×384×144 blocks / 1,944 sections**. +At radius 8 it is **19×19×24**, or **304×384×304 blocks / 8,664 sections**. +At radius 64 it is **131×131×24**, or **2,096×384×2,096 blocks / 411,864 sections**, +including the one-chunk preload ring. The queue covers this entire window. +It is sorted once when the view changes; each tick examines a bounded lookahead +of 256 pending sections plus collision neighborhoods. Up to 128 sections are +sent per 50 ms interval, including at most 32 nonuniform sections, with a +256 KiB estimated section-payload budget, backpressure and time budgets. + +Horizontal streaming has one chunk of hysteresis: crossing a single chunk +boundary does not move the loading anchor or unload its opposite edge. The +anchor moves after a two-chunk displacement. The extra ring preserves coverage +of the selected radius between anchor changes. Generation, delivery and meshing +prioritize nearby sections; overlapping cells and meshes remain in place. +Expansion retains overlap; explicit reduction unloads excess sections. Distance +fog completely blends the visible edge into the sky before the buffer is +unloaded. Horizontal culling and the projection far plane follow the radius. + +The current client negotiates `full_height_v1`: every loaded column spans +**Y=−64 through 319**, all 24 vertical sections. Climbing and flying above the +world ceiling retain ground and roof sections. Missing interior terrain still +blocks prediction; space outside the world height is known void. Clients that +do not negotiate this feature retain the legacy 80-block sliding window. +Distant-terrain LOD is not implemented; these bounds and local benchmarks do +not establish parity with Minecraft's renderer. + +Already delivered distant sections do not need to remain in the server cache; +only collision neighborhoods and unsent client sections trigger regeneration. +Requests from multiple players still deduplicate, including a new player +requesting terrain previously sent to someone else. Natural sections live in a bounded LRU: at most 8,192 decoded sections, or +128 MiB of voxel payload. This is separate from the core's encoded-section +cache, SQLite, metadata, meshes and total process RSS. Visiting more terrain does +not allocate a permanent world-sized array. Before editing a natural section, +the server materializes its immutable baseline in `generated_sections` in +`worlds.sqlite3`; the existing revision/idempotency journal then stores the edit. +An explicit air deletion survives cache eviction and restart. Undo/reset use +the saved baseline. World templates inherit the generator configuration and +pin existing edited sections. Natural materialization does not advance the +edit revision. Standalone core exports contain stored sections; exporting the +entire procedural world is not supported. Back up both SQLite databases. + +The browser stores nonuniform cells in `Uint32Array` sections: **30.38 MiB** for a dense +1,944-section default voxel map, or **135.38 MiB** at radius 8, excluding worker +copies, lighting and GPU geometry. Uniform received sections use four bytes +instead of 16 KiB, retain known-air readiness, and expand on edit. Empty sections +need no mesh job. Lighting has a separate allocation cap of 7,393,280 cells; +it does not scale with the maximum stream volume. With the local mesh worker, +the shared actor field is limited to 112×384×112 blocks around the view center. +Palette/RLE avoids JSON objects and position strings per block. + +A dedicated terrain-mesh worker builds local light, AO, culling and geometry +without waiting for whole-view lighting. A light tile covers 2×2 chunk columns +plus an 18-block horizontal halo, at most **68×384×68 / 1,775,616 cells**. The full +active vertical range preserves skylight under tall roofs. An eight-entry LRU +shares these fields between nearby and vertically stacked sections. Spatial +invalidation and per-section tickets reject obsolete work without discarding +nearby results on every distant section batch. Moving a distant view edge keeps +an interior tile's light and mesh valid if its sampling bounds are unchanged. + +A separate persistent worker computes a bounded local light field for actor and quick-edit +sampling, starting after 225 ms without a new sync. Its completion does not gate +terrain publication. Large views still need time to finish distant meshes. +Uploads are staged in 64 KiB steps targeting 2 ms per frame; old meshes remain +until their replacements are ready. Driver calls are not preemptible. + +Unknown sections are distinct from received air. Both authoritative simulation +and prediction wait for collision data instead of falling through missing +terrain. The server resets the input epoch on a terrain wait, holds the body, +and resumes when its neighborhood is available. Teleports also reset prediction. + +Skylight includes full-height column occlusion data. A cave remains dark when +its roof lies above the streamed vertical window. Editing a roof updates its +column data. The background uses the camera's sky exposure to avoid bright sky +appearing through unloaded underground terrain. Full lateral lighting outside +the active window is not simulated. + +## Section protocol v2 + +Join with `features:["chunk_stream_v2","movement_prediction_v1","view_buffer_v1","full_height_v1"]` and optional +`view_distance:2..64` (default 3). A server advertising `view_distance_v1` accepts +`{"type":"view_distance","chunks":64}` while connected. Noninteger or out-of-range +values are rejected before mutation; changes are limited to once per 500 ms. +The server responds with a normal `view` transition containing `view_distance` +and the new `total_sections`, preserving position, motion epoch and revision. +Negotiating `view_buffer_v1` adds the extra loaded ring and horizontal hysteresis; +`stream_radius` reports the loaded radius, while `view_distance` remains the +selected visible radius. Older v2 clients retain the unbuffered behavior: 245 +sections by default and 1,445 at radius 8. +`full_height_v1` fixes the vertical bounds to the world height and adds +`full_height:true` to view/welcome/snapshot messages. It also enables batches +of up to 128 sections; older clients receive at most 32. +Welcome/snapshot messages also expose `max_view_distance:64`. The initial +`welcome` or explicit `snapshot` supplies the world, revision, motion, generation, +`view_center`, inclusive `view_min`/`view_max`, `total_sections`, terrain settings +and world bounds. Its `blocks` array is empty; sections follow incrementally. +Generation numbers increase for each new view or resync. + +A `view` message updates bounds and lists departing section coordinates in +`unload`. Retained sections keep their current data. A `sections` message contains +at most 128 complete sections (32 for legacy clients), each as: + +```json +{"section":[0,4,0],"palette":[0,123],"runs":[256,1,3840,0]} +``` + +Runs are `(count, paletteIndex)` pairs totaling exactly 4096 cells. Cell order is +`x + 16*z + 256*y`. Section coordinates are integers; world coordinates are +section coordinates multiplied by 16. Even empty sections are sent explicitly. +Messages include `world`, `revision`, `generation`, material definitions and +`columns:[{column:[sectionX,sectionZ],heights:[256 values]}]`. + +Batches run at most once per 50 ms per client. The server pauses section sends +above 512 KiB of queued output; existing queue and timeout limits still apply. +Packing has a soft 10 ms work budget. Clients validate the complete batch before +mutation, ignore obsolete generations and resync on revision gaps. Ordinary +block edits advance revisions; view changes never do. + +Old clients retain v1/full-snapshot behavior in nonprocedural worlds. A client +without v2 is rejected from a procedural world with an instruction to refresh. + +## Diagnostics + +Open **F3 → Movement diagnostics**. Choose manual recording, automatic flight, +automatic running, or flight with turns, then start a 30-second recording. +Automatic scenarios use ordinary authoritative input, climb obstacles during +flight, and need no pointer lock. Recording starts after terrain/meshes settle. +Stop or Esc cancels automatic movement. Hiding the tab stops recording and marks +the result `interrupted: "tab-hidden"`. + +The panel stays open while playing or recording; clicking the world restores control, and F3 or the close button hides it. Its controls pause manual input while focused. The panel shows a rolling frame/CPU chart and a summary. **Download JSON** exports +bounded frame samples, network/stream events and server samples. It measures +frame p50/p95/p99/max, frames over 25/50 ms, main-thread physics/update and draw +submission time, long tasks, position corrections, terrain waits, distance, +view changes, batches and mesh resets. Draw CPU duration is not GPU duration. +The canvas DOM exposes the summary as `data-dynamics-result`, plus current +section counts, coordinates, readiness and streaming timings for browser checks. +`data-first-terrain-ms` measures first nonempty mesh publication after the world +reset. `data-near-meshes` / `data-near-loaded` count completed and received +sections in the camera's 3×3×3 neighborhood, including completed empty sections. +`data-terrain-meshing`, `data-local-light-cells` and `data-local-light-ms` expose +the independent geometry path and its local lighting work. + +Authenticated Control API methods for repeatable diagnosis: + +```json +{"method":"diagnostics.snapshot","params":{}} +{"method":"terrain.inspect","params":{"world":"overworld","position":[1000000,0,-1000000]}} +{"method":"player.teleport","params":{"id":"player-id","position":[1000000.125,180,-1000000.875],"flying":true}} +{"method":"world.create","params":{"world":"another-overworld","terrain":{"seed":42,"version":1}}} +``` + +`diagnostics.snapshot` reports generation queues/cache, simulation timings, +positions, velocity, input backlog, waits and per-player stream state, including +`view_distance` and `stream_radius`. Teleport +is an administrative API, not a client movement permission. `/api/metrics` +adds generation/stream counters and the most recent 600 tick durations. +`world.read`, `world.edit`, build commit and `camera.capture` may return +`terrain pending` for cold sections; retry the same read or idempotent operation +after a short interval. No blocking generation is performed for a cold read. + +## Local verification, 2026-09-14 + +A release server and the real browser ran against an isolated copy of the local +data, using the Pixel32 Study package. Automatic flight traversed **495.08 m in +30.003 s**, with **39 view changes and 78 section batches**. Across 4,317 frames, +frame p95 was **7.0 ms**, p99 **7.1 ms**, maximum **13.8 ms**; no frame exceeded +25 ms. The run reported no terrain-wait frames, position correction or full mesh +reset. Section application p95 was 5.6 ms, maximum 7.3 ms. At the last server +sample, tick work was 0.53 ms and RSS was 78.5 MiB. Three entering sections were +still in transit at recording end; all 245 completed after stopping. + +These measurements are one local workload, not a comparison with Minecraft or +a guarantee for other devices, multiplayer loads or long sessions. The local +summary is saved in ignored `artifacts/large-world-flight.json`. + +Automated checks cover generation determinism, negative/far coordinates, deep +content, skyline accuracy, durable mining/undo/restart, remote reads without +players, bounded batches, overlap retention, malformed/stale data, known air, +shared physics and section-local geometry precision. Browser checks also cover +an open ravine at Y=10, a closed cave at Y=−29, and coordinates near ±29,999,000. + +## Render distance and live edit verification, 2026-09-15 + +These measurements predate the additional loaded ring described above. + +In an isolated copy of the Overworld, the real browser expanded from radius 3 +to 8 and received all 1445 sections (23,674,880 raw voxel bytes). Shrinking to +radius 2 retained 125 sections (2,048,000 bytes), without a full snapshot, mesh +reset, revision change or player displacement. Reloading restored the selected +radius from local storage. The server regression also checks per-player +isolation, invalid values, overlap retention and the world-border margin. + +At radius 8, confirmed-to-visible-section publication took 121.9 ms for a break +and 120.6 ms for placement in two browser samples. Including server response, +they took 149.4 ms and 137.2 ms. Local packet preparation was 0.6–0.9 ms. The +regular whole-view light calculation in the same session took about 4.7 seconds; +the independent edit worker published geometry before it finished. These are +local observations at 1280×720 with shadows and about 1.27 million triangles, +not frame-time or network guarantees. Propagated light can still catch up later +on large views. + +F3 remained open on canvas clicks, block edits and recording start; explicit F3 +toggled it closed and open. The full verification script passed (126 Rust and +171 JavaScript tests, storage crash checks and all three network suites); an +additional staged edit-upload regression also passed afterwards. + +## Nearby mesh priority and buffered streaming verification, 2026-09-15 + +In the real browser at radius 8 with the buffered 1,805-section window, first +nonempty mesh publication took **354 ms and 388 ms** in two startup samples. +All **27 nearest sections** had completed while only **1,167 of 1,805** sections +had arrived and whole-view lighting was still building. Subsequent local-light +cache tiling improves reuse; those startup samples were taken before that tuning. +A final reload with the tiled cache and a warm server published its first mesh +in **189 ms**, completed all 1,805 sections and logged no browser warnings/errors. + +A 30-second automatic flight at 1280×720 with 2048² shadows traversed **497.12 m**. +Across 3,746 frames, frame p95 was **13.9 ms**, p99 **20.9 ms**, maximum **48.6 ms**. +There were no frames above 50 ms, terrain-wait frames or full mesh resets during +the recording. It recorded 39 view changes and 147 batches. Stream application +p95 was 9.4 ms. These are local workload observations, not cross-device or +Minecraft comparison results. + +Server regressions exercise repeated crossings of a single chunk boundary, +two-chunk anchor changes, coverage of the selected radius and departing rows +outside that radius. Client regressions compare local-light mesh bytes with the +whole-view result, including roofs, water, glass and negative coordinates; they +also check distant-update retention, bounded caches, stale jobs, unload/re-entry +tickets and unchanged interior tiles during view shifts. The verification script +passed 127 Rust tests, storage crash checks and all three network suites. The +final client suite passed 180 JavaScript tests. diff --git a/docs/WORLD_STREAMING_PLAN.md b/docs/WORLD_STREAMING_PLAN.md new file mode 100644 index 0000000..2adeec6 --- /dev/null +++ b/docs/WORLD_STREAMING_PLAN.md @@ -0,0 +1,28 @@ +# Large world and movement diagnostics + +## Objective + +Create a playable, deterministic Overworld with 384 block layers (-64 through 319), wide horizontal coordinates, caves and surface terrain. Keep server simulation responsive while terrain is generated and streamed. Preserve existing lobby, arenas, packages and user edits. + +## Implementation sequence + +1. Add durable procedural section baselines to the core store. Only sections touched by edits need materialization; natural terrain is reproducible from a versioned seed. Cover air deletion, undo/reset, restart and template behavior. +2. Add a deterministic terrain generator and a bounded server worker pool/cache. Prioritize player collision neighborhoods, then nearby visible sections; never simulate unknown terrain as empty air. Add generation, cache, queue and tick timing metrics. +3. Add a negotiated compact section-stream protocol with explicit loaded sections, cancellation of obsolete view requests, bounded batches and overlap retention. Keep legacy clients supported. Use dense typed section arrays on the browser side and transferable worker payloads. +4. Support precise distant positions and rendering relative to the camera's section origin. Seed skylight from authoritative column occlusion heights so deep caves stay dark even when roofs lie above the loaded vertical window. +5. Add reusable movement diagnostics: rolling timings, trace recording/export, automatic flight/running scenarios, streaming and prediction counters, accessible controls and machine-readable results. +6. Test deterministic seams, underground content, persistence, streaming order/unknown sections, distant coordinates and worker behavior. Run live browser routes against the real server and report observed results and remaining limits. +7. Create/open `overworld` on the local server; keep the existing worlds available. Update documentation and the project graph. + +## Working choices + +- Original terrain algorithm, inspired by voxel sandbox worlds; no claim of matching Minecraft's exact seed output or every biome/structure. +- Server remains CPU-only. Background work uses a small fixed worker pool and bounded queues/cache. +- 16x16x16 storage/transfer sections; independent vertical streaming instead of loading all underground layers. +- Explicit readiness for collision data, and staged GPU publication retaining old meshes. +- Seed and generator version are durable world configuration. User edits survive cache eviction and restart. +- Local benchmark routes use normal player inputs. They never clear or overwrite existing worlds. + +## Completion + +All seven steps are implemented. The main server runs the release build at `http://localhost:4000/`, with the separate `overworld` open in the browser; the existing lobby remains at revision 210. Full verification passed: 125 Rust tests, 158 JavaScript tests, Clippy, formatting, crash recovery and HTTP/WebSocket scenarios. A focused follow-up test also verifies that legacy clients cannot enter procedural worlds through a world switch. See [implementation, measured route and limits](WORLD_STREAMING.md). diff --git a/docs/images/minigames-lobby-night.png b/docs/images/minigames-lobby-night.png new file mode 100644 index 0000000..37bfd8f Binary files /dev/null and b/docs/images/minigames-lobby-night.png differ diff --git a/docs/interop.md b/docs/interop.md index 5a8a524..77beaa1 100644 --- a/docs/interop.md +++ b/docs/interop.md @@ -14,6 +14,9 @@ cargo build --release -p shacraft-compat -p shacraft-server # Import a copy of a stopped Java world into a new native store. target/release/shacraft-compat import-anvil /path/to/java-world data/imported +# Large playable maps: load compressed section baselines instead of block edit history. +target/release/shacraft-compat import-anvil /path/to/java-world data/imported-large --baseline + # Open the import in the browser through the server; select main in the world list. target/release/shacraft-server --data data/imported --listen 127.0.0.1:4000 @@ -34,6 +37,8 @@ target/release/shacraft-compat export-schem data artifacts/build --world lobby - The CLI prints a JSON report to stdout; errors produce JSON on stderr and a nonzero exit code. Conversion runs offline: `WorldStore` holds the same single-writer lock as the server, preventing concurrent edits from mixing revisions within one export. The source Java world must itself be a copy of a stopped world: changes to file size/timestamps during copying are detected, but this does not replace a consistent backup of a running Minecraft instance. +`import-anvil --baseline` keeps the original source and conversion checks, but stores each nonempty section directly as the immutable world baseline. It avoids creating an undo record for every imported block. Imported revisions start at zero; later edits use the normal journal, and reset restores the imported map. Snapshots include the baseline. Unchanged exact exports and edited best-effort exports remain supported. Without this flag, the existing import behavior is unchanged. + ## Implemented features - Big-endian NBT: all 12 payload types, numeric widths, float/double bit patterns, signed arrays, Java modified UTF-8/CESU-8, Unicode, the element type of an empty list, and unknown compound fields. Duplicate compound keys, impossible lengths, excessive depth, and trailing decompressed bytes are rejected. diff --git a/scripts/build_physics.sh b/scripts/build_physics.sh new file mode 100755 index 0000000..eb1fdab --- /dev/null +++ b/scripts/build_physics.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +# Install once with: rustup target add wasm32-unknown-unknown +# One implementation supplies native authority and browser prediction. +cargo build --locked -p shacraft-physics --target wasm32-unknown-unknown --release +cp target/wasm32-unknown-unknown/release/shacraft_physics.wasm client/physics.wasm diff --git a/scripts/check_chunk_streaming.mjs b/scripts/check_chunk_streaming.mjs new file mode 100644 index 0000000..f6d0f29 --- /dev/null +++ b/scripts/check_chunk_streaming.mjs @@ -0,0 +1,293 @@ +import assert from "node:assert/strict"; +import { harness, Client, options } from "./server-harness.mjs"; +import { + getViewBounds, + mergeChunkUpdate, + mergeSnapshot, + positionInView, +} from "../client/world-view.js"; + +// Isolated data and port: this check never edits the running demo world. +const opts = { port: "4012", ...options() }, + h = await harness(opts), + clients = [], + checks = [], + transitions = [], + world = "test_chunk_stream"; +const key = (pos) => pos.join(","), + sorted = (map) => [...map].sort(([a], [b]) => a.localeCompare(b)), + view = { + world, + revision: 0, + viewCenter: null, + viewBounds: null, + blocks: new Map(), + }; +let receiveError, + explicitResync = false, + snapshots = 0; + +function observe(message) { + if (receiveError) return; + try { + if (message.type === "error") throw Error(message.message); + if (["welcome", "snapshot"].includes(message.type)) { + if (message.type === "snapshot") { + assert.ok(explicitResync, "Walking must not send a full snapshot"); + snapshots++; + } + assert.equal(message.world, world); + assert.ok(message.features.includes("chunk_stream_v1")); + view.revision = message.revision; + view.viewCenter = message.view_center; + view.viewBounds = getViewBounds(message); + mergeSnapshot(view.blocks, message.blocks); + } else if (message.type === "blocks") { + assert.equal(message.revision, view.revision + 1); + for (const change of message.changes) { + if (!positionInView(change.pos, view.viewBounds)) continue; + if (change.block) view.blocks.set(key(change.pos), change.block); + else view.blocks.delete(key(change.pos)); + } + view.revision = message.revision; + } else if (message.type === "chunks") { + assert.ok(!("spawn" in message), "Movement must not reset the camera"); + assert.ok(!("blocks" in message), "Movement must send section deltas"); + assert.equal(message.sections.length, 12); + assert.equal(message.unload.length, 12); + assert.equal( + Math.abs(message.view_center[0] - message.from_center[0]), + 16, + ); + assert.deepEqual(message.view_center.slice(1), [0, 0]); + assert.ok(message.sections.some((section) => !section.blocks.length)); + const enteringIds = new Set( + message.sections.flatMap((section) => + section.blocks.map((record) => record.block), + ), + ), + materialIds = new Set(message.materials.map((material) => material.id)); + assert.deepEqual(materialIds, enteringIds, "Only entering materials sent"); + const departing = new Set(message.unload.map(key)), + retained = [...view.blocks].filter( + ([id]) => + !departing.has( + key(id.split(",").map((value) => Math.floor(Number(value) / 16))), + ), + ), + result = mergeChunkUpdate(view, message); + assert.equal(result.status, "applied", "Real client accepts server delta"); + for (const [id, block] of retained) + assert.equal(view.blocks.get(id), block, "Retained geometry is unchanged"); + transitions.push({ + from: message.from_center, + to: message.view_center, + entering_sections: message.sections.length, + departing_sections: message.unload.length, + retained_sections: 48 - message.unload.length, + entering_blocks: message.sections.reduce( + (count, section) => count + section.blocks.length, + 0, + ), + retained_blocks: retained.length, + bytes: Buffer.byteLength(JSON.stringify(message)), + }); + } + } catch (error) { + receiveError = error; + } +} + +function assertReceived() { + if (receiveError) throw receiveError; +} + +async function assertCurrentRegion() { + assertReceived(); + const region = await h.control("world.read", { + world, + ...view.viewBounds, + }); + assert.equal(view.revision, region.revision); + assert.deepEqual( + sorted(view.blocks), + sorted(new Map(region.blocks.map((record) => [key(record.pos), record.block]))), + "Merged client view must exactly match the authoritative region", + ); +} + +async function edit(client, changes, operation) { + const result = await h.control("world.edit", { + world, + expected_revision: view.revision, + operation_id: operation, + changes, + }); + await client.next("blocks", (message) => message.revision === result.revision); + await assertCurrentRegion(); +} + +async function walkTo(client, x, center, expectType = "chunks") { + const initial = (await client.state()).players.find( + (player) => player.id === client.id, + ), + strafe = x > initial.position[0] ? 1 : -1, + seq = client.input({ strafe }), + pulse = setInterval(() => client.input({ strafe }), 100); + try { + await client.state( + (player, message) => + message.ack >= seq && + (strafe > 0 ? player.position[0] >= x : player.position[0] <= x), + 10000, + ); + } finally { + clearInterval(pulse); + const stopped = client.input(); + await client.state((_player, message) => message.ack >= stopped); + } + const update = await client.next( + expectType, + (message) => message.view_center[0] === center, + ); + assertReceived(); + return update; +} + +try { + const manifest = await h.get("/api/manifest"); + async function material(state) { + const found = await h.control("catalog.search", { query: state, limit: 64 }); + const item = found.items.find((item) => item.state === state); + assert.ok(item, `Catalog contains ${state}`); + return item.id; + } + const stone = await material("minecraft:stone"), + gold = await material("minecraft:gold_block"), + diamond = await material("minecraft:diamond_block"), + iron = await material("minecraft:iron_block"); + await h.control("world.create", { world }); + const plan = await h.control("build.plan", { + world, + expected_revision: 0, + operations: [ + { type: "box", min: [-64, 0, -32], max: [79, 0, 31], block: stone }, + ], + }); + await h.control("build.commit", { + plan_id: plan.plan_id, + operation_id: "chunk-stream-floor", + }); + await h.control("arena.configure", { + world, + mode: "creative", + spawn: [15, 2, 8], + }); + const client = new Client(h.url); + clients.push(client); + client.ws.addEventListener("message", (event) => observe(JSON.parse(event.data))); + await client.open; + client.send({ + type: "join", + protocol: 1, + manifest_hash: manifest.hash, + world, + name: "ChunkStreamProbe", + features: ["chunk_stream_v1"], + }); + const welcome = await client.next("welcome"); + assertReceived(); + assert.deepEqual(view.viewCenter, [0, 0, 0]); + assert.deepEqual(view.viewBounds, { min: [-32, -16, -32], max: [31, 31, 31] }); + await client.state((player) => player.position[1] < 1.15); + await assertCurrentRegion(); + checks.push("Negotiated welcome supplies the complete 48-section view"); + + await edit( + client, + [ + { pos: [5, 1, 10], block: gold }, + { pos: [-24, 1, 10], block: diamond }, + { pos: [40, 1, 10], block: iron }, + ], + "chunk-stream-before-east", + ); + assert.equal(view.blocks.has("40,1,10"), false); + await walkTo(client, 18, 16); + await assertCurrentRegion(); + assert.equal(view.blocks.get("5,1,10"), gold); + assert.equal(view.blocks.has("-24,1,10"), false); + assert.equal(view.blocks.get("40,1,10"), iron); + checks.push("East crossing loads 12 sections, unloads 12, retains 36 without snapshot"); + + await edit( + client, + [ + { pos: [5, 1, 10], block: 0 }, + { pos: [-24, 1, 10], block: gold }, + { pos: [40, 1, 10], block: 0 }, + ], + "chunk-stream-while-away", + ); + assert.equal(view.blocks.has("-24,1,10"), false); + await walkTo(client, 14, 0); + await assertCurrentRegion(); + assert.equal(view.blocks.get("-24,1,10"), gold); + assert.equal(view.blocks.has("5,1,10"), false); + checks.push("Retained deletions persist and returning sections include edits made while unloaded"); + + await edit( + client, + [ + { pos: [24, 1, 10], block: gold }, + { pos: [-8, 1, 10], block: diamond }, + { pos: [-40, 1, 10], block: iron }, + ], + "chunk-stream-before-negative", + ); + await walkTo(client, -2, -16); + await assertCurrentRegion(); + assert.equal(view.blocks.has("24,1,10"), false); + assert.equal(view.blocks.get("-8,1,10"), diamond); + assert.equal(view.blocks.get("-40,1,10"), iron); + assert.equal(snapshots, 0); + assert.equal(transitions.length, 3); + assert.ok(transitions.every((transition) => transition.entering_blocks < welcome.blocks.length)); + checks.push("Negative-coordinate crossing matches authoritative region and sends only entering blocks"); + + const beforeResync = sorted(view.blocks); + explicitResync = true; + client.send({ type: "resync" }); + await client.next("snapshot"); + assertReceived(); + assert.equal(snapshots, 1); + assert.deepEqual(sorted(view.blocks), beforeResync); + await assertCurrentRegion(); + checks.push("Explicit full resync remains available and preserves the current world contents"); + + const legacy = new Client(h.url); + clients.push(legacy); + const legacyWelcome = await legacy.join(manifest, world, "LegacyChunkProbe"); + assert.ok(!legacyWelcome.features?.includes("chunk_stream_v1")); + await walkTo(legacy, 18, 16, "snapshot"); + assert.equal(legacy.queue.some((message) => message.type === "chunks"), false); + checks.push("Clients without feature negotiation retain protocol-1 snapshot fallback"); + + await h.report({ + passed: true, + checks, + transitions, + movement_snapshots: 0, + explicit_resync_snapshots: snapshots, + initial_blocks: welcome.blocks.length, + initial_snapshot_bytes: Buffer.byteLength(JSON.stringify(welcome)), + client_merge: "client/world-view.js", + binary: h.binary, + }); +} catch (error) { + await h.report({ passed: false, checks, transitions, error: error.stack, logs: h.logs().slice(-8000) }); + throw error; +} finally { + await Promise.all(clients.map((client) => client.close())); + await h.stop(); +} diff --git a/scripts/check_player_physics.mjs b/scripts/check_player_physics.mjs new file mode 100644 index 0000000..e9284f4 --- /dev/null +++ b/scripts/check_player_physics.mjs @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { harness, Client, options } from "./server-harness.mjs"; +import { loadPhysics, samplePhysicsWorld } from "../client/player-physics.js"; +import { getViewBounds, mergeChunkUpdate, mergeSnapshot } from "../client/world-view.js"; + +// This check starts a separate server and temporary database. It never connects +// to the user's running game or modifies their worlds. +const opts = { + port: "4013", + binary: "target/release/shacraft-server", + output: "artifacts/physics/network-report.json", + ...options(), +}; +const serverHash = createHash("sha256").update(await readFile(opts.binary)).digest("hex"); +const h = await harness(opts), clients = [], checks = [], samples = []; +const world = "test_player_physics", groundedWorld = "test_player_grounded"; +const inputs = new Map([[0, {}]]); +const view = { world, revision: 0, viewCenter: null, viewBounds: null, blocks: new Map() }; +const materials = new Map(); +let client, step, previous, heldInput = {}, receiveError, permitSnapshot = false; +let snapshots = 0, chunks = 0, comparedTicks = 0, maximumError = 0, resetCount = 0; +const zero = { yaw: 0, pitch: 0, forward: 0, strafe: 0, jump: false, sprint: false, sneak: false, fly_toggle: false }; +const controls = (input) => ({ + ...zero, + ...input, + // The wire protocol stores these fields as f32 before passing them to Rust. + ...Object.fromEntries(["yaw", "pitch", "forward", "strafe"].map((key) => [key, Math.fround(input[key] || 0)])), +}); +function received() { if (receiveError) throw receiveError; } +function observe(message) { + if (receiveError) return; + try { + if (message.type === "error") throw Error(message.message); + for (const material of message.materials || []) materials.set(material.id, material); + if (message.type === "welcome" || message.type === "snapshot") { + if (message.type === "snapshot") { + assert.ok(permitSnapshot, "Movement must not send a full snapshot"); + snapshots++; + } + assert.ok(message.features.includes("movement_prediction_v1")); + assert.ok(message.features.includes("chunk_stream_v1")); + view.world = message.world; + view.revision = message.revision; + view.viewCenter = message.view_center; + view.viewBounds = getViewBounds(message); + mergeSnapshot(view.blocks, message.blocks); + previous = message.motion; + heldInput = { ...zero }; + } else if (message.type === "chunks") { + assert.ok(!("spawn" in message) && !("blocks" in message)); + assert.equal(mergeChunkUpdate(view, message).status, "applied"); + chunks++; + } else if (message.type === "state") { + const motion = message.motion; + assert.ok(motion, "Negotiated state carries authoritative motion"); + assert.equal(message.ack, motion.ack); + assert.equal(message.tick, motion.tick); + if (previous && motion.epoch !== previous.epoch) { + resetCount++; + heldInput = { ...zero }; + } else if (previous && motion.tick === previous.tick + 1) { + assert.ok(motion.ack >= previous.ack, "Acknowledgements cannot regress"); + assert.ok(motion.ack - previous.ack <= 1, "At most one queued command is processed per tick"); + if (motion.ack !== previous.ack) { + assert.ok(inputs.has(motion.ack), `Known consumed command ${motion.ack}`); + heldInput = controls(inputs.get(motion.ack)); + } else heldInput = { ...heldInput, fly_toggle: false }; + const neighborhood = samplePhysicsWorld(previous.body, view.blocks, materials, view.viewBounds); + assert.ok(neighborhood, "Collision neighborhood is loaded during the probe"); + const predicted = step({ body: previous.body, input: heldInput, world: neighborhood, settings: motion.settings }); + let error = 0; + for (const field of ["position", "velocity"]) + for (let axis = 0; axis < 3; axis++) + error = Math.max(error, Math.abs(predicted[field][axis] - motion.body[field][axis])); + maximumError = Math.max(maximumError, error); + assert.ok(error < 1e-10, `Native/WASM drift at tick ${motion.tick}, ack ${motion.ack}: ${error}`); + for (const field of ["pose", "on_ground", "sprinting", "flying", "jump_cooldown", "in_water", "in_lava"]) + assert.equal(predicted[field], motion.body[field], `${field} matches at tick ${motion.tick}`); + comparedTicks++; + } + samples.push({ tick: motion.tick, ack: motion.ack, epoch: motion.epoch, + position: motion.body.position, pose: motion.body.pose, flying: motion.body.flying }); + previous = motion; + } + } catch (error) { receiveError = error; } +} +function input(fields = {}) { + const message = { type: "input", seq: ++client.seq, ...zero, ...fields }; + inputs.set(message.seq, message); + client.send(message); + return message.seq; +} +async function state(predicate = () => true, timeout = 10000) { + const message = await client.next("state", (m) => predicate(m.motion, m), timeout); + received(); + return message.motion; +} +async function acknowledged(fields) { + const seq = input(fields); + return state((motion) => motion.ack >= seq); +} +async function ticks(count) { + const start = previous.tick; + return state((motion) => motion.tick >= start + count); +} +try { + const manifest = await h.get("/api/manifest"); + step = await loadPhysics(`${h.url}/physics.wasm`); + const binary = Buffer.from(await (await fetch(`${h.url}/physics.wasm`)).arrayBuffer()); + const wasmHash = createHash("sha256").update(binary).digest("hex"); + const catalog = await h.control("catalog.search", { query: "minecraft:stone", limit: 64 }); + const stone = catalog.items.find((item) => item.state === "minecraft:stone"); + assert.ok(stone); + await h.control("world.create", { world }); + const plan = await h.control("build.plan", { world, expected_revision: 0, + operations: [{ type: "box", min: [-96, 0, -16], max: [160, 0, 16], block: stone.id }] }); + await h.control("build.commit", { plan_id: plan.plan_id, operation_id: "physics-floor" }); + await h.control("arena.configure", { world, mode: "creative", spawn: [15, 1, 8] }); + await h.control("world.create", { world: groundedWorld, template: world }); + await h.control("arena.configure", { world: groundedWorld, mode: "spleef", spawn: [0, 1, 8] }); + client = new Client(h.url); clients.push(client); + client.ws.addEventListener("message", (event) => observe(JSON.parse(event.data))); + await client.open; + client.send({ type: "join", protocol: 1, manifest_hash: manifest.hash, world, + name: "PhysicsProbe", features: ["chunk_stream_v1", "movement_prediction_v1"] }); + const welcome = await client.next("welcome"); received(); + assert.ok(welcome.motion.settings.allow_flight); + await state((motion) => motion.body.on_ground); + checks.push("HTTP-served WebAssembly loads and both movement/chunk features negotiate"); + + const beforeBurst = previous; + const burstStart = samples.length; + for (let index = 0; index < 12; index++) input({ forward: 1, strafe: .25, yaw: .3, + sprint: true, jump: index === 2 }); + const burstEnd = client.seq; + const burstMotion = await state((motion) => motion.ack === burstEnd); + const acks = samples.slice(burstStart).filter((m) => m.ack > beforeBurst.ack).map((m) => m.ack); + assert.deepEqual([...new Set(acks)], Array.from({ length: 12 }, (_, i) => beforeBurst.ack + i + 1)); + assert.ok(burstMotion.tick - beforeBurst.tick >= 12); + client.send({ type: "input", seq: burstEnd, ...zero, forward: -1 }); + client.send({ type: "input", seq: burstEnd - 1, ...zero, forward: -1 }); + const duplicate = await ticks(2); assert.equal(duplicate.ack, burstEnd); + checks.push("A 12-command burst advances at most one acknowledgement per tick; duplicate/stale inputs are ignored"); + + await acknowledged({}); await ticks(14); + const crouch = await acknowledged({ sneak: true }); + assert.equal(crouch.body.pose, "crouching"); + const jump = await acknowledged({ jump: true }); + assert.equal(jump.body.pose, "standing"); + assert.ok(jump.body.velocity[1] > 0 && !jump.body.on_ground); + const flying = await acknowledged({ fly_toggle: true, jump: true }); + assert.equal(flying.body.flying, true); + const ascended = await ticks(4); + assert.equal(ascended.body.flying, true, "Held input cannot re-toggle flight"); + assert.ok(ascended.body.position[1] > flying.body.position[1]); + const stoppedFlying = await acknowledged({ fly_toggle: true }); + assert.equal(stoppedFlying.body.flying, false); + await state((motion) => motion.body.on_ground, 10000); + checks.push("Crouch dimensions, normal jump, authorized flight ascent and one-shot flight toggles are authoritative"); + + const epochBeforeReset = previous.epoch; + for (let i = 0; i < 12; i++) input({ forward: 1 }); + client.send({ type: "input_reset" }); + const reset = await state((motion) => motion.epoch > epochBeforeReset); + assert.equal(reset.ack, client.seq); + await acknowledged({}); await ticks(3); + const epochBeforeRespawn = previous.epoch; + client.send({ type: "respawn" }); + const respawn = await state((motion) => motion.epoch > epochBeforeRespawn); + assert.ok(Math.abs(respawn.body.position[0] - 15) < 1e-9); + assert.ok(Math.abs(respawn.body.position[2] - 8) < 1e-9); + checks.push("Input reset discards queued commands with a new epoch; respawn clears motion and resets position"); + + const chunksBeforeWalk = chunks, snapshotsBeforeWalk = snapshots; + let walk = await acknowledged({ forward: 1, sprint: true, yaw: Math.PI / 2 }); + const startX = walk.body.position[0]; + for (let i = 0; i < 145; i++) walk = await acknowledged({ forward: 1, sprint: true, yaw: Math.PI / 2 }); + await acknowledged({}); await ticks(8); + assert.ok(walk.body.position[0] - startX > 35); + assert.ok(chunks - chunksBeforeWalk >= 2); + assert.equal(snapshots, snapshotsBeforeWalk); + checks.push("Sustained predicted sprint crosses multiple chunk boundaries using deltas without full snapshots"); + + const oldEpoch = previous.epoch; + permitSnapshot = true; + client.send({ type: "switch_world", world: groundedWorld }); + const switched = await client.next("snapshot", (m) => m.world === groundedWorld); received(); + permitSnapshot = false; + assert.ok(switched.motion.epoch > oldEpoch); + assert.equal(switched.motion.settings.allow_flight, false); + const deniedFlight = await acknowledged({ fly_toggle: true, jump: true }); + assert.equal(deniedFlight.body.flying, false); + await acknowledged({}); + checks.push("World switch resets the epoch and denies flight outside creative mode"); + + const legacy = new Client(h.url); clients.push(legacy); + const legacyWelcome = await legacy.join(manifest, groundedWorld, "LegacyPhysicsProbe"); + assert.equal(legacyWelcome.motion, undefined); + const legacyStart = (await legacy.state()).players.find((p) => p.id === legacy.id).position; + const legacySeq = legacy.input({ strafe: 1 }); + const legacyMoved = await legacy.state((player, message) => message.ack >= legacySeq && player.position[0] > legacyStart[0] + .5); + assert.equal(legacyMoved.motion, undefined); + legacy.input(); + checks.push("A client without prediction support still receives movement and acknowledgements without motion payloads"); + received(); + assert.ok(comparedTicks > 170); + await h.report({ ok: true, checks, server: h.url, server_sha256: serverHash, wasm_sha256: wasmHash, + native_wasm_tolerance: 1e-10, native_wasm_max_error: maximumError, + native_wasm_compared_ticks: comparedTicks, chunk_updates: chunks, + expected_full_snapshots: snapshots, observed_epoch_resets: resetCount, samples }); +} catch (error) { + await h.report({ ok: false, checks, error: String(error.stack || error), + receive_error: receiveError?.stack, native_wasm_compared_ticks: comparedTicks, + native_wasm_max_error: maximumError, samples, server_log: h.logs() }); + throw error; +} finally { + await Promise.all(clients.map((client) => client.close())); + await h.stop(); +} diff --git a/scripts/lighting_reference.java b/scripts/lighting_reference.java new file mode 100644 index 0000000..161ca3b --- /dev/null +++ b/scripts/lighting_reference.java @@ -0,0 +1,129 @@ +// Copyright Shacraft contributors. MIT OR Apache-2.0. +// Extract factual lighting properties through the pinned runtime's public API. +import java.nio.file.*; +import java.util.*; +import com.google.gson.*; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.lighting.LightEngine; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; + +class lighting_reference { + static String canonical(BlockState state) { + String id=BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(); + var properties=new TreeMap(); + for(var property:state.getProperties()) + properties.put(property.getName(),state.getValue(property).toString().toLowerCase(Locale.ROOT)); + return id+(properties.isEmpty()?"":"["+String.join(",",properties.entrySet().stream().map(e->e.getKey()+"="+e.getValue()).toList())+"]"); + } + static JsonArray boxes(VoxelShape shape) { + var result=new JsonArray(); + for(var box:shape.toAabbs()) { + var coordinates=new JsonArray(); + for(double value:new double[]{box.minX,box.minY,box.minZ,box.maxX,box.maxY,box.maxZ})coordinates.add(value); + result.add(coordinates); + } + return result; + } + static BlockState state(String name) { + return BuiltInRegistries.BLOCK.getValue(Identifier.parse("minecraft:"+name)).defaultBlockState(); + } + static JsonObject properties(BlockState state) { + var result=new JsonObject(); + result.addProperty("minecraft_id",Block.getId(state)); + result.addProperty("state",canonical(state)); + result.addProperty("emission",state.getLightEmission()); + result.addProperty("dampening",state.getLightDampening()); + result.addProperty("can_occlude",state.canOcclude()); + result.addProperty("use_shape",state.useShapeForLightOcclusion()); + result.addProperty("propagates_skylight_down",state.propagatesSkylightDown()); + result.add("occlusion",boxes(state.canOcclude()&&state.useShapeForLightOcclusion()?state.getOcclusionShape():Shapes.empty())); + return result; + } + static JsonObject crossing(BlockState from,BlockState to,Direction direction) { + var result=new JsonObject(); + result.addProperty("from",canonical(from)); result.addProperty("to",canonical(to)); + result.addProperty("direction",direction.getName()); + result.addProperty("block_dampening",LightEngine.getLightDampeningInto(from,to,direction,Math.max(1,to.getLightDampening()))); + result.addProperty("sky_column_dampening",LightEngine.getLightDampeningInto(from,to,direction,to.getLightDampening())); + return result; + } + public static void main(String[] args) throws Exception { + SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); + var root=new JsonObject(); root.addProperty("version","26.2"); + var states=new JsonArray(); var shapeTable=new JsonArray(); var shapes=new HashMap(); + var defaults=new JsonObject(); var blocks=new JsonObject(); var schemas=new JsonArray(); + var schemaIds=new HashMap(); + // Block state IDs are the same IDs already recorded by catalog generation. + var byId=new TreeMap(); + for(var block:BuiltInRegistries.BLOCK) { + String name=BuiltInRegistries.BLOCK.getKey(block).toString(); + defaults.addProperty(name,Block.getId(block.defaultBlockState())); + var possible=block.getStateDefinition().getPossibleStates(); + int first=possible.stream().mapToInt(Block::getId).min().orElseThrow(); + var schema=new JsonArray(); + for(var property:block.getStateDefinition().getProperties()) { + var item=new JsonArray(); item.add(property.getName()); + var values=new JsonArray(); + for(var value:property.getPossibleValues())values.add(value.toString().toLowerCase(Locale.ROOT)); + item.add(values); schema.add(item); + } + for(var state:possible) { + byId.put(Block.getId(state),state); + int offset=0; + for(var property:block.getStateDefinition().getProperties()) { + var values=new ArrayList<>(property.getPossibleValues()); + offset=offset*values.size()+values.indexOf(state.getValue(property)); + } + if(first+offset!=Block.getId(state))throw new IllegalStateException("Non-contiguous state schema: "+canonical(state)); + } + String schemaKey=schema.toString(); + if(!schemaIds.containsKey(schemaKey)) { schemaIds.put(schemaKey,schemas.size()); schemas.add(schema); } + var definition=new JsonArray(); definition.add(first); definition.add(Block.getId(block.defaultBlockState())); + definition.add(schemaIds.get(schemaKey)); blocks.add(name,definition); + } + for(var entry:byId.entrySet()) { + var state=entry.getValue(); + var occlusion=boxes(state.canOcclude()&&state.useShapeForLightOcclusion()?state.getOcclusionShape():Shapes.empty()); + String key=occlusion.toString(); + if(!shapes.containsKey(key)) { shapes.put(key,shapeTable.size());shapeTable.add(occlusion); } + var record=new JsonArray(); record.add(entry.getKey()); record.add(state.getLightDampening()); + record.add(shapes.get(key)); record.add(state.getLightEmission()); + record.add(state.canOcclude()); record.add(state.useShapeForLightOcclusion()); + record.add(state.propagatesSkylightDown()); states.add(record); + } + root.add("states",states); root.add("shapes",shapeTable); root.add("defaults",defaults); + root.add("blocks",blocks); root.add("schemas",schemas); + root.addProperty("state_columns","minecraft_id,dampening,occlusion_shape_id,emission,can_occlude,use_shape,propagates_skylight_down"); + var examples=new JsonArray(); + for(String name:List.of("air","stone","glass","tinted_glass","white_stained_glass","glass_pane","ice","packed_ice","blue_ice","water","lava","oak_leaves","azalea_leaves","oak_slab","oak_stairs","oak_trapdoor","oak_door","snow","snow_block","torch","wall_torch","redstone_torch","soul_torch","glowstone","sea_lantern","redstone_lamp","lantern","jack_o_lantern","light","sculk_sensor","copper_bulb","copper_grate")) + examples.add(properties(state(name))); + examples.add(properties(Blocks.REDSTONE_LAMP.defaultBlockState().setValue(BlockStateProperties.LIT,true))); + examples.add(properties(Blocks.OAK_SLAB.defaultBlockState().setValue(BlockStateProperties.SLAB_TYPE,net.minecraft.world.level.block.state.properties.SlabType.TOP))); + examples.add(properties(Blocks.OAK_SLAB.defaultBlockState().setValue(BlockStateProperties.SLAB_TYPE,net.minecraft.world.level.block.state.properties.SlabType.DOUBLE))); + examples.add(properties(Blocks.OAK_SLAB.defaultBlockState().setValue(BlockStateProperties.WATERLOGGED,true))); + root.add("examples",examples); + var crossings=new JsonArray(); var air=Blocks.AIR.defaultBlockState(); + for(String name:List.of("stone","glass","tinted_glass","ice","water","oak_leaves","oak_slab","oak_stairs","glowstone","sea_lantern")) + for(Direction direction:Direction.values())crossings.add(crossing(air,state(name),direction)); + var slab=Blocks.OAK_SLAB.defaultBlockState(); + var top=slab.setValue(BlockStateProperties.SLAB_TYPE,net.minecraft.world.level.block.state.properties.SlabType.TOP); + for(Direction direction:Direction.values()) { + crossings.add(crossing(slab,top,direction)); + crossings.add(crossing(top,slab,direction)); + crossings.add(crossing(state("glowstone"),air,direction)); + } + root.add("crossings",crossings); + root.addProperty("measurement_scope","Public original Java 26.2 block-state light properties and LightEngine.getLightDampeningInto calls. Effective shape is empty unless canOcclude and useShapeForLightOcclusion are both true. Crossings measure directional shape blocking and target attenuation, not a full running light engine or skylight source map."); + Files.writeString(Path.of(args[0]),new GsonBuilder().create().toJson(root)+"\n"); + System.out.println("Measured "+states.size()+" states, "+shapeTable.size()+" effective shapes, "+examples.size()+" examples and "+crossings.size()+" directional crossings."); + } +} diff --git a/scripts/measure_lighting.py b/scripts/measure_lighting.py new file mode 100644 index 0000000..369af4e --- /dev/null +++ b/scripts/measure_lighting.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Measure Java 26.2 light properties using the verified local catalog cache.""" +from pathlib import Path +import argparse +import hashlib +import json +import os +import subprocess +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_SHA1 = "823e2250d24b3ddac457a60c92a6a941943fcd6a" + + +def digest(path, algorithm="sha256"): + with path.open("rb") as source: + return hashlib.file_digest(source, algorithm).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--java", type=Path, required=True) + parser.add_argument("--cache", type=Path, default=ROOT / "artifacts/catalog-cache") + parser.add_argument("--output", type=Path, default=ROOT / "artifacts/lighting/reference.json") + parser.add_argument("--runtime-output", type=Path, default=ROOT / "client/light-properties.json") + parser.add_argument("--fixture-output", type=Path, default=ROOT / "client/tests/fixtures/lighting-java26.2.json") + args = parser.parse_args() + cache = args.cache.resolve() + source = cache / "server.jar" + if digest(source, "sha1") != SOURCE_SHA1: + raise SystemExit("The official source JAR does not match pinned Java 26.2") + executable = cache / "versions/26.2/server-26.2.jar" + with zipfile.ZipFile(source) as bundle: + with bundle.open("META-INF/versions/26.2/server-26.2.jar") as embedded: + expected = hashlib.file_digest(embedded, "sha256").hexdigest() + if digest(executable) != expected: + raise SystemExit("The cached executable differs from the verified official bundle") + classpath = os.pathsep.join(map(str, [executable, *sorted((cache / "libraries").rglob("*.jar"))])) + java = args.java.resolve() + script = ROOT / "scripts/lighting_reference.java" + measured = cache / "lighting-measurements.json" + commands = [ + [str(java.with_name("javac")), "-cp", classpath, "-d", str(cache), str(script)], + [str(java), "-Xmx1G", "-cp", str(cache) + os.pathsep + classpath, "lighting_reference", str(measured)], + ] + with (cache / "lighting-measurements.log").open("w") as log: + for command in commands: + result = subprocess.run(command, cwd=cache, stdout=log, stderr=subprocess.STDOUT) + if result.returncode: + raise SystemExit(f"Reference probe failed. Read {cache / 'lighting-measurements.log'}") + reference = json.loads(measured.read_text()) + reference["provenance"] = { + "source_url": f"https://piston-data.mojang.com/v1/objects/{SOURCE_SHA1}/server.jar", + "source_sha1": SOURCE_SHA1, + "source_sha256": digest(source), + "executable_sha256": digest(executable), + "probe_sha256": digest(script), + "command": "python3 scripts/measure_lighting.py --java /path/to/java25/bin/java", + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(reference, separators=(",", ":")) + "\n") + print(f"Wrote {args.output}: {len(reference['states'])} states, {len(reference['shapes'])} shapes, {args.output.stat().st_size} bytes") + codes = [] + for state in reference["states"]: + state_id, dampening, shape_id = state[:3] + assert state_id == len(codes), "Minecraft state IDs must be contiguous" + assert 0 <= dampening <= 15 and 0 <= shape_id < len(reference["shapes"]) + codes.append(dampening + 16 * shape_id) + runtime = { + "version": reference["version"], + "encoding": "codes[minecraft_id] = light_dampening + 16 * occlusion_shape_index; blocks[name] = [first_state_id, default_state_id, schema_index]", + "codes": codes, + "shapes": reference["shapes"], + "blocks": reference["blocks"], + "schemas": reference["schemas"], + "provenance": reference["provenance"], + } + args.runtime_output.parent.mkdir(parents=True, exist_ok=True) + args.runtime_output.write_text(json.dumps(runtime, separators=(",", ":")) + "\n") + print(f"Wrote {args.runtime_output}: {args.runtime_output.stat().st_size} bytes") + fixture = {key: reference[key] for key in ("version", "examples", "crossings", "measurement_scope", "provenance")} + args.fixture_output.parent.mkdir(parents=True, exist_ok=True) + args.fixture_output.write_text(json.dumps(fixture, indent=2) + "\n") + print(f"Wrote {args.fixture_output}: {len(fixture['crossings'])} original directional crossings") + + +if __name__ == "__main__": + main() diff --git a/scripts/measure_physics.py b/scripts/measure_physics.py new file mode 100644 index 0000000..45122ff --- /dev/null +++ b/scripts/measure_physics.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Measure factual Java 26.2 movement through a pinned local official runtime. + +Requires the cache prepared by catalog_generate.py and an existing Java 25 JDK. +Does not download assets or start a Minecraft server. See measurement_scope in +the generated fixture for the precise boundary of the original-runtime probe. +""" +from pathlib import Path +import argparse +import hashlib +import json +import os +import subprocess +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_SHA1 = "823e2250d24b3ddac457a60c92a6a941943fcd6a" + + +def digest(path, algorithm="sha256"): + with path.open("rb") as source: + return hashlib.file_digest(source, algorithm).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--java", type=Path, required=True) + parser.add_argument("--cache", type=Path, default=ROOT / "artifacts/catalog-cache") + parser.add_argument("--output", type=Path, default=ROOT / "artifacts/catalog-cache/physics-reference.json") + args = parser.parse_args() + cache = args.cache.resolve() + source = cache / "server.jar" + if digest(source, "sha1") != SOURCE_SHA1: + raise SystemExit("The official source JAR does not match the pinned Java 26.2 hash") + executable = cache / "versions/26.2/server-26.2.jar" + with zipfile.ZipFile(source) as bundle: + with bundle.open("META-INF/versions/26.2/server-26.2.jar") as embedded: + expected = hashlib.file_digest(embedded, "sha256").hexdigest() + if digest(executable) != expected: + raise SystemExit("The cached executable differs from the verified official bundle") + classpath = os.pathsep.join(map(str, [executable, *sorted((cache / "libraries").rglob("*.jar"))])) + java = args.java.resolve() + script = ROOT / "scripts/physics_reference.java" + measured = cache / "physics-measurements.json" + commands = [ + [str(java.with_name("javac")), "-cp", classpath, "-d", str(cache), str(script)], + [str(java), "-Xmx1G", "-cp", str(cache) + os.pathsep + classpath, "physics_reference", str(measured)], + ] + with (cache / "physics-measurements.log").open("w") as log: + for command in commands: + result = subprocess.run(command, cwd=cache, stdout=log, stderr=subprocess.STDOUT) + if result.returncode: + raise SystemExit(f"Reference probe failed. Read {cache / 'physics-measurements.log'}") + fixture = json.loads(measured.read_text()) + fixture["provenance"] = { + "source_url": f"https://piston-data.mojang.com/v1/objects/{SOURCE_SHA1}/server.jar", + "source_sha1": SOURCE_SHA1, + "source_sha256": digest(source), + "executable_sha256": digest(executable), + "probe_sha256": digest(script), + "command": "python3 scripts/measure_physics.py --java /path/to/java25/bin/java --output crates/shacraft-physics/tests/fixtures/java26.2.json", + "java": subprocess.run([str(java), "-version"], text=True, capture_output=True, check=True).stderr.strip(), + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(fixture, indent=2) + "\n") + print(f"Wrote {args.output}") + + +if __name__ == "__main__": + main() diff --git a/scripts/physics_reference.java b/scripts/physics_reference.java new file mode 100644 index 0000000..ffb72d3 --- /dev/null +++ b/scripts/physics_reference.java @@ -0,0 +1,332 @@ +// Copyright Shacraft contributors. MIT OR Apache-2.0. +// Measure the original Java runtime. No original source or assets are exported. +import java.nio.file.*; +import java.lang.reflect.*; +import java.util.*; +import com.google.gson.*; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.*; +import net.minecraft.world.entity.player.*; +import net.minecraft.world.entity.ai.attributes.*; +import net.minecraft.world.effect.*; +import net.minecraft.world.level.*; +import net.minecraft.world.level.block.*; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.level.material.Fluid; +import net.minecraft.world.level.gameevent.GameEvent; +import net.minecraft.world.level.border.WorldBorder; +import net.minecraft.world.phys.*; +import net.minecraft.world.phys.shapes.*; +import net.minecraft.tags.*; + +class physics_reference { + static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + static final sun.misc.Unsafe ALLOCATOR; + static final Method COLLIDE; + static { + try { + var field = sun.misc.Unsafe.class.getDeclaredField("theUnsafe"); + field.setAccessible(true); + ALLOCATOR = (sun.misc.Unsafe)field.get(null); + COLLIDE = Entity.class.getDeclaredMethod("collideWithShapes", Vec3.class, AABB.class, List.class); + COLLIDE.setAccessible(true); + } catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); } + } + static void field(Object target, Class owner, String name, Object value) { + try { var f=owner.getDeclaredField(name); f.setAccessible(true); f.set(target,value); } + catch (ReflectiveOperationException e) { throw new RuntimeException(e); } + } + static double sprintModifier() { + try { + var f=LivingEntity.class.getDeclaredField("SPEED_MODIFIER_SPRINTING"); f.setAccessible(true); + return ((AttributeModifier)f.get(null)).amount(); + } catch (ReflectiveOperationException e) { throw new RuntimeException(e); } + } + static JsonArray vector(Vec3 v) { + var a=new JsonArray(); a.add(v.x); a.add(v.y); a.add(v.z); return a; + } + static Block block(String name) { return BuiltInRegistries.BLOCK.getValue(Identifier.parse("minecraft:"+name)); } + + static class SampleWorld implements BlockGetter { + BlockState state; + SampleWorld(BlockState state) { this.state=state; } + public BlockState getBlockState(BlockPos p) { return p.equals(BlockPos.ZERO)?state:Blocks.AIR.defaultBlockState(); } + public FluidState getFluidState(BlockPos p) { return getBlockState(p).getFluidState(); } + public BlockEntity getBlockEntity(BlockPos p) { return null; } + public int getHeight() { return 384; } + public int getMinY() { return -64; } + } + + // Constructors are deliberately never invoked: this registry-only harness + // does not create a running server, world files, connections, or EULA state. + static class PlaneLevel extends ServerLevel { + BlockState surface; + List obstacles; + WorldBorder border; + PlaneLevel() { super(null,null,null,null,null,null,false,0,List.of(),false); } + public BlockState getBlockState(BlockPos p) { return p.getY()<0?surface:Blocks.AIR.defaultBlockState(); } + public FluidState getFluidState(BlockPos p) { return getBlockState(p).getFluidState(); } + public WorldBorder getWorldBorder() { return border; } + public List getEntityCollisions(Entity entity,AABB bounds) { return List.of(); } + public Iterable getBlockCollisions(Entity entity,AABB bounds) { + return obstacles.stream().filter(s->s.bounds().intersects(bounds.inflate(1e-7))).toList(); + } + } + static class ProbePlayer extends Player { + PlaneLevel plane; + boolean grounded, sprint; + String medium; + double fluidDepth; + Vec3 coordinates, velocity; + Abilities testAbilities; + AttributeSupplier defaults; + List obstacles; + ProbePlayer() { super(null,null); } + public GameType gameMode() { return GameType.SURVIVAL; } + public Level level() { return plane; } + public boolean onGround() { return grounded; } + public boolean isPassenger() { return false; } + public boolean isSwimming() { return false; } + public boolean isSprinting() { return sprint; } + public boolean isInWater() { return "water".equals(medium); } + public boolean isInLava() { return "lava".equals(medium); } + public boolean isFallFlying() { return false; } + public boolean isNoGravity() { return false; } + public boolean onClimbable() { return false; } + public boolean shouldDiscardFriction() { return false; } + public boolean isSteppingCarefully() { return false; } + public boolean isSuppressingBounce() { return false; } + public void gameEvent(Holder event) {} + public double getFluidHeight(TagKey fluid) { return fluidDepth; } + public double getFluidJumpThreshold() { return .4; } + public boolean hasEffect(Holder effect) { return false; } + public MobEffectInstance getEffect(Holder effect) { return null; } + public Abilities getAbilities() { return testAbilities; } + public double getAttributeValue(Holder attribute) { + double value=defaults.getBaseValue(attribute); + return attribute.equals(Attributes.MOVEMENT_SPEED)&&sprint?value*(1+sprintModifier()):value; + } + public Vec3 getDeltaMovement() { return velocity; } + public void setDeltaMovement(Vec3 v) { velocity=v; } + public void setDeltaMovement(double x,double y,double z) { velocity=new Vec3(x,y,z); } + protected float getBlockJumpFactor() { return plane.surface.getBlock().getJumpFactor(); } + public void liquidJump() { jumpInLiquid(isInWater()?FluidTags.WATER:FluidTags.LAVA); } + public BlockPos getBlockPosBelowThatAffectsMyMovement() { return new BlockPos(0,-1,0); } + public void move(MoverType type,Vec3 requested) { + try { + var dimensions=getDefaultDimensions(Pose.STANDING); + var bounds=dimensions.makeBoundingBox(coordinates); + var moved=(Vec3)COLLIDE.invoke(null,requested,bounds,obstacles); + horizontalCollision=moved.x!=requested.x||moved.z!=requested.z; + verticalCollision=moved.y!=requested.y; + grounded=verticalCollision&&requested.y<0; + coordinates=coordinates.add(moved); + field(this,Entity.class,"position",coordinates); + if(moved.x!=requested.x)velocity=new Vec3(0,velocity.y,velocity.z); + if(moved.y!=requested.y)velocity=new Vec3(velocity.x,0,velocity.z); + if(moved.z!=requested.z)velocity=new Vec3(velocity.x,velocity.y,0); + } catch (ReflectiveOperationException e) { throw new RuntimeException(e); } + } + } + static ProbePlayer player(String surface,boolean sprint,double y) throws Exception { + var level=(PlaneLevel)ALLOCATOR.allocateInstance(PlaneLevel.class); + level.surface=block(surface).defaultBlockState(); + var player=(ProbePlayer)ALLOCATOR.allocateInstance(ProbePlayer.class); + player.plane=level; player.grounded=y==0; player.sprint=sprint; + player.coordinates=new Vec3(0,y,0); player.velocity=new Vec3(0,y==0?-0.0784000015258789:0,0); + player.defaults=Player.createAttributes().build(); + player.testAbilities=new Abilities(); + player.obstacles=List.of(Shapes.create(new AABB(-1000,-1,-1000,1000,0,1000))); + level.obstacles=player.obstacles; level.border=new WorldBorder(); + field(player,Entity.class,"position",player.coordinates); + field(player,Entity.class,"type",EntityTypes.PLAYER); + field(player,Entity.class,"level",level); + field(player,Entity.class,"blockPosition",BlockPos.ZERO); + field(player,Player.class,"abilities",player.testAbilities); + return player; + } + static JsonObject trajectory(String surface,boolean sprint,boolean jump,double y,int moveTicks,int ticks) throws Exception { + return trajectory(surface,sprint,jump,y,moveTicks,ticks,"air",0,false); + } + static JsonObject trajectory(String surface,boolean sprint,boolean jump,double y,int moveTicks,int ticks,String medium,double fluidDepth,boolean flying) throws Exception { + var player=player(surface,sprint,y); + player.medium=medium; player.fluidDepth=fluidDepth; player.testAbilities.flying=flying; + var out=new JsonObject(); out.addProperty("surface",surface); out.addProperty("sprint",sprint); + out.addProperty("medium",medium); out.addProperty("fluid_depth",fluidDepth); out.addProperty("flying",flying); + out.addProperty("jump_first_tick",jump); out.addProperty("input_ticks",moveTicks); + out.add("initial_position",vector(player.coordinates)); out.add("initial_velocity",vector(player.velocity)); + var samples=new JsonArray(); + for(int tick=1;tick<=ticks;tick++) { + // Match the documented aiStep input boundary. These two threshold rules + // are harness preparation, not claimed as a full original aiStep call. + var v=player.velocity; + double x=v.x,z=v.z; + if(v.horizontalDistanceSqr()<9e-6) { x=0; z=0; } + player.velocity=new Vec3(x,Math.abs(v.y)<.003?0:v.y,z); + if(jump&&tick==1) { + if("air".equals(medium)) player.jumpFromGround(); else player.liquidJump(); + } + player.travel(new Vec3(0,0,tick<=moveTicks?(double).98f:0)); + var sample=new JsonObject(); sample.addProperty("tick",tick); + sample.add("position",vector(player.coordinates)); sample.add("velocity",vector(player.velocity)); + sample.addProperty("on_ground",player.grounded); samples.add(sample); + } + out.add("samples",samples); return out; + } + static JsonObject callbacks() throws Exception { + var root=new JsonObject(); var bounces=new JsonArray(); + var bounce=Entity.class.getDeclaredMethod("restituteMovementAfterCollisions",BlockState.class,boolean.class,boolean.class,Vec3.class); + bounce.setAccessible(true); + for(String name:List.of("stone","slime_block","white_bed")) + for(double incoming:List.of(-.0784000015258789,-.3,-1.0)) + for(double fraction:List.of(0.0,.25,.9)) { + var p=player(name,false,0); p.velocity=new Vec3(.1,incoming,.2); + p.verticalCollision=true; p.verticalCollisionBelow=true; + bounce.invoke(p,block(name).defaultBlockState(),false,false,new Vec3(.1,incoming*fraction,.2)); + var v=new JsonObject(); v.addProperty("surface",name); v.addProperty("incoming_y",incoming); + v.addProperty("moved_fraction",fraction); v.add("velocity",vector(p.velocity)); bounces.add(v); + } + root.add("vertical_collision_restitution",bounces); + var slides=new JsonArray(); + var slide=HoneyBlock.class.getDeclaredMethod("doSlideMovement",Entity.class); slide.setAccessible(true); + for(double incoming:List.of(-.16,-.3,-1.0)) { + var p=player("honey_block",false,10); p.velocity=new Vec3(.1,incoming,.2); + slide.invoke(Blocks.HONEY_BLOCK,p); + var v=new JsonObject(); v.addProperty("incoming_y",incoming); v.add("velocity",vector(p.velocity)); slides.add(v); + } + root.add("honey_slide_callback",slides); + var slimeSteps=new JsonArray(); + for(double incoming:List.of(0.0,-.0784000015258789,.05,.2)) { + var p=player("slime_block",false,0); p.velocity=new Vec3(.1,incoming,.2); + Blocks.SLIME_BLOCK.stepOn(p.plane,BlockPos.ZERO,Blocks.SLIME_BLOCK.defaultBlockState(),p); + var v=new JsonObject(); v.addProperty("incoming_y",incoming); v.add("velocity",vector(p.velocity)); slimeSteps.add(v); + } + root.add("slime_step_callback",slimeSteps); + var bubbles=new JsonArray(); + for(boolean above:List.of(false,true))for(boolean down:List.of(false,true))for(double incoming:List.of(-1.0,0.0,1.0)) { + var p=player("stone",false,10); p.velocity=new Vec3(.1,incoming,.2); + // Null private level suppresses the callback's optional server particles. + field(p,Entity.class,"level",null); + if(above)p.onAboveBubbleColumn(down,BlockPos.ZERO); else p.onInsideBubbleColumn(down); + var v=new JsonObject(); v.addProperty("above",above); v.addProperty("down",down); + v.addProperty("incoming_y",incoming); v.add("velocity",vector(p.velocity)); bubbles.add(v); + } + root.add("bubble_column_callback",bubbles); return root; + } + static JsonArray currentCases() throws Exception { + var result=new JsonArray(); + var tracker=Class.forName("net.minecraft.world.entity.EntityFluidInteraction$Tracker"); + var constructor=tracker.getDeclaredConstructor(); constructor.setAccessible(true); + var accumulate=tracker.getDeclaredMethod("accumulateCurrent",Vec3.class); accumulate.setAccessible(true); + var apply=tracker.getDeclaredMethod("applyCurrentTo",Entity.class,double.class); apply.setAccessible(true); + for(double incomingX:List.of(0.0,.01))for(double currentX:List.of(.001,.01,1.0)) { + var p=player("stone",false,10); p.velocity=new Vec3(incomingX,0,0); + var state=constructor.newInstance(); accumulate.invoke(state,new Vec3(currentX,0,0)); + apply.invoke(state,p,.014); + var v=new JsonObject(); v.addProperty("incoming_x",incomingX); v.addProperty("current_x",currentX); + v.addProperty("strength",.014); v.add("velocity",vector(p.velocity)); result.add(v); + } + return result; + } + static JsonObject collisionCase(String label,double y,boolean grounded,Vec3 requested,List boxes) throws Exception { + var p=player("stone",false,y); p.grounded=grounded; + p.plane.obstacles=boxes.stream().map(Shapes::create).toList(); + field(p,Entity.class,"bb",p.getDefaultDimensions(Pose.STANDING).makeBoundingBox(p.coordinates)); + var method=Entity.class.getDeclaredMethod("collide",Vec3.class); method.setAccessible(true); + var result=(Vec3)method.invoke(p,requested); + var out=new JsonObject(); out.addProperty("name",label); out.addProperty("on_ground",grounded); + out.add("position",vector(p.coordinates)); out.add("requested",vector(requested)); out.add("result",vector(result)); + out.addProperty("step_height",(double)p.maxUpStep()); + var geometry=new JsonArray(); + for(var b:boxes) { + var object=new JsonObject(); object.add("min",vector(new Vec3(b.minX,b.minY,b.minZ))); + object.add("max",vector(new Vec3(b.maxX,b.maxY,b.maxZ))); geometry.add(object); + } + out.add("boxes",geometry); return out; + } + static JsonArray collisionCases() throws Exception { + var result=new JsonArray(); + var floor=new AABB(-10,-1,-10,10,0,10); + var slab=new AABB(.8,0,-1,1.8,.5,1); + var full=new AABB(.8,0,-1,1.8,1,1); + var thin=new AABB(.8,0,-1,1.8,.0625,1); + var ceiling=new AABB(-1,2.3,-1,2,2.4,1); + var wall=new AABB(-1,0,.8,2,2,1.8); + result.add(collisionCase("half_slab",0,true,new Vec3(.8,-.0784,0),List.of(floor,slab))); + result.add(collisionCase("half_slab_low_ceiling",0,true,new Vec3(.8,-.0784,0),List.of(floor,slab,ceiling))); + result.add(collisionCase("full_block",0,true,new Vec3(.8,-.0784,0),List.of(floor,full))); + result.add(collisionCase("thin_step",0,true,new Vec3(.8,-.0784,0),List.of(floor,thin))); + result.add(collisionCase("lowest_improving_step",0,true,new Vec3(.8,-.0784,0),List.of(floor,new AABB(.4,0,-1,1.4,.125,1),slab))); + result.add(collisionCase("descending_into_step",.2,false,new Vec3(.8,-.4,0),List.of(floor,slab))); + result.add(collisionCase("corner_major_z",0,true,new Vec3(.8,-.0784,.9),List.of(floor,slab,wall))); + result.add(collisionCase("airborne_no_step",0,false,new Vec3(.8,.1,0),List.of(floor,slab))); + return result; + } + public static void main(String[] args) throws Exception { + SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); + var root=new JsonObject(); root.addProperty("version","26.2"); + var surfaces=new JsonObject(); + for(String name:List.of("stone","ice","packed_ice","blue_ice","frosted_ice","slime_block","honey_block","soul_sand","soul_soil","white_bed","water","lava","cobweb","powder_snow","ladder","scaffolding")) { + var b=block(name); var values=new JsonObject(); + values.addProperty("friction",(double)b.getFriction()); + values.addProperty("speed_factor",(double)b.getSpeedFactor()); + values.addProperty("jump_factor",(double)b.getJumpFactor()); + values.addProperty("bounce_restitution",(double)b.getBounceRestitution()); + surfaces.add(name,values); + } + root.add("surfaces",surfaces); + var attrs=new JsonObject(); var defaults=Player.createAttributes().build(); + for(var holder:BuiltInRegistries.ATTRIBUTE.listElements().toList()) + if(defaults.hasAttribute(holder))attrs.addProperty(holder.unwrapKey().get().identifier().toString(),defaults.getBaseValue(holder)); + root.add("player_attributes",attrs); + root.addProperty("sprint_attribute_modifier",sprintModifier()); + var poses=new JsonObject(); var actor=player("stone",false,0); + for(var pose:List.of(Pose.STANDING,Pose.CROUCHING,Pose.SWIMMING,Pose.FALL_FLYING,Pose.SLEEPING)) { + var d=actor.getDefaultDimensions(pose); var values=new JsonObject(); + values.addProperty("width",(double)d.width()); values.addProperty("height",(double)d.height()); + values.addProperty("eye_height",(double)d.eyeHeight()); poses.add(pose.name().toLowerCase(Locale.ROOT),values); + } + root.add("poses",poses); + var fluids=new JsonObject(); + for(String name:List.of("water","lava")) { + var levels=new JsonArray(); + for(int level=0;level<=15;level++) { + var state=block(name).defaultBlockState().setValue(net.minecraft.world.level.block.state.properties.BlockStateProperties.LEVEL,level); + var world=new SampleWorld(state); var fluid=state.getFluidState(); var values=new JsonObject(); + values.addProperty("level",level); values.addProperty("height",(double)fluid.getHeight(world,BlockPos.ZERO)); + values.addProperty("source",fluid.isSource()); levels.add(values); + } + fluids.add(name,levels); + } + root.add("fluid_levels",fluids); + var trajectories=new JsonObject(); + trajectories.add("stone_walk_stop",trajectory("stone",false,false,0,20,40)); + trajectories.add("stone_sprint_stop",trajectory("stone",true,false,0,20,40)); + trajectories.add("stone_jump",trajectory("stone",false,true,0,0,16)); + trajectories.add("stone_sprint_jump",trajectory("stone",true,true,0,15,20)); + trajectories.add("ice_walk_stop",trajectory("ice",false,false,0,20,40)); + trajectories.add("blue_ice_walk_stop",trajectory("blue_ice",false,false,0,20,40)); + trajectories.add("water_move_stop",trajectory("stone",false,false,10,20,40,"water",1,false)); + trajectories.add("water_sprint_stop",trajectory("stone",true,false,10,20,40,"water",1,false)); + trajectories.add("water_jump",trajectory("stone",false,true,10,0,12,"water",1,false)); + trajectories.add("lava_deep_move_stop",trajectory("stone",false,false,10,20,40,"lava",1,false)); + trajectories.add("lava_shallow_move_stop",trajectory("stone",false,false,10,20,40,"lava",.2,false)); + trajectories.add("creative_fly_move_stop",trajectory("stone",false,false,10,20,40,"air",0,true)); + trajectories.add("creative_fly_sprint_stop",trajectory("stone",true,false,10,20,40,"air",0,true)); + root.add("travel_kernel_trajectories",trajectories); + root.add("callback_measurements",callbacks()); + root.add("collision_cases",collisionCases()); + root.add("fluid_current_cases",currentCases()); + root.addProperty("measurement_scope","Public block and attribute APIs; original Player.travel, jumpFromGround and Entity.collideWithShapes execute in an isolated flat-plane harness. Player constructors and server constructors are bypassed. The harness supplies inputs, threshold preparation, the measured sprint attribute modifier, constant medium/depth and flat-plane movement bookkeeping. Collision cases call original Entity.collide including step selection on explicit boxes. Collision restitution, honey slide, slime step, bubble column and fluid current application are invoked separately. This is not an original full game tick, multiplayer, fluid-world sampling, or automatic callback-dispatch measurement. The water sprint case intentionally leaves swimming pose false to isolate travelInWater."); + Files.writeString(Path.of(args[0]),GSON.toJson(root)+"\n"); + System.out.println("Measured "+surfaces.size()+" surfaces, "+poses.size()+" poses, "+trajectories.size()+" original travel-kernel trajectories."); + } +} diff --git a/scripts/prepare_texture_pack.py b/scripts/prepare_texture_pack.py new file mode 100644 index 0000000..dd72bc2 --- /dev/null +++ b/scripts/prepare_texture_pack.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Bundle selected pixel block textures with the local Shacraft packages. + +Uses only the Python standard library. Source images are copied verbatim; this +does not generate art, resample pixels, or infer distribution rights. +""" + +import argparse +import hashlib +import json +from pathlib import Path +import re +import shutil +import struct +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_TEXTURES = ( + "stone cobblestone oak_planks oak_log oak_log_top dirt grass_block_top " + "grass_block_side sand gravel bricks stone_bricks glass oak_leaves " + "diamond_ore iron_ore coal_ore gold_ore redstone_ore deepslate obsidian " + "snow netherrack oak_door_bottom" +).split() +MARKER = ".shacraft-texture-bundle.json" + + +def write_json(path, data): + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + +def resource(path, package, role): + data = path.read_bytes() + return { + "path": path.relative_to(package).as_posix(), + "scope": "client", + "role": role, + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + } + + +def texture_bytes(path, pixel_size=32): + data = path.read_bytes() + if ( + len(data) < 33 + or data[:16] != b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + or struct.unpack(">II", data[16:24]) != (pixel_size, pixel_size) + ): + raise ValueError(f"Expected a real {pixel_size}x{pixel_size} PNG: {path}") + if len(data) > 16 * 1024 * 1024: + raise ValueError(f"Texture exceeds the server resource limit: {path}") + return data + + +def prepare(args): + source = args.source.resolve() + output = args.output.resolve() + if source == output or source.is_relative_to(output): + raise ValueError("The output must not contain the source pack.") + if output == ROOT / "packages" or (ROOT / "packages").is_relative_to(output): + raise ValueError("The output must not replace the bundled core packages.") + if output.exists(): + if not args.overwrite: + raise ValueError("Output already exists; use --overwrite to rebuild it.") + marker = output / MARKER + if not marker.is_file() or json.loads(marker.read_text()).get("generator") != "prepare_texture_pack.py": + raise ValueError("Refusing to replace a directory not created by this script.") + if not re.fullmatch(r"[a-z0-9_.-]+", args.id): + raise ValueError("Package id must use lowercase letters, digits, '.', '_' or '-'.") + if args.id in {"shacraft.base", "shacraft.trampoline"}: + raise ValueError("The texture package needs its own id.") + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", args.version): + raise ValueError("Package version must contain three numeric components.") + if not args.name.strip() or len(args.name) > 120: + raise ValueError("Texture pack name must contain 1 to 120 characters.") + if not args.license or len(args.license) > 256 or any(ord(c) < 32 for c in args.license): + raise ValueError("Supply a nonempty license or local-use notice (at most 256 characters).") + names = args.textures or DEFAULT_TEXTURES + if not 1 <= len(names) <= 127 or len(set(names)) != len(names): + raise ValueError("Choose 1 to 127 unique texture names.") + if any(not re.fullmatch(r"[a-z0-9_]+", name) for name in names): + raise ValueError("Texture names must be plain Minecraft block texture names.") + source_textures = source / "assets/minecraft/textures/block" + # Validate every selected source before touching an existing output bundle. + textures = [(name, texture_bytes(source_textures / f"{name}.png", args.pixel_size)) for name in names] + output.parent.mkdir(parents=True, exist_ok=True) + stage = Path(tempfile.mkdtemp(prefix=f".{output.name}-", dir=output.parent)) + backup = None + try: + for name in ("base", "trampoline"): + shutil.copytree(ROOT / "packages" / name, stage / name) + package = stage / f"pixel{args.pixel_size}" + images = package / "client/textures" + images.mkdir(parents=True) + entries, resources = [], [] + for name, data in textures: + path = images / f"{name}.png" + path.write_bytes(data) + entries.append({"name": name, "path": path.relative_to(package).as_posix()}) + resources.append(resource(path, package, "texture")) + style = package / "client/style.json" + write_json(style, {"schema": 1, "texture_pack": { + "name": args.name, "pixel_size": args.pixel_size, "textures": entries, + }}) + resources.append(resource(style, package, "client-style")) + base_manifest = json.loads((stage / "base/manifest.json").read_text()) + write_json(package / "manifest.json", { + "schema": 1, "id": args.id, "version": args.version, + "license": args.license, + "dependencies": [{"id": base_manifest["id"], "version": base_manifest["version"]}], + "capabilities": ["client.texture", "client.style"], + "resources": resources, + }) + write_json(stage / MARKER, { + "generator": "prepare_texture_pack.py", "schema": 1, + "package": args.id, "name": args.name, "textures": len(entries), "pixel_size": args.pixel_size, + "texture_bytes": sum(len(data) for _, data in textures), + }) + if output.exists(): + backup = Path(tempfile.mkdtemp(prefix=f".{output.name}-old-", dir=output.parent)) + backup.rmdir() + output.rename(backup) + try: + stage.rename(output) + except OSError: + if backup is not None: + backup.rename(output) + backup = None + raise + if backup is not None: + shutil.rmtree(backup) + print(f"Prepared {len(entries)} textures ({sum(len(data) for _, data in textures):,} PNG bytes)") + print(f"Package bundle: {output}") + finally: + if stage.exists(): + shutil.rmtree(stage) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True, help="Unpacked Minecraft-format resource pack") + parser.add_argument("--output", type=Path, required=True, help="Output bundle for the server --packages option") + parser.add_argument("--name", default="Shacraft Pixel32 Study") + parser.add_argument("--id", default="shacraft.pixel32.study") + parser.add_argument("--version", default="1.0.0") + parser.add_argument("--pixel-size", type=int, choices=(16, 32, 64, 128), default=32, + help="Required native PNG dimensions (default: 32)") + parser.add_argument("--license", default="Local reference-derived study; redistribution rights not granted") + parser.add_argument("--textures", nargs="+", help="Selected texture names without .png (defaults to the 24-texture study)") + parser.add_argument("--overwrite", action="store_true", help="Replace a bundle previously created by this script") + args = parser.parse_args() + try: + prepare(args) + except (OSError, ValueError) as error: + parser.exit(1, f"Texture pack preparation failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/prepare_vanilla_texture_pack.py b/scripts/prepare_vanilla_texture_pack.py new file mode 100644 index 0000000..9e4c753 --- /dev/null +++ b/scripts/prepare_vanilla_texture_pack.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Prepare a local test atlas from an existing Java client JAR (no downloads). + +Keeps original pixel colors; smaller sprites use integer nearest-neighbor +scaling. Animated sprites use their first declared frame. Face materials are +projected onto Shacraft's existing geometry, not a replacement model renderer. +Requires Pillow. Generated assets must not be redistributed without permission. +""" +import argparse +from functools import lru_cache +import hashlib +from io import BytesIO +import json +import math +from pathlib import Path +import shutil +import zipfile + +from PIL import Image + +ROOT = Path(__file__).resolve().parents[1] +PREFIX = 'assets/minecraft/' +DIRECTIONS = ['east', 'west', 'up', 'down', 'south', 'north'] +NORMALS = [(1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)] + + +def norm(name): + return name.removeprefix('minecraft:') + + +def projection(direction, p): + x, y, z = p + return [(1-z, 1-y), (z, 1-y), (x, z), (x, 1-z), (x, 1-y), (1-x, 1-y)][direction] + + +def rotate(p, x, y): + # Java blockstate rotations are clockwise around the block center. + x, y = math.radians(-x), math.radians(-y) + a, b, c = p + b, c = b*math.cos(x)-c*math.sin(x), b*math.sin(x)+c*math.cos(x) + return (a*math.cos(y)+c*math.sin(y), b, -a*math.sin(y)+c*math.cos(y)) + + +def condition(when, props): + if 'OR' in when: + return any(condition(c, props) for c in when['OR']) + if 'AND' in when: + return all(condition(c, props) for c in when['AND']) + return all(props.get(k) in str(v).split('|') for k, v in when.items()) + + +class Compiler: + def __init__(self, jar, images): + self.jar, self.images = jar, images + + @lru_cache(None) + def read(self, path): + return json.loads(self.jar.read(PREFIX + path)) + + @lru_cache(None) + def model(self, name): + data = self.read('models/' + norm(name) + '.json') + parent = data.get('parent') + base = self.model(parent) if parent and 'builtin/' not in parent else {} + return {**base, **data, 'textures': {**base.get('textures', {}), **data.get('textures', {})}} + + def resolve(self, value, textures): + seen = set() + while isinstance(value, str) and value.startswith('#') and value not in seen: + seen.add(value) + value = textures.get(value[1:]) + if isinstance(value, dict): + value = value.get('sprite') + if isinstance(value, str) and norm(value).startswith('block/'): + name = norm(value).removeprefix('block/') + if name in self.images: + return name + return None + + @lru_cache(None) + def model_faces(self, name, rx=0, ry=0, uvlock=False): + model = self.model(name) + textures = model.get('textures', {}) + result, areas = [None]*6, [-1]*6 + for element in model.get('elements', []): + lo = [v/16 for v in element['from']] + hi = [v/16 for v in element['to']] + for direction, data in element.get('faces', {}).items(): + texture = self.resolve(data.get('texture'), textures) + if not texture or texture == 'grass_block_side_overlay': + continue # The opaque grass side already includes its edge. + face = DIRECTIONS.index(direction) + normal = tuple(round(v) for v in rotate(NORMALS[face], rx, ry)) + dest = NORMALS.index(normal) + axes = [i for i, v in enumerate(NORMALS[face]) if not v] + area = math.prod(abs(hi[i]-lo[i]) for i in axes) + if area <= areas[dest]: + continue + areas[dest] = area + corners = [projection(face, p) for p in [lo, hi]] + low = [min(c[i] for c in corners) for i in range(2)] + high = [max(c[i] for c in corners) for i in range(2)] + rect = [v/16 for v in data.get('uv', [low[0]*16, low[1]*16, high[0]*16, high[1]*16])] + + def uv_at(world): + # Inverse rotation is the transpose of the orthogonal basis. + offset = [v-.5 for v in world] + basis = [rotate(axis, rx, ry) for axis in [(1, 0, 0), (0, 1, 0), (0, 0, 1)]] + local = [.5 + sum(a*b for a, b in zip(offset, axis)) for axis in basis] + uv = projection(face, local) + if uvlock: + return projection(dest, world) + uv = [(uv[i]-low[i])/max(high[i]-low[i], 1e-6) for i in range(2)] + for _ in range(data.get('rotation', 0)//90): + uv = [uv[1], 1-uv[0]] + return [rect[i] + uv[i]*(rect[i+2]-rect[i]) for i in range(2)] + + zero = uv_at([0, 0, 0]) + unit = [uv_at(p) for p in [[1, 0, 0], [0, 1, 0], [0, 0, 1]]] + transform = [round(v, 6) for i in range(2) for v in [*(p[i]-zero[i] for p in unit), zero[i]]] + result[dest] = {'texture': texture, 'uv': transform, 'tinted': 'tintindex' in data} + particle = self.resolve(textures.get('particle'), textures) + fallback = next((v for v in result if v), None) + for i, face in enumerate(result): + if face is None: + texture = particle or (fallback and fallback['texture']) + result[i] = {'texture': texture, 'tinted': bool(fallback and fallback['tinted'])} if texture else None + return result + + def faces(self, block, props): + definition = self.read('blockstates/' + norm(block) + '.json') + models = [] + for selector, model in definition.get('variants', {}).items(): + when = dict(part.split('=', 1) for part in selector.split(',') if part) + if condition(when, props): + models.append(model[0] if isinstance(model, list) else model) + break + for part in definition.get('multipart', []): + if condition(part.get('when', {}), props): + model = part['apply'] + models.append(model[0] if isinstance(model, list) else model) + faces = [None]*6 + for model in models: + incoming = self.model_faces(model['model'], model.get('x', 0), model.get('y', 0), model.get('uvlock', False)) + faces = [a or b for a, b in zip(faces, incoming)] + result = [] + for face in faces: + if not face: + result.append(None) + continue + face = face.copy() + tinted = face.pop('tinted', False) + texture = face['texture'] + if tinted: + tint = [0.58, 0.8, 0.34] + if 'leaves' in block or 'vine' in block: + tint = [0.46, 0.7, 0.28] + if 'spruce' in block: + tint = [0.38, 0.60, 0.38] + if block == 'minecraft:redstone_wire': + power = int(props.get('power', '0'))/15 + tint = [.3+.7*power, max(0, power*power*.7-.5), 0] + face['tint'] = tint + if block in ('minecraft:water', 'minecraft:bubble_column'): + face['tint'] = [0.25, 0.46, 0.9] + alpha = self.images[texture].getchannel('A').getextrema() + face['cutout'] = alpha[0] == 0 and alpha[1] == 255 + result.append(face) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--jar', type=Path, required=True) + parser.add_argument('--states', type=Path, required=True, help='Matching generated/reports/blocks.json') + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + output = args.output.resolve() + if output.exists(): + parser.error('Output already exists; choose a new directory.') + jar = zipfile.ZipFile(args.jar) + images, animated, native_sizes = {}, [], {} + for path in sorted(jar.namelist()): + if not path.startswith(PREFIX + 'textures/block/') or not path.endswith('.png'): + continue + name = Path(path).stem + image = Image.open(BytesIO(jar.read(path))).convert('RGBA') + metadata = json.loads(jar.read(path + '.mcmeta')).get('animation', {}) if path + '.mcmeta' in jar.namelist() else {} + width = metadata.get('width', min(image.size)) + height = metadata.get('height', width) + frames = metadata.get('frames', [0]) + first = frames[0] if frames else 0 + index = first.get('index', 0) if isinstance(first, dict) else first + x, y = index % (image.width//width)*width, index // (image.width//width)*height + frame = image.crop((x, y, x+width, y+height)) + native_sizes[name] = [width, height] + if metadata or image.size != frame.size: + animated.append(name) + images[name] = frame.resize((32, 32), Image.Resampling.NEAREST) + compiler = Compiler(jar, images) + report = json.loads(args.states.read_text()) + sets, lookup, states, defaults, uncovered = [], {}, {}, {}, [] + for block, definition in report.items(): + for state in definition['states']: + props = state.get('properties', {}) + try: + faces = compiler.faces(block, props) + except KeyError: + faces = [None]*6 + key = json.dumps(faces, separators=(',', ':'), sort_keys=True) + if key not in lookup: + lookup[key] = len(sets) + sets.append(faces) + canonical = block + ('['+','.join(f'{k}={v}' for k, v in sorted(props.items()))+']' if props else '') + states[canonical] = lookup[key] + if state.get('default'): + defaults[block] = lookup[key] + if not any(faces): + uncovered.append(block) + output.mkdir(parents=True) + for base in ('base', 'trampoline'): + shutil.copytree(ROOT/'packages'/base, output/base) + package = output/'vanilla' + (package/'client').mkdir(parents=True) + columns = 40 + rows = math.ceil(len(images)/columns) + atlas = Image.new('RGBA', (columns*32, rows*32)) + for i, image in enumerate(images.values()): + atlas.paste(image, (i % columns*32, i//columns*32)) + atlas.save(package/'client/atlas.png', optimize=True) + descriptor = {'schema': 1, 'texture_pack': { + 'name': 'Minecraft 26.2 Original — Local Test', 'pixel_size': 32, + 'atlas': {'path': 'client/atlas.png', 'columns': columns, 'rows': rows}, + 'textures': [{'name': name} for name in images], + 'grass_overlay': {'base': 'grass_block_side', 'overlay': 'grass_block_side_overlay'}, + 'block_faces': {'sets': sets, 'states': states, 'defaults': defaults}, + }} + (package/'client/style.json').write_text(json.dumps(descriptor, separators=(',', ':'))+'\n') + resources = [] + for path, role in [('client/atlas.png', 'texture'), ('client/style.json', 'client-style')]: + data = (package/path).read_bytes() + assert len(data) < 16*1024*1024 + resources.append({'path': path, 'scope': 'client', 'role': role, 'size': len(data), 'sha256': hashlib.sha256(data).hexdigest()}) + base = json.loads((output/'base/manifest.json').read_text()) + manifest = {'schema': 1, 'id': 'shacraft.vanilla.local', 'version': '1.0.1', + 'license': 'Minecraft assets copyright Mojang/Microsoft. Local testing only; no redistribution rights granted.', + 'dependencies': [{'id': base['id'], 'version': base['version']}], + 'capabilities': ['client.texture', 'client.style'], 'resources': resources} + (package/'manifest.json').write_text(json.dumps(manifest, indent=2)+'\n') + receipt = {'textures': len(images), 'states': len(states), 'face_sets': len(sets), + 'source_sha256': hashlib.sha256(args.jar.read_bytes()).hexdigest(), + 'native_sizes': dict((str(size), list(native_sizes.values()).count(size)) for size in native_sizes.values()), + 'animated_first_frame': animated, 'untextured_default_blocks': uncovered, + 'atlas_bytes': (package/'client/atlas.png').stat().st_size, + 'mapping_bytes': (package/'client/style.json').stat().st_size, + 'limitations': ['Static first animation frame', 'Existing engine geometry; complex multipart and entity-rendered blocks are approximated', 'Fixed foliage tint rather than biome colors']} + (output/'receipt.json').write_text(json.dumps(receipt, indent=2)+'\n') + print(json.dumps(receipt, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/scripts/verify.sh b/scripts/verify.sh index bbfc4e8..0836577 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -2,10 +2,13 @@ set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." cargo fmt --all -- --check -rustfmt --edition 2024 --check crates/shacraft-server/src/control.rs +rustfmt --edition 2024 --check crates/shacraft-server/src/control.rs crates/shacraft-server/src/world_streaming.rs +bash scripts/build_physics.sh cargo clippy --workspace --all-targets --locked -- -D warnings cargo test --workspace --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 +node scripts/check_server.mjs --binary target/debug/shacraft-server --port "${SHACRAFT_TEST_PORT:-4011}" --output artifacts/server-e2e-debug.json +node scripts/check_chunk_streaming.mjs --binary target/debug/shacraft-server --port "${SHACRAFT_TEST_PORT:-4011}" --output artifacts/chunk-streaming/network-report.json +node scripts/check_player_physics.mjs --binary target/debug/shacraft-server --port "${SHACRAFT_TEST_PORT:-4011}" --output artifacts/physics/network-report.json