Skip to content

Commit f651189

Browse files
authored
Load the map at the speed the cache made possible (#136) (#139)
* Load the map at the speed the cache made possible (#136) Three causes, each undoing part of what #134 bought. **The parse brake was throttling cache hits.** The 250 ms gap exists so back-to-back region PARSES do not hold the main thread — and it was applied to every job in the queue, including the ones that read a cached region in about a millisecond. After the disk cache landed, most reads are cache hits, so the brake had ended up throttling almost exclusively the fast path. That is why loading felt no quicker with the cache than without it. The queue now asks whether a job will actually parse before waiting for anything. **The client only asked again on the two-second position poll.** `mapFetchTiles` runs from `mapDraw`, so a viewport needing six requests filled in over twelve seconds, in visible bands — which is the "it loads chunk by chunk" in the report. While anything is still pending it asks again in 180 ms. **Empty chunks were requested forever.** A chunk was marked "known empty" only when the whole response reported nothing pending, and on a busy viewport something always is — so a chunk the server had read and found empty was never marked and came back on every draw. The response now names them: `empty` is "I read that region and there is nothing there", which is a different fact from "not read yet", and the client can tell them apart. This is the "sometimes it never loads". **And an empty area now says so.** Ungenerated chunks were black, which is indistinguishable from still-loading and from broken — an operator was waiting for a load that was never coming. They are drawn as a faint hatch in the site's own accent colour, with a line saying nobody has been there, once enough of the view is empty to be worth explaining. * Self-review: measure the parse instead of predicting it, and stop an infinite poll I gated the brake behind a `willParse()` that re-stat'd the region file and checked for a cache file — to guess at work the very next call was about to do anyway. Two stats per queued job, thousands of jobs. The queue runs the job and then looks at whether `lastParseAt` moved: no prediction, no extra filesystem work, and the brake applies exactly when a parse happened rather than when one was expected. Worse, and mine from this PR: asking again every 180 ms while anything is pending turns an unreadable region into a permanent polling loop. `loadRegion` returned null on a read failure, `peekChunkTile` therefore answered "not read yet" forever, and the client would have asked for that chunk every 180 ms for as long as the page stayed open. A region that cannot be read is remembered as EMPTY — which is honest, there is nothing to draw — and terminates. Also dropped a `pending` counter I computed in the ungenerated-area note and never used.
1 parent 2223a75 commit f651189

4 files changed

Lines changed: 110 additions & 17 deletions

File tree

src/main/core/worldTiles.ts

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -536,13 +536,28 @@ function loadRegion(
536536
}
537537

538538
lastParseAt = Date.now()
539+
/**
540+
* A region that cannot be read is remembered as EMPTY rather than as "not
541+
* read yet".
542+
*
543+
* Returning null leaves `peekChunkTile` answering `undefined` forever, so the
544+
* client keeps asking, the queue keeps re-running it, and the chunk never
545+
* resolves — one unreadable region file is a permanent polling loop. An empty
546+
* entry is honest (there is nothing to draw) and terminates.
547+
*/
548+
const giveUp = (): RegionEntry => {
549+
const empty: RegionEntry = { at: Date.now(), mtimeMs, tiles: new Map() }
550+
regions.set(path, empty)
551+
return empty
552+
}
553+
539554
let file: Buffer
540555
try {
541556
file = readFileSync(path)
542557
} catch {
543-
return null
558+
return giveUp()
544559
}
545-
if (file.length < SECTOR_HEADER) return null
560+
if (file.length < SECTOR_HEADER) return giveUp()
546561

547562
const table = parseLocationTable(file.subarray(0, 4096))
548563
const tiles = new Map<number, ChunkTile | null>()
@@ -629,12 +644,19 @@ export function requestTiles(
629644
dim: string,
630645
want: { cx: number; cz: number }[],
631646
opts: { marks?: boolean } = {}
632-
): { tiles: Record<string, { c: number[]; h: number[]; m?: StructureMark[] }>; pending: number } {
647+
): {
648+
tiles: Record<string, { c: number[]; h: number[]; m?: StructureMark[] }>
649+
/** Chunks read and found to hold nothing — as opposed to not read yet. */
650+
empty: string[]
651+
pending: number
652+
} {
633653
const tiles: Record<string, { c: number[]; h: number[]; m?: StructureMark[] }> = {}
654+
const empty: string[] = []
634655
const missing: { cx: number; cz: number }[] = []
635656
for (const w of want) {
636657
const t = peekChunkTile(serverId, dim, w.cx, w.cz)
637658
if (t === undefined) missing.push(w)
659+
else if (!t) empty.push(w.cx + ',' + w.cz)
638660
// Structures are omitted unless asked for. They are a spoiler, and a
639661
// payload that carries them "in case" is one the public feed could leak.
640662
else if (t) {
@@ -652,27 +674,35 @@ export function requestTiles(
652674
}
653675
}
654676
if (missing.length && !working) void drain()
655-
return { tiles, pending: missing.length }
677+
return { tiles, empty, pending: missing.length }
656678
}
657679

658680
async function drain(): Promise<void> {
659681
working = true
660682
try {
661683
while (queue.length) {
662-
// Yielding between chunks is not enough on its own: the first chunk of an
663-
// unseen region parses the whole file. Wait for the parse budget.
664-
if (!parseBudgetReady(queue[0]?.serverId)) {
665-
await new Promise((r) => setTimeout(r, 60))
666-
continue
667-
}
668684
const job = queue.shift()
669685
if (!job) break
686+
// The budget is a brake on PARSING, and applying it to every job throttled
687+
// the cache hits too — which after #134 is almost every read, and is why
688+
// loading felt no quicker with the cache than without it (#136).
689+
//
690+
// Measured rather than predicted: run the job, then look at whether it
691+
// parsed. Predicting meant re-stat'ing the region file to guess at work
692+
// the very next call was about to do anyway.
693+
const before = lastParseAt
670694
try {
671695
chunkTile(job.serverId, job.dim, job.cx, job.cz)
672696
} catch {
673697
/* one bad region must not stop the queue */
674698
}
675-
await new Promise((r) => setImmediate(r))
699+
if (lastParseAt !== before) {
700+
// It really parsed. Stand back for the configured gap so the console
701+
// reader and everything else on this thread get a turn.
702+
await new Promise((r) => setTimeout(r, perfFor(job.serverId).parseGapMs))
703+
} else {
704+
await new Promise((r) => setImmediate(r))
705+
}
676706
}
677707
} finally {
678708
working = false

src/renderer/src/components/LiveMap.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,15 +277,22 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
277277
.mapTiles(serverId, dim, want, marks)
278278
.then((r) => {
279279
tilesPending.current = false
280+
// The empty list is "read, and nothing there" — as opposed to "not read
281+
// yet". Marking null only when the whole response had nothing pending
282+
// meant a genuinely empty chunk was re-requested on every draw (#136).
283+
const known = new Set(r.empty ?? [])
280284
for (const w of want) {
281285
const k = w.cx + ',' + w.cz
282286
const t = r.tiles[k]
283287
if (t) {
284288
tiles.current.set(k, bakeTile(t))
285289
if (t.m) markStore.current.set(k, t.m)
286-
} else if (!r.pending) tiles.current.set(k, null)
290+
} else if (known.has(k) || !r.pending) tiles.current.set(k, null)
287291
}
288292
setTick2((n) => n + 1)
293+
// Ask again while anything is still coming, rather than waiting for the
294+
// 2-second position poll — that wait is why a view filled in bands.
295+
if (r.pending > 0) window.setTimeout(() => setTick2((n) => n + 1), 180)
289296
})
290297
.catch(() => {
291298
tilesPending.current = false

src/shared/ipc.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,8 @@ export interface MsmsApi {
351351
marks?: boolean
352352
): Promise<{
353353
tiles: Record<string, { c: number[]; h: number[]; m?: StructureMark[] }>
354+
/** Chunks read and found to hold nothing, as opposed to not read yet. */
355+
empty: string[]
354356
pending: number
355357
}>
356358
/** Drop every cached region. Returns how many files went. */

src/shared/mapUi.ts

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export const MAP_CSS = `
4141
font-size:12px;font-variant-numeric:tabular-nums;pointer-events:none;
4242
background:rgba(0,0,0,.55);color:#fff}
4343
.mp-cursor:empty{display:none}
44+
/* Said in words as well as drawn, so "nobody has been here" is not left to a
45+
faint hatch nobody reads as deliberate (#136). */
46+
.mp-ungen{position:absolute;right:10px;top:10px;max-width:230px;padding:7px 11px;border-radius:10px;
47+
font-size:12px;line-height:1.35;pointer-events:none;
48+
border:1px dashed color-mix(in srgb,var(--accent,#dc2727) 45%,transparent);
49+
background:color-mix(in srgb,var(--accent,#dc2727) 10%,rgba(0,0,0,.55));color:#fff}
50+
.mp-ungen.hidden{display:none}
4451
.mp-legend{display:flex;flex-wrap:wrap;gap:10px;font-size:12px;opacity:.75}
4552
.mp-legend .mp-hint{margin-left:auto;opacity:.6}
4653
.mp-legend b{font-weight:800;opacity:1}
@@ -89,6 +96,7 @@ export const MAP_HTML = `
8996
<div class="mp-canvas-wrap">
9097
<canvas id="mpCanvas"></canvas>
9198
<div id="mpEmpty" class="mp-empty"></div>
99+
<div id="mpUngen" class="mp-ungen hidden"></div>
92100
<div id="mpCursor" class="mp-cursor"></div>
93101
</div>
94102
<div class="mp-legend">
@@ -327,15 +335,24 @@ function mapFetchTiles(){
327335
mapGet(mapTilesUrl(MAP.dim,want.map(function(c){return c.cx+','+c.cz}).join(';'),MAP.marksOn)).then(function(d){
328336
MAP_TILE_PENDING=false;
329337
if(!d||!d.tiles)return;
338+
/* The empty list is the server saying "I have read that region and there is
339+
nothing in that chunk". Marking a chunk null only when the WHOLE response had
340+
nothing pending was the bug: on a busy viewport something is always
341+
pending, so genuinely empty chunks were never marked and were re-requested
342+
on every single draw, forever (#136). */
343+
var known={};
344+
for(var e=0;e<(d.empty||[]).length;e++)known[d.empty[e]]=1;
330345
for(var i=0;i<want.length;i++){
331346
var k=mapTileKey(want[i].cx,want[i].cz);
332347
var t=d.tiles[k];
333-
/* null, not undefined, for a chunk the server has parsed and found empty —
334-
otherwise it is re-requested forever. A pending one is left unset so the
335-
next poll asks again. */
336348
if(t){MAP_TILES[k]=mapBakeTile(t);if(t.m)MAP_MARKS[k]=t.m}
337-
else if(!d.pending)MAP_TILES[k]=null}
338-
mapDraw()})}
349+
else if(known[k]||!d.pending)MAP_TILES[k]=null}
350+
mapDraw();
351+
/* Ask again straight away while anything is still coming. Waiting for the
352+
2-second position poll is why a viewport filled in visible bands over ten
353+
seconds instead of arriving at once. */
354+
if(d.pending>0){clearTimeout(MAP_TILE_SOON);MAP_TILE_SOON=setTimeout(mapFetchTiles,180)}})}
355+
var MAP_TILE_SOON=null;
339356
/* Structure markers. Off by default: they are a spoiler for the players and
340357
clutter for everyone else. The server decides whether they arrive at all —
341358
the public feed sends none unless an operator published them. */
@@ -401,6 +418,42 @@ function mapBakeTile(t){
401418
img.data[o+2]=Math.max(0,Math.min(255,Math.round((c&255)*f)));
402419
img.data[o+3]=255}
403420
g.putImageData(img,0,0);return cv}
421+
/* Area nobody has ever been to.
422+
Drawn as a deliberate, themed hatch rather than left black, because black is
423+
indistinguishable from "still loading" and from "broken" — an operator was
424+
waiting for a load that was never coming (#136). */
425+
function mapDrawUngenerated(g,w,h,dpr){
426+
if(!MAP.world||!MAP.view)return;
427+
var b=mapChunkBox();if(!b)return;
428+
if((b.x1-b.x0+1)*(b.z1-b.z0+1)>4096)return;
429+
var sx=w/MAP.vp.width,sy=h/MAP.vp.height;
430+
var size=16*MAP.view.scale;
431+
/* Too small to read as anything but noise; leave it plain. */
432+
if(size*sx<3)return;
433+
var accent=mapAccent();var ungen=0;
434+
for(var cz=b.z0;cz<=b.z1;cz++)for(var cx=b.x0;cx<=b.x1;cx++){
435+
if(MAP_TILES[mapTileKey(cx,cz)]!==null)continue;
436+
var p=mapW2S({x:cx*16,z:cz*16});
437+
var x=p.x*sx,y=p.y*sy,d=size*sx,dh2=size*sy;
438+
g.fillStyle='rgba('+accent+',0.055)';
439+
g.fillRect(x,y,d+1,dh2+1);ungen++}
440+
mapUngenNote(ungen,(b.x1-b.x0+1)*(b.z1-b.z0+1))}
441+
/* How much of the view is area nobody has generated, said in words. A hatch on
442+
its own is just a colour; the sentence is what stops someone waiting. */
443+
function mapUngenNote(ungen,total){
444+
var el=document.getElementById('mpUngen');if(!el)return;
445+
var show=total>0&&ungen/total>0.15;
446+
el.classList.toggle('hidden',!show);
447+
if(show)el.textContent='Shaded area has never been generated — no player has been there, so there is nothing to draw.'}
448+
/* The site accent as an "r,g,b" triple, so the hatch belongs to the theme
449+
rather than being a hardcoded red. */
450+
function mapAccent(){
451+
try{
452+
var v=getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
453+
var m=/^#([0-9a-f]{6})$/i.exec(v);
454+
if(m){var n=parseInt(m[1],16);return ((n>>16)&255)+','+((n>>8)&255)+','+(n&255)}
455+
}catch(e){}
456+
return '220,39,39'}
404457
function mapDrawTiles(g,w,h){
405458
if(!MAP.world)return;
406459
var chunks=mapDrawableChunks();
@@ -471,6 +524,7 @@ function mapDraw(){
471524
var g=cv.getContext('2d');g.clearRect(0,0,w,h);
472525
var sx=w/MAP.vp.width,sy=h/MAP.vp.height;
473526
/* The world first: everything else is drawn on top of it. */
527+
mapDrawUngenerated(g,w,h,dpr);
474528
mapDrawTiles(g,w,h);
475529
mapFetchTiles();
476530
var px=function(x){return mapW2S({x:x,z:0}).x*sx};

0 commit comments

Comments
 (0)