diff --git a/app/electron/src/session.js b/app/electron/src/session.js index b6d66518..2e78503b 100644 --- a/app/electron/src/session.js +++ b/app/electron/src/session.js @@ -66,6 +66,12 @@ export class Session { if (files.length === 0) { throw new Error("chunker-cli executable is missing!"); } + + // The dev build directory can contain multiple versioned jars (e.g. an older + // chunker-cli-1.17.0.jar left next to chunker-cli-1.18.1.jar). Sort by name using + // numeric ordering so we always launch the newest build instead of whichever the OS + // happens to list first. + files.sort((a, b) => b.name.localeCompare(a.name, undefined, {numeric: true})); executable = path.join(cliDirectory, files[0].name); } @@ -277,6 +283,12 @@ export class Session { case "generate_preview": await this.generatePreview(data.requestId); break; + case "request_preview_tiles": + await this.requestPreviewTiles(data); + break; + case "cancel_preview_tiles": + await this.cancelPreviewTiles(data); + break; case "convert": await this.convertWorld(data.outputType, data.requestId, data); break; @@ -665,12 +677,32 @@ export class Session { outputPath: previewOutputPath } - // Send the preview request - this.sendToProcess(request, async (response) => { - // Use the base64 of the map.bin for output - response.output = (await fs.readFile(path.join(previewOutputPath, "map.bin"))).toString("base64"); + // Send the preview request. The renderer fetches map.bin via session:// URL after + // receiving the success response — no base64 payload on the wire. + this.sendToProcess(request); + } - return response; + requestPreviewTiles(data) { + this.sendToProcess({ + type: "request_preview_tiles", + requestId: data.requestId, + anonymousId: this._sessionID, + world: data.world, + lod: data.lod, + minTx: data.minTx, + minTz: data.minTz, + maxTx: data.maxTx, + maxTz: data.maxTz + }); + } + + cancelPreviewTiles(data) { + this.sendToProcess({ + type: "cancel_preview_tiles", + requestId: data.requestId, + anonymousId: this._sessionID, + world: data.world, + lod: data.lod }); } diff --git a/app/ui/src/api.js b/app/ui/src/api.js index 492df8d6..1f3c6c0e 100644 --- a/app/ui/src/api.js +++ b/app/ui/src/api.js @@ -1,51 +1,48 @@ let api = { connection: undefined, replyHandlers: {}, + listeners: {}, connect: function (connectHandler) { let handlers = {}; - // Connection open handler handlers.onopen = function () { connectHandler(); }; - // Connection close handler handlers.onclose = function (e) { api.connection = undefined; - if (e.code === 1000) return; // Don't show error, as it was completed cleanly (and successful!) + if (e.code === 1000) return; if (e.code === 200) { - // Connection closed intentionally (eg. inactive) - } else { - // Connection closed for other reason, should reconnect? + // intentional close } connectHandler(e.code); }; - // Connection message handler handlers.onmessage = function (e) { let msg = JSON.parse(e.data); let requestId = msg.requestId; - if (api.replyHandlers[requestId] === undefined) { - console.warn("No reply handler found: ", msg); - } else { - let handler = api.replyHandlers[requestId]; - - // Check if handler needs removing + let handler = api.replyHandlers[requestId]; + if (handler !== undefined) { if (msg.continue === undefined || msg.continue === false) { - // Remove as it's done delete api.replyHandlers[requestId]; } - - // Call handler handler(msg); + return; + } + + let typed = api.listeners[msg.type]; + if (typed && typed.length > 0) { + for (let cb of typed) cb(msg); + return; } + + console.warn("No reply handler or listener found: ", msg); }; - // Set connection this.connection = window.chunker.connect(handlers); }, send: function (obj, replyHandler) { - obj.requestId = crypto.randomUUID();// Generate random id + obj.requestId = crypto.randomUUID(); if (this.connection !== undefined) { this.replyHandlers[obj.requestId] = replyHandler; this.connection.send(JSON.stringify(obj)); @@ -53,6 +50,13 @@ let api = { throw Error("Not connected!"); } }, + addListener: function (type, callback) { + if (!this.listeners[type]) this.listeners[type] = []; + this.listeners[type].push(callback); + return () => { + this.listeners[type] = (this.listeners[type] || []).filter(cb => cb !== callback); + }; + }, isConnected: function () { return this.connection !== undefined; }, @@ -63,4 +67,4 @@ let api = { } }; -export default api; \ No newline at end of file +export default api; diff --git a/app/ui/src/components/app.js b/app/ui/src/components/app.js index 3522929e..d138f196 100644 --- a/app/ui/src/components/app.js +++ b/app/ui/src/components/app.js @@ -6,14 +6,35 @@ import {Header} from "./page/header"; import {StepDisplay} from "./page/stepDisplay"; import {ErrorDisplay} from "./modal/errorDisplay"; import {Footer} from "./page/footer"; -import {decode} from "base64-arraybuffer" import {getFormatName, getVersionName} from "./screen/mode/modeOption"; +import {parseMapBin, worldBoundsForAutoFit, isTileEmpty} from "./screen/settings/tab/preview/mapBin"; +import {computeMinZoom} from "./screen/settings/tab/preview/autoFit"; +import {ClientTileCache} from "./screen/settings/tab/preview/clientTileCache"; export class App extends Component { errorModal = React.createRef(); previewProgress = new ProgressTracker("Generating world preview", (newState) => this.setState({previewProgress: newState}), () => { this.cancelTask() }); + // Tracks the second preview phase — pre-loading default-LOD tiles of the world bounds + // so the Map mounts with content already on disk/cached. queuePosition is pinned to -1 + // so ProgressComponent renders the percentage bar from the start, not the "queue" UI. + // cancelTilePreload aborts the Java-side rendering for the world and leaves the bar in + // its cancelled state so the Map never mounts; the user explicitly opted out of the preview. + previewTileProgress = (() => { + const t = new ProgressTracker( + "Loading map tiles", + (newState) => this.setState({previewTileProgress: newState}), + () => this.cancelTilePreload() + ); + t.state.queuePosition = -1; + return t; + })(); + // Shared between the App-level preloader and the Map's ChunkerPreviewLayer. Tiles fetched + // before Map mounts stay resident in this cache, so createTile finds them on first paint. + clientTileCache = new ClientTileCache(); + _preloadUnsub = null; + _preloadingWorld = null; settingsProgress = new ProgressTracker("Grabbing world information", (newState) => this.setState({settingsProgress: newState})); screen = React.createRef(); defaultConverterSettings = { @@ -28,6 +49,7 @@ export class App extends Component { }; state = { previewProgress: this.previewProgress.state, + previewTileProgress: this.previewTileProgress.state, settingsProgress: this.previewProgress.state, previewData: undefined, requestedPreview: false, @@ -200,55 +222,226 @@ export class App extends Component { generatePreview = () => { let self = this; + // Reset all three preview signals so a second generatePreview (different world / + // re-trigger) re-runs the full cycle cleanly. Order matters under legacy React's + // non-batched setState: kick previewProgress to 0 first so PreviewComponent flips to + // the generation branch before previewData is cleared — otherwise we'd flash + // "Loading map tiles" or an empty render in between. + if (this._preloadUnsub) { + this._preloadUnsub(); + this._preloadUnsub = null; + } + this._preloadingWorld = null; + this.clientTileCache.evictAllExcept([]); + this.previewProgress.setProgress(0); + // Reset progress + clear any cancelled/errored flags left from a previous run so + // ProgressComponent doesn't render "This task was cancelled." for the new generation. + this.previewTileProgress.updateState({progress: 0, queuePosition: -1, cancelled: false, errored: false}); + this.setState({previewData: undefined}); + api.send({type: "flow", method: "generate_preview"}, this.previewProgress.pipe(function (message) { if (message.type === "error") { if (!message.cancelled) { console.info("Failed to preview: " + message.error); self.showError("Failed to render preview", message.error, message.errorId, message.stackTrace, true); // Preview isn't required } + // Unblock the tile-loading branch so PreviewComponent doesn't hang there + // (previewData stays undefined so the Map branch won't fire either). + self.previewTileProgress.setProgress(100); } else if (message.type === "response") { - // Doesn't need to do anything, as progress.js will mark as complete :) - // Decode minX, minZ, maxX, maxZ - let base64 = message.output; - - if (base64.length > 0) { - let buffer = decode(base64); - let worlds = []; - let dataView = new DataView(buffer); - let bufferIndex = 0; - let worldCount = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - - for (let i = 0; i < worldCount; i++) { - let worldIndex = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - - // World Index - worlds[worldIndex] = {}; - worlds[worldIndex].minX = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - worlds[worldIndex].minZ = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - worlds[worldIndex].maxX = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - worlds[worldIndex].maxZ = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - let regionCount = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - bufferIndex += regionCount * (8 + 128); // Skip region bytes - } - - self.setState({previewData: worlds}); - } else { - // Set to empty data (meaning preview is streamed) - self.setState({previewData: []}); + let sessionId = self.state.sessionData && self.state.sessionData.session; + if (!sessionId) { + console.info("Preview response received but no session id available"); + self.setState({previewData: undefined}); + self.previewTileProgress.setProgress(100); + return; } + fetch(`session://${sessionId}/preview/map.bin`) + .then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.arrayBuffer(); + }) + .then((buffer) => { + if (buffer.byteLength > 0) { + const parsed = parseMapBin(buffer); + self.setState({previewData: parsed}); + self.preloadDefaultWorldTiles(parsed, sessionId); + } else { + self.setState({previewData: undefined}); + self.previewTileProgress.setProgress(100); + } + }) + .catch((err) => { + console.info("Failed to fetch map.bin", err); + self.showError("Failed to load preview metadata", err.message || String(err), undefined, undefined, true); + self.setState({previewData: undefined}); + self.previewTileProgress.setProgress(100); + }); } else { console.info("Unknown response", message); } })); }; + /** + * Pre-loads the default world's tiles before the Map mounts so it paints from a warm + * cache. We enqueue tiles across multiple LODs — starting at the auto-fit LOD and + * stepping toward LOD 0 — so the progress bar has enough granularity to look smooth. + * The Java side's {@code PreviewTileCache} reuses the lower-LOD tiles when aggregating + * higher-LOD ones, so adding intermediate LODs costs essentially no extra region work; + * it only exposes more {@code tile_ready} events for us to count. + */ + preloadDefaultWorldTiles = (parsedMapBin, sessionId) => { + // User clicked Cancel before the fetch returned: respect that decision instead of + // resetting progress to 0 and resuming the pre-load. Check both isComplete (the + // legacy "skip-to-Map" cancel path) and isCancelled (the current path where the + // bar stays at the cancelled message instead of jumping to 100). + if (this.previewTileProgress.isComplete() || this.previewTileProgress.isCancelled()) { + return; + } + const dims = this.state.settings && this.state.settings.dimensions; + if (!dims || dims.length === 0) { + this.previewTileProgress.setProgress(100); + return; + } + const layer = (this.state.previewState && this.state.previewState.layer) || dims[0]; + this._preloadingWorld = layer; // cancelTilePreload needs this to address the right Java queue + const bounds = worldBoundsForAutoFit(parsedMapBin, layer); + if (!bounds) { + this.previewTileProgress.setProgress(100); + return; + } + const autoFitLod = computeMinZoom(bounds); + + // Soft cap on tracked tiles. At auto-fit LOD alone the world is 1–4 tiles, which + // makes the bar jump in 25–100% steps. We grow the tracked set by stepping toward + // LOD 0 (4x more tiles each step) until the next LOD would blow past this budget, + // which lands us around 80–120 tracked events regardless of world size — fine + // granularity without unbounded enqueue for huge worlds. + const MAX_TRACKED = 120; + const expected = new Set(); // keys: "lod,tx,tz" + const lodRequests = []; + for (let l = autoFitLod; l <= 0; l++) { + const s = 1 << (-l); + const chunksPerTile = 32 * s; // 1 native tile = 512 px = 32 chunks at LOD 0 + const minTx = Math.floor(bounds.minX / chunksPerTile); + const maxTx = Math.floor(bounds.maxX / chunksPerTile); + const minTz = Math.floor(bounds.minZ / chunksPerTile); + const maxTz = Math.floor(bounds.maxZ / chunksPerTile); + const candidates = []; + for (let tx = minTx; tx <= maxTx; tx++) { + for (let tz = minTz; tz <= maxTz; tz++) { + if (!isTileEmpty(parsedMapBin, layer, l, tx, tz)) { + candidates.push(`${l},${tx},${tz}`); + } + } + } + if (candidates.length === 0) continue; + // Stop expanding to deeper LODs if adding them would push past the budget — but + // only once we already have a coarser LOD enqueued. For the very first LOD we + // accept its tiles unconditionally, otherwise a huge world would pre-load nothing. + if (lodRequests.length > 0 && expected.size + candidates.length > MAX_TRACKED) break; + for (const k of candidates) expected.add(k); + lodRequests.push({lod: l, minTx, maxTx, minTz, maxTz}); + } + + if (expected.size === 0) { + this.previewTileProgress.setProgress(100); + return; + } + + const total = expected.size; + let received = 0; + this.previewTileProgress.setProgress(0); + + // Lerp the displayed progress toward the real one. With ~85 tracked tiles the bar + // moves in ~1% steps anyway, but tile_ready events arrive in bursts (a slow region + // load releases a flurry of cheap aggregated tiles right after), so the raw bar + // stutters. The lerp + the existing 300 ms CSS ease on .progress_fill gives a smooth + // continuous animation between bursts. + let displayedProgress = 0; + let targetProgress = 0; + const lerpTimer = setInterval(() => { + const diff = targetProgress - displayedProgress; + if (Math.abs(diff) < 0.1) return; + displayedProgress += diff * 0.15; + this.previewTileProgress.setProgress(displayedProgress); + }, 100); + + const finish = () => { + clearInterval(lerpTimer); + if (this._preloadUnsub) { + this._preloadUnsub(); + this._preloadUnsub = null; + } + this.previewTileProgress.setProgress(100); + }; + const advance = () => { + received++; + if (received >= total) { + finish(); + } else { + targetProgress = (received / total) * 100; + } + }; + const onTile = (event) => { + if (event.world !== layer) return; + const key = `${event.lod},${event.tx},${event.tz}`; + if (!expected.has(key)) return; + expected.delete(key); + // Populate the shared cache for every preloaded LOD so ChunkerPreviewLayer.createTile + // finds a hit when the user zooms within the preloaded range (not just at autoFit). + const cacheKey = this.clientTileCache.keyFor(layer, event.lod, event.tx, event.tz); + const resolvedUrl = event.path && event.path.startsWith("session://") + ? event.path + : `session://${sessionId}/preview/${event.path}`; + this.clientTileCache.put(cacheKey, {blobUrl: resolvedUrl, sizeBytes: 512 * 512 * 4}); + advance(); + }; + const onTileError = (event) => { + if (event.world !== layer) return; + const key = `${event.lod},${event.tx},${event.tz}`; + if (!expected.has(key)) return; + expected.delete(key); + advance(); + }; + const unsubReady = api.addListener("tile_ready", onTile); + const unsubError = api.addListener("tile_error", onTileError); + this._preloadUnsub = () => { + clearInterval(lerpTimer); + if (unsubReady) unsubReady(); + if (unsubError) unsubError(); + }; + + // One rect request per LOD. Java's PreviewTileService dedupes via inFlight, so + // overlapping work between LODs (e.g. LOD -3 internally aggregating cached LOD -2 + // children) won't cause duplicated region loads. + for (const r of lodRequests) { + api.send({type: "flow", method: "request_preview_tiles", + world: layer, lod: r.lod, minTx: r.minTx, minTz: r.minTz, maxTx: r.maxTx, maxTz: r.maxTz}, () => {}); + } + }; + + /** + * Cancels the tile pre-load: tells Java to drop all queued tile work for the world and + * releases the UI listeners. Progress stays where it was — ProgressTracker.cancel() has + * already flipped {@code cancelled} on, so ProgressComponent renders "This task was + * cancelled." and PreviewComponent stays in the tile-loading branch (the Map does not + * mount). The user explicitly opted out, so we don't auto-recover. + */ + cancelTilePreload = () => { + if (this._preloadingWorld) { + // No `lod` field → Java drops all in-flight tiles for the world across every LOD + // we enqueued during the pre-load. + api.send({type: "flow", method: "cancel_preview_tiles", world: this._preloadingWorld}, () => {}); + this._preloadingWorld = null; + } + if (this._preloadUnsub) { + this._preloadUnsub(); + this._preloadUnsub = null; + } + }; + cancelTask = (callback) => { api.send({type: "flow", method: "cancel"}, function (message) { if (callback) { diff --git a/app/ui/src/components/screen/settings/tab/preview/autoFit.js b/app/ui/src/components/screen/settings/tab/preview/autoFit.js new file mode 100644 index 00000000..e8c89c6a --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/autoFit.js @@ -0,0 +1,12 @@ +export const MIN_ZOOM_FLOOR = -12; +const TILE_SIZE_PX = 512; + +export function computeMinZoom(bounds) { + if (!bounds) return MIN_ZOOM_FLOOR; + const chunksX = bounds.maxX - bounds.minX + 1; + const chunksZ = bounds.maxZ - bounds.minZ + 1; + const worldPx = Math.max(chunksX, chunksZ) * 16; + if (worldPx <= 0) return MIN_ZOOM_FLOOR; + const neededLod = Math.max(0, Math.ceil(Math.log2(worldPx / TILE_SIZE_PX))); + return -Math.min(neededLod, -MIN_ZOOM_FLOOR); +} diff --git a/app/ui/src/components/screen/settings/tab/preview/autoFit.test.js b/app/ui/src/components/screen/settings/tab/preview/autoFit.test.js new file mode 100644 index 00000000..72dc90ff --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/autoFit.test.js @@ -0,0 +1,22 @@ +import {computeMinZoom} from "./autoFit"; + +describe("computeMinZoom", () => { + test("small world: ~200x200 chunks -> -3", () => { + expect(computeMinZoom({minX: 0, minZ: 0, maxX: 199, maxZ: 199})).toBe(-3); + }); + test("medium world: 1000x1000 chunks -> -5", () => { + expect(computeMinZoom({minX: 0, minZ: 0, maxX: 999, maxZ: 999})).toBe(-5); + }); + test("very large world: 10000x10000 chunks -> -9", () => { + expect(computeMinZoom({minX: 0, minZ: 0, maxX: 9999, maxZ: 9999})).toBe(-9); + }); + test("huge world: 50000x50000 chunks -> -11", () => { + expect(computeMinZoom({minX: 0, minZ: 0, maxX: 49999, maxZ: 49999})).toBe(-11); + }); + test("massive world capped at -12", () => { + expect(computeMinZoom({minX: 0, minZ: 0, maxX: 200000, maxZ: 200000})).toBe(-12); + }); + test("null bounds yields the cap", () => { + expect(computeMinZoom(null)).toBe(-12); + }); +}); diff --git a/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js b/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js new file mode 100644 index 00000000..2f8284e7 --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js @@ -0,0 +1,70 @@ +import L from "leaflet"; + +export const CenterCoordsControl = L.Control.extend({ + options: {position: "bottomleft"}, + + initialize(options) { + L.setOptions(this, options); + this._getBounds = options.getBounds; // optional () => {minX, minZ, maxX, maxZ} + }, + + onAdd(map) { + const container = L.DomUtil.create("div", "leaflet-bar chunker-coords-control"); + L.DomEvent.disableClickPropagation(container); + L.DomEvent.disableScrollPropagation(container); + + const labelX = L.DomUtil.create("span", "chunker-coords-label", container); + labelX.textContent = "X:"; + this._inputX = L.DomUtil.create("input", "chunker-coords-input", container); + this._inputX.type = "number"; + + const labelZ = L.DomUtil.create("span", "chunker-coords-label", container); + labelZ.textContent = "Z:"; + this._inputZ = L.DomUtil.create("input", "chunker-coords-input", container); + this._inputZ.type = "number"; + + this._map = map; + // `move` fires continuously during a drag, so the inputs update in real time + // rather than only when the user releases the mouse. + map.on("move", this._syncFromMap, this); + + const onCommit = () => this._commit(); + this._inputX.addEventListener("keydown", (e) => { if (e.key === "Enter") onCommit(); }); + this._inputZ.addEventListener("keydown", (e) => { if (e.key === "Enter") onCommit(); }); + this._inputX.addEventListener("blur", onCommit); + this._inputZ.addEventListener("blur", onCommit); + + this._syncFromMap(); + return container; + }, + + onRemove(map) { + map.off("move", this._syncFromMap, this); + }, + + _syncFromMap() { + if (document.activeElement === this._inputX || document.activeElement === this._inputZ) return; + const c = this._map.getCenter(); + const x = Math.round(c.lng); + const z = Math.round(-c.lat); + this._inputX.value = x; + this._inputZ.value = z; + }, + + _commit() { + const x = parseInt(this._inputX.value, 10); + const z = parseInt(this._inputZ.value, 10); + if (Number.isNaN(x) || Number.isNaN(z)) { + this._syncFromMap(); + return; + } + const bounds = this._getBounds ? this._getBounds() : null; + let clampedX = x; + let clampedZ = z; + if (bounds) { + clampedX = Math.max(bounds.minX * 16, Math.min((bounds.maxX + 1) * 16 - 1, x)); + clampedZ = Math.max(bounds.minZ * 16, Math.min((bounds.maxZ + 1) * 16 - 1, z)); + } + this._map.setView([-clampedZ, clampedX], this._map.getZoom(), {animate: false}); + } +}); diff --git a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js new file mode 100644 index 00000000..a93de1f2 --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js @@ -0,0 +1,290 @@ +import L from "leaflet"; +import {isTileEmpty} from "./mapBin"; +import {MIN_ZOOM_FLOOR} from "./autoFit"; + +const PADDING_FACTOR = 1.5; +const DISPATCH_DEBOUNCE_MS = 50; +const TRANSPARENT_PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + +export const ChunkerPreviewLayer = L.GridLayer.extend({ + initialize(options) { + L.setOptions(this, { + tileSize: 512, + noWrap: true, + // L.GridLayer's defaults hide the layer at zoom < 0. Explicitly extend the visible + // zoom range to match the map's autoFit floor; otherwise createTile is never called + // for negative zoom levels and the user sees an empty viewport on dezoom. + minZoom: MIN_ZOOM_FLOOR, + maxZoom: 5, + // Native tiles only exist at LOD 0 down to the auto-fit floor. For zoom > 0 Leaflet + // stretches LOD 0 tiles client-side; for zoom < MIN_ZOOM_FLOOR it stretches the floor tiles. + // Without these, Leaflet would call createTile for any zoom and isTileEmpty would + // be invoked with positive lod, where `1 << -lod` wraps to a huge value and hangs. + maxNativeZoom: 0, + minNativeZoom: MIN_ZOOM_FLOOR, + // Keep more off-viewport tiles in DOM so a fast pan or zoom-out doesn't reveal blank + // areas while the new tiles are still being generated. + keepBuffer: 4, + ...options + }); + this._mapBin = options.mapBin; + this._sessionId = options.session; + this._ipc = options.ipc; + this._cache = options.tileCache; + this._pending = new Map(); + this._zoomTransitionActive = false; + this._dispatchTimer = null; + this._unsubReady = this._ipc.onTileReady((e) => this._handleTileReady(e)); + this._unsubError = this._ipc.onTileError((e) => this._handleTileError(e)); + }, + + onAdd(map) { + L.GridLayer.prototype.onAdd.call(this, map); + map.on("zoomstart", this._onZoomStart, this); + map.on("zoomend", this._onZoomEnd, this); + map.on("moveend", this._onMoveEnd, this); + // Initial warm: fires once the layer has its tile range settled. setTimeout(0) defers + // past L.GridLayer.onAdd's internal _resetView so this._tileZoom and the px bounds + // are valid when we read them. + setTimeout(() => this._warmAdjacentLods(), 0); + return this; + }, + + onRemove(map) { + if (this._unsubReady) this._unsubReady(); + if (this._unsubError) this._unsubError(); + map.off("zoomstart", this._onZoomStart, this); + map.off("zoomend", this._onZoomEnd, this); + map.off("moveend", this._onMoveEnd, this); + if (this._dispatchTimer) { + clearTimeout(this._dispatchTimer); + this._dispatchTimer = null; + } + this._ipc.cancelTiles({world: this.options.identifier}); + L.GridLayer.prototype.onRemove.call(this, map); + return this; + }, + + // Keep placeholders alive while their tile_ready hasn't arrived yet — but only at the + // CURRENT zoom level. Leaflet's default _pruneTiles evicts any tile whose viewport + // rectangle is no longer current, which would orphan a pending placeholder and turn it + // into a permanent hole in the map. Protecting current-zoom pending tiles lets the + // tile_ready handler swap the real image in-place without flicker. + // + // For old-zoom pending tiles (left over from before the user zoomed), we delete the + // pending entry and let Leaflet remove them normally. Otherwise the upcoming tile_ready + // would update a placeholder that's positioned at the wrong scale, briefly flashing the + // image in the wrong place before the next prune cycle disposes of it. + _removeTile(key) { + const tile = this._tiles && this._tiles[key]; + if (tile && tile.coords) { + const cacheKey = this._cache.keyFor( + this.options.identifier, tile.coords.z, tile.coords.x, tile.coords.y + ); + if (this._pending.has(cacheKey)) { + if (tile.coords.z === this._tileZoom) { + return; + } + this._pending.delete(cacheKey); + } + } + L.GridLayer.prototype._removeTile.call(this, key); + }, + + createTile(coords, done) { + const tx = coords.x; + const tz = coords.y; + const lod = coords.z; + const cacheKey = this._cache.keyFor(this.options.identifier, lod, tx, tz); + + if (isTileEmpty(this._mapBin, this.options.identifier, lod, tx, tz)) { + const div = document.createElement("div"); + div.className = "chunker-tile chunker-tile-empty"; + setTimeout(() => done(null), 0); + return div; + } + + const cached = this._cache.get(cacheKey); + if (cached && cached.blobUrl) { + const img = new Image(); + img.src = cached.blobUrl; + img.className = "chunker-tile"; + setTimeout(() => done(null), 0); + return img; + } + + const placeholder = new Image(); + placeholder.src = TRANSPARENT_PIXEL; + placeholder.className = "chunker-tile chunker-tile-pending"; + this._pending.set(cacheKey, {tile: placeholder, done, coords: {tx, tz, lod}}); + this._scheduleDispatch(); + return placeholder; + }, + + _scheduleDispatch() { + if (this._dispatchTimer) return; + this._dispatchTimer = setTimeout(() => { + this._dispatchTimer = null; + this._dispatch(); + }, DISPATCH_DEBOUNCE_MS); + }, + + _dispatch() { + if (this._pending.size === 0) return; + const grouped = new Map(); + for (const [, p] of this._pending) { + const groupKey = `${p.coords.lod}`; + const g = grouped.get(groupKey) || { + lod: p.coords.lod, + minTx: Infinity, minTz: Infinity, + maxTx: -Infinity, maxTz: -Infinity + }; + g.minTx = Math.min(g.minTx, p.coords.tx); + g.maxTx = Math.max(g.maxTx, p.coords.tx); + g.minTz = Math.min(g.minTz, p.coords.tz); + g.maxTz = Math.max(g.maxTz, p.coords.tz); + grouped.set(groupKey, g); + } + const factor = this._zoomTransitionActive ? 0 : PADDING_FACTOR; + for (const g of grouped.values()) { + const width = g.maxTx - g.minTx + 1; + const height = g.maxTz - g.minTz + 1; + const padX = Math.floor((width * factor) / 2); + const padZ = Math.floor((height * factor) / 2); + this._ipc.requestTiles({ + world: this.options.identifier, + lod: g.lod, + minTx: g.minTx - padX, + minTz: g.minTz - padZ, + maxTx: g.maxTx + padX, + maxTz: g.maxTz + padZ + }); + } + }, + + _handleTileReady(event) { + if (event.world !== this.options.identifier) return; + const cacheKey = this._cache.keyFor(event.world, event.lod, event.tx, event.tz); + const pending = this._pending.get(cacheKey); + const resolvedUrl = event.path.startsWith("session://") + ? event.path + : `session://${this._sessionId}/preview/${event.path}`; + const layer = this; + + if (pending) { + // Update the placeholder directly. Browser fetches the PNG asynchronously and + // fires onload on the in the DOM; that's when we mark the tile loaded for + // Leaflet. Previously we routed through a separate internal Image() and only + // updated the placeholder once THAT had loaded — which created a race where the + // internal image could fail or fire out of order, leaving the live tile stuck + // on its transparent placeholder src. + const tileEl = pending.tile; + const done = pending.done; + layer._pending.delete(cacheKey); + tileEl.onload = () => { + tileEl.className = "chunker-tile"; + layer._cache.put(cacheKey, {img: tileEl, blobUrl: resolvedUrl, sizeBytes: 512 * 512 * 4}); + done(null); + }; + tileEl.onerror = () => { + tileEl.className = "chunker-tile chunker-tile-error"; + done(null); + }; + tileEl.src = resolvedUrl; + return; + } + + // No pending entry: cache for future createTile lookups, and if Leaflet still tracks + // this tile (the placeholder was created but pruned mid-load, leaving a stale entry in + // its `_tiles` registry), refresh that DOM element so the image becomes visible + // without waiting for a zoom/pan to retrigger createTile. + const cached = new Image(); + cached.className = "chunker-tile"; + cached.src = resolvedUrl; + cached.onload = () => { + layer._cache.put(cacheKey, {img: cached, blobUrl: resolvedUrl, sizeBytes: 512 * 512 * 4}); + const leafletKey = event.tx + ":" + event.tz + ":" + event.lod; + const tile = layer._tiles && layer._tiles[leafletKey]; + if (tile && tile.el) { + if (tile.el.tagName === "IMG") { + tile.el.src = resolvedUrl; + tile.el.className = "chunker-tile"; + } + if (!tile.loaded && typeof layer._tileReady === "function") { + layer._tileReady(tile.coords, null, tile.el); + } + } + }; + }, + + _handleTileError(event) { + if (event.world !== this.options.identifier) return; + const cacheKey = this._cache.keyFor(event.world, event.lod, event.tx, event.tz); + const pending = this._pending.get(cacheKey); + if (pending) { + pending.tile.className = "chunker-tile chunker-tile-error"; + this._pending.delete(cacheKey); + pending.done(null); + } + }, + + _onZoomStart() { + this._zoomTransitionActive = true; + }, + + _onZoomEnd() { + this._zoomTransitionActive = false; + }, + + _onMoveEnd() { + // moveend fires after both pan and zoom completion (Leaflet emits move events even + // during a zoom), so one handler covers both cases for the adjacent-LOD warm. + this._warmAdjacentLods(); + }, + + // Background-renders the lod ± 1 tiles for the current viewport so the next user zoom + // step paints from a warm cache instead of placeholders. Same IPC plumbing as the + // foreground dispatch — tile_ready lands in _handleTileReady's else branch, which + // populates the shared clientTileCache without disturbing any visible tile. + _warmAdjacentLods() { + if (!this._map || this._zoomTransitionActive) return; + const currentLod = this._tileZoom; + if (currentLod === undefined || currentLod === null) return; + const pxBounds = this._getTiledPixelBounds(this._map.getCenter()); + if (!pxBounds) return; + const range = this._pxBoundsToTileRange(pxBounds); + if (!range) return; + const sourceMinTx = range.min.x; + const sourceMaxTx = range.max.x; + const sourceMinTz = range.min.y; + const sourceMaxTz = range.max.y; + this._dispatchAdjacentLod(currentLod, sourceMinTx, sourceMaxTx, sourceMinTz, sourceMaxTz, currentLod + 1); + this._dispatchAdjacentLod(currentLod, sourceMinTx, sourceMaxTx, sourceMinTz, sourceMaxTz, currentLod - 1); + }, + + _dispatchAdjacentLod(sourceLod, sourceMinTx, sourceMaxTx, sourceMinTz, sourceMaxTz, targetLod) { + if (targetLod > this.options.maxNativeZoom) return; + if (targetLod < this.options.minNativeZoom) return; + // Tile coords scale with LOD: each step toward 0 (finer) doubles the tile count per + // side over the same world area; each step away from 0 (coarser) halves it. + let minTx, maxTx, minTz, maxTz; + if (targetLod > sourceLod) { + const f = 1 << (targetLod - sourceLod); + minTx = sourceMinTx * f; + maxTx = (sourceMaxTx + 1) * f - 1; + minTz = sourceMinTz * f; + maxTz = (sourceMaxTz + 1) * f - 1; + } else { + const f = 1 << (sourceLod - targetLod); + minTx = Math.floor(sourceMinTx / f); + maxTx = Math.floor(sourceMaxTx / f); + minTz = Math.floor(sourceMinTz / f); + maxTz = Math.floor(sourceMaxTz / f); + } + this._ipc.requestTiles({ + world: this.options.identifier, + lod: targetLod, + minTx, minTz, maxTx, maxTz + }); + } +}); diff --git a/app/ui/src/components/screen/settings/tab/preview/clientTileCache.js b/app/ui/src/components/screen/settings/tab/preview/clientTileCache.js new file mode 100644 index 00000000..23f08c2c --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/clientTileCache.js @@ -0,0 +1,67 @@ +const MIN_BYTES = 64 * 1024 * 1024; +const MAX_BYTES = 384 * 1024 * 1024; + +export function computeBudgetBytes() { + const deviceMemoryGb = (typeof navigator !== "undefined" && navigator.deviceMemory) || 4; + const tenPercent = deviceMemoryGb * 1024 * 1024 * 1024 * 0.10; + return Math.max(MIN_BYTES, Math.min(MAX_BYTES, tenPercent)); +} + +export class ClientTileCache { + constructor(budgetBytes = computeBudgetBytes()) { + this.budgetBytes = budgetBytes; + this.bytesInUse = 0; + this.map = new Map(); // insertion order doubles as LRU; re-insert on access + } + + keyFor(world, lod, tx, tz) { + return `${world}|${lod}|${tx}|${tz}`; + } + + get(key) { + const entry = this.map.get(key); + if (!entry) return null; + // Touch for LRU. + this.map.delete(key); + this.map.set(key, entry); + return entry; + } + + put(key, entry) { + const existing = this.map.get(key); + if (existing) { + this.bytesInUse -= existing.sizeBytes; + this.map.delete(key); + } + this.map.set(key, entry); + this.bytesInUse += entry.sizeBytes; + while (this.bytesInUse > this.budgetBytes && this.map.size > 1) { + const firstKey = this.map.keys().next().value; + this.evict(firstKey); + } + } + + evict(key) { + const entry = this.map.get(key); + if (!entry) return; + this.map.delete(key); + this.bytesInUse -= entry.sizeBytes; + if (entry.blobUrl) { + // Only revoke if it's actually a blob: URL we created. + if (typeof entry.blobUrl === "string" && entry.blobUrl.startsWith("blob:")) { + URL.revokeObjectURL(entry.blobUrl); + } + } + if (entry.img) { + entry.img.src = ""; + if (entry.img.parentNode) entry.img.parentNode.removeChild(entry.img); + } + } + + evictAllExcept(keepKeys) { + const keep = new Set(keepKeys); + for (const key of Array.from(this.map.keys())) { + if (!keep.has(key)) this.evict(key); + } + } +} diff --git a/app/ui/src/components/screen/settings/tab/preview/loadingIndicator.js b/app/ui/src/components/screen/settings/tab/preview/loadingIndicator.js new file mode 100644 index 00000000..1534f435 --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/loadingIndicator.js @@ -0,0 +1,77 @@ +import L from "leaflet"; + +/** + * Tiny "Loading tiles…" badge in the corner of the map. Reflects whether any GridLayer + * currently has outstanding tiles. We listen on the map's `layeradd` so the badge stays + * in sync as the user switches dimensions via the layer picker. + */ +export const LoadingIndicator = L.Control.extend({ + options: {position: "topleft"}, + + initialize(options) { + L.setOptions(this, options); + this._activeLoaders = 0; + }, + + onAdd(map) { + const container = L.DomUtil.create("div", "chunker-loading-indicator"); + container.textContent = "Loading tiles…"; + L.DomEvent.disableClickPropagation(container); + + this._container = container; + this._map = map; + this._refreshVisibility(); + + // Attach to any GridLayer already on the map, and to any added later. + map.eachLayer((l) => this._attachIfGridLayer(l)); + map.on("layeradd", (e) => this._attachIfGridLayer(e.layer), this); + map.on("layerremove", (e) => this._detachIfGridLayer(e.layer), this); + + return container; + }, + + onRemove(map) { + map.off("layeradd", null, this); + map.off("layerremove", null, this); + map.eachLayer((l) => this._detachIfGridLayer(l)); + }, + + _attachIfGridLayer(layer) { + if (!(layer instanceof L.GridLayer)) return; + if (layer._chunkerLoadingHooked) return; + layer._chunkerLoadingHooked = true; + + const onLoading = () => { + this._activeLoaders++; + this._refreshVisibility(); + }; + const onLoad = () => { + this._activeLoaders = Math.max(0, this._activeLoaders - 1); + this._refreshVisibility(); + }; + layer._chunkerLoadingHandlers = {onLoading, onLoad}; + layer.on("loading", onLoading); + layer.on("load", onLoad); + }, + + _detachIfGridLayer(layer) { + if (!layer || !layer._chunkerLoadingHooked) return; + const handlers = layer._chunkerLoadingHandlers; + if (handlers) { + layer.off("loading", handlers.onLoading); + layer.off("load", handlers.onLoad); + } + layer._chunkerLoadingHooked = false; + layer._chunkerLoadingHandlers = null; + // If the removed layer was mid-load, our counter would never decrement; clamp. + this._refreshVisibility(); + }, + + _refreshVisibility() { + if (this._activeLoaders > 0) { + this._container.classList.add("chunker-loading-indicator-visible"); + } else { + this._container.classList.remove("chunker-loading-indicator-visible"); + } + } +}); diff --git a/app/ui/src/components/screen/settings/tab/preview/mapBin.js b/app/ui/src/components/screen/settings/tab/preview/mapBin.js new file mode 100644 index 00000000..78c15456 --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/mapBin.js @@ -0,0 +1,66 @@ +const BEDROCK_ID_TO_IDENTIFIER = { + 0: "minecraft:overworld", + 1: "minecraft:the_nether", + 2: "minecraft:the_end" +}; + +export function parseMapBin(buffer) { + const view = new DataView(buffer); + let offset = 0; + const worldCount = view.getInt32(offset, true); offset += 4; + const worlds = new Map(); + for (let w = 0; w < worldCount; w++) { + const bedrockId = view.getInt32(offset, true); offset += 4; + const minX = view.getInt32(offset, true); offset += 4; + const minZ = view.getInt32(offset, true); offset += 4; + const maxX = view.getInt32(offset, true); offset += 4; + const maxZ = view.getInt32(offset, true); offset += 4; + const regionCount = view.getInt32(offset, true); offset += 4; + const regionPresence = new Map(); + for (let r = 0; r < regionCount; r++) { + const rx = view.getInt32(offset, true); offset += 4; + const rz = view.getInt32(offset, true); offset += 4; + // Slice once into a view backed by the same buffer — avoid the heavy buffer.slice + new ArrayBuffer + // allocation per region. The view stays valid because we hold the buffer reference via DataView. + const bits = new Uint8Array(buffer, offset, 128); + offset += 128; + regionPresence.set(`${rx},${rz}`, bits); + } + const identifier = BEDROCK_ID_TO_IDENTIFIER[bedrockId] || `custom:${bedrockId}`; + worlds.set(identifier, {bedrockId, minX, minZ, maxX, maxZ, regionPresence}); + } + return {worlds}; +} + +function regionHasAnyChunk(entry, rx, rz) { + const bits = entry.regionPresence.get(`${rx},${rz}`); + if (!bits) return false; + for (let i = 0; i < bits.length; i++) if (bits[i] !== 0) return true; + return false; +} + +export function isTileEmpty(parsed, world, lod, tx, tz) { + const entry = parsed.worlds.get(world); + if (!entry) return true; + // Only defined for lod <= 0. The native tile pyramid bottoms out at lod 0; higher zooms + // are produced by Leaflet scaling native tiles. Guarding against `lod > 0` here also + // avoids `1 << -lod` wrapping to a huge value (JS bit-shift uses low 5 bits of the operand). + if (lod > 0) return false; + const scale = 1 << (-lod); + const minRx = tx * scale; + const minRz = tz * scale; + const maxRx = minRx + scale; + const maxRz = minRz + scale; + for (let rx = minRx; rx < maxRx; rx++) { + for (let rz = minRz; rz < maxRz; rz++) { + if (regionHasAnyChunk(entry, rx, rz)) return false; + } + } + return true; +} + +export function worldBoundsForAutoFit(parsed, world) { + const entry = parsed.worlds.get(world); + if (!entry) return null; + return {minX: entry.minX, minZ: entry.minZ, maxX: entry.maxX, maxZ: entry.maxZ}; +} diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.css b/app/ui/src/components/screen/settings/tab/preview/previewComponent.css index d2da900e..3a4defdb 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.css +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.css @@ -26,4 +26,113 @@ image-rendering: crisp-edges; /* CSS4 Proposed */ image-rendering: pixelated; /* CSS4 Proposed */ -ms-interpolation-mode: nearest-neighbor; /* IE8+ */ +} + +/* Anchor the coords control to the right side of the zoom +/- buttons rather than stacking + * it above them. The bottomleft corner already contains the zoom control + the zoom indicator + * stacked vertically; absolute positioning lifts the coords box out of that stack and pins it + * next to the - button. Chained selector wins over leaflet.css's `.leaflet-control { position: relative }`. + */ +.leaflet-control.chunker-coords-control { + /* Match the zoom control's vertical position and height (53px = two 26px buttons + 1px border) + * so the coords bar visually sits as an extension of the +/- stack to its right. */ + position: absolute; + left: 44px; + bottom: 16px; + height: 35px; + box-sizing: border-box; + margin: 0; + background: rgba(255, 255, 255, 0.9); + padding: 0 6px; + display: flex; + align-items: center; + gap: 4px; + font: 12px monospace; +} +.chunker-coords-label { + color: #555; + user-select: none; +} +/* Higher specificity than the app's global `input[type="number"]` style so width/height/font stick. */ +input[type="number"].chunker-coords-input { + width: 90px; + height: 20px; + line-height: 20px; + padding: 0 3px; + margin: 0; + border: 1px solid #ccc; + border-radius: 2px; + font: 12px monospace; + box-sizing: content-box; + background: #fff; +} +input[type="number"].chunker-coords-input:focus { + outline: 1px solid #6aa84f; +} +.chunker-coords-input::-webkit-inner-spin-button, +.chunker-coords-input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +.chunker-zoom-indicator { + background: rgba(255, 255, 255, 0.9); + padding: 4px; +} +.chunker-zoom-indicator-track { + position: relative; + width: 8px; + height: 100px; + background: #eee; + border: 1px solid #bbb; + border-radius: 4px; + cursor: pointer; +} +.chunker-zoom-indicator-track:hover { + background: #f4f4f4; +} +.chunker-zoom-indicator-fill { + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: #6aa84f; + border-radius: 3px 3px 0 0; + transition: height 60ms linear; + pointer-events: none; +} +.chunker-zoom-indicator-handle { + position: absolute; + left: -4px; + right: -4px; + height: 4px; + transform: translateY(50%); + background: #2c2c2c; + border-radius: 2px; + transition: bottom 60ms linear; + pointer-events: none; +} +/* Match the existing leaflet-mouse-position control styling that lives at bottom-left, + * but anchored to top-left with Leaflet's normal corner margin. */ +.chunker-loading-indicator { + background-color: rgba(255, 255, 255, 0.7); + box-shadow: 0 0 5px #bbb; + padding: 0 5px; + color: #333; + font: 11px / 1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; + opacity: 0; + transition: opacity 120ms ease-in; + pointer-events: none; +} +.chunker-loading-indicator.chunker-loading-indicator-visible { + opacity: 1; +} +.chunker-tile-empty { + background: transparent; +} +.chunker-tile-pending { + background: rgba(0, 0, 0, 0.05); +} +.chunker-tile-error { + background: #ddd; + background-image: linear-gradient(45deg, transparent 45%, #aaa 45% 55%, transparent 55%); } \ No newline at end of file diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js index 0e0ac8bd..b19af0eb 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js @@ -1,15 +1,22 @@ import React, {Component} from "react"; -import "./previewComponent.css" -import "leaflet/dist/leaflet.css" +import "./previewComponent.css"; +import "leaflet/dist/leaflet.css"; import L from "leaflet"; import "leaflet-draw"; import {ProgressComponent} from "../../../../progress"; import "leaflet-mouse-position/src/L.Control.MousePosition.css"; import "leaflet-fullscreen/dist/leaflet.fullscreen.css"; import {getDimensionDisplayName} from "../dimensionPruningTab"; +import {worldBoundsForAutoFit} from "./mapBin"; +import {computeMinZoom} from "./autoFit"; +import {ChunkerPreviewLayer} from "./chunkerPreviewLayer"; +import {CenterCoordsControl} from "./centerCoordsControl"; +import {ZoomIndicator} from "./zoomIndicator"; +import {LoadingIndicator} from "./loadingIndicator"; +import api from "../../../../../api"; -require("leaflet-mouse-position/src/L.Control.MousePosition"); // As it adds new controls, need to be required -require("leaflet-fullscreen/dist/Leaflet.fullscreen"); // As it adds new controls, need to be required +require("leaflet-mouse-position/src/L.Control.MousePosition"); +require("leaflet-fullscreen/dist/Leaflet.fullscreen"); // Fix leaflet delete L.Icon.Default.prototype._getIconUrl; @@ -24,16 +31,33 @@ export class PreviewComponent extends Component { app = this.props.app; render() { + const settingsDone = this.app.settingsProgress.isComplete(); + const genDone = this.app.previewProgress.isComplete(); + const data = this.app.state.previewData; + const tilesDone = this.app.previewTileProgress.isComplete(); return ( - {!this.app.previewProgress.isComplete() && + {!settingsDone && +
+ +
+ } + {settingsDone && !genDone &&
} - {this.app.previewProgress.isComplete() && this.app.state.previewData !== undefined && + {/* Covers the gap between generation completing and previewData arriving from + * the map.bin fetch, as well as the actual tile pre-load phase. */} + {settingsDone && genDone && !tilesDone && +
+ +
+ } + {settingsDone && genDone && tilesDone && data !== undefined && }
@@ -46,79 +70,151 @@ export class Map extends Component { app = this.props.app; componentDidMount() { - let self = this; - // Render map + const self = this; + // this.props.data is the already-parsed map.bin (Task 14). + this._mapBin = this.props.data; + // App owns the cache so tiles pre-loaded before mount survive into the layer here. + this._cache = this.props.clientTileCache; + + const dimensions = this.app.state.settings.dimensions; + const defaultIdentifier = (this.app.state.previewState && this.app.state.previewState.layer) || dimensions[0]; + const minZoom = computeMinZoom(worldBoundsForAutoFit(this._mapBin, defaultIdentifier)); + + // Resolve the initial view BEFORE constructing the map so we can pass it to L.map(). + // Without this, the layer's addTo() runs while the map is at its default state + // (no view set) and triggers tile loads at the wrong zoom, then setView() reshuffles + // everything — that race left some tiles in flight at the old LOD that never get + // their tile_ready honoured, manifesting as "2 of 4 tiles missing on first open". + let initialCenter; + let initialZoom; + let defaultLayerIdentifier; + if (this.app.state.previewState !== undefined) { + initialCenter = this.app.state.previewState.center; + initialZoom = this.app.state.previewState.zoom; + defaultLayerIdentifier = this.app.state.previewState.layer; + } else { + defaultLayerIdentifier = dimensions[0]; + const bounds = worldBoundsForAutoFit(this._mapBin, defaultLayerIdentifier); + if (bounds) { + // Frame the map on the world bounds. Centring on SpawnX/SpawnZ at a fixed + // zoom of 2 leaves worlds with content built far from spawn opening onto an + // empty viewport — the tiles exist, just off-screen. Auto-fit puts the actual + // content in front of the user from the start. + const centerX = (bounds.minX + bounds.maxX + 1) * 8; + const centerZ = (bounds.minZ + bounds.maxZ + 1) * 8; + initialCenter = xy(centerX, centerZ); + initialZoom = computeMinZoom(bounds); + } else { + const centerX = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnX")[0].value; + const centerZ = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnZ")[0].value; + initialCenter = xy(centerX, centerZ); + initialZoom = 2; + } + } + this.mymap = L.map("map", { crs: L.CRS.Simple, - minZoom: -5, + minZoom, maxZoom: 5, - attributionControl: false + attributionControl: false, + center: initialCenter, + zoom: initialZoom }); - let worlds = this.app.state.settings.dimensions.map((a, k) => { - return L.tileLayer("session://{session}/preview/{world}.{x}.{y}.png", { - maxNativeZoom: 0, - minNativeZoom: 0, - minZoom: -5, - maxZoom: 5, - world: a.replace(":", "_"), - id: "blocks", - session: self.props.session, - tileSize: 512, - noWrap: true, - identifier: a, - index: k, - continuousWorld: true - }); - }); - - if (this.app.state.previewState === undefined) { - // Default - let defaultWorld = worlds.length > 0 ? worlds[0] : undefined; - if (defaultWorld !== undefined) { - defaultWorld.addTo(this.mymap); - let centerX = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnX")[0].value; - let centerZ = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnZ")[0].value; - - // L.marker(xy(centerX * 16, centerZ * 16)).addTo(mymap).bindPopup("Center (" + (centerX * 16) + "," + (centerZ * 16) + ")"); - this.mymap.setView(xy(centerX, centerZ), 2); + const worlds = dimensions.map((identifier, k) => new ChunkerPreviewLayer({ + id: "blocks", + identifier, + world: identifier.replace(":", "_"), + session: self.props.session, + index: k, + continuousWorld: true, + mapBin: this._mapBin, + tileCache: this._cache, + ipc: { + requestTiles: (req) => api.send({type: "flow", method: "request_preview_tiles", ...req}, () => {}), + cancelTiles: (req) => api.send({type: "flow", method: "cancel_preview_tiles", ...req}, () => {}), + onTileReady: (cb) => api.addListener("tile_ready", cb), + onTileError: (cb) => api.addListener("tile_error", cb) } - } else { - let defaultWorld = worlds.filter(a => this.app.state.previewState.layer === a.options.identifier)[0]; - defaultWorld.addTo(this.mymap); - this.mymap.setView(this.app.state.previewState.center, this.app.state.previewState.zoom, {animate: false}); - } + })); + + const defaultWorld = worlds.find(a => a.options.identifier === defaultLayerIdentifier) || worlds[0]; + if (defaultWorld) defaultWorld.addTo(this.mymap); this.renderPruningRegion(); - // Add controls - let baseMaps = {}; + const baseMaps = {}; worlds.forEach(a => { baseMaps[getDimensionDisplayName(a.options.identifier)] = a; }); - // Mouse position L.control.mousePosition({ emptyString: "0, 0 (0, 0)", formatter: this.formatCoords, wrapLng: false }).addTo(this.mymap); - // Zoom + Layers this.mymap.zoomControl.setPosition("bottomleft"); L.control.fullscreen({position: "bottomright"}).addTo(this.mymap); L.control.layers(baseMaps, null, {position: "bottomright"}).addTo(this.mymap); - // Add handler for pruning render + new CenterCoordsControl({ + getBounds: () => worldBoundsForAutoFit(this._mapBin, this._currentWorld()) + }).addTo(this.mymap); + + // Adding the zoom indicator after the (relocated) zoom control causes it to stack + // above the +/- buttons in the bottom-left corner. + new ZoomIndicator().addTo(this.mymap); + + new LoadingIndicator().addTo(this.mymap); + this.mymap.on("baselayerchange", function (e) { self.renderPruningRegion(); }); + + this.mymap.on("fullscreenchange", () => { + // Aggressive evict before the renderer holds two viewports' worth of tiles at once. + self._cache.evictAllExcept([]); + }); + + // Leaflet measures the container size at construction time and only loads tiles for + // that viewport. If the #map element wasn't fully laid out yet (mid-React-mount, or the + // preview tab was hidden when the layer was added) the initial viewport can be 0px + // wide and almost no tiles get requested. Force one recomputation now, then keep + // watching for size changes so the same fix applies when the user switches tabs and + // comes back, resizes the window, or toggles fullscreen. `_update()` is the additive + // version of `redraw()`: it requests any tiles that are currently missing from the + // viewport without disturbing the ones already on screen — no visible flicker. + const mapEl = document.getElementById("map"); + const refreshTiles = () => { + if (!this.mymap) return; + this.mymap.invalidateSize({pan: false}); + this.mymap.eachLayer((layer) => { + if (layer instanceof L.GridLayer && typeof layer._update === "function") { + layer._update(); + } + }); + }; + setTimeout(refreshTiles, 0); + setTimeout(refreshTiles, 200); + if (typeof ResizeObserver !== "undefined" && mapEl) { + this._resizeObserver = new ResizeObserver(refreshTiles); + this._resizeObserver.observe(mapEl); + } + } + + _currentWorld() { + let id; + this.mymap.eachLayer((layer) => { + if (layer.options && layer.options.identifier) id = layer.options.identifier; + }); + return id || this.app.state.settings.dimensions[0]; } formatCoords = (long, lat) => { - let x = long; - let z = -lat; - return Math.floor(x) + ", " + Math.floor(z) + " (" + Math.floor(x / 16) + ", " + Math.floor(z / 16) + ")" + const x = long; + const z = -lat; + return Math.floor(x) + ", " + Math.floor(z) + " (" + Math.floor(x / 16) + ", " + Math.floor(z / 16) + ")"; }; componentWillUnmount() { @@ -136,62 +232,58 @@ export class Map extends Component { layer: currentLayer }); } + + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + this._resizeObserver = null; + } } moveRegion = (world, regionIndex, minX, minZ, maxX, maxZ) => { this.app.setState((prevState) => { - let pruningSettingsClone = JSON.parse(JSON.stringify(prevState.pruningSettings)); - - // Add new state + const pruningSettingsClone = JSON.parse(JSON.stringify(prevState.pruningSettings)); pruningSettingsClone[world].regions[regionIndex] = { - ...pruningSettingsClone[world].regions[regionIndex], // Merge old settings + ...pruningSettingsClone[world].regions[regionIndex], minChunkX: Math.floor(Math.min(minX, maxX)), minChunkZ: Math.floor(Math.min(minZ, maxZ)), maxChunkX: Math.ceil(Math.max(minX, maxX)) - 1, maxChunkZ: Math.ceil(Math.max(minZ, maxZ)) - 1 }; - return {pruningSettings: pruningSettingsClone}; }, () => { - // Force re-render this.renderPruningRegion(); }); } - renderPruningRegion = () => { - let self = this; - // Get selected map + renderPruningRegion = () => { + const self = this; this.mymap.eachLayer(function (layer) { if (layer.options.identifier !== undefined) { self.renderWorldPruningRegion(layer.options.identifier); } if (layer.options.region) { - // Remove old polygons self.mymap.removeLayer(layer); } }); }; renderWorldPruningRegion = (world) => { - if (!(this.app.state.pruningSettings[world] && this.app.state.pruningSettings[world].regions)) return; // No regions + if (!(this.app.state.pruningSettings[world] && this.app.state.pruningSettings[world].regions)) return; - let pruningRegions = this.app.state.pruningSettings[world]; + const pruningRegions = this.app.state.pruningSettings[world]; pruningRegions.regions.forEach((region, index) => { - // Drawing the region - let minX = Math.min(region.minChunkX, region.maxChunkX) * 16; - let minZ = Math.min(region.minChunkZ, region.maxChunkZ) * 16; + const minX = Math.min(region.minChunkX, region.maxChunkX) * 16; + const minZ = Math.min(region.minChunkZ, region.maxChunkZ) * 16; - let maxX = (Math.max(region.minChunkX, region.maxChunkX) * 16) + 16; - let maxZ = (Math.max(region.minChunkZ, region.maxChunkZ) * 16) + 16; + const maxX = (Math.max(region.minChunkX, region.maxChunkX) * 16) + 16; + const maxZ = (Math.max(region.minChunkZ, region.maxChunkZ) * 16) + 16; - // Draw rectangle - let rect = L.rectangle([xy(minX, minZ), xy(minX, maxZ), xy(maxX, maxZ), xy(maxX, minZ)], { + const rect = L.rectangle([xy(minX, minZ), xy(minX, maxZ), xy(maxX, maxZ), xy(maxX, minZ)], { color: pruningRegions.include ? "green" : "red", region: true }); - // Add a label for the region if there are multiple (or if it's named) if (pruningRegions.regions.length > 1 || region.name) { rect.bindTooltip(region.name ?? ("Region " + (index + 1)), { direction: "center", @@ -199,13 +291,10 @@ export class Map extends Component { }); } - // Add event for edit - let self = this; + const self = this; rect.on("edit", function (e) { - let max = rect.getBounds().getNorthEast(); - let min = rect.getBounds().getSouthWest(); - - // Reverse lat long into co-ords + const max = rect.getBounds().getNorthEast(); + const min = rect.getBounds().getSouthWest(); self.moveRegion(world, index, min.lng / 16, -min.lat / 16, max.lng / 16, -max.lat / 16); }); rect.addTo(this.mymap); @@ -218,11 +307,9 @@ export class Map extends Component { } } -let xy = function (x, y) { - if (L.Util.isArray(x)) { // When doing xy([x, y]); +const xy = function (x, y) { + if (L.Util.isArray(x)) { return xy(x[0], x[1]); } - - // Modified specifically for this leaflet scale of 512 - return [-y, x]; // When doing xy(x, y); -}; \ No newline at end of file + return [-y, x]; +}; diff --git a/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js b/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js new file mode 100644 index 00000000..7aa5a8ef --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js @@ -0,0 +1,82 @@ +import L from "leaflet"; + +export const ZoomIndicator = L.Control.extend({ + options: {position: "bottomleft"}, + + initialize(options) { + L.setOptions(this, options); + }, + + onAdd(map) { + const container = L.DomUtil.create("div", "leaflet-bar chunker-zoom-indicator"); + L.DomEvent.disableClickPropagation(container); + L.DomEvent.disableScrollPropagation(container); + + const track = L.DomUtil.create("div", "chunker-zoom-indicator-track", container); + const fill = L.DomUtil.create("div", "chunker-zoom-indicator-fill", track); + const handle = L.DomUtil.create("div", "chunker-zoom-indicator-handle", track); + + this._map = map; + this._track = track; + this._fill = fill; + this._handle = handle; + + this._update(); + map.on("zoom zoomend", this._update, this); + + // Click on the track jumps to the zoom level at that position. + L.DomEvent.on(track, "mousedown", this._onPointerDown, this); + + return container; + }, + + onRemove(map) { + map.off("zoom zoomend", this._update, this); + L.DomEvent.off(this._track, "mousedown", this._onPointerDown, this); + }, + + _update() { + const z = this._map.getZoom(); + const minZ = this._map.getMinZoom(); + const maxZ = this._map.getMaxZoom(); + const span = maxZ - minZ; + const frac = span > 0 ? (z - minZ) / span : 0; + const pct = Math.max(0, Math.min(1, frac)) * 100; + this._fill.style.height = pct + "%"; + // Handle visually sits at the same fraction. + this._handle.style.bottom = pct + "%"; + }, + + _zoomFromClientY(clientY) { + const rect = this._track.getBoundingClientRect(); + const fromBottom = rect.bottom - clientY; + const frac = Math.max(0, Math.min(1, fromBottom / rect.height)); + const minZ = this._map.getMinZoom(); + const maxZ = this._map.getMaxZoom(); + return Math.round(minZ + frac * (maxZ - minZ)); + }, + + _onPointerDown(e) { + L.DomEvent.preventDefault(e); + L.DomEvent.stopPropagation(e); + const moveHandler = (ev) => this._onPointerMove(ev); + const upHandler = () => { + document.removeEventListener("mousemove", moveHandler); + document.removeEventListener("mouseup", upHandler); + }; + document.addEventListener("mousemove", moveHandler); + document.addEventListener("mouseup", upHandler); + this._applyClientY(e.clientY); + }, + + _onPointerMove(e) { + this._applyClientY(e.clientY); + }, + + _applyClientY(clientY) { + const target = this._zoomFromClientY(clientY); + if (target !== this._map.getZoom()) { + this._map.setZoom(target, {animate: false}); + } + } +}); diff --git a/app/ui/src/components/screen/settings/tab/previewTab.js b/app/ui/src/components/screen/settings/tab/previewTab.js index 074be7f1..9a8f1171 100644 --- a/app/ui/src/components/screen/settings/tab/previewTab.js +++ b/app/ui/src/components/screen/settings/tab/previewTab.js @@ -1,22 +1,16 @@ import React, {Component} from "react"; import {PreviewComponent} from "./preview/previewComponent"; -import {ProgressComponent} from "../../../progress"; export class PreviewTab extends Component { app = this.props.app; render() { + // PreviewComponent owns the full loading sequence (settings → generation → tile + // pre-load → Map) so the user sees a single, consistently-styled progress screen + // through all phases instead of a brief "Grabbing world information" pane in a + // different wrapper before our preview-side bars take over. return ( - - {(!this.app.settingsProgress.isComplete() && -
- -
- )} - {(this.app.settingsProgress.isComplete() && - - )} -
+ ); } } \ No newline at end of file diff --git a/cli/src/main/java/com/hivemc/chunker/cli/messenger/Messenger.java b/cli/src/main/java/com/hivemc/chunker/cli/messenger/Messenger.java index f79071e6..c3016997 100644 --- a/cli/src/main/java/com/hivemc/chunker/cli/messenger/Messenger.java +++ b/cli/src/main/java/com/hivemc/chunker/cli/messenger/Messenger.java @@ -9,13 +9,20 @@ import com.hivemc.chunker.cli.messenger.messaging.response.OutputResponse; import com.hivemc.chunker.cli.messenger.messaging.response.ProgressResponse; import com.hivemc.chunker.cli.messenger.messaging.response.ProgressStateResponse; +import com.hivemc.chunker.cli.messenger.messaging.response.TileErrorResponse; +import com.hivemc.chunker.cli.messenger.messaging.response.TileReadyResponse; import com.hivemc.chunker.conversion.WorldConverter; import com.hivemc.chunker.conversion.encoding.EncodingType; import com.hivemc.chunker.conversion.encoding.base.Converter; import com.hivemc.chunker.conversion.encoding.base.Version; import com.hivemc.chunker.conversion.encoding.base.reader.LevelReader; import com.hivemc.chunker.conversion.encoding.base.writer.LevelWriter; -import com.hivemc.chunker.conversion.encoding.preview.PreviewLevelWriter; +import com.hivemc.chunker.conversion.encoding.preview.ChunkerWorldRegionRgbaSource; +import com.hivemc.chunker.conversion.encoding.preview.PreviewMapBin; +import com.hivemc.chunker.conversion.encoding.preview.PreviewMetadataReader; +import com.hivemc.chunker.conversion.encoding.preview.PreviewTileCache; +import com.hivemc.chunker.conversion.encoding.preview.PreviewTileService; +import com.hivemc.chunker.conversion.encoding.preview.RegionRgbaSource; import com.hivemc.chunker.conversion.encoding.settings.SettingsLevelWriter; import com.hivemc.chunker.conversion.intermediate.column.biome.ChunkerBiome; import com.hivemc.chunker.conversion.intermediate.column.biome.ChunkerCustomBiome; @@ -41,12 +48,14 @@ import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; /** * Messenger handles JSON communication between ChunkerWeb and itself through System.out. */ public class Messenger { private static final Map> SESSION_ID_TO_WORLD_CONVERTERS = new Object2ObjectOpenHashMap<>(); + private static final Map SESSION_ID_TO_PREVIEW_SERVICE = new ConcurrentHashMap<>(); private static final File BIOMES_DUMMY_DIR = new File("."); private static final Gson GSON = new GsonBuilder() .registerTypeHierarchyAdapter(BasicMessage.class, new BasicMessageTypeAdapter()) @@ -130,52 +139,78 @@ public static void main(String[] args) { } case PREVIEW -> { PreviewRequest previewRequest = (PreviewRequest) message; - WorldConverter worldConverter = createWorldConverter(previewRequest.getAnonymousId(), previewRequest.getRequestId()); - boolean started = startConversionRequest( - previewRequest.getAnonymousId(), - previewRequest.getRequestId(), - worldConverter, - new File(previewRequest.getInputPath()), - new PreviewLevelWriter(new File(previewRequest.getOutputPath())) - ); + UUID anonymousId = previewRequest.getAnonymousId(); + UUID requestId = previewRequest.getRequestId(); + File inputDir = new File(previewRequest.getInputPath()); + File outputDir = new File(previewRequest.getOutputPath()); - worldConverter.setDimensionMapping(previewRequest.getInputToOutputDimension()); + // Immediately signal that we have started — moves the renderer off the queue screen. + write(new ProgressResponse(requestId, 0.0)); - // Turn the String identifiers into a Dimension based map - if (previewRequest.getPruningList() != null && previewRequest.getPruningList().getConfigs() != null && !previewRequest.getPruningList().getConfigs().isEmpty()) { - DimensionRegistry registry = worldConverter.getDimensionRegistry(); - Map pruning = previewRequest.getPruningList().getConfigs(); + // Run the metadata pass + tile service init off the messenger thread so the main loop stays responsive. + CompletableFuture.runAsync(() -> { + try { + // 1) Run the metadata pass. Choose Bedrock vs Java by what's in the world directory. + PreviewMetadataReader metadataReader = new PreviewMetadataReader(); + if (new File(inputDir, "db/CURRENT").isFile()) { + metadataReader.readBedrockWorld(inputDir, outputDir); + } else { + metadataReader.readJavaWorld(inputDir, outputDir); + } - Map pruningConfigs = new Object2ObjectOpenHashMap<>(pruning.size()); - for (String key : pruning.keySet()) { - pruningConfigs.put(registry.getByIdentifier(key), pruning.get(key)); + // Coarse progress signal halfway through. + write(new ProgressResponse(requestId, 0.5)); + + // 2) Spin up the long-lived tile service for this session. + PreviewMapBin mapBin = PreviewMapBin.read(new File(outputDir, "map.bin")); + RegionRgbaSource source = new ChunkerWorldRegionRgbaSource(inputDir); + int cacheEntries = PreviewTileCache.computeCapacityEntries(Runtime.getRuntime().maxMemory()); + int workers = Math.min(4, Math.max(1, Runtime.getRuntime().availableProcessors() / 2)); + PreviewTileService service = new PreviewTileService(outputDir, mapBin, source, new PreviewTileCache(cacheEntries), workers); + service.setEventListener(new PreviewTileService.EventListener() { + @Override public void onTileReady(TileReadyResponse r) { write(r); } + @Override public void onTileError(TileErrorResponse r) { write(r); } + }); + + // Stop and replace any pre-existing service for this session. + PreviewTileService previous = SESSION_ID_TO_PREVIEW_SERVICE.put(anonymousId, service); + if (previous != null) previous.shutdown(); + + // 3) Tell the client we're done with the metadata pass. + write(new OutputResponse(requestId, null)); + } catch (Throwable t) { + // Use Throwable: catches any unexpected runtime issue including OOMs, so we always + // surface an error to the client instead of leaving it stuck on the progress screen. + write(new ErrorResponse( + requestId, + false, + "Failed to start preview.", + null, + t.getMessage(), + printStackTrace(t) + )); } - - worldConverter.setPruningConfigs(pruningConfigs); + }); + } + case REQUEST_PREVIEW_TILES -> { + RequestPreviewTilesRequest req = (RequestPreviewTilesRequest) message; + PreviewTileService svc = SESSION_ID_TO_PREVIEW_SERVICE.get(req.getAnonymousId()); + if (svc == null) { + write(new ErrorResponse(req.getRequestId(), false, + "No active preview session.", null, null, null)); + } else { + svc.enqueueRange(req.getWorld(), req.getLod(), + req.getMinTx(), req.getMinTz(), req.getMaxTx(), req.getMaxTz()); + write(new OutputResponse(req.getRequestId(), null)); } - - // Turn off certain features for preview - worldConverter.setProcessMaps(false); - worldConverter.setProcessLighting(false); - worldConverter.setProcessHeightMap(false); - worldConverter.setProcessBlockEntities(false); - worldConverter.setProcessColumnPreTransform(false); - worldConverter.setProcessEntities(false); - worldConverter.setProcessBiomes(false); - worldConverter.setProcessItems(false); - - // Write an error if it failed to start - if (!started) { - removeWorldConverter(previewRequest.getAnonymousId(), previewRequest.getRequestId()); - write(new ErrorResponse( - previewRequest.getRequestId(), - false, - "Failed to start preview.", - null, - null, - null - )); + } + case CANCEL_PREVIEW_TILES -> { + CancelPreviewTilesRequest req = (CancelPreviewTilesRequest) message; + PreviewTileService svc = SESSION_ID_TO_PREVIEW_SERVICE.get(req.getAnonymousId()); + if (svc != null) { + svc.cancel(req.getWorld(), req.getLod()); } + write(new OutputResponse(req.getRequestId(), null)); } case CONVERT -> { ConvertRequest convertRequest = (ConvertRequest) message; @@ -368,6 +403,10 @@ public static void main(String[] args) { case KILL -> { KillRequest killRequest = (KillRequest) message; + // Shutdown any preview tile service for this session. + PreviewTileService previewService = SESSION_ID_TO_PREVIEW_SERVICE.remove(killRequest.getAnonymousId()); + if (previewService != null) previewService.shutdown(); + // Loop through all the converters under this anonymous ID and cancel them Map converters = SESSION_ID_TO_WORLD_CONVERTERS.get(killRequest.getAnonymousId()); if (converters != null) { diff --git a/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/BasicMessage.java b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/BasicMessage.java index 9d4e47b8..4ad7d261 100644 --- a/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/BasicMessage.java +++ b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/BasicMessage.java @@ -6,6 +6,8 @@ import com.hivemc.chunker.cli.messenger.messaging.response.OutputResponse; import com.hivemc.chunker.cli.messenger.messaging.response.ProgressResponse; import com.hivemc.chunker.cli.messenger.messaging.response.ProgressStateResponse; +import com.hivemc.chunker.cli.messenger.messaging.response.TileErrorResponse; +import com.hivemc.chunker.cli.messenger.messaging.response.TileReadyResponse; import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import java.util.Map; @@ -69,6 +71,10 @@ public enum BasicMessageType { CONVERT(ConvertRequest.class), @SerializedName("kill") KILL(KillRequest.class), + @SerializedName("request_preview_tiles") + REQUEST_PREVIEW_TILES(RequestPreviewTilesRequest.class), + @SerializedName("cancel_preview_tiles") + CANCEL_PREVIEW_TILES(CancelPreviewTilesRequest.class), @SerializedName("biomes") BIOMES(BiomesRequest.class), @@ -80,7 +86,11 @@ public enum BasicMessageType { @SerializedName("progress_state") PROGRESS_STATE(ProgressStateResponse.class), @SerializedName("error") - ERROR(ErrorResponse.class); + ERROR(ErrorResponse.class), + @SerializedName("tile_ready") + TILE_READY(TileReadyResponse.class), + @SerializedName("tile_error") + TILE_ERROR(TileErrorResponse.class); private static final Map, BasicMessageType> CLASS_TO_MESSAGE = new Object2ObjectOpenHashMap<>(); diff --git a/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/CancelPreviewTilesRequest.java b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/CancelPreviewTilesRequest.java new file mode 100644 index 00000000..b9c5c7d9 --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/CancelPreviewTilesRequest.java @@ -0,0 +1,72 @@ +package com.hivemc.chunker.cli.messenger.messaging.request; + +import com.hivemc.chunker.cli.messenger.messaging.BasicMessage; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; + +/** + * Request to cancel preview tile generation for a world (optionally filtered by LOD). + */ +public class CancelPreviewTilesRequest extends BasicMessage { + private final UUID anonymousId; + private final String world; + @Nullable + private final Integer lod; + + /** + * Create a new request to cancel preview tiles. + * + * @param anonymousId the session ID for the user. + * @param world the name of the world. + * @param lod the level of detail, or null to cancel all LODs. + */ + public CancelPreviewTilesRequest(UUID anonymousId, String world, @Nullable Integer lod) { + this.anonymousId = anonymousId; + this.world = world; + this.lod = lod; + } + + /** + * Create a new request to cancel preview tiles. + * + * @param requestId the request ID to use for this request. + * @param anonymousId the session ID for the user. + * @param world the name of the world. + * @param lod the level of detail, or null to cancel all LODs. + */ + public CancelPreviewTilesRequest(UUID requestId, UUID anonymousId, String world, @Nullable Integer lod) { + super(requestId); + this.anonymousId = anonymousId; + this.world = world; + this.lod = lod; + } + + /** + * The session ID used by the user. + * + * @return a random UUID used by the user. + */ + public UUID getAnonymousId() { + return anonymousId; + } + + /** + * The name of the world. + * + * @return the world name. + */ + public String getWorld() { + return world; + } + + /** + * The level of detail. + * + * @return the LOD, or null to cancel all LODs. + */ + @Nullable + public Integer getLod() { + return lod; + } +} diff --git a/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/RequestPreviewTilesRequest.java b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/RequestPreviewTilesRequest.java new file mode 100644 index 00000000..6d485465 --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/RequestPreviewTilesRequest.java @@ -0,0 +1,126 @@ +package com.hivemc.chunker.cli.messenger.messaging.request; + +import com.hivemc.chunker.cli.messenger.messaging.BasicMessage; + +import java.util.UUID; + +/** + * Request indicating that the client wants a range of preview tiles to be generated + * for the given world at the given LOD. + */ +public class RequestPreviewTilesRequest extends BasicMessage { + private final UUID anonymousId; + private final String world; + private final int lod; + private final int minTx; + private final int minTz; + private final int maxTx; + private final int maxTz; + + /** + * Create a new request for preview tiles. + * + * @param anonymousId the session ID for the user. + * @param world the name of the world. + * @param lod the level of detail. + * @param minTx the minimum tile X coordinate. + * @param minTz the minimum tile Z coordinate. + * @param maxTx the maximum tile X coordinate. + * @param maxTz the maximum tile Z coordinate. + */ + public RequestPreviewTilesRequest(UUID anonymousId, String world, int lod, int minTx, int minTz, int maxTx, int maxTz) { + this.anonymousId = anonymousId; + this.world = world; + this.lod = lod; + this.minTx = minTx; + this.minTz = minTz; + this.maxTx = maxTx; + this.maxTz = maxTz; + } + + /** + * Create a new request for preview tiles. + * + * @param requestId the request ID to use for this request. + * @param anonymousId the session ID for the user. + * @param world the name of the world. + * @param lod the level of detail. + * @param minTx the minimum tile X coordinate. + * @param minTz the minimum tile Z coordinate. + * @param maxTx the maximum tile X coordinate. + * @param maxTz the maximum tile Z coordinate. + */ + public RequestPreviewTilesRequest(UUID requestId, UUID anonymousId, String world, int lod, int minTx, int minTz, int maxTx, int maxTz) { + super(requestId); + this.anonymousId = anonymousId; + this.world = world; + this.lod = lod; + this.minTx = minTx; + this.minTz = minTz; + this.maxTx = maxTx; + this.maxTz = maxTz; + } + + /** + * The session ID used by the user. + * + * @return a random UUID used by the user. + */ + public UUID getAnonymousId() { + return anonymousId; + } + + /** + * The name of the world. + * + * @return the world name. + */ + public String getWorld() { + return world; + } + + /** + * The level of detail. + * + * @return the LOD. + */ + public int getLod() { + return lod; + } + + /** + * The minimum tile X coordinate. + * + * @return the minimum tile X. + */ + public int getMinTx() { + return minTx; + } + + /** + * The minimum tile Z coordinate. + * + * @return the minimum tile Z. + */ + public int getMinTz() { + return minTz; + } + + /** + * The maximum tile X coordinate. + * + * @return the maximum tile X. + */ + public int getMaxTx() { + return maxTx; + } + + /** + * The maximum tile Z coordinate. + * + * @return the maximum tile Z. + */ + public int getMaxTz() { + return maxTz; + } +} diff --git a/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileErrorResponse.java b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileErrorResponse.java new file mode 100644 index 00000000..5627910a --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileErrorResponse.java @@ -0,0 +1,32 @@ +package com.hivemc.chunker.cli.messenger.messaging.response; + +import com.hivemc.chunker.cli.messenger.messaging.BasicMessage; + +import java.util.UUID; + +/** + * Push event indicating that a preview tile failed to generate; the client should treat + * the tile as gray-placeholder and not retry. + */ +public final class TileErrorResponse extends BasicMessage { + private final String world; + private final int lod; + private final int tx; + private final int tz; + private final String reason; + + public TileErrorResponse(UUID requestId, String world, int lod, int tx, int tz, String reason) { + super(requestId); + this.world = world; + this.lod = lod; + this.tx = tx; + this.tz = tz; + this.reason = reason; + } + + public String world() { return world; } + public int lod() { return lod; } + public int tx() { return tx; } + public int tz() { return tz; } + public String reason() { return reason; } +} diff --git a/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileReadyResponse.java b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileReadyResponse.java new file mode 100644 index 00000000..f3a1254f --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileReadyResponse.java @@ -0,0 +1,31 @@ +package com.hivemc.chunker.cli.messenger.messaging.response; + +import com.hivemc.chunker.cli.messenger.messaging.BasicMessage; + +import java.util.UUID; + +/** + * Push event indicating that a preview tile PNG is on disk and ready to be loaded by the client. + */ +public final class TileReadyResponse extends BasicMessage { + private final String world; + private final int lod; + private final int tx; + private final int tz; + private final String path; + + public TileReadyResponse(UUID requestId, String world, int lod, int tx, int tz, String path) { + super(requestId); + this.world = world; + this.lod = lod; + this.tx = tx; + this.tz = tz; + this.path = path; + } + + public String world() { return world; } + public int lod() { return lod; } + public int tx() { return tx; } + public int tz() { return tz; } + public String path() { return path; } +} diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/EncodingType.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/EncodingType.java index 74fc2df8..97dd6365 100644 --- a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/EncodingType.java +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/EncodingType.java @@ -6,7 +6,6 @@ import com.hivemc.chunker.conversion.encoding.base.writer.LevelWriter; import com.hivemc.chunker.conversion.encoding.bedrock.BedrockEncoders; import com.hivemc.chunker.conversion.encoding.java.JavaEncoders; -import com.hivemc.chunker.conversion.encoding.preview.PreviewLevelWriter; import com.hivemc.chunker.conversion.encoding.settings.SettingsLevelWriter; import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet; @@ -54,7 +53,7 @@ public class EncodingType { "Preview", true, null, - (directory, version, settings) -> Optional.of(new PreviewLevelWriter(directory)), + (directory, version, settings) -> Optional.empty(), Collections.emptyList() ); diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSource.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSource.java new file mode 100644 index 00000000..bd33575e --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSource.java @@ -0,0 +1,136 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import com.hivemc.chunker.conversion.WorldConverter; +import com.hivemc.chunker.conversion.encoding.EncodingType; +import com.hivemc.chunker.conversion.encoding.base.reader.LevelReader; +import com.hivemc.chunker.conversion.intermediate.world.Dimension; +import com.hivemc.chunker.pruning.PruningConfig; +import com.hivemc.chunker.pruning.PruningRegion; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Production {@link RegionRgbaSource} that drives a fresh {@link WorldConverter} for each region + * request, constraining conversion to a single Minecraft region via {@link PruningConfig}. + * + *

All non-block-data processing is disabled so each call is as fast as possible. The result is + * captured by {@link RegionRgbaCapturingWriter} and returned as an {@code int[262144]} ARGB array + * (row-major, 512 stride), or {@code null} when no chunks are present in the region. + */ +public class ChunkerWorldRegionRgbaSource implements RegionRgbaSource { + + /** Maximum time to wait for a single region conversion before giving up. */ + private static final long TIMEOUT_SECONDS = 30L; + + private final File worldDir; + + /** + * Guards concurrent {@link #loadRegion} calls. Bedrock worlds back onto a single LevelDB + * instance whose file lock (db/LOCK) the JVM refuses to acquire twice via + * {@link java.nio.channels.OverlappingFileLockException}; without this mutex two preview + * workers entering loadRegion concurrently both spin up a fresh BedrockLevelReader and the + * second one fails immediately. Anvil worlds are unaffected by the lock contention but the + * serialization cost there is negligible because the heavy work (downsampling, PNG encoding) + * runs on the calling worker outside this method. + */ + private final Object regionLoadLock = new Object(); + + /** + * Create a new source backed by the given world directory. + * + * @param worldDir the root folder of the Minecraft world. + */ + public ChunkerWorldRegionRgbaSource(File worldDir) { + this.worldDir = worldDir; + } + + @Override + public int[] loadRegion(String world, int rx, int rz) throws IOException { + synchronized (regionLoadLock) { + return loadRegionLocked(world, rx, rz); + } + } + + private int[] loadRegionLocked(String world, int rx, int rz) throws IOException { + File dbDir = new File(worldDir, "db"); + if (dbDir.isDirectory()) { + // Bedrock: every region opens the world's LevelDB afresh (the converter frees — and so + // closes — the reader after each region). That open can transiently fail to acquire + // db/LOCK, e.g. when the previous region's handle hasn't been released yet, so retry + // briefly rather than dropping the tile. + return PreviewLevelDb.withLockRetry(dbDir, () -> readRegion(world, rx, rz)); + } + return readRegion(world, rx, rz); + } + + private int[] readRegion(String world, int rx, int rz) throws IOException { + // Compute the chunk range covered by this region + int minCx = rx * 32; + int minCz = rz * 32; + int maxCx = minCx + 31; + int maxCz = minCz + 31; + + // Fresh converter with a random session id — no persistent state needed + WorldConverter worldConverter = new WorldConverter(UUID.randomUUID()); + + // Resolve the requested dimension + Dimension targetDimension = worldConverter.getDimensionRegistry().getByIdentifier(world); + if (targetDimension == null) { + return null; + } + + // Constrain conversion to the single region + worldConverter.setPruningConfigs(Map.of( + targetDimension, + new PruningConfig(true, List.of(new PruningRegion(minCx, minCz, maxCx, maxCz))) + )); + + // Disable all processing that isn't needed for block-colour extraction + worldConverter.setProcessMaps(false); + worldConverter.setProcessLighting(false); + worldConverter.setProcessHeightMap(false); + worldConverter.setProcessBlockEntities(false); + worldConverter.setProcessColumnPreTransform(false); + worldConverter.setProcessEntities(false); + worldConverter.setProcessBiomes(false); + worldConverter.setProcessItems(false); + + // Destination pixel buffer + int[] dst = new int[262144]; + RegionRgbaCapturingWriter writer = new RegionRgbaCapturingWriter(rx, rz, dst); + + // Attempt to detect a reader for this world directory + Optional readerOpt = EncodingType.findReader(worldDir, worldConverter); + if (readerOpt.isEmpty()) { + return null; + } + + // Drive the conversion synchronously with a timeout + try { + worldConverter.convert(readerOpt.get(), writer) + .future() + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (TimeoutException e) { + throw new IOException("Timed out reading region (" + rx + ", " + rz + ") from " + worldDir, e); + } catch (Exception e) { + // Unwrap and re-throw as IOException so callers have a stable exception contract + Throwable cause = e.getCause() != null ? e.getCause() : e; + throw new IOException("Failed to read region (" + rx + ", " + rz + ") from " + worldDir + ": " + cause.getMessage(), cause); + } + + // Return null when no block data was written (all pixels are transparent/zero) + for (int pixel : dst) { + if (pixel != 0) { + return dst; + } + } + return null; + } +} diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewColumnWriter.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewColumnWriter.java deleted file mode 100644 index b54dda8f..00000000 --- a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewColumnWriter.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.hivemc.chunker.conversion.encoding.preview; - -import com.google.common.collect.Sets; -import com.hivemc.chunker.conversion.encoding.base.writer.ColumnWriter; -import com.hivemc.chunker.conversion.intermediate.column.ChunkerColumn; -import com.hivemc.chunker.conversion.intermediate.column.chunk.ChunkCoordPair; -import com.hivemc.chunker.conversion.intermediate.column.chunk.RegionCoordPair; -import com.hivemc.chunker.conversion.intermediate.column.chunk.identifier.ChunkerBlockIdentifier; -import it.unimi.dsi.fastutil.Pair; - -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.File; -import java.io.IOException; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; - -/** - * Write all the regions as images based on the block colors. - */ -public class PreviewColumnWriter implements ColumnWriter { - private final File outputFolder; - private final ConcurrentMap> chunkRGBA = new ConcurrentHashMap<>(); - private final PreviewWorldWriter.WorldData worldData; - - /** - * Create a new preview column writer. - * - * @param outputFolder the folder where the images should be written. - * @param worldData the world data to add present chunks to. - */ - public PreviewColumnWriter(File outputFolder, PreviewWorldWriter.WorldData worldData) { - this.outputFolder = outputFolder; - this.worldData = worldData; - } - - @Override - public void writeColumn(ChunkerColumn chunkerColumn) { - int[] argb = new int[256]; - boolean present = false; - - // Loop through each column to calculate color - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - // Find the highest block that has RGB color - Pair block = chunkerColumn.getHighestBlock(x, z, ChunkerBlockIdentifier::hasRGBColor); - if (block != null) { - // Mark the chunk as present - present = true; - - // Grab the color - int rgb = block.value().getRGBColor(); - - // Convert to ARGB - argb[(z << 4) | x] = rgb == 0 ? 0 : 0xFF000000 | rgb; - } - } - } - - RegionCoordPair regionCoordPair = chunkerColumn.getPosition().getRegion(); - ConcurrentMap regionRGBA = chunkRGBA.computeIfAbsent(regionCoordPair, (ignored) -> new ConcurrentHashMap<>()); - if (present) { - // Add the RGB - regionRGBA.put(chunkerColumn.getPosition(), argb); - - // Record the chunk being present in this region - Set chunks = worldData.regionToPresentChunks.computeIfAbsent(regionCoordPair, (ignored) -> Sets.newConcurrentHashSet()); - chunks.add(chunkerColumn.getPosition()); - } - } - - @Override - public void flushColumns() { - // Calculate min & max for the world - for (Set regionChunks : worldData.regionToPresentChunks.values()) { - for (ChunkCoordPair chunk : regionChunks) { - if (chunk.chunkX() < worldData.minX) { - worldData.minX = chunk.chunkX(); - } - - if (chunk.chunkX() > worldData.maxX) { - worldData.maxX = chunk.chunkX(); - } - - if (chunk.chunkZ() < worldData.minZ) { - worldData.minZ = chunk.chunkZ(); - } - - if (chunk.chunkZ() > worldData.maxZ) { - worldData.maxZ = chunk.chunkZ(); - } - } - } - - // Ensure output is a directory - outputFolder.mkdirs(); - - // Create the images for each region and write them - for (Map.Entry> entry : chunkRGBA.entrySet()) { - RegionCoordPair region = entry.getKey(); - - BufferedImage image = new BufferedImage(512, 512, BufferedImage.TYPE_INT_ARGB); - for (Map.Entry chunk : entry.getValue().entrySet()) { - // If the chunk isn't present, we don't need to write any data (as it should be transparent) - if (chunk.getValue().length == 0) continue; - - // Copy pixels - image.setRGB( - ((chunk.getKey().chunkX() & 31) << 4), // Place our chunk inside the region (512x512) - ((chunk.getKey().chunkZ() & 31) << 4), - 16, // Each chunk is 16x16 - 16, - chunk.getValue(), // ARGB array - 0, // Starts from the initial value - 16 // Size of each Y - ); - } - - String name = worldData.dimension.getIdentifier().replace(":", "_"); - // Write the region PNG - File outputFile = new File(outputFolder, name + "." + region.regionX() + "." + region.regionZ() + ".png"); - try { - ImageIO.write(image, "png", outputFile); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - } -} diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelDb.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelDb.java new file mode 100644 index 00000000..f108579d --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelDb.java @@ -0,0 +1,76 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import java.io.File; +import java.io.IOException; +import java.nio.channels.OverlappingFileLockException; +import java.util.Locale; +import java.util.concurrent.Callable; + +/** + * Helpers for opening a Bedrock world's LevelDB defensively while generating preview data. + * + *

iq80 LevelDB guards a database directory with a single {@code db/LOCK} file and refuses to + * open it twice ({@link OverlappingFileLockException} / "Unable to acquire lock"). During preview + * generation the database is opened repeatedly — once for the metadata pass and again for every + * region tile — so a stale LOCK left by a crashed run, or the brief window where the OS has not yet + * released the handle from the previously closed reader, can make an open fail. Clearing the stale + * LOCK and retrying lets the preview survive that transient contention instead of failing the pass. + */ +final class PreviewLevelDb { + /** Number of times to attempt the action before giving up. */ + private static final int MAX_ATTEMPTS = 4; + /** Delay between attempts; long enough for the OS to release a just-closed file handle. */ + private static final long RETRY_DELAY_MS = 120L; + + private PreviewLevelDb() { + } + + /** + * Run an action that opens / reads the LevelDB at {@code dbDir}, clearing a stale {@code LOCK} + * before each attempt and retrying when the failure is a lock-acquisition problem. + * + * @param dbDir the {@code db} directory of the world. + * @param action the work to perform (typically opening the database). + * @param the result type. + * @return the action's result. + * @throws IOException if the action keeps failing or fails for a non-lock reason. + */ + static T withLockRetry(File dbDir, Callable action) throws IOException { + Throwable last = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + // Clear a LOCK left behind by a crashed reader (or one still being released) before trying. + new File(dbDir, "LOCK").delete(); + try { + return action.call(); + } catch (Throwable t) { + last = t; + + // Only a lock-acquisition failure is worth retrying; anything else (corrupt data, a + // read timeout, ...) won't be fixed by waiting, so fail fast. + if (attempt == MAX_ATTEMPTS || !isLockFailure(t)) break; + + try { + Thread.sleep(RETRY_DELAY_MS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + break; + } + } + } + + if (last instanceof IOException io) throw io; + throw new IOException("Failed to access LevelDB at " + dbDir, last); + } + + /** + * @return true if the throwable (or one of its causes) is an iq80 LevelDB lock-acquisition error. + */ + static boolean isLockFailure(Throwable throwable) { + for (Throwable cause = throwable; cause != null; cause = cause.getCause()) { + if (cause instanceof OverlappingFileLockException) return true; + String message = cause.getMessage(); + if (message != null && message.toLowerCase(Locale.ROOT).contains("acquire lock")) return true; + } + return false; + } +} diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelWriter.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelWriter.java deleted file mode 100644 index d19dd444..00000000 --- a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelWriter.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.hivemc.chunker.conversion.encoding.preview; - -import com.hivemc.chunker.conversion.encoding.EncodingType; -import com.hivemc.chunker.conversion.encoding.base.Version; -import com.hivemc.chunker.conversion.encoding.base.writer.LevelWriter; -import com.hivemc.chunker.conversion.encoding.base.writer.WorldWriter; -import com.hivemc.chunker.conversion.intermediate.column.biome.ChunkerBiome; -import com.hivemc.chunker.conversion.intermediate.level.ChunkerLevel; - -import java.io.File; -import java.util.Set; - -/** - * PreviewLevelWriter is a level writer that outputs a folder with preview images / a binary of present chunks. - */ -public class PreviewLevelWriter implements LevelWriter { - private final File outputFolder; - - /** - * Create a new preview level writer. - * - * @param outputFolder the output folder to write the data to. - */ - public PreviewLevelWriter(File outputFolder) { - this.outputFolder = outputFolder; - } - - @Override - public WorldWriter writeLevel(ChunkerLevel chunkerLevel) { - // Create a new world writer with the created worldData - return new PreviewWorldWriter(outputFolder); - } - - @Override - public EncodingType getEncodingType() { - return EncodingType.PREVIEW; - } - - @Override - public Version getVersion() { - return new Version(1, 0, 0); - } - - @Override - public Set getSupportedBiomes() { - return Set.of(); // Biomes aren't used in the PreviewLevelWriter - } -} diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBin.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBin.java new file mode 100644 index 00000000..7e8e4222 --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBin.java @@ -0,0 +1,172 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import com.hivemc.chunker.nbt.io.Writer; + +import java.io.*; +import java.util.*; + +/** + * Reads and writes the map.bin metadata blob in the format produced by the legacy + * PreviewWorldWriter, and answers empty-tile queries used by the lazy tile service + * to skip generation of tiles that cannot contain any block data. + */ +public final class PreviewMapBin { + private final List worlds; + + private PreviewMapBin(List worlds) { + this.worlds = worlds; + } + + public WorldData findByIdentifier(String identifier) { + for (WorldData w : worlds) { + if (w.identifier.equals(identifier)) return w; + } + return null; + } + + /** + * A tile is empty when every region it covers at LOD 0 has zero present chunks. + * For lod == 0 each tile maps to one region. For lod < 0 a tile covers 2^|lod| x 2^|lod| regions. + * Positive lod is undefined here (the native pyramid bottoms out at LOD 0); always returns + * false in that case so the caller does not skip a tile the client may legitimately request. + */ + public boolean isTileEmpty(String world, int lod, int tx, int tz) { + WorldData w = findByIdentifier(world); + if (w == null) return true; + // Guard against positive lod: Java bit-shift uses the low 5 bits of the right operand, + // so `1 << -lod` wraps to a huge value and the loop below would never terminate. + if (lod > 0) return false; + int scale = 1 << (-lod); + int minRx = tx * scale; + int minRz = tz * scale; + int maxRx = minRx + scale; // exclusive + int maxRz = minRz + scale; + for (int rx = minRx; rx < maxRx; rx++) { + for (int rz = minRz; rz < maxRz; rz++) { + if (w.regionHasAnyChunk(rx, rz)) return false; + } + } + return true; + } + + public static PreviewMapBin read(File file) throws IOException { + try (FileInputStream fis = new FileInputStream(file); + BufferedInputStream bis = new BufferedInputStream(fis); + DataInputStream dis = new DataInputStream(bis)) { + int worldCount = Integer.reverseBytes(dis.readInt()); + List worlds = new ArrayList<>(worldCount); + for (int i = 0; i < worldCount; i++) { + int bedrockId = Integer.reverseBytes(dis.readInt()); + int minX = Integer.reverseBytes(dis.readInt()); + int minZ = Integer.reverseBytes(dis.readInt()); + int maxX = Integer.reverseBytes(dis.readInt()); + int maxZ = Integer.reverseBytes(dis.readInt()); + int regionCount = Integer.reverseBytes(dis.readInt()); + Map regionToChunks = new HashMap<>(regionCount * 2); + for (int r = 0; r < regionCount; r++) { + int rx = Integer.reverseBytes(dis.readInt()); + int rz = Integer.reverseBytes(dis.readInt()); + byte[] bitset = dis.readNBytes(128); + regionToChunks.put(packRegion(rx, rz), BitSet.valueOf(bitset)); + } + worlds.add(new WorldData(bedrockId, identifierForBedrockId(bedrockId), minX, minZ, maxX, maxZ, regionToChunks)); + } + return new PreviewMapBin(worlds); + } + } + + private static String identifierForBedrockId(int id) { + return switch (id) { + case 0 -> "minecraft:overworld"; + case 1 -> "minecraft:the_nether"; + case 2 -> "minecraft:the_end"; + default -> "custom:" + id; + }; + } + + private static long packRegion(int rx, int rz) { + return ((long) rx << 32) | (rz & 0xFFFFFFFFL); + } + + /** + * Builder mirrors the layout of the legacy PreviewWorldWriter for byte-for-byte compatibility. + */ + public static final class Builder { + private final List worlds = new ArrayList<>(); + + public Builder addWorld(int bedrockId, String identifier, int minX, int minZ, int maxX, int maxZ) { + worlds.add(new WorldData(bedrockId, identifier, minX, minZ, maxX, maxZ, new HashMap<>())); + return this; + } + + public Builder addRegion(int worldIndex, int rx, int rz, BitSet present) { + worlds.get(worldIndex).regionToChunks.put(packRegion(rx, rz), present); + return this; + } + + public int size() { + return worlds.size(); + } + + public Builder setBoundsForWorld(int worldIndex, int minX, int minZ, int maxX, int maxZ) { + WorldData old = worlds.get(worldIndex); + WorldData updated = new WorldData(old.bedrockId, old.identifier, minX, minZ, maxX, maxZ, old.regionToChunks); + worlds.set(worldIndex, updated); + return this; + } + + public void writeTo(File file) throws IOException { + try (FileOutputStream fos = new FileOutputStream(file); + BufferedOutputStream bos = new BufferedOutputStream(fos); + DataOutputStream dos = new DataOutputStream(bos)) { + Writer writer = Writer.toLittleEndianWriter(dos); + writer.writeInt(worlds.size()); + for (WorldData w : worlds) { + writer.writeInt(w.bedrockId); + writer.writeInt(w.minX); + writer.writeInt(w.minZ); + writer.writeInt(w.maxX); + writer.writeInt(w.maxZ); + writer.writeInt(w.regionToChunks.size()); + for (Map.Entry e : w.regionToChunks.entrySet()) { + long packed = e.getKey(); + int rx = (int) (packed >> 32); + int rz = (int) packed; + writer.writeInt(rx); + writer.writeInt(rz); + byte[] bits = e.getValue().toByteArray(); + writer.writeBytes(bits); + for (int i = bits.length; i < 128; i++) writer.writeByte(0); + } + } + } + } + } + + public static final class WorldData { + public final int bedrockId; + public final String identifier; + private final int minX, minZ, maxX, maxZ; + private final Map regionToChunks; + + WorldData(int bedrockId, String identifier, int minX, int minZ, int maxX, int maxZ, Map regionToChunks) { + this.bedrockId = bedrockId; + this.identifier = identifier; + this.minX = minX; + this.minZ = minZ; + this.maxX = maxX; + this.maxZ = maxZ; + this.regionToChunks = regionToChunks; + } + + public int minX() { return minX; } + public int minZ() { return minZ; } + public int maxX() { return maxX; } + public int maxZ() { return maxZ; } + + public boolean regionHasAnyChunk(int rx, int rz) { + BitSet b = regionToChunks.get(packRegion(rx, rz)); + return b != null && !b.isEmpty(); + } + } +} diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReader.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReader.java new file mode 100644 index 00000000..3817a547 --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReader.java @@ -0,0 +1,230 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import org.iq80.leveldb.*; +import org.iq80.leveldb.impl.Iq80DBFactory; +import org.iq80.leveldb.table.BloomFilterPolicy; + +import java.io.*; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Reads only the chunk-location headers of a Minecraft world's region files to determine + * which chunks exist, without decompressing any chunk data. Output matches the byte layout + * produced by the legacy PreviewWorldWriter. + */ +public final class PreviewMetadataReader { + private static final Pattern REGION_FILE = Pattern.compile("^r\\.(-?\\d+)\\.(-?\\d+)\\.mca$"); + + public void readJavaWorld(File worldDir, File outputFolder) throws IOException { + if (!outputFolder.exists() && !outputFolder.mkdirs()) { + throw new IOException("Could not create preview output folder: " + outputFolder); + } + + PreviewMapBin.Builder builder = new PreviewMapBin.Builder(); + + // Overworld: /region + scanDimensionInto(builder, 0, "minecraft:overworld", new File(worldDir, "region")); + + // Nether: /DIM-1/region + File nether = new File(worldDir, "DIM-1/region"); + if (nether.isDirectory()) { + scanDimensionInto(builder, 1, "minecraft:the_nether", nether); + } + + // End: /DIM1/region + File end = new File(worldDir, "DIM1/region"); + if (end.isDirectory()) { + scanDimensionInto(builder, 2, "minecraft:the_end", end); + } + + builder.writeTo(new File(outputFolder, "map.bin")); + } + + private void scanDimensionInto(PreviewMapBin.Builder builder, int bedrockId, String identifier, File regionDir) { + int worldIndex = builder.size(); + builder.addWorld(bedrockId, identifier, 0, 0, 0, 0); + if (!regionDir.isDirectory()) return; + + File[] files = regionDir.listFiles(); + if (files == null) return; + + int minX = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE; + + for (File f : files) { + Matcher m = REGION_FILE.matcher(f.getName()); + if (!m.matches()) continue; + int rx, rz; + try { + rx = Integer.parseInt(m.group(1)); + rz = Integer.parseInt(m.group(2)); + } catch (NumberFormatException ignored) { + continue; + } + BitSet present = readRegionPresence(f); + if (present == null) continue; // corrupt or unreadable; skip + if (present.isEmpty()) continue; + builder.addRegion(worldIndex, rx, rz, present); + + // Update bounds in chunk coordinates from the present chunks of this region. + for (int bit = present.nextSetBit(0); bit >= 0; bit = present.nextSetBit(bit + 1)) { + int localX = bit & 31; + int localZ = (bit >> 5) & 31; + int cx = (rx << 5) | localX; + int cz = (rz << 5) | localZ; + if (cx < minX) minX = cx; + if (cz < minZ) minZ = cz; + if (cx > maxX) maxX = cx; + if (cz > maxZ) maxZ = cz; + } + } + + if (minX != Integer.MAX_VALUE) { + builder.setBoundsForWorld(worldIndex, minX, minZ, maxX, maxZ); + } + } + + private BitSet readRegionPresence(File regionFile) { + try (FileInputStream fis = new FileInputStream(regionFile); + DataInputStream dis = new DataInputStream(new BufferedInputStream(fis, 4096))) { + byte[] header = dis.readNBytes(4096); + if (header.length < 4096) return null; + BitSet present = new BitSet(1024); + for (int i = 0; i < 1024; i++) { + int off = i * 4; + int entry = ((header[off] & 0xFF) << 24) + | ((header[off + 1] & 0xFF) << 16) + | ((header[off + 2] & 0xFF) << 8) + | (header[off + 3] & 0xFF); + if (entry != 0) present.set(i); + } + return present; + } catch (IOException e) { + return null; + } + } + + public void readBedrockWorld(File worldDir, File outputFolder) throws IOException { + if (!outputFolder.exists() && !outputFolder.mkdirs()) { + throw new IOException("Could not create preview output folder: " + outputFolder); + } + + File dbDir = new File(worldDir, "db"); + + Options options = new Options(); + options.compressionType(CompressionType.ZLIB_RAW); + options.blockSize(160 * 1024); + options.filterPolicy(new BloomFilterPolicy(10)); + options.createIfMissing(true); + + // Per-dimension state: bounds and region presence. + // dimensionID -> (minX, minZ, maxX, maxZ) + Map dimensionBounds = new HashMap<>(); + // dimensionID -> (regionKey -> BitSet) + Map> dimensionRegions = new HashMap<>(); + + // LevelDBChunkType byte values that count as a chunk being present: + // DATA_3D=43, DATA_2D=45, SUB_CHUNK_PREFIX=47, BLOCK_ENTITY=49, ENTITY=50 + final byte TYPE_DATA_3D = 43; + final byte TYPE_DATA_2D = 45; + final byte TYPE_SUB_CHUNK_PREFIX = 47; + final byte TYPE_BLOCK_ENTITY = 49; + final byte TYPE_ENTITY = 50; + + // Open defensively: a stale db/LOCK (or one not yet released) would otherwise fail the + // whole metadata pass, leaving the preview with no map.bin and nothing to display. + DB db = PreviewLevelDb.withLockRetry(dbDir, () -> new Iq80DBFactory().open(dbDir, options)); + try (db; DBIterator iterator = db.iterator()) { + while (iterator.hasNext()) { + byte[] key = iterator.next().getKey(); + int keyLength = key.length; + + boolean containsSubChunk = keyLength == 14 || keyLength == 10; + boolean containsDimension = keyLength == 14 || keyLength == 13; + + if (keyLength != 9 && !containsSubChunk && !containsDimension) continue; + + ByteBuffer buffer = ByteBuffer.wrap(key).order(ByteOrder.LITTLE_ENDIAN); + int x = buffer.getInt(); + int z = buffer.getInt(); + int dimensionID = containsDimension ? buffer.getInt() : 0; + // The type byte precedes the optional sub-chunk Y in the key (see LevelDBKey), so + // read the type directly; the trailing Y isn't needed to detect chunk presence. + byte type = buffer.get(); + + if (type != TYPE_DATA_3D && type != TYPE_DATA_2D && type != TYPE_SUB_CHUNK_PREFIX + && type != TYPE_ENTITY && type != TYPE_BLOCK_ENTITY) { + continue; + } + + // Compute region coordinates and bit index within the region. + int rx = x >> 5; + int rz = z >> 5; + int bit = ((z & 31) << 5) | (x & 31); + + // Record the chunk in the region presence map. + Map regions = dimensionRegions.computeIfAbsent(dimensionID, id -> new HashMap<>()); + long regionKey = ((long) rx << 32) | (rz & 0xFFFFFFFFL); + BitSet regionBits = regions.computeIfAbsent(regionKey, k -> new BitSet(1024)); + regionBits.set(bit); + + // Update bounds. + int[] bounds = dimensionBounds.computeIfAbsent(dimensionID, + id -> new int[]{Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE}); + if (x < bounds[0]) bounds[0] = x; + if (z < bounds[1]) bounds[1] = z; + if (x > bounds[2]) bounds[2] = x; + if (z > bounds[3]) bounds[3] = z; + } + } + + PreviewMapBin.Builder builder = new PreviewMapBin.Builder(); + + // Always include the three vanilla dimensions. + int[] vanillaDims = {0, 1, 2}; + String[] vanillaIdentifiers = {"minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"}; + for (int i = 0; i < vanillaDims.length; i++) { + int dimId = vanillaDims[i]; + int worldIndex = builder.size(); + builder.addWorld(dimId, vanillaIdentifiers[i], 0, 0, 0, 0); + populateDimension(builder, worldIndex, dimId, dimensionBounds, dimensionRegions); + } + + // Custom dimensions (id > 2) — only add if they have chunks. + for (Map.Entry> entry : dimensionRegions.entrySet()) { + int dimId = entry.getKey(); + if (dimId <= 2) continue; + int worldIndex = builder.size(); + builder.addWorld(dimId, "custom:" + dimId, 0, 0, 0, 0); + populateDimension(builder, worldIndex, dimId, dimensionBounds, dimensionRegions); + } + + builder.writeTo(new File(outputFolder, "map.bin")); + } + + private void populateDimension(PreviewMapBin.Builder builder, int worldIndex, int dimId, + Map dimensionBounds, + Map> dimensionRegions) { + Map regions = dimensionRegions.get(dimId); + if (regions == null) return; + + for (Map.Entry entry : regions.entrySet()) { + long packed = entry.getKey(); + int rx = (int) (packed >> 32); + int rz = (int) packed; + BitSet bits = entry.getValue(); + if (!bits.isEmpty()) { + builder.addRegion(worldIndex, rx, rz, bits); + } + } + + int[] bounds = dimensionBounds.get(dimId); + if (bounds != null && bounds[0] != Integer.MAX_VALUE) { + builder.setBoundsForWorld(worldIndex, bounds[0], bounds[1], bounds[2], bounds[3]); + } + } +} diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCache.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCache.java new file mode 100644 index 00000000..f7d84b07 --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCache.java @@ -0,0 +1,50 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Bounded LRU cache of decoded preview tile ARGB arrays (one entry per tile, 1 MB each). + * Thread-safe via synchronized methods; the access pattern is moderate and not on the hot path + * for the renderer. + */ +public final class PreviewTileCache { + public static final int BYTES_PER_ENTRY = 262144 * 4; // int[262144] of ARGB + private static final long MIN_CAPACITY_BYTES = 64L * 1024 * 1024; + private static final long MAX_CAPACITY_BYTES = 512L * 1024 * 1024; + + private final int capacityEntries; + private final LinkedHashMap map; + + public PreviewTileCache(int capacityEntries) { + this.capacityEntries = capacityEntries; + this.map = new LinkedHashMap<>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > PreviewTileCache.this.capacityEntries; + } + }; + } + + /** + * Compute the number of cache entries given the JVM max heap in bytes. + * Clamped to [64MB / 1MB-per-entry, 512MB / 1MB-per-entry]. + */ + public static int computeCapacityEntries(long maxHeapBytes) { + long tenPercent = maxHeapBytes / 10; + long clampedBytes = Math.max(MIN_CAPACITY_BYTES, Math.min(MAX_CAPACITY_BYTES, tenPercent)); + return (int) (clampedBytes / BYTES_PER_ENTRY); + } + + public synchronized int[] get(PreviewTileKey key) { + return map.get(key); + } + + public synchronized void put(PreviewTileKey key, int[] data) { + map.put(key, data); + } + + public synchronized int size() { + return map.size(); + } +} diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsampler.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsampler.java new file mode 100644 index 00000000..aba1d200 --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsampler.java @@ -0,0 +1,62 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +/** + * Stateless 2x downsampling for ARGB preview tiles, alpha-aware so that empty chunks + * (alpha == 0) do not contaminate the colour of their neighbours when building higher LOD tiles. + */ +public final class PreviewTileDownsampler { + public static final int TILE_SIZE = 512; + private static final int TILE_PIXELS = TILE_SIZE * TILE_SIZE; + private static final int HALF = TILE_SIZE / 2; + + private PreviewTileDownsampler() { + } + + /** + * Combine four source tiles laid out as a 2x2 grid into one tile. + * Each source quadrant is downsampled 2x and copied to the matching quadrant of the output. + * + * @param topLeft source for the upper-left output quadrant + * @param topRight source for the upper-right output quadrant + * @param bottomLeft source for the lower-left output quadrant + * @param bottomRight source for the lower-right output quadrant + * @return a new int[TILE_PIXELS] ARGB tile + */ + public static int[] aggregate(int[] topLeft, int[] topRight, int[] bottomLeft, int[] bottomRight) { + int[] out = new int[TILE_PIXELS]; + downsampleInto(topLeft, out, 0, 0); + downsampleInto(topRight, out, HALF, 0); + downsampleInto(bottomLeft, out, 0, HALF); + downsampleInto(bottomRight, out, HALF, HALF); + return out; + } + + private static void downsampleInto(int[] src, int[] dst, int destX, int destY) { + for (int sy = 0; sy < TILE_SIZE; sy += 2) { + int oy = destY + (sy >> 1); + int srcRow0 = sy << 9; // sy * TILE_SIZE + int srcRow1 = (sy + 1) << 9; + int dstRow = oy << 9; + for (int sx = 0; sx < TILE_SIZE; sx += 2) { + int p00 = src[srcRow0 | sx]; + int p01 = src[srcRow0 | (sx + 1)]; + int p10 = src[srcRow1 | sx]; + int p11 = src[srcRow1 | (sx + 1)]; + + int r = 0, g = 0, b = 0, count = 0; + if ((p00 >>> 24) != 0) { r += (p00 >>> 16) & 0xFF; g += (p00 >>> 8) & 0xFF; b += p00 & 0xFF; count++; } + if ((p01 >>> 24) != 0) { r += (p01 >>> 16) & 0xFF; g += (p01 >>> 8) & 0xFF; b += p01 & 0xFF; count++; } + if ((p10 >>> 24) != 0) { r += (p10 >>> 16) & 0xFF; g += (p10 >>> 8) & 0xFF; b += p10 & 0xFF; count++; } + if ((p11 >>> 24) != 0) { r += (p11 >>> 16) & 0xFF; g += (p11 >>> 8) & 0xFF; b += p11 & 0xFF; count++; } + + int outArgb; + if (count == 0) { + outArgb = 0; + } else { + outArgb = 0xFF000000 | ((r / count) << 16) | ((g / count) << 8) | (b / count); + } + dst[dstRow | (destX + (sx >> 1))] = outArgb; + } + } + } +} diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKey.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKey.java new file mode 100644 index 00000000..5b68cb7e --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKey.java @@ -0,0 +1,24 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import java.util.Objects; + +/** + * Identifies a single tile in the preview tile pyramid. + * + * @param world the dimension identifier (e.g. "minecraft:overworld") + * @param lod the level-of-detail (0 = native, negative = downsampled) + * @param tx tile x at this LOD + * @param tz tile z at this LOD + */ +public record PreviewTileKey(String world, int lod, int tx, int tz) { + public PreviewTileKey { + Objects.requireNonNull(world, "world"); + } + + /** + * @return the file name used when persisting this tile under the preview folder. + */ + public String toFileName() { + return world.replace(':', '_') + "." + lod + "." + tx + "." + tz + ".png"; + } +} diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileService.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileService.java new file mode 100644 index 00000000..8e172799 --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileService.java @@ -0,0 +1,176 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import com.hivemc.chunker.cli.messenger.messaging.response.TileErrorResponse; +import com.hivemc.chunker.cli.messenger.messaging.response.TileReadyResponse; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.Set; +import java.util.concurrent.*; + +/** + * Long-lived per-session service that lazily generates preview tile PNGs on demand. + * Owns a worker pool, a bounded LRU of decoded region ARGB arrays, and a dedup set so + * repeated client requests for the same tile do not cause duplicate work. + */ +public final class PreviewTileService { + public interface EventListener { + void onTileReady(TileReadyResponse r); + void onTileError(TileErrorResponse r); + } + + private final File outputFolder; + private final PreviewMapBin mapBin; + private final RegionRgbaSource source; + private final PreviewTileCache cache; + private final ExecutorService workers; + private final Set inFlight = ConcurrentHashMap.newKeySet(); + private final Set writtenTiles = ConcurrentHashMap.newKeySet(); + private volatile EventListener listener = NULL_LISTENER; + + public PreviewTileService(File outputFolder, PreviewMapBin mapBin, RegionRgbaSource source, + PreviewTileCache cache, int workerCount) { + this.outputFolder = outputFolder; + this.mapBin = mapBin; + this.source = source; + this.cache = cache; + this.workers = Executors.newFixedThreadPool(workerCount, runnable -> { + Thread t = new Thread(runnable, "preview-tile-worker"); + t.setDaemon(true); + return t; + }); + } + + public void setEventListener(EventListener listener) { + this.listener = listener == null ? NULL_LISTENER : listener; + } + + public void enqueueRange(String world, int lod, int minTx, int minTz, int maxTx, int maxTz) { + for (int tx = minTx; tx <= maxTx; tx++) { + for (int tz = minTz; tz <= maxTz; tz++) { + if (mapBin.isTileEmpty(world, lod, tx, tz)) continue; + PreviewTileKey key = new PreviewTileKey(world, lod, tx, tz); + if (writtenTiles.contains(key)) { + emitReady(key); + continue; + } + if (!inFlight.add(key)) continue; // dedup + workers.submit(() -> processTile(key)); + } + } + } + + public void cancel(String world, Integer lod) { + inFlight.removeIf(k -> k.world().equals(world) && (lod == null || k.lod() == lod)); + } + + public void shutdown() { + workers.shutdownNow(); + } + + private void processTile(PreviewTileKey key) { + try { + if (!inFlight.contains(key)) return; // cancelled before start + int[] rgba = loadOrAggregate(key); + if (rgba == null) { + emitError(key, "missing-data"); + return; + } + File outFile = new File(outputFolder, key.toFileName()); + writePng(rgba, outFile); + writtenTiles.add(key); + cache.put(key, rgba); + emitReady(key); + } catch (Throwable t) { + emitError(key, t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage()); + } finally { + inFlight.remove(key); + } + } + + private int[] loadOrAggregate(PreviewTileKey key) throws IOException { + int[] cached = cache.get(key); + if (cached != null) return cached; + if (key.lod() == 0) { + return loadLodZero(key.world(), key.tx(), key.tz()); + } + int childLod = key.lod() + 1; + int cx = key.tx() << 1; + int cz = key.tz() << 1; + int[] tl = ensureChild(key.world(), childLod, cx, cz); + int[] tr = ensureChild(key.world(), childLod, cx + 1, cz); + int[] bl = ensureChild(key.world(), childLod, cx, cz + 1); + int[] br = ensureChild(key.world(), childLod, cx + 1, cz + 1); + if (tl == null && tr == null && bl == null && br == null) return null; + int[] empty = new int[PreviewTileDownsampler.TILE_SIZE * PreviewTileDownsampler.TILE_SIZE]; + return PreviewTileDownsampler.aggregate( + tl == null ? empty : tl, + tr == null ? empty : tr, + bl == null ? empty : bl, + br == null ? empty : br + ); + } + + private int[] ensureChild(String world, int lod, int tx, int tz) throws IOException { + if (mapBin.isTileEmpty(world, lod, tx, tz)) return null; + PreviewTileKey ck = new PreviewTileKey(world, lod, tx, tz); + int[] cached = cache.get(ck); + if (cached != null) return cached; + int[] data = loadOrAggregate(ck); + if (data != null) cache.put(ck, data); + return data; + } + + private int[] loadLodZero(String world, int rx, int rz) throws IOException { + PreviewTileKey key = new PreviewTileKey(world, 0, rx, rz); + int[] cached = cache.get(key); + if (cached != null) return cached; + int[] data = source.loadRegion(world, rx, rz); + if (data == null) return null; + cache.put(key, data); + return data; + } + + private void writePng(int[] argb, File outFile) throws IOException { + int size = PreviewTileDownsampler.TILE_SIZE; + BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + image.setRGB(0, 0, size, size, argb, 0, size); + // Write to a temp file alongside the target then rename. Without this, a tile_ready + // can race a still-streaming write: the client opens the file mid-encode and the + // browser renders a truncated PNG (typically a single flat colour) until a later + // fetch picks up the full bytes. The rename is observed atomically by the protocol + // handler so the file never appears partially written. + File tmpFile = new File(outFile.getParent(), outFile.getName() + ".tmp"); + ImageIO.write(image, "png", tmpFile); + try { + Files.move(tmpFile.toPath(), outFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException atomicNotSupported) { + // Some filesystems (rare on local disk) don't support atomic moves — fall back to a + // regular replace. Still safer than letting ImageIO write straight into the target. + Files.move(tmpFile.toPath(), outFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + + private void emitReady(PreviewTileKey key) { + listener.onTileReady(new TileReadyResponse( + null, key.world(), key.lod(), key.tx(), key.tz(), key.toFileName() + )); + } + + private void emitError(PreviewTileKey key, String reason) { + listener.onTileError(new TileErrorResponse( + null, key.world(), key.lod(), key.tx(), key.tz(), reason + )); + } + + private static final EventListener NULL_LISTENER = new EventListener() { + @Override public void onTileReady(TileReadyResponse r) {} + @Override public void onTileError(TileErrorResponse r) {} + }; +} diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewWorldWriter.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewWorldWriter.java deleted file mode 100644 index 7b68824c..00000000 --- a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewWorldWriter.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.hivemc.chunker.conversion.encoding.preview; - -import com.hivemc.chunker.conversion.encoding.base.writer.ColumnWriter; -import com.hivemc.chunker.conversion.encoding.base.writer.WorldWriter; -import com.hivemc.chunker.conversion.intermediate.column.chunk.ChunkCoordPair; -import com.hivemc.chunker.conversion.intermediate.column.chunk.RegionCoordPair; -import com.hivemc.chunker.conversion.intermediate.world.ChunkerWorld; -import com.hivemc.chunker.conversion.intermediate.world.Dimension; -import com.hivemc.chunker.nbt.io.Writer; - -import java.io.*; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; - -/** - * PreviewWorldWriter writes binary data on present regions in the various dimensions. - */ -public class PreviewWorldWriter implements WorldWriter { - private final List worldDataList = Collections.synchronizedList(new ArrayList<>(4)); - private final File outputFolder; - - /** - * Create a new preview world writer. - * - * @param outputFolder the output folder which the preview / binary data should be written to. - */ - public PreviewWorldWriter(File outputFolder) { - this.outputFolder = outputFolder; - } - - @Override - public ColumnWriter writeWorld(ChunkerWorld chunkerWorld) { - // Create the world entry - WorldData worldData = new WorldData(); - worldData.dimension = chunkerWorld.getDimension(); - - // Add it to the list - worldDataList.add(worldData); - - // Return a new column writer - return new PreviewColumnWriter(outputFolder, worldData); - } - - @Override - public void flushWorlds() throws IOException { - // Save a binary file with worlds, min/max and present chunks - File outputFile = new File(outputFolder, "map.bin"); - try (FileOutputStream fileOutputStream = new FileOutputStream(outputFile); - BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(fileOutputStream); - DataOutputStream writerStream = new DataOutputStream(bufferedOutputStream)) { - Writer writer = Writer.toLittleEndianWriter(writerStream); - - // Encode bytes - writer.writeInt(worldDataList.size()); - for (WorldData worldData : worldDataList) { - writer.writeInt(worldData.dimension.getBedrockID()); - writer.writeInt(worldData.minX); - writer.writeInt(worldData.minZ); - writer.writeInt(worldData.maxX); - writer.writeInt(worldData.maxZ); - - // Write each region - writer.writeInt(worldData.regionToPresentChunks.size()); - for (Map.Entry> entry : worldData.regionToPresentChunks.entrySet()) { - // Write region position - writer.writeInt(entry.getKey().regionX()); - writer.writeInt(entry.getKey().regionZ()); - - // Write a bitset of present chunks - BitSet presentChunks = new BitSet(1024); - for (ChunkCoordPair pos : entry.getValue()) { - presentChunks.set(pos.to10BitIndex(), true); - } - - // Write the byte array - byte[] bitSet = presentChunks.toByteArray(); - writer.writeBytes(bitSet); - - // Pad to 128 bytes - for (int i = bitSet.length; i < 128; i++) { - writer.writeByte(0); - } - } - } - } - } - - /** - * World data to record the region the world covers. - */ - public static class WorldData { - public final Map> regionToPresentChunks = new ConcurrentHashMap<>(); - public int minX = Integer.MAX_VALUE; - public int minZ = Integer.MAX_VALUE; - public int maxX = Integer.MIN_VALUE; - public int maxZ = Integer.MIN_VALUE; - public Dimension dimension; - } -} diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaCapturingWriter.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaCapturingWriter.java new file mode 100644 index 00000000..e6827d17 --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaCapturingWriter.java @@ -0,0 +1,104 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import com.hivemc.chunker.conversion.encoding.EncodingType; +import com.hivemc.chunker.conversion.encoding.base.Version; +import com.hivemc.chunker.conversion.encoding.base.writer.ColumnWriter; +import com.hivemc.chunker.conversion.encoding.base.writer.LevelWriter; +import com.hivemc.chunker.conversion.encoding.base.writer.WorldWriter; +import com.hivemc.chunker.conversion.intermediate.column.ChunkerColumn; +import com.hivemc.chunker.conversion.intermediate.column.biome.ChunkerBiome; +import com.hivemc.chunker.conversion.intermediate.column.chunk.identifier.ChunkerBlockIdentifier; +import com.hivemc.chunker.conversion.intermediate.level.ChunkerLevel; +import com.hivemc.chunker.conversion.intermediate.world.ChunkerWorld; +import it.unimi.dsi.fastutil.Pair; + +import java.util.Set; + +/** + * A minimal LevelWriter that captures per-column ARGB pixels into a flat int[262144] array + * representing a single 512×512 region image (row-major, 512 stride). + * + *

Only columns whose chunk position falls inside the target region {@code (rx, rz)} are + * processed. All other writer lifecycle methods are no-ops. + */ +public class RegionRgbaCapturingWriter implements LevelWriter { + + private final int rx; + private final int rz; + private final int[] dst; + + /** + * Create a new capturing writer. + * + * @param rx the region X coordinate to capture. + * @param rz the region Z coordinate to capture. + * @param dst a pre-allocated {@code int[262144]} destination array (row-major, 512 stride). + */ + public RegionRgbaCapturingWriter(int rx, int rz, int[] dst) { + this.rx = rx; + this.rz = rz; + this.dst = dst; + } + + @Override + public WorldWriter writeLevel(ChunkerLevel chunkerLevel) { + return new CapturingWorldWriter(); + } + + @Override + public EncodingType getEncodingType() { + return EncodingType.PREVIEW; + } + + @Override + public Version getVersion() { + return new Version(1, 0, 0); + } + + @Override + public Set getSupportedBiomes() { + return Set.of(); // Biomes aren't used in the preview capturing writer + } + + // ------------------------------------------------------------------------- + // Inner classes implementing the writer chain + // ------------------------------------------------------------------------- + + private class CapturingWorldWriter implements WorldWriter { + @Override + public ColumnWriter writeWorld(ChunkerWorld chunkerWorld) { + return new CapturingColumnWriter(); + } + } + + private class CapturingColumnWriter implements ColumnWriter { + @Override + public void writeColumn(ChunkerColumn chunkerColumn) { + // Only process columns that belong to the target region + if (chunkerColumn.getPosition().regionX() != rx + || chunkerColumn.getPosition().regionZ() != rz) { + return; + } + + int chunkX = chunkerColumn.getPosition().chunkX(); + int chunkZ = chunkerColumn.getPosition().chunkZ(); + + // Pixel origin of this 16×16 chunk block inside the 512×512 image + int pxBaseX = (chunkX & 31) << 4; // column in the image [0, 511] + int pxBaseZ = (chunkZ & 31) << 4; // row in the image [0, 511] + + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + Pair block = + chunkerColumn.getHighestBlock(localX, localZ, ChunkerBlockIdentifier::hasRGBColor); + if (block != null) { + int rgb = block.value().getRGBColor(); + int argb = rgb == 0 ? 0 : 0xFF000000 | rgb; + // Row-major layout: row = (pxBaseZ + localZ), col = (pxBaseX + localX) + dst[(pxBaseZ + localZ) * 512 + (pxBaseX + localX)] = argb; + } + } + } + } + } +} diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaSource.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaSource.java new file mode 100644 index 00000000..05614faf --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaSource.java @@ -0,0 +1,14 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import java.io.IOException; + +/** + * Source of decoded ARGB pixel data for one region at LOD 0. + */ +public interface RegionRgbaSource { + /** + * @return an int[262144] ARGB array for the region (row-major, 512 width), + * or null if no chunks are present in that region. + */ + int[] loadRegion(String world, int rx, int rz) throws IOException; +} diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSourceTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSourceTests.java new file mode 100644 index 00000000..c8fac840 --- /dev/null +++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSourceTests.java @@ -0,0 +1,22 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Lightweight tests for ChunkerWorldRegionRgbaSource. Full integration validation + * against real fixture worlds is exercised manually via Task 20 smoke testing. + */ +public class ChunkerWorldRegionRgbaSourceTests { + @Test + public void testReturnsNullForMissingWorldDirectory(@TempDir Path tmp) throws Exception { + File worldDir = tmp.resolve("does-not-exist").toFile(); + ChunkerWorldRegionRgbaSource source = new ChunkerWorldRegionRgbaSource(worldDir); + assertNull(source.loadRegion("minecraft:overworld", 0, 0)); + } +} diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBinTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBinTests.java new file mode 100644 index 00000000..44741979 --- /dev/null +++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBinTests.java @@ -0,0 +1,73 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Path; +import java.util.BitSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Round-trip tests for the map.bin reader/writer and empty-tile lookups. + */ +public class PreviewMapBinTests { + @Test + public void testRoundTrip(@TempDir Path tmp) throws Exception { + PreviewMapBin.Builder b = new PreviewMapBin.Builder(); + BitSet present = new BitSet(1024); + present.set(0); + present.set(17); + b.addWorld(0, "minecraft:overworld", -5, -3, 2, 4) + .addRegion(0, 0, 0, present); + + File out = tmp.resolve("map.bin").toFile(); + b.writeTo(out); + + PreviewMapBin loaded = PreviewMapBin.read(out); + PreviewMapBin.WorldData ow = loaded.findByIdentifier("minecraft:overworld"); + assertEquals(-5, ow.minX()); + assertEquals(2, ow.maxX()); + assertEquals(-3, ow.minZ()); + assertEquals(4, ow.maxZ()); + assertTrue(ow.regionHasAnyChunk(0, 0)); + assertFalse(ow.regionHasAnyChunk(1, 0)); + } + + @Test + public void testTileEmptinessAtLodZero(@TempDir Path tmp) throws Exception { + // Region (0,0) has at least one chunk, region (1,0) does not exist. + PreviewMapBin.Builder b = new PreviewMapBin.Builder(); + BitSet present = new BitSet(1024); + present.set(5); + b.addWorld(0, "minecraft:overworld", 0, 0, 0, 0).addRegion(0, 0, 0, present); + + File out = tmp.resolve("map.bin").toFile(); + b.writeTo(out); + + PreviewMapBin loaded = PreviewMapBin.read(out); + assertFalse(loaded.isTileEmpty("minecraft:overworld", 0, 0, 0)); + assertTrue(loaded.isTileEmpty("minecraft:overworld", 0, 1, 0)); + } + + @Test + public void testTileEmptinessAtNegativeLodAggregatesChildren(@TempDir Path tmp) throws Exception { + // At LOD -1 a single tile covers a 2x2 block of LOD-0 regions. + // Region (0,0) present, (1,0), (0,1), (1,1) absent -> LOD -1 tile (0,0) is NOT empty. + // Regions (2..3, 2..3) all absent -> LOD -1 tile (1,1) IS empty. + PreviewMapBin.Builder b = new PreviewMapBin.Builder(); + BitSet present = new BitSet(1024); + present.set(0); + b.addWorld(0, "minecraft:overworld", 0, 0, 3, 3).addRegion(0, 0, 0, present); + + File out = tmp.resolve("map.bin").toFile(); + b.writeTo(out); + + PreviewMapBin loaded = PreviewMapBin.read(out); + assertFalse(loaded.isTileEmpty("minecraft:overworld", -1, 0, 0)); + assertTrue(loaded.isTileEmpty("minecraft:overworld", -1, 1, 1)); + } +} diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReaderTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReaderTests.java new file mode 100644 index 00000000..bc99d729 --- /dev/null +++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReaderTests.java @@ -0,0 +1,165 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.*; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the fast metadata-only scan for Java Edition (Anvil) worlds and Bedrock Edition worlds. + */ +public class PreviewMetadataReaderTests { + private static void writeRegionWithChunks(File regionFile, int... chunkLocalIndices) throws IOException { + // Minimum viable Anvil region: 8 KB header. We only set the location-table entries (first 4 KB). + // A non-zero 4-byte entry (we write 0x00000201 -> offset=2 sectors, sectorCount=1) marks the chunk as present. + byte[] header = new byte[8192]; + for (int idx : chunkLocalIndices) { + int off = idx * 4; + header[off] = 0x00; + header[off + 1] = 0x00; + header[off + 2] = 0x02; + header[off + 3] = 0x01; + } + try (FileOutputStream fos = new FileOutputStream(regionFile)) { + fos.write(header); + } + } + + @Test + public void testReadsRegionHeadersAndProducesMapBin(@TempDir Path tmp) throws Exception { + File world = tmp.resolve("world").toFile(); + File regionDir = new File(world, "region"); + assertTrue(regionDir.mkdirs()); + + // Region (0,0) with chunks at local indices 0 and 17 + writeRegionWithChunks(new File(regionDir, "r.0.0.mca"), 0, 17); + // Region (1,0) is empty (header all zeros) + writeRegionWithChunks(new File(regionDir, "r.1.0.mca")); + // Region (-1,2) with one chunk + writeRegionWithChunks(new File(regionDir, "r.-1.2.mca"), 42); + + File out = tmp.resolve("preview").toFile(); + assertTrue(out.mkdirs()); + + new PreviewMetadataReader().readJavaWorld(world, out); + + PreviewMapBin loaded = PreviewMapBin.read(new File(out, "map.bin")); + PreviewMapBin.WorldData ow = loaded.findByIdentifier("minecraft:overworld"); + assertNotNull(ow); + assertTrue(ow.regionHasAnyChunk(0, 0)); + assertFalse(ow.regionHasAnyChunk(1, 0)); + assertTrue(ow.regionHasAnyChunk(-1, 2)); + } + + @Test + public void testSkipsCorruptRegionFile(@TempDir Path tmp) throws Exception { + File world = tmp.resolve("world").toFile(); + File regionDir = new File(world, "region"); + assertTrue(regionDir.mkdirs()); + + writeRegionWithChunks(new File(regionDir, "r.0.0.mca"), 0); + // Truncated (only 10 bytes) — must be silently skipped, not abort the whole pass. + try (FileOutputStream fos = new FileOutputStream(new File(regionDir, "r.1.0.mca"))) { + fos.write(new byte[10]); + } + + File out = tmp.resolve("preview").toFile(); + assertTrue(out.mkdirs()); + + new PreviewMetadataReader().readJavaWorld(world, out); + + PreviewMapBin loaded = PreviewMapBin.read(new File(out, "map.bin")); + PreviewMapBin.WorldData ow = loaded.findByIdentifier("minecraft:overworld"); + assertTrue(ow.regionHasAnyChunk(0, 0)); + assertFalse(ow.regionHasAnyChunk(1, 0)); + } + + @Test + public void testReadsBedrockWorldKeysOnly(@TempDir Path tmp) throws Exception { + File worldDir = tmp.resolve("bedrock_world").toFile(); + File dbDir = new File(worldDir, "db"); + assertTrue(dbDir.mkdirs()); + + // Create a fresh LevelDB with the same options the converter uses. + org.iq80.leveldb.Options options = new org.iq80.leveldb.Options(); + options.compressionType(org.iq80.leveldb.CompressionType.ZLIB_RAW); + options.blockSize(160 * 1024); + options.filterPolicy(new org.iq80.leveldb.table.BloomFilterPolicy(10)); + options.createIfMissing(true); + + org.iq80.leveldb.DBFactory factory = new org.iq80.leveldb.impl.Iq80DBFactory(); + try (org.iq80.leveldb.DB db = factory.open(dbDir, options)) { + // Overworld chunk (10, 20), SubChunkPrefix tag (0x2F), subChunkY 0. + // Key length = 10: x(4) z(4) tag(1) subY(1) + db.put(bedrockKey(10, 20, 0, true, 0x2F), new byte[]{0x01}); + // Overworld chunk (-3, 7), Data2D tag (0x2D), no subchunk. + // Key length = 9: x(4) z(4) tag(1) + db.put(bedrockKeyNoSub(-3, 7, 0, 0x2D), new byte[]{0x02}); + // Nether chunk (0, 0) with Version tag (0x2C). Dimension 1, no subchunk. + // Key length = 13: x(4) z(4) dim(4) tag(1) + // 0x2C (44 = VERSION) is NOT in our "counts as present" set; this chunk should be IGNORED. + db.put(bedrockKeyWithDim(0, 0, 1, 0x2C), new byte[]{0x03}); + // Nether chunk (0, 0) with Data3D tag (0x2B). Dimension 1, no subchunk. + // Key length = 13: x(4) z(4) dim(4) tag(1) + db.put(bedrockKeyWithDim(0, 0, 1, 0x2B), new byte[]{0x04}); + // End chunk (5, -5) with SubChunkPrefix. Dimension 2, with subChunkY 3. + // Key length = 14: x(4) z(4) dim(4) tag(1) subY(1) + db.put(bedrockKeyDimSub(5, -5, 2, 3, 0x2F), new byte[]{0x05}); + // Junk key that doesn't fit any chunk key length — should be ignored. + db.put(new byte[]{0x01, 0x02, 0x03}, new byte[]{0x00}); + } + + File out = tmp.resolve("preview").toFile(); + assertTrue(out.mkdirs()); + + new PreviewMetadataReader().readBedrockWorld(worldDir, out); + + PreviewMapBin loaded = PreviewMapBin.read(new File(out, "map.bin")); + PreviewMapBin.WorldData ow = loaded.findByIdentifier("minecraft:overworld"); + PreviewMapBin.WorldData nether = loaded.findByIdentifier("minecraft:the_nether"); + PreviewMapBin.WorldData end = loaded.findByIdentifier("minecraft:the_end"); + + assertNotNull(ow); + assertNotNull(nether); + assertNotNull(end); + + // Overworld: chunk (10, 20) and (-3, 7) should be marked. + assertTrue(ow.regionHasAnyChunk(0, 0), "Overworld region (0,0) should contain chunk (10,20)"); + assertTrue(ow.regionHasAnyChunk(-1, 0), "Overworld region (-1,0) should contain chunk (-3,7)"); + + // Nether: only (0,0) via Data3D — the Version tag-only chunk is ignored. + assertTrue(nether.regionHasAnyChunk(0, 0), "Nether region (0,0) should contain chunk (0,0) via Data3D"); + + // End: chunk (5, -5) → region (0, -1) + assertTrue(end.regionHasAnyChunk(0, -1), "End region (0,-1) should contain chunk (5,-5)"); + } + + // Bedrock key builders (little-endian). The type tag precedes the optional sub-chunk Y, + // matching the real on-disk layout produced by LevelDBKey. + private static byte[] bedrockKey(int x, int z, int subY, boolean withSub, int tag) { + java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocate(withSub ? 10 : 9).order(java.nio.ByteOrder.LITTLE_ENDIAN); + buf.putInt(x).putInt(z); + buf.put((byte) tag); + if (withSub) buf.put((byte) subY); + return buf.array(); + } + private static byte[] bedrockKeyNoSub(int x, int z, int dim, int tag) { + // Overworld: no dimension prefix in the key, length 9. + java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocate(9).order(java.nio.ByteOrder.LITTLE_ENDIAN); + buf.putInt(x).putInt(z).put((byte) tag); + return buf.array(); + } + private static byte[] bedrockKeyWithDim(int x, int z, int dim, int tag) { + java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocate(13).order(java.nio.ByteOrder.LITTLE_ENDIAN); + buf.putInt(x).putInt(z).putInt(dim).put((byte) tag); + return buf.array(); + } + private static byte[] bedrockKeyDimSub(int x, int z, int dim, int subY, int tag) { + java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocate(14).order(java.nio.ByteOrder.LITTLE_ENDIAN); + buf.putInt(x).putInt(z).putInt(dim).put((byte) tag).put((byte) subY); + return buf.array(); + } +} diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCacheTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCacheTests.java new file mode 100644 index 00000000..5475f1e1 --- /dev/null +++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCacheTests.java @@ -0,0 +1,54 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Tests the bounded LRU used by the preview tile service. + */ +public class PreviewTileCacheTests { + private static int[] tile(int marker) { + int[] a = new int[262144]; + a[0] = marker; + return a; + } + + @Test + public void testPutAndGetSameInstance() { + PreviewTileCache cache = new PreviewTileCache(4); + PreviewTileKey k = new PreviewTileKey("minecraft:overworld", 0, 1, 1); + int[] data = tile(7); + cache.put(k, data); + assertNotNull(cache.get(k)); + assertEquals(7, cache.get(k)[0]); + } + + @Test + public void testEvictsLeastRecentlyUsed() { + PreviewTileCache cache = new PreviewTileCache(2); + PreviewTileKey a = new PreviewTileKey("w", 0, 1, 1); + PreviewTileKey b = new PreviewTileKey("w", 0, 2, 2); + PreviewTileKey c = new PreviewTileKey("w", 0, 3, 3); + cache.put(a, tile(1)); + cache.put(b, tile(2)); + // touch a so b becomes the LRU + assertNotNull(cache.get(a)); + cache.put(c, tile(3)); + assertNotNull(cache.get(a)); + assertNull(cache.get(b)); + assertNotNull(cache.get(c)); + } + + @Test + public void testCapacityFromHeapClampedToFloorCeiling() { + // 64 MB at minimum, 512 MB at maximum, otherwise ~10% of max heap. + // Each entry is 1 MB so capacity entries == capacity MB. + assertEquals(64, PreviewTileCache.computeCapacityEntries(100L * 1024 * 1024)); // 10MB < 64MB floor + assertEquals(64, PreviewTileCache.computeCapacityEntries(640L * 1024 * 1024)); // exactly 64MB at 10%, equal to floor + assertEquals(200, PreviewTileCache.computeCapacityEntries(2000L * 1024 * 1024)); // 200MB + assertEquals(512, PreviewTileCache.computeCapacityEntries(8000L * 1024 * 1024)); // capped at 512MB ceiling + } +} diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsamplerTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsamplerTests.java new file mode 100644 index 00000000..c2e2bb64 --- /dev/null +++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsamplerTests.java @@ -0,0 +1,72 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests for alpha-aware 2x downsampling used to build higher LOD preview tiles. + */ +public class PreviewTileDownsamplerTests { + private static final int W = 512; + + private static int[] solid(int argb) { + int[] a = new int[W * W]; + for (int i = 0; i < a.length; i++) a[i] = argb; + return a; + } + + @Test + public void testAllTransparentInputsProduceTransparentOutput() { + int[] zero = new int[W * W]; + int[] out = PreviewTileDownsampler.aggregate(zero, zero, zero, zero); + for (int v : out) assertEquals(0, v); + } + + @Test + public void testSolidColorRoundTrips() { + int red = 0xFFFF0000; + int[] r = solid(red); + int[] out = PreviewTileDownsampler.aggregate(r, r, r, r); + for (int v : out) assertEquals(red, v); + } + + @Test + public void testTopLeftGoesToUpperLeftQuadrant() { + int red = 0xFFFF0000; + int blue = 0xFF0000FF; + int[] tl = solid(red); + int[] tr = solid(blue); + int[] bl = solid(blue); + int[] br = solid(blue); + int[] out = PreviewTileDownsampler.aggregate(tl, tr, bl, br); + // Upper-left quadrant: red. Upper-right: blue. Lower-left: blue. Lower-right: blue. + assertEquals(red, out[(10 << 9) | 10]); + assertEquals(blue, out[(10 << 9) | 300]); + assertEquals(blue, out[(300 << 9) | 10]); + assertEquals(blue, out[(300 << 9) | 300]); + } + + @Test + public void testAlphaZeroInputsDoNotPolluteAverage() { + // 3 transparent, 1 red 0xFFFF0000 inside a 2x2 block of the top-left source. + int[] tl = new int[W * W]; + tl[(0 << 9) | 0] = 0xFFFF0000; // one opaque red pixel + int[] zero = new int[W * W]; + int[] out = PreviewTileDownsampler.aggregate(tl, zero, zero, zero); + // The output pixel covering (0,0)..(1,1) of tl lives at output (0,0). + assertEquals(0xFFFF0000, out[0]); + } + + @Test + public void testMixedColorAverage() { + // 2x2 block: red, blue, transparent, transparent. Expected: average of red and blue, opaque. + int[] tl = new int[W * W]; + tl[(0 << 9) | 0] = 0xFFFF0000; + tl[(0 << 9) | 1] = 0xFF0000FF; + int[] zero = new int[W * W]; + int[] out = PreviewTileDownsampler.aggregate(tl, zero, zero, zero); + // Average of R=(255+0)/2=127, G=0, B=(0+255)/2=127, A=255 + assertEquals(0xFF7F007F, out[0]); + } +} diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKeyTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKeyTests.java new file mode 100644 index 00000000..bd744c09 --- /dev/null +++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKeyTests.java @@ -0,0 +1,40 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for the PreviewTileKey value type used to identify a tile in the preview pyramid. + */ +public class PreviewTileKeyTests { + @Test + public void testEqualityByValue() { + PreviewTileKey a = new PreviewTileKey("minecraft:overworld", -3, 2, -1); + PreviewTileKey b = new PreviewTileKey("minecraft:overworld", -3, 2, -1); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + public void testInequalityOnAnyField() { + PreviewTileKey base = new PreviewTileKey("minecraft:overworld", -3, 2, -1); + assertNotEquals(base, new PreviewTileKey("minecraft:nether", -3, 2, -1)); + assertNotEquals(base, new PreviewTileKey("minecraft:overworld", -2, 2, -1)); + assertNotEquals(base, new PreviewTileKey("minecraft:overworld", -3, 3, -1)); + assertNotEquals(base, new PreviewTileKey("minecraft:overworld", -3, 2, 0)); + } + + @Test + public void testToFileNameReplacesColonAndIncludesSignedLod() { + PreviewTileKey k = new PreviewTileKey("minecraft:overworld", -3, 2, -1); + assertEquals("minecraft_overworld.-3.2.-1.png", k.toFileName()); + } + + @Test + public void testNullWorldRejected() { + assertThrows(NullPointerException.class, () -> new PreviewTileKey(null, 0, 0, 0)); + } +} diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileServiceTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileServiceTests.java new file mode 100644 index 00000000..1ccc9960 --- /dev/null +++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileServiceTests.java @@ -0,0 +1,156 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import com.hivemc.chunker.cli.messenger.messaging.response.TileErrorResponse; +import com.hivemc.chunker.cli.messenger.messaging.response.TileReadyResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the lazy preview tile service at LOD 0: enqueueRange, dedup, empty-tile skipping. + */ +public class PreviewTileServiceTests { + + private static class FakeSource implements RegionRgbaSource { + final Map data = new HashMap<>(); + final AtomicInteger loads = new AtomicInteger(); + + @Override + public int[] loadRegion(String world, int rx, int rz) { + loads.incrementAndGet(); + return data.get(world + ":" + rx + ":" + rz); + } + } + + private static int[] solid(int argb) { + int[] a = new int[262144]; + Arrays.fill(a, argb); + return a; + } + + private static PreviewMapBin oneRegionMap(Path tmp) throws Exception { + PreviewMapBin.Builder b = new PreviewMapBin.Builder(); + BitSet present = new BitSet(1024); + present.set(0); + b.addWorld(0, "minecraft:overworld", 0, 0, 0, 0).addRegion(0, 0, 0, present); + File out = tmp.resolve("map.bin").toFile(); + b.writeTo(out); + return PreviewMapBin.read(out); + } + + @Test + public void testEmitsTileReadyForLodZeroRequest(@TempDir Path tmp) throws Exception { + FakeSource src = new FakeSource(); + src.data.put("minecraft:overworld:0:0", solid(0xFF00FF00)); + + File out = tmp.resolve("preview").toFile(); + assertTrue(out.mkdirs()); + BlockingQueue events = new LinkedBlockingQueue<>(); + + PreviewTileService svc = new PreviewTileService(out, oneRegionMap(tmp), src, new PreviewTileCache(8), 2); + svc.setEventListener(new PreviewTileService.EventListener() { + @Override public void onTileReady(TileReadyResponse r) { events.add(r); } + @Override public void onTileError(TileErrorResponse r) { } + }); + + svc.enqueueRange("minecraft:overworld", 0, 0, 0, 0, 0); + + TileReadyResponse r = events.poll(2, TimeUnit.SECONDS); + assertNotNull(r); + assertEquals("minecraft:overworld", r.world()); + assertEquals(0, r.lod()); + assertEquals(0, r.tx()); + assertEquals(0, r.tz()); + assertTrue(new File(out, "minecraft_overworld.0.0.0.png").exists()); + svc.shutdown(); + } + + @Test + public void testSkipsTilesEmptyPerMapBin(@TempDir Path tmp) throws Exception { + FakeSource src = new FakeSource(); + File out = tmp.resolve("preview").toFile(); + assertTrue(out.mkdirs()); + BlockingQueue events = new LinkedBlockingQueue<>(); + + PreviewMapBin map = oneRegionMap(tmp); + PreviewTileService svc = new PreviewTileService(out, map, src, new PreviewTileCache(8), 2); + svc.setEventListener(new PreviewTileService.EventListener() { + @Override public void onTileReady(TileReadyResponse r) { events.add(r); } + @Override public void onTileError(TileErrorResponse r) { } + }); + + // Region (1,0) is empty. Requesting tile (lod=0, tx=1, tz=0) must not load anything. + svc.enqueueRange("minecraft:overworld", 0, 1, 0, 1, 0); + Thread.sleep(150); + assertEquals(0, src.loads.get()); + assertTrue(events.isEmpty()); + svc.shutdown(); + } + + @Test + public void testDedupsConcurrentRequestsForSameTile(@TempDir Path tmp) throws Exception { + FakeSource src = new FakeSource(); + src.data.put("minecraft:overworld:0:0", solid(0xFFFF0000)); + File out = tmp.resolve("preview").toFile(); + assertTrue(out.mkdirs()); + + PreviewTileService svc = new PreviewTileService(out, oneRegionMap(tmp), src, new PreviewTileCache(8), 2); + for (int i = 0; i < 10; i++) { + svc.enqueueRange("minecraft:overworld", 0, 0, 0, 0, 0); + } + Thread.sleep(300); + assertEquals(1, src.loads.get()); + svc.shutdown(); + } + + @Test + public void testLodMinusOneAggregatesFourChildren(@TempDir Path tmp) throws Exception { + FakeSource src = new FakeSource(); + src.data.put("minecraft:overworld:0:0", solid(0xFFFF0000)); + src.data.put("minecraft:overworld:1:0", solid(0xFF00FF00)); + src.data.put("minecraft:overworld:0:1", solid(0xFF0000FF)); + src.data.put("minecraft:overworld:1:1", solid(0xFFFFFFFF)); + + PreviewMapBin.Builder b = new PreviewMapBin.Builder(); + BitSet present = new BitSet(1024); + present.set(0); + b.addWorld(0, "minecraft:overworld", 0, 0, 1, 1); + b.addRegion(0, 0, 0, present); + b.addRegion(0, 1, 0, present); + b.addRegion(0, 0, 1, present); + b.addRegion(0, 1, 1, present); + File mapFile = tmp.resolve("map.bin").toFile(); + b.writeTo(mapFile); + PreviewMapBin map = PreviewMapBin.read(mapFile); + + File out = tmp.resolve("preview").toFile(); + assertTrue(out.mkdirs()); + BlockingQueue events = new LinkedBlockingQueue<>(); + + PreviewTileService svc = new PreviewTileService(out, map, src, new PreviewTileCache(16), 2); + svc.setEventListener(new PreviewTileService.EventListener() { + @Override public void onTileReady(TileReadyResponse r) { events.add(r); } + @Override public void onTileError(TileErrorResponse r) { } + }); + + svc.enqueueRange("minecraft:overworld", -1, 0, 0, 0, 0); + + // Expect the LOD -1 tile_ready (children may also be emitted; drain until we see it). + boolean sawAggregated = false; + long deadline = System.currentTimeMillis() + 3000; + while (System.currentTimeMillis() < deadline) { + TileReadyResponse r = events.poll(100, TimeUnit.MILLISECONDS); + if (r != null && r.lod() == -1) { sawAggregated = true; break; } + } + assertTrue(sawAggregated, "Expected a tile_ready at LOD -1"); + assertTrue(new File(out, "minecraft_overworld.-1.0.0.png").exists()); + svc.shutdown(); + } +} diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/integration/WorldConversionIntegrationTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/integration/WorldConversionIntegrationTests.java index 2c2ec890..06ed3a54 100644 --- a/cli/src/test/java/com/hivemc/chunker/conversion/integration/WorldConversionIntegrationTests.java +++ b/cli/src/test/java/com/hivemc/chunker/conversion/integration/WorldConversionIntegrationTests.java @@ -207,36 +207,6 @@ public void testWorldSettings(String inputWorldName) throws IOException { } } - @ParameterizedTest - @MethodSource("getWorldNames") - public void testWorldPreview(String inputWorldName) throws IOException { - URL worldZip = Resources.getResource("integration/worlds/" + inputWorldName + ".zip"); - Path unzipped = unzip(worldZip); - Path output = tempFolder(); - try { - // Create a new world converter for our world - WorldConverter converter = new WorldConverter(UUID.randomUUID()) { - @Override - public void logMissingMapping(MissingMappingType type, String identifier) { - // Don't log missing mappings for our tests - } - }; - convertWorld(converter, unzipped, output, EncodingType.PREVIEW, new Version(1, 0, 0)); - - // Assert that no errors happened - assertFalse(converter.isExceptions()); - - // Assert that map data was written - assertTrue(output.resolve("map.bin").toFile().exists()); - - // Assert that image data was written - assertTrue(Objects.requireNonNull(output.toFile().listFiles((dir, file) -> file.endsWith(".png"))).length > 0); - } finally { - remove(unzipped); - remove(output); - } - } - public void convertWorld(WorldConverter converter, Path input, Path output, EncodingType outputType, Version outputVersion) { // Create the reader / writer (note: converter settings cannot be set after construction) Optional reader = EncodingType.findReader(input.toFile(), converter);