diff --git a/src/main/core/worldTiles.ts b/src/main/core/worldTiles.ts index ac14424..9f86ae6 100644 --- a/src/main/core/worldTiles.ts +++ b/src/main/core/worldTiles.ts @@ -41,6 +41,7 @@ import { parseLocationTable, regionOf, unpackIndices, + scanRuleFor, seeThrough, structureKind, CHUNK_AXIS, @@ -85,6 +86,7 @@ function perfFor(serverId: string): MapPerfConfig { export function _resetWorldTiles(): void { regions.clear() + resolvedDirs.clear() } // ---- the on-disk cache (#133) ---- @@ -220,16 +222,69 @@ function levelName(id: string): string { return map['level-name'] || 'world' } -function dimensionFolder(dim: string): string { - if (dim === 'nether') return join('DIM-1', 'region') - if (dim === 'end') return join('DIM1', 'region') - return 'region' +/** + * Where a dimension's region files live — and there is no single answer. + * + * Vanilla keeps them under one world folder: `world/DIM-1/region`. Bukkit and + * everything descended from it (Paper, Purpur, Spigot) split them into sibling + * folders instead: `world_nether/DIM-1/region`, `world_the_end/DIM1/region`. + * MSMS only ever built the vanilla path, so on a Paper server — the type this + * app is most used with — the nether and the end resolved to a folder that does + * not exist and every tile lookup missed. Neither has ever rendered. + * + * Anything else is a custom world (Multiverse and friends), which is its own + * top-level folder with a plain `region` inside it. + * + * Ordered candidates rather than a guess, because the layout is a property of + * the server software and MSMS manages several kinds at once. + */ +function regionDirCandidates(serverId: string, dim: string): string[] { + const s = getServer(serverId) + if (!s) return [] + const level = levelName(serverId) + if (dim === 'overworld') return [join(s.path, level, 'region')] + if (dim === 'nether') { + return [join(s.path, level + '_nether', 'DIM-1', 'region'), join(s.path, level, 'DIM-1', 'region')] + } + if (dim === 'end') { + return [join(s.path, level + '_the_end', 'DIM1', 'region'), join(s.path, level, 'DIM1', 'region')] + } + // A custom world. The name is not trusted — it arrives from a bridge message + // and is about to become a path segment. + const safe = dim.replace(/[^A-Za-z0-9_.-]/g, '') + if (!safe || safe === '.' || safe === '..') return [] + return [join(s.path, safe, 'region'), join(s.path, safe, 'DIM-1', 'region'), join(s.path, safe, 'DIM1', 'region')] +} + +/** + * Which candidate directory this server actually uses, remembered. + * + * Resolving means an `existsSync` per candidate, and `peekChunkTile` runs once + * per requested chunk — 64 a request, three candidates each. The layout is a + * property of the server software and does not change while it runs. + */ +const resolvedDirs = new Map() + +function regionDirFor(serverId: string, dim: string): string | null { + const key = serverId + '|' + dim + const hit = resolvedDirs.get(key) + if (hit) return hit + const dirs = regionDirCandidates(serverId, dim) + for (const d of dirs) { + if (existsSync(d)) { + resolvedDirs.set(key, d) + return d + } + } + // Nothing on disk yet. Answer with the first candidate rather than null, so a + // caller reports a definite miss instead of "unknown" — "unknown" is what + // makes a chunk get requested forever. + return dirs[0] ?? null } function regionPath(serverId: string, dim: string, rx: number, rz: number): string | null { - const s = getServer(serverId) - if (!s) return null - return join(s.path, levelName(serverId), dimensionFolder(dim), `r.${rx}.${rz}.mca`) + const dir = regionDirFor(serverId, dim) + return dir ? join(dir, `r.${rx}.${rz}.mca`) : null } /** Longs out of prismarine-nbt, which gives signed 64-bit values as [hi, lo]. */ @@ -278,7 +333,7 @@ function listOf(v: any): any[] { * but air the whole way — under an unlit sky, or a chunk that is only partly * generated — is left transparent rather than drawn as the void. */ -export function tileFromChunk(chunk: any): ChunkTile | null { +export function tileFromChunk(chunk: any, dim = 'overworld'): ChunkTile | null { const v = tag(chunk) if (!v) return null const dataVersion = tag(v.DataVersion) @@ -296,8 +351,23 @@ export function tileFromChunk(chunk: any): ChunkTile | null { const height = new Array(CHUNK_AXIS * CHUNK_AXIS).fill(0) let remaining = colour.length + // The nether has a bedrock roof: a top-down scan finds it in every column and + // paints the whole dimension one flat grey. `sawAir` per column is how a map + // gets under it — solid blocks are skipped until an air gap has been seen. + const rule = scanRuleFor(dim) + const sawAir = rule.underRoof ? new Array(colour.length).fill(false) : null + // A column that is solid from the ceiling all the way down — a netherrack + // pillar joining floor to roof — never shows an air gap, so the under-roof + // rule would skip every block in it and leave a hole. The highest solid block + // seen while skipping is kept as the answer for exactly that case. + const fallbackColour = sawAir ? new Array(colour.length).fill(-1) : null + const fallbackHeight = sawAir ? new Array(colour.length).fill(0) : null + for (const { s, y: sectionY } of withY) { if (remaining === 0) break + // Above the ceiling there is nothing worth looking at, and on the nether + // that is most of the sections. + if (rule.ceiling !== null && sectionY * CHUNK_AXIS > rule.ceiling) continue const states = tag(s.block_states) ?? tag(s.BlockStates) // A list, so unwrapped twice. See `listOf`. const paletteRaw = listOf(states?.palette).length ? listOf(states?.palette) : listOf(s.Palette) @@ -320,20 +390,47 @@ export function tileFromChunk(chunk: any): ChunkTile | null { for (let x = 0; x < CHUNK_AXIS; x++) { const col = x + z * CHUNK_AXIS if (colour[col] >= 0) continue + const worldY = sectionY * CHUNK_AXIS + y + if (rule.ceiling !== null && worldY > rule.ceiling) continue const name = names[indices[y * 256 + z * CHUNK_AXIS + x]] ?? '' const short = name.replace(/^minecraft:/, '') // Air, and the plants a map looks through — see `seeThrough`. Without // it the surface is whatever is standing ON the ground rather than // the ground, which is how a bamboo jungle rendered as a maroon smear. - if (!short || INVISIBLE.has(short) || seeThrough(short)) continue + const invisible = !short || INVISIBLE.has(short) || seeThrough(short) + if (sawAir) { + // Under a roof: remember the gap, and skip everything solid until + // one has been seen. Without this the first hit is the roof itself. + if (invisible) sawAir[col] = true + if (!sawAir[col]) { + if (!invisible && fallbackColour && fallbackColour[col] < 0) { + const fc = blockColour(short) + fallbackColour[col] = (fc.r << 16) | (fc.g << 8) | fc.b + if (fallbackHeight) fallbackHeight[col] = worldY + } + continue + } + } + if (invisible) continue const c = blockColour(short) colour[col] = (c.r << 16) | (c.g << 8) | c.b - height[col] = sectionY * CHUNK_AXIS + y + height[col] = worldY remaining-- } } } } + // Columns the under-roof rule skipped entirely fall back to the highest solid + // block, so a floor-to-ceiling pillar is drawn rather than punched out. + if (fallbackColour && fallbackHeight) { + for (let i = 0; i < colour.length; i++) { + if (colour[i] < 0 && fallbackColour[i] >= 0) { + colour[i] = fallbackColour[i] + height[i] = fallbackHeight[i] + remaining-- + } + } + } // Nothing at all: an ungenerated or empty chunk, which is not a tile. if (remaining === colour.length) return null const marks = structuresOf(v) @@ -408,7 +505,12 @@ export function parseBudgetReady(serverId?: string, now = Date.now()): boolean { * Synchronous and slow by design — the callers are expected to keep this off * any request path, and to respect `parseBudgetReady`. */ -function loadRegion(serverId: string, path: string, perf: MapPerfConfig): RegionEntry | null { +function loadRegion( + serverId: string, + path: string, + dim: string, + perf: MapPerfConfig +): RegionEntry | null { if (!existsSync(path)) return null let mtimeMs = 0 try { @@ -454,7 +556,7 @@ function loadRegion(serverId: string, path: string, perf: MapPerfConfig): Region const raw = decompress(file.subarray(loc.offset + 5, end), kind) if (!raw) continue try { - tiles.set(slot, tileFromChunk(nbt.parseUncompressed(raw))) + tiles.set(slot, tileFromChunk(nbt.parseUncompressed(raw), dim)) } catch { /* one unreadable chunk must not lose the region */ } @@ -601,7 +703,7 @@ export function chunkTile( ): ChunkTile | null { const path = regionPath(serverId, dim, regionOf(chunkX), regionOf(chunkZ)) if (!path) return null - const region = loadRegion(serverId, path, perfFor(serverId)) + const region = loadRegion(serverId, path, dim, perfFor(serverId)) if (!region) return null return region.tiles.get(chunkSlot(localChunk(chunkX), localChunk(chunkZ))) ?? null } diff --git a/src/main/smoke.ts b/src/main/smoke.ts index e60257d..d5a9212 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -1138,6 +1138,43 @@ export async function runModUpdateSmoke(): Promise { if (planted.colour[0] !== ((grass.r << 16) | (grass.g << 8) | grass.b)) { return fail('the map coloured a column by the plant standing on it, not the ground') } + // The nether has a bedrock roof, so a top-down scan finds bedrock in + // every column and paints the whole dimension one flat grey. A nether + // map has to get UNDER the roof: skip solid blocks until an air gap has + // been seen, then take the first thing below it (#135). + const netherish = { + type: 'compound', + name: '', + value: { + DataVersion: { type: 'int', value: 4435 }, + sections: { + type: 'list', + value: { + type: 'compound', + value: [ + section(8, ['minecraft:bedrock']), // the roof, at y=128..143 + section(7, ['minecraft:air']), // the gap a player flies through + section(6, ['minecraft:netherrack']) // the floor they stand on + ] + } + } + } + } + const roof = worldTilesMod.tileFromChunk(netherish, 'overworld') + const floor = worldTilesMod.tileFromChunk(netherish, 'nether') + const bedrock = blockColour('bedrock') + const netherrack = blockColour('netherrack') + if (!roof || !floor) return fail('a nether-shaped chunk produced no tile') + // Read as an overworld it finds the roof — which is the bug. + if (roof.colour[0] !== ((bedrock.r << 16) | (bedrock.g << 8) | bedrock.b)) { + return fail('the overworld rule should have stopped at the bedrock roof') + } + // Read as the nether it finds the floor. + if (floor.colour[0] !== ((netherrack.r << 16) | (netherrack.g << 8) | netherrack.b)) { + return fail('the nether rule did not get under the roof') + } + if (floor.height[0] >= 128) return fail('the nether surface is above the roof: ' + floor.height[0]) + // An all-air chunk is not a tile at all. if ( worldTilesMod.tileFromChunk({ diff --git a/src/renderer/src/components/LiveMap.tsx b/src/renderer/src/components/LiveMap.tsx index 7ef656e..24323f6 100644 --- a/src/renderer/src/components/LiveMap.tsx +++ b/src/renderer/src/components/LiveMap.tsx @@ -223,19 +223,45 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { const tilesPending = useRef(false) const [tick2, setTick2] = useState(0) - const visibleChunks = useCallback((): { cx: number; cz: number }[] => { - if (!view) return [] + const chunkBox = useCallback((): { x0: number; x1: number; z0: number; z1: number } | null => { + if (!view) return null const tl = screenToWorld({ x: 0, y: 0 }, view, vp) const br = screenToWorld({ x: vp.width, y: vp.height }, view, vp) - const x0 = Math.floor(tl.x / 16) - const x1 = Math.floor(br.x / 16) - const z0 = Math.floor(tl.z / 16) - const z1 = Math.floor(br.z / 16) - if ((x1 - x0 + 1) * (z1 - z0 + 1) > 4096) return [] + return { + x0: Math.floor(tl.x / 16), + x1: Math.floor(br.x / 16), + z0: Math.floor(tl.z / 16), + z1: Math.floor(br.z / 16) + } + }, [view, vp]) + + /** What to REQUEST. Capped: zoomed out this is millions of chunks. */ + const visibleChunks = useCallback((): { cx: number; cz: number }[] => { + const b = chunkBox() + if (!b) return [] + if ((b.x1 - b.x0 + 1) * (b.z1 - b.z0 + 1) > 4096) return [] const out: { cx: number; cz: number }[] = [] - for (let z = z0; z <= z1; z++) for (let x = x0; x <= x1; x++) out.push({ cx: x, cz: z }) + for (let z = b.z0; z <= b.z1; z++) for (let x = b.x0; x <= b.x1; x++) out.push({ cx: x, cz: z }) return out - }, [view, vp]) + }, [chunkBox]) + + /** + * What to DRAW: everything already held that falls in view. A different + * question from what to request — conflating them is why the terrain vanished + * when zoomed out (#135). + */ + const drawableChunks = useCallback((): { cx: number; cz: number }[] => { + const b = chunkBox() + if (!b) return [] + const out: { cx: number; cz: number }[] = [] + for (const k of tiles.current.keys()) { + if (!tiles.current.get(k)) continue + const [cx, cz] = k.split(',').map(Number) + if (cx < b.x0 - 1 || cx > b.x1 + 1 || cz < b.z0 - 1 || cz > b.z1 + 1) continue + out.push({ cx, cz }) + } + return out + }, [chunkBox]) // Ask for what is on screen and not yet held. The main process owns the queue // and the parse budget, shared with the web surfaces. @@ -318,7 +344,7 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { // The world first; everything else sits on top of it. if (world) { g.imageSmoothingEnabled = false - for (const c of visibleChunks()) { + for (const c of drawableChunks()) { const t = tiles.current.get(c.cx + ',' + c.cz) if (!t) continue const p = worldToScreen({ x: c.cx * 16, z: c.cz * 16 }, v, size) @@ -376,7 +402,7 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { g.textAlign = 'center' g.textBaseline = 'middle' g.font = `bold ${10 * dpr}px Inter, system-ui, sans-serif` - for (const c of visibleChunks()) { + for (const c of drawableChunks()) { for (const mk of markStore.current.get(c.cx + ',' + c.cz) ?? []) { if (markKind && mk.kind !== markKind) continue const st = MARK_STYLE[mk.kind] ?? MARK_STYLE.other @@ -420,13 +446,40 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { g.fillStyle = 'rgba(255,255,255,.92)' g.fillText(p.name, x, y - (head ? 12 : 7) * dpr) } - }, [shown, bounds, heat, cell, showHeat, view, vp, dim, heads, world, marks, markKind, tick2, visibleChunks]) + }, [shown, bounds, heat, cell, showHeat, view, vp, dim, heads, world, marks, markKind, tick2, drawableChunks]) const localPoint = (e: React.MouseEvent): { x: number; y: number } => { const r = (e.target as HTMLCanvasElement).getBoundingClientRect() return { x: e.clientX - r.left, y: e.clientY - r.top } } + /** + * Wheel-to-zoom, bound by hand rather than through `onWheel`. + * + * React registers wheel listeners as PASSIVE, so `preventDefault` inside an + * `onWheel` handler does nothing and the page scrolls behind the map (#135). + * The only way to stop that is a listener registered with `passive: false`. + */ + const viewRef = useRef(null) + viewRef.current = view + const vpRef = useRef(vp) + vpRef.current = vp + useEffect(() => { + const cv = canvasRef.current + if (!cv) return + const onWheel = (e: WheelEvent): void => { + const v = viewRef.current + if (!v) return + e.preventDefault() + const r = cv.getBoundingClientRect() + setView( + zoomAt(v, vpRef.current, { x: e.clientX - r.left, y: e.clientY - r.top }, e.deltaY < 0 ? 1.15 : 1 / 1.15) + ) + } + cv.addEventListener('wheel', onWheel, { passive: false }) + return () => cv.removeEventListener('wheel', onWheel) + }, []) + return (
@@ -601,10 +654,6 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { } setCursor(screenToWorld(localPoint(e), view, vp)) }} - onWheel={(e) => { - if (!view) return - setView(zoomAt(view, vp, localPoint(e), e.deltaY < 0 ? 1.15 : 1 / 1.15)) - }} /> {cursor && (
*{pointer-events:auto} .mp-empty .mp-note{font-size:12.5px;opacity:.65;max-width:380px} .mp-canvas-wrap canvas{cursor:grab} .mp-canvas-wrap canvas:active{cursor:grabbing} @@ -263,17 +269,40 @@ function mapCursorText(){ that stutters; a chunk only changes when the server rewrites its region. */ var MAP_TILES={},MAP_TILE_PENDING=false; function mapTileKey(cx,cz){return cx+','+cz} -function mapVisibleChunks(){ - if(!MAP.view)return []; +/* The viewport in chunk coordinates. */ +function mapChunkBox(){ + if(!MAP.view)return null; var tl=mapS2W({x:0,y:0}),br=mapS2W({x:MAP.vp.width,y:MAP.vp.height}); + return {x0:Math.floor(tl.x/16),x1:Math.floor(br.x/16), + z0:Math.floor(tl.z/16),z1:Math.floor(br.z/16)}} +/** + * Chunks to REQUEST. Capped, because zoomed out a viewport covers tens of + * thousands and asking for them is pointless as well as expensive — at that + * scale a chunk is a fraction of a pixel. + */ +function mapVisibleChunks(){ + var b=mapChunkBox();if(!b)return []; + if((b.x1-b.x0+1)*(b.z1-b.z0+1)>4096)return []; var out=[]; - var x0=Math.floor(tl.x/16),x1=Math.floor(br.x/16); - var z0=Math.floor(tl.z/16),z1=Math.floor(br.z/16); - /* Capped: zoomed all the way out a viewport covers tens of thousands of - chunks, and asking for them would be pointless as well as expensive — at - that scale a chunk is a fraction of a pixel. */ - if((x1-x0+1)*(z1-z0+1)>4096)return []; - for(var z=z0;z<=z1;z++)for(var x=x0;x<=x1;x++)out.push({cx:x,cz:z}); + for(var z=b.z0;z<=b.z1;z++)for(var x=b.x0;x<=b.x1;x++)out.push({cx:x,cz:z}); + return out} +/** + * Tiles to DRAW: everything already held that falls in view. + * + * A different question from what to request, and conflating the two is why the + * terrain vanished when zoomed out (#135) — the request cap correctly refused + * to ask for a million chunks and took the drawing down with it. Iterating what + * is HELD rather than what is visible also costs the size of the cache instead + * of the size of the viewport, so it stays cheap however far out you go. + */ +function mapDrawableChunks(){ + var b=mapChunkBox();if(!b)return []; + var out=[]; + for(var k in MAP_TILES){ + if(!MAP_TILES[k])continue; + var p=k.split(',');var cx=+p[0],cz=+p[1]; + if(cxb.x1+1||czb.z1+1)continue; + out.push({cx:cx,cz:cz})} return out} var MAP_MARKS={}; function mapFetchTiles(){ @@ -292,6 +321,7 @@ function mapFetchTiles(){ for(var rz=z0;rz<=z1;rz++)for(var rx=x0;rx<=x1;rx++)chunks.push({cx:rx,cz:rz})}} var want=chunks.filter(function(c){return MAP_TILES[mapTileKey(c.cx,c.cz)]===undefined}); if(!want.length)return; + mapTrimTiles(); want=want.slice(0,64); MAP_TILE_PENDING=true; mapGet(mapTilesUrl(MAP.dim,want.map(function(c){return c.cx+','+c.cz}).join(';'),MAP.marksOn)).then(function(d){ @@ -315,7 +345,7 @@ function mapDrawMarks(g,w,h,dpr){ if(!MAP.marksOn)return; var sx=w/MAP.vp.width,sy=h/MAP.vp.height; g.textAlign='center';g.textBaseline='middle';g.font='bold '+(10*dpr)+'px Inter,system-ui,sans-serif'; - var chunks=mapVisibleChunks(); + var chunks=mapDrawableChunks(); for(var i=0;ib.x1+8||czb.z1+8){delete MAP_TILES[keys[i]];delete MAP_MARKS[keys[i]]}}} function mapBakeTile(t){ var cv=document.createElement('canvas');cv.width=16;cv.height=16; var g=cv.getContext('2d');var img=g.createImageData(16,16); @@ -361,7 +403,7 @@ function mapBakeTile(t){ g.putImageData(img,0,0);return cv} function mapDrawTiles(g,w,h){ if(!MAP.world)return; - var chunks=mapVisibleChunks(); + var chunks=mapDrawableChunks(); if(!chunks.length)return; var sx=w/MAP.vp.width,sy=h/MAP.vp.height; var size=16*MAP.view.scale; diff --git a/src/shared/regionFormat.ts b/src/shared/regionFormat.ts index 5c839cd..1d8950f 100644 --- a/src/shared/regionFormat.ts +++ b/src/shared/regionFormat.ts @@ -380,6 +380,31 @@ export interface StructureMark { z: number } +/** + * Where to start looking down, per dimension. + * + * The nether has a bedrock roof at y=127. A plain top-down scan finds it in + * every column and renders the whole dimension as one flat grey slab, which is + * why it looked broken rather than empty. + * + * Every map renderer solves this the same way: start below the roof, walk down + * to the first air, and only then take the first solid block under it — the + * floor a player is actually standing on rather than the ceiling above them. + * + * The end and custom worlds have no roof, so they scan from the top like the + * overworld. + */ +export interface ScanRule { + /** Highest block to consider. `null` means start at the top of the world. */ + ceiling: number | null + /** Skip solid blocks until an air gap has been seen. */ + underRoof: boolean +} + +export function scanRuleFor(dim: string): ScanRule { + return dim === 'nether' ? { ceiling: 126, underRoof: true } : { ceiling: null, underRoof: false } +} + export function shade(colour: Rgb, dh: number): Rgb { const f = dh > 0 ? 1.12 : dh < 0 ? 0.86 : 1 const clamp = (v: number): number => Math.max(0, Math.min(255, Math.round(v * f))) diff --git a/src/shared/tileCache.ts b/src/shared/tileCache.ts index a9dcd2a..c5eb243 100644 --- a/src/shared/tileCache.ts +++ b/src/shared/tileCache.ts @@ -26,7 +26,7 @@ import { STRUCTURE_KINDS } from './regionFormat' * map with the old colours has no way to connect it to an update they * installed. Changing any of those things and NOT bumping this is the bug. */ -export const TILE_CACHE_VERSION = 3 +export const TILE_CACHE_VERSION = 4 const MAGIC = 0x4d53544c // 'MSTL' const COLUMNS = 256