Logbook progress map in the style of decomp.dev: "<project> is NN.NN% decompiled", a layered Code bar (exact solid, verified lighter, readable hatched, with per-segment tooltips), a Data bar (named globals of the data addresses the code touches), Previous / Next / Latest through the history reports, a Units dropdown and filter (text, <70%, >10kb), and a canvas treemap of units (original source files, else 64 KB blocks) that drills into functions

- GET /api/logbook/progress-map: one compact document, cached until an input changes (main's HEAD, the Logbook symbol table and statistics, match/emulator/library results, facts, names, source files, globals, deferral notes); ?have=<version> answers {"same": true}
- definitions follow the Logbook's progress.py (pc102.csv category game, status decompiled or later), so the headline equals game_bytes_decompiled_percent; engine, library and unsorted code are shown but not counted
- history points now record the commit and the game-code bytes
- the page lays out once per load, filter or size change, hit-tests hover on the computed rectangles, builds the 600-entry units list only when opened, and polls once a minute only while the Logbook is visible
This commit is contained in:
KeelrLeek committed 2026-10-06 15:46:42 -05:00
1 parent 315b63b571
commit d5eb82261e
5 files changed
+556 -4

No files matched your search

+149 -1
View File
@@ -56,6 +56,25 @@ body { overflow: hidden; }
.view { display: none; max-width: 1380px; margin: 0 auto; gap: 20px; }
.view.on { display: grid; }
.lb { --lb-game: var(--blue); --lb-engine: var(--violet); --lb-platform: var(--amber); --lb-crt: var(--muted); --lb-unsorted: var(--line-2); --lb-done: var(--green); }
/* progress map (decomp.dev style) */
.lb { --pm-exact: var(--green); --pm-verified: color-mix(in srgb, var(--green) 62%, var(--surface)); --pm-readable: color-mix(in srgb, color-mix(in srgb, var(--green) 52%, #d8c22e) 68%, var(--surface));
--pm-raw: var(--line-2); --pm-deferred: color-mix(in srgb, var(--line-2) 62%, var(--amber)); --pm-unsorted: color-mix(in srgb, var(--line-2) 50%, var(--surface-3));
--pm-engine: color-mix(in srgb, var(--blue) 30%, var(--line-2)); --pm-library: color-mix(in srgb, var(--blue) 22%, var(--line-2)); }
.pm-title { margin: 0 0 14px; font: 700 22px/1.25 var(--font-display); letter-spacing: -.4px; } .pm-title b { color: var(--green); font-variant-numeric: tabular-nums; }
.pm-bars { display: grid; gap: 12px; }
.pm-bar-l { display: flex; align-items: baseline; gap: 6px; font-size: 13px; margin-bottom: 5px; flex-wrap: wrap; } .pm-bar-l .grow { flex: 1; } .pm-bar-l .mono { font: 600 12.5px var(--mono); font-variant-numeric: tabular-nums; }
.pm-bar { display: flex; height: 14px; border-radius: 7px; overflow: hidden; background: var(--surface-3); }
.pm-seg { display: block; height: 100%; min-width: 2px; cursor: default; }
.pm-seg.pm-exact { background: var(--pm-exact); } .pm-seg.pm-verified { background: var(--pm-verified); }
.pm-seg.pm-readable { background: repeating-linear-gradient(135deg, var(--pm-readable) 0 4px, color-mix(in srgb, var(--pm-readable) 45%, var(--surface-3)) 4px 8px); }
.pm-seg.pm-named { background: var(--blue); }
.pm-commit { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin-top: 12px; font-size: 13px; color: var(--muted); } .pm-commit b { color: var(--text); font-weight: 600; } .pm-commit .grow { flex: 1; }
.pm-tools { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; margin-top: 16px; } .pm-tools select { max-width: min(100%, 340px); } .pm-tools input { flex: 1 1 220px; min-width: 0; }
.pm-crumb { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin-top: 12px; font-size: 13px; } .pm-crumb:empty { display: none; } .pm-crumb .pm-bar-row { flex: 1 1 100%; }
.pm-wrap { position: relative; margin-top: 12px; } canvas#pmMap { width: 100%; height: 560px; display: block; border-radius: 9px; cursor: pointer; }
.pm-tip { position: fixed; z-index: 50; pointer-events: none; max-width: 340px; padding: 8px 10px; border-radius: 8px; background: var(--surface); color: var(--text); border: 1px solid var(--line-2); box-shadow: var(--shadow-3); font-size: 12.5px; line-height: 1.45; }
.pm-tip .mono { font-family: var(--mono); font-size: 11.5px; color: var(--muted); } .pm-tip b { overflow-wrap: anywhere; }
@media (max-width: 700px) { canvas#pmMap { height: 380px; } .pm-title { font-size: 19px; } }
.lb-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 10px 20px; }
.lb-head .chip { display: inline-block; font: 600 11.5px/1 var(--font, inherit); letter-spacing: .06em; text-transform: uppercase; color: var(--accent-ink); background: var(--accent-soft); border-radius: 999px; padding: 6px 10px; }
.lb-head p { margin: 8px 0 0; color: var(--muted); max-width: 70ch; }
@@ -677,6 +696,22 @@ button.chip:hover { color: var(--accent-ink); background: var(--accent-soft); }
<div><span class="chip" id="lbMilestone">Milestone</span>
<p>Decompiling Sid Meier’s Pirates! (2004) into readable C, then rebuilding it in Rust, charted function by function against the PC 1.0.2 executable.</p></div>
<div class="lb-stamp" id="lbStamp">Loading…</div></div></section>
<section class="card" aria-labelledby="h-pm" id="pmCard">
<div class="body">
<h2 class="pm-title" id="h-pm"><span id="pmProject">The project</span> is <b id="pmPct">–</b> decompiled</h2>
<div class="pm-bars" id="pmBars"></div>
<div class="pm-commit"><span id="pmCommit"></span><span class="grow"></span>
<button class="btn sm" id="pmPrev" type="button">Previous</button><button class="btn sm" id="pmNext" type="button">Next</button><button class="btn sm" id="pmLatest" type="button">Latest</button></div>
<div class="note" id="pmHistNote" style="margin-top:6px"></div>
<div class="pm-tools"><select id="pmUnits" aria-label="Jump to a unit"></select>
<input type="search" id="pmFilter" placeholder="Filter: name, &lt;70%, &gt;10kb" aria-label="Filter units"><span class="note" id="pmCount"></span></div>
<div class="pm-crumb" id="pmCrumb"></div>
<div class="pm-wrap"><canvas id="pmMap" aria-label="Treemap of units, area by code bytes, colour by share decompiled"></canvas></div>
<div class="lb-legend"><span><i class="lb-sw" style="background:var(--pm-exact)"></i>exact match</span><span><i class="lb-sw" style="background:var(--pm-verified)"></i>verified in the emulator</span><span><i class="lb-sw" style="background:var(--pm-readable)"></i>readable</span><span><i class="lb-sw" style="background:var(--pm-raw)"></i>raw</span><span><i class="lb-sw" style="background:var(--pm-deferred)"></i>deferred</span><span><i class="lb-sw" style="background:var(--pm-unsorted)"></i>unsorted (not counted)</span><span><i class="lb-sw" style="background:var(--pm-engine)"></i>engine and libraries: named, not decompiled (not counted)</span></div>
<div class="note" style="margin-top:8px">A unit is an original source file (from the Xbox build’s asserts) or, without one, a 64 KB address block. Area is code bytes; a unit’s green is the share of its game-code bytes decompiled. Click a unit to see its functions.</div>
<div class="pm-tip" id="pmTip" hidden></div>
</div>
</section>
<div class="lb-figs" id="lbFigs"></div>
<section class="card" aria-labelledby="h-lbmap">
<header><h2 id="h-lbmap"><svg class="i"><use href="static/icons.svg#layout"/></svg>Chart of the executable</h2><span class="note">bytes per category along the code addresses</span></header>
@@ -1154,7 +1189,7 @@ async function renderLogbook(){try{LB=await get('api/logbook')}catch(e){put('lbS
[fmt(uns),'functions not sorted yet','each one is either game code (to decompile) or engine/library code (to name)']]
.map(([v,l,n])=>'<div class="lb-fig"><div class="v">'+esc(v)+'</div><div class="l">'+esc(l)+'</div><div class="n">'+esc(n)+'</div></div>').join(''));
put('lbLegend',LB_CATS.map(([k,n])=>'<span><i class="lb-sw'+(k==='unsorted'?' lb-hatch':'')+'" style="background:var(--lb-'+k+')"></i>'+esc(n)+'</span>').join('')+'<span><i class="lb-sw" style="background:var(--lb-done)"></i>decompiled share under each column</span>');
drawLbMap();renderLbWhere(s);renderLbTrend(LB.history);renderLbMilestones(LB.milestones);
drawLbMap();pmLoad().then(()=>{if(PM.doc)pmLayout()});renderLbWhere(s);renderLbTrend(LB.history);renderLbMilestones(LB.milestones);
el('lbLogCount').textContent=LB.log_total?LB.log_total+' entries':'';
put('lbLog',LB.log.length?LB.log.map(e=>'<li><time>'+esc(central(e.at))+'</time><div><div class="t"><span class="kind">'+esc(e.kind)+'</span>'+esc(e.title)+'</div><div class="d">'+esc(e.detail)+'</div></div></li>').join(''):'<li class="note">No entries yet.</li>')}
async function renderLbWhere(s){const total=s.bytes||0,cat=k=>(s.by_category||{})[k]||{functions:0,bytes:0},uns=cat('unsorted'),sorted=total?100*(total-uns.bytes)/total:0;
@@ -1182,6 +1217,119 @@ function renderLbMilestones(list){let last='';put('lbMilestones',list.length?lis
const items=m.items||[],done=items.filter(i=>i.done).length,li=items.map(i=>'<li class="'+(i.done?'done':'')+'">'+esc(i.text)+'</li>').join(''),open=/progress/i.test(m.state||'');
return head+'<div class="lb-ms"><h3><span class="code">'+esc(m.code)+'</span>'+esc(m.title)+'<span class="note">'+done+'/'+items.length+(m.state?' · '+esc(m.state):'')+'</span></h3>'+(open?'<ul>'+li+'</ul>':'<details data-k="ms-'+esc(m.id)+'"><summary class="note">Show '+items.length+' steps</summary><ul>'+li+'</ul></details>')+'</div>'}).join(''):empty('flag','No milestones yet',''))}
window.addEventListener('resize',()=>{if(current==='logbook')drawLbMap()});
/* progress map (decomp.dev style): one compact document per change, laid out once per load, filter or resize, drawn on a canvas */
const PM={doc:null,version:null,unit:null,hist:null,rects:[],items:[],hover:-1,base:null,colors:null,busy:false,topFilter:''};
const PM_LABEL={exact:'exact match',verified:'verified in the emulator',readable:'readable',deferred:'deferred',raw:'raw',unsorted:'unsorted (not counted)',engine:'engine: named, not decompiled',library:'library: named, not decompiled'};
const PM_KIND={game:'game code',unsorted:'unsorted',engine:'engine',library:'library'};
const kB=b=>b>=1e6?(b/1e6).toFixed(2)+' MB':(b/1e3).toFixed(2)+' kB';
const hx=a=>'0x'+Number(a).toString(16).toUpperCase();
const pc=(b,t)=>(t?100*b/t:0).toFixed(2);
function rel(iso){const t=Date.parse(iso||'');if(!t)return 'at an unknown time';const s=(Date.now()-t)/1000;if(s<90)return 'just now';const m=s/60;if(m<90)return Math.round(m)+' minutes ago';const h=m/60;if(h<36)return Math.round(h)+' hours ago';return Math.round(h/24)+' days ago'}
async function pmLoad(){if(PM.busy)return;PM.busy=true;try{
const d=await get('api/logbook/progress-map'+(PM.version?'?have='+encodeURIComponent(PM.version):''));if(d.same)return;
const U=d.unit_fields;d.units=d.units.map((u,i)=>{const o={i,fns:[]};U.forEach((k,j)=>o[k]=u[j]);o.pct=o.counted?100*(o.exact+o.verified+o.readable)/o.counted:null;o.lname=o.name.toLowerCase();return o});
d.functions.forEach(f=>{const o={unit:f[0],address:f[1],size:f[2],status:d.statuses[f[3]],name:f[4]};o.lname=o.name.toLowerCase();d.units[o.unit].fns.push(o)});delete d.functions;
PM.doc=d;PM.version=d.version;if(PM.unit!=null&&!d.units[PM.unit])PM.unit=null;
PM.latest=(()=>{const h=d.history;for(let i=h.length-1;i>=0;i--)if(h[i][0]===d.updated_at)return i;return h.length})();
pmUnitsSelect();pmHeader();pmLayout()}
catch(e){put('pmBars','<div class="err">'+esc(e.message)+'</div>')}finally{PM.busy=false}}
function pmBar(label,size,segs,total,extra){const sum=segs.reduce((a,s)=>a+s[1],0);
return '<div class="pm-bar-row"><div class="pm-bar-l"><b>'+esc(label)+'</b><span class="note">('+esc(size)+')</span>'+(extra?'<span class="note">'+esc(extra)+'</span>':'')+'<span class="grow"></span><span class="mono">'+pc(sum,total)+'%</span></div>'
+'<div class="pm-bar" role="img" aria-label="'+esc(label+': '+segs.filter(s=>s[1]>0).map(s=>s[2]).join('; '))+'">'+segs.map(([c,b,t])=>b>0?'<span class="pm-seg '+c+'" style="width:'+(total?100*b/total:0).toFixed(3)+'%" data-tip="'+esc(t)+'"></span>':'').join('')+'</div></div>'}
function pmCodeSegs(x,total){return [['pm-exact',x.exact,pc(x.exact,total)+'% perfect match ('+kB(x.exact)+')'],
['pm-verified',x.verified,pc(x.verified,total)+'% decompiled, verified in the emulator, not exact ('+kB(x.verified)+')'],
['pm-readable',x.readable,pc(x.readable,total)+'% decompiled, readable only ('+kB(x.readable)+')']]}
function pmHeader(){const d=PM.doc;if(!d)return;const h=d.history,pt=PM.hist!=null?h[PM.hist]:null,c=d.code;
el('pmProject').textContent=d.project;
if(!pt){el('pmPct').textContent=d.percent.toFixed(2)+'%';
put('pmBars',pmBar('Code',kB(c.bytes),pmCodeSegs(c,c.bytes),c.bytes,'game code · '+fmt(c.decompiled_functions)+' of '+fmt(c.functions)+' functions')
+(d.data?pmBar('Data',fmt(d.data.addresses)+' addresses',[['pm-named',d.data.named,pc(d.data.named,d.data.addresses)+'% of the data addresses the code touches have a name in globals.csv ('+fmt(d.data.named)+' of '+fmt(d.data.addresses)+')']],d.data.addresses,'globals named'):''));
const at=d.commit.at||d.updated_at,moved=d.main.sha&&d.commit.sha&&d.main.sha.slice(0,7)!==String(d.commit.sha).slice(0,7);
put('pmCommit','Updated <b title="'+esc(at||'')+'">'+esc(rel(at))+'</b> in commit <b class="mono">'+esc(d.commit.sha||'–')+'</b>'+(moved?' <span class="note">· main has moved on to '+esc(d.main.sha)+'; the statistics follow within minutes</span>':''));
el('pmHistNote').textContent=d.percent!==d.logbook_percent&&d.logbook_percent!=null?'The Logbook statistics say '+d.logbook_percent+'%; they are being rebuilt.':''}
else{const p=pt[1],gb=pt[3];el('pmPct').textContent=Number(p).toFixed(2)+'%';
put('pmBars',pmBar('Code',gb?kB(gb):'size not recorded',[['pm-readable',p,Number(p).toFixed(2)+'% decompiled'+(gb?' ('+kB(gb*p/100)+')':'')+'; the split into exact, verified and readable was not recorded']],100,'game code'));
put('pmCommit','Recorded <b title="'+esc(pt[0])+'">'+esc(rel(pt[0]))+'</b>'+(pt[2]?' in commit <b class="mono">'+esc(pt[2])+'</b>':' <span class="note">· commit not recorded</span>')+' <span class="note">· report '+(PM.hist+1)+' of '+h.length+'</span>');
el('pmHistNote').textContent='An earlier report: the headline and the Code bar are from then. The map and the Data bar show Latest, since per-function history is not stored.'}
el('pmPrev').disabled=!h.length||PM.hist===0||(PM.hist==null&&PM.latest<1);el('pmNext').disabled=pt==null;el('pmLatest').disabled=pt==null}
function pmStep(k){const d=PM.doc;if(!d)return;const L=PM.latest;let i=PM.hist==null?L:PM.hist;
i=k==='latest'?L:k==='prev'?Math.max(0,i-1):i+1;PM.hist=i>=L?null:i;pmHeader()}
function pmFilter(){const conds=[],words=[],cmp=(op,a,b)=>op==='<'?a<b:op==='<='?a<=b:op==='>'?a>b:a>=b;
for(const t of (el('pmFilter').value||'').trim().toLowerCase().split(/\s+/).filter(Boolean)){let m;
if((m=t.match(/^([<>]=?)(\d+(?:\.\d+)?)%$/)))conds.push(['pct',m[1],+m[2]]);
else if((m=t.match(/^([<>]=?)(\d+(?:\.\d+)?)(b|k|kb|m|mb)?$/)))conds.push(['size',m[1],+m[2]*({b:1,k:1e3,kb:1e3,m:1e6,mb:1e6}[m[3]||'b'])]);
else words.push(t)}
return (name,kind,pct,size,more)=>conds.every(([k,op,v])=>k==='pct'?pct!=null&&cmp(op,pct,v):cmp(op,size,v))&&words.every(w=>name.includes(w)||kind===w||(more?more(w):false))}
function pmLayout(){const d=PM.doc,c=el('pmMap');if(!d||!c||!c.clientWidth)return;const test=pmFilter();let items,of;
if(PM.unit==null){items=d.units.filter(u=>u.bytes>0&&test(u.lname,u.kind,u.pct,u.bytes,w=>u.fns.some(f=>f.lname.includes(w))));of=d.units.length+' units'}
else{const u=d.units[PM.unit];items=u.fns.filter(f=>f.size>0&&test(f.lname,f.status,['exact','verified','readable'].includes(f.status)?100:['engine','library'].includes(f.status)?null:0,f.size));of=u.fns.length+' functions'}
const sz=x=>x.fns?x.bytes:x.size;items.sort((a,b)=>sz(b)-sz(a));
PM.items=items;PM.w=c.clientWidth;PM.h=c.clientHeight;PM.rects=squarify(items.map(sz),0,0,PM.w,PM.h);PM.hover=-1;
el('pmCount').textContent=items.length+' of '+of+' · '+kB(items.reduce((a,x)=>a+sz(x),0));pmCrumb();pmPaint()}
function squarify(vals,x,y,w,h){const out=new Array(vals.length),total=vals.reduce((a,b)=>a+b,0);if(!total||w<=0||h<=0)return out;
const scale=w*h/total,worst=(s,mn,mx,side)=>Math.max(side*side*mx/(s*s),s*s/(side*side*mn));let i=0;
while(i<vals.length){const side=Math.min(w,h);let a=vals[i]*scale,sum=a,mn=a,mx=a,best=worst(sum,mn,mx,side),j=i+1;
while(j<vals.length){const b=vals[j]*scale,s2=sum+b,m2=Math.min(mn,b),M2=Math.max(mx,b),w2=worst(s2,m2,M2,side);if(w2>best)break;sum=s2;mn=m2;mx=M2;best=w2;j++}
if(w>=h){const cw=sum/h;let yy=y;for(let k=i;k<j;k++){const hh=vals[k]*scale/cw;out[k]=[x,yy,cw,hh];yy+=hh}x+=cw;w-=cw}
else{const rh=sum/w;let xx=x;for(let k=i;k<j;k++){const ww=vals[k]*scale/rh;out[k]=[xx,y,ww,rh];xx+=ww}y+=rh;h-=rh}
i=j}
return out}
function pmColors(){if(PM.colors)return PM.colors;const host=el('view-logbook'),probe=document.createElement('i'),cv=document.createElement('canvas');host.appendChild(probe);cv.width=cv.height=1;
const g=cv.getContext('2d',{willReadFrequently:true}),rgb=v=>{probe.style.color='var('+v+')';const s=getComputedStyle(probe).color;g.clearRect(0,0,1,1);g.fillStyle='#808080';g.fillStyle=s;g.fillRect(0,0,1,1);const p=g.getImageData(0,0,1,1).data;return [p[0],p[1],p[2]]};
const C={};['exact','verified','readable','deferred','raw','unsorted','engine','library'].forEach(k=>C[k]=rgb('--pm-'+k));C.bg=rgb('--surface');C.text=rgb('--text');probe.remove();
return PM.colors=C}
const pmCss=a=>'rgb('+a.map(v=>Math.round(v)).join(',')+')';
function pmFill(it,C){if(!it.fns)return C[it.status];if(it.kind!=='game'||it.pct==null)return C[it.kind==='unsorted'?'unsorted':it.kind==='engine'?'engine':'library'];
const t=it.pct<=0?0:.18+.82*it.pct/100;return C.raw.map((v,k)=>v+(C.exact[k]-v)*t)}
function pmPaint(){const c=el('pmMap');if(!c||!c.clientWidth)return;const r=window.devicePixelRatio||1,W=c.clientWidth,H=c.clientHeight,C=pmColors();
c.width=Math.round(W*r);c.height=Math.round(H*r);const off=PM.base||(PM.base=document.createElement('canvas'));off.width=c.width;off.height=c.height;
const g=off.getContext('2d');g.setTransform(r,0,0,r,0,0);g.fillStyle=pmCss(C.bg);g.fillRect(0,0,W,H);g.textBaseline='top';
const font=getComputedStyle(c).fontFamily;
PM.items.forEach((it,i)=>{const q=PM.rects[i];if(!q||q[2]<.5||q[3]<.5)return;const [x,y,w,h]=q,f=pmFill(it,C);g.fillStyle=pmCss(f);g.fillRect(x+.5,y+.5,Math.max(.5,w-1),Math.max(.5,h-1));
if(w<44||h<17)return;const light=(.299*f[0]+.587*f[1]+.114*f[2])>150;g.fillStyle=light?'rgba(0,0,0,.82)':'rgba(255,255,255,.92)';
g.save();g.beginPath();g.rect(x+1,y+1,w-2,h-2);g.clip();g.font='600 11.5px '+font;g.fillText(it.fns?it.name.split('/').pop():it.name,x+5,y+4);
if(h>32){g.font='11px '+font;g.globalAlpha=.8;g.fillText(it.fns?(it.pct!=null&&it.kind==='game'?it.pct.toFixed(1)+'%':PM_KIND[it.kind])+' · '+kB(it.bytes):PM_LABEL[it.status].split(':')[0]+' · '+it.size+' B',x+5,y+18)}g.restore()});
pmBlit()}
function pmBlit(){const c=el('pmMap');if(!PM.base||!c.width)return;const g=c.getContext('2d'),r=window.devicePixelRatio||1;g.setTransform(1,0,0,1,0,0);g.drawImage(PM.base,0,0);
const q=PM.rects[PM.hover];if(q){g.setTransform(r,0,0,r,0,0);g.strokeStyle=pmCss(pmColors().text);g.lineWidth=2;g.strokeRect(q[0]+1,q[1]+1,Math.max(1,q[2]-2),Math.max(1,q[3]-2))}}
function pmTipHtml(it){if(it.fns){const g=it.kind==='game'&&it.pct!=null;
return '<b>'+esc(it.name)+'</b><div class="mono">'+hx(it.lo)+'–'+hx(it.hi)+' · '+kB(it.bytes)+' · '+fmt(it.functions)+' functions</div>'
+(g?'<div><b>'+it.pct.toFixed(2)+'%</b> of '+kB(it.counted)+' game code decompiled</div><div class="mono">exact '+kB(it.exact)+' · verified '+kB(it.verified)+' · readable '+kB(it.readable)+'</div>'
:'<div>'+esc(PM_KIND[it.kind])+(it.kind==='unsorted'?': not counted until sorted':': named, not decompiled; not counted')+'</div>')+'<div class="mono">click to see its functions</div>'}
return '<b>'+esc(it.name)+'</b><div class="mono">'+hx(it.address)+'–'+hx(it.address+it.size)+' · '+fmt(it.size)+' bytes</div><div>'+esc(PM_LABEL[it.status])+'</div><div class="mono">click to inspect in the Decomp view</div>'}
function pmTip(html,x,y){const t=el('pmTip');if(html==null){t.hidden=true;t._for=null;return}if(t._for!==html){t.innerHTML=html;t._for=html}t.hidden=false;
const w=t.offsetWidth,h=t.offsetHeight;t.style.left=Math.max(8,Math.min(x+14,innerWidth-w-8))+'px';t.style.top=(y+16+h>innerHeight?y-h-10:y+16)+'px'}
function pmCrumb(){const d=PM.doc;if(PM.unit==null){put('pmCrumb','');return}const u=d.units[PM.unit];
put('pmCrumb','<button class="btn sm" type="button" id="pmBack">← All units</button><b>'+esc(u.name)+'</b><span class="tag">'+esc(PM_KIND[u.kind])+'</span><span class="note mono">'+hx(u.lo)+'–'+hx(u.hi)+' · '+fmt(u.functions)+' functions · '+kB(u.bytes)+'</span>'
+(u.counted?pmBar('Game code in this unit',kB(u.counted),pmCodeSegs(u,u.counted),u.counted):''))}
function pmDrill(i){if(PM.unit==null)PM.topFilter=el('pmFilter').value;PM.unit=i;el('pmFilter').value='';if(el('pmUnits')._filled)el('pmUnits').value=String(i);else pmUnitsSelect();pmTip(null);pmLayout()}
function pmBack(){PM.unit=null;el('pmFilter').value=PM.topFilter||'';if(el('pmUnits')._filled)el('pmUnits').value='';else pmUnitsSelect();pmTip(null);pmLayout()}
function pmUnitsSelect(){const d=PM.doc,s=el('pmUnits'),u=PM.unit!=null?d.units[PM.unit]:null;s._filled=false; /* the full list is built only when someone opens it */
put(s,'<option value="">All units ('+d.units.length+')</option>'+(u?'<option value="'+u.i+'">'+esc(u.name)+'</option>':''));s.value=u?String(u.i):''}
function pmFillUnits(){const d=PM.doc,s=el('pmUnits');if(!d||s._filled)return;s._filled=true;const groups={game:[],unsorted:[],engine:[],library:[]};d.units.forEach(u=>(groups[u.kind]||groups.library).push(u));
put(s,'<option value="">All units ('+d.units.length+')</option>'+[['game','Game code'],['unsorted','Unsorted'],['engine','Engine (Gamebryo)'],['library','Libraries']].map(([k,l])=>groups[k].length?'<optgroup label="'+esc(l)+'">'
+groups[k].sort((a,b)=>a.name.localeCompare(b.name)).map(u=>'<option value="'+u.i+'">'+esc(u.name)+(k==='game'&&u.pct!=null?' · '+Math.floor(u.pct)+'%':'')+'</option>').join('')+'</optgroup>':'').join(''));
s.value=PM.unit==null?'':String(PM.unit)}
(()=>{const c=el('pmMap');let pending=null;document.body.appendChild(el('pmTip')); /* fixed position must be relative to the viewport, not a card */
const hit=(x,y)=>{for(let i=0;i<PM.rects.length;i++){const q=PM.rects[i];if(q&&x>=q[0]&&x<q[0]+q[2]&&y>=q[1]&&y<q[1]+q[3])return i}return -1};
c.addEventListener('mousemove',e=>{const b=c.getBoundingClientRect(),i=hit(e.clientX-b.left,e.clientY-b.top);
if(i!==PM.hover){PM.hover=i;if(!pending)pending=requestAnimationFrame(()=>{pending=null;pmBlit()})}pmTip(i<0?null:pmTipHtml(PM.items[i]),e.clientX,e.clientY)});
c.addEventListener('mouseleave',()=>{PM.hover=-1;pmBlit();pmTip(null)});
c.addEventListener('click',e=>{const b=c.getBoundingClientRect(),i=hit(e.clientX-b.left,e.clientY-b.top);if(i<0)return;const it=PM.items[i];
if(it.fns)return pmDrill(it.i);pmTip(null);show('decomp');el('fnTarget').value=hx(it.address);inspectFn();el('h-inspect').scrollIntoView({block:'start'})});
const card=el('pmCard');card.addEventListener('mouseover',e=>{const s=e.target.closest('[data-tip]');if(s)pmTip(esc(s.dataset.tip),e.clientX,e.clientY)});
card.addEventListener('mousemove',e=>{const s=e.target.closest('[data-tip]');if(s)pmTip(esc(s.dataset.tip),e.clientX,e.clientY)});
card.addEventListener('mouseout',e=>{if(e.target.closest('[data-tip]'))pmTip(null)});
card.addEventListener('click',e=>{if(e.target.closest('#pmBack'))pmBack()});
['pointerdown','focus','keydown'].forEach(t=>el('pmUnits').addEventListener(t,pmFillUnits));
el('pmUnits').addEventListener('change',e=>{const v=e.target.value;if(v==='')pmBack();else pmDrill(Number(v))});
let ft=0;el('pmFilter').addEventListener('input',()=>{clearTimeout(ft);ft=setTimeout(pmLayout,180)});
el('pmPrev').onclick=()=>pmStep('prev');el('pmNext').onclick=()=>pmStep('next');el('pmLatest').onclick=()=>pmStep('latest');
let rt=0;new ResizeObserver(()=>{clearTimeout(rt);rt=setTimeout(()=>{if(current==='logbook'&&c.clientWidth&&(c.clientWidth!==PM.w||c.clientHeight!==PM.h))pmLayout()},120)}).observe(c); /* only when its size really changes */
const recolor=()=>{PM.colors=null;if(current==='logbook'&&PM.doc)pmPaint()};
new MutationObserver(recolor).observe(document.documentElement,{attributes:true,attributeFilter:['data-theme','data-palette']});
matchMedia('(prefers-color-scheme: dark)').addEventListener('change',recolor);
every(()=>{if(current==='logbook')return pmLoad()},60000,null)})();
el('fnForm').addEventListener('submit',e=>{e.preventDefault();inspectFn()});
el('refreshDecomp').addEventListener('click',renderDecomp);
document.addEventListener('click',e=>{const a=e.target.closest('[data-fn]');if(!a)return;e.preventDefault();if(current!=='decomp')show('decomp');el('fnTarget').value=a.dataset.fn;inspectFn();el('h-inspect').scrollIntoView({behavior:'smooth',block:'start'})});