Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 115 additions & 13 deletions src/main/core/worldTiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
parseLocationTable,
regionOf,
unpackIndices,
scanRuleFor,
seeThrough,
structureKind,
CHUNK_AXIS,
Expand Down Expand Up @@ -85,6 +86,7 @@ function perfFor(serverId: string): MapPerfConfig {

export function _resetWorldTiles(): void {
regions.clear()
resolvedDirs.clear()
}

// ---- the on-disk cache (#133) ----
Expand Down Expand Up @@ -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<string, string>()

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]. */
Expand Down Expand Up @@ -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)
Expand All @@ -296,8 +351,23 @@ export function tileFromChunk(chunk: any): ChunkTile | null {
const height = new Array<number>(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<boolean>(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<number>(colour.length).fill(-1) : null
const fallbackHeight = sawAir ? new Array<number>(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)
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 */
}
Expand Down Expand Up @@ -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
}
Expand Down
37 changes: 37 additions & 0 deletions src/main/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,43 @@ export async function runModUpdateSmoke(): Promise<void> {
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({
Expand Down
81 changes: 65 additions & 16 deletions src/renderer/src/components/LiveMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<MapView | null>(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 (
<div>
<BridgeNotice serverId={serverId} />
Expand Down Expand Up @@ -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 && (
<div
Expand Down
4 changes: 3 additions & 1 deletion src/shared/livemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,9 @@ export const PUBLIC_MAP_DEFAULTS: PublicMapConfig = {
round: 64,
heads: false,
names: true,
world: false,
// The terrain is the map. Publishing a grid with dots on it and calling it a
// live map was the thing that made the feature look broken (#135).
world: true,
structures: false,
loadAhead: false
}
Expand Down
Loading
Loading