Files
samplez/tunie/viewer/template.html
uhryniuk 80c9373ded Mark device-flag confidence honestly (SAI confirmed, O2 probable)
The NO-SAI-NO-O2 reference map bundles SAI + O2 + airbox deletes, so it validates
the three flag bytes as a GROUP but doesn't isolate O2 individually; no single-mod
map exists and the x.a() device-layout branch for this ECU is too nested to trace.

- SAI @ 0x53801: confirmed (code lc() -> Devices[0] via fe[33], + the delete map).
- O2 @ 0x53818/0x53819: probable (2 O2 sensors declared off <-> 2 adjacent bytes
  cleared; 865 has no air-flap so airbox removal is fuel-only).

Viewer now shows confirmed/probable badges and a warning that flags alone are not
a tune: O2 delete forces open-loop and needs fuel enrichment, so prefer flashing a
complete matching delete map over hand-toggling a stock one. See DEVICES.md.
2026-08-11 08:16:22 -05:00

564 lines
27 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>tunie — Triumph tuning viewer</title>
<style>
:root{
--bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a323d;
--fg:#e6edf3; --dim:#8b949e; --accent:#4cc4ff; --hot:#ff5a52; --ok:#51d88a;
--target:#ffb454;
}
*{box-sizing:border-box}
body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
background:var(--bg);color:var(--fg)}
header{padding:14px 20px;border-bottom:1px solid var(--line);
display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;background:var(--panel)}
header h1{margin:0;font-size:17px;letter-spacing:.3px}
header .sub{color:var(--dim);font-size:12px}
nav{display:flex;gap:4px;padding:8px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
nav button{background:none;border:0;color:var(--dim);padding:8px 14px;border-radius:7px;
cursor:pointer;font-size:13px}
nav button:hover{background:var(--panel2);color:var(--fg)}
nav button.active{background:var(--accent);color:#04121c;font-weight:600}
main{padding:18px 20px;max-width:1200px}
.tab{display:none} .tab.active{display:block}
.controls{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
input,select{background:var(--panel2);border:1px solid var(--line);color:var(--fg);
padding:7px 10px;border-radius:7px;font-size:13px}
input[type=text]{min-width:220px}
.chip{background:var(--panel2);border:1px solid var(--line);border-radius:20px;padding:6px 12px;
cursor:pointer;font-size:12px;color:var(--dim)}
.chip.on{background:var(--accent);color:#04121c;border-color:var(--accent);font-weight:600}
.count{color:var(--dim);font-size:12px;margin-left:auto}
table{border-collapse:collapse;width:100%;font-size:13px}
th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{color:var(--dim);font-weight:600;position:sticky;top:0;background:var(--bg);cursor:pointer;user-select:none}
tr:hover td{background:var(--panel)}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.id{color:var(--accent);font-weight:600}
.mine{box-shadow:inset 3px 0 0 var(--ok)}
.desc{color:var(--dim);white-space:pre-line;font-size:12px}
.grid-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
.card h3{margin:0 0 4px;font-size:14px}
.card .note{color:var(--dim);font-size:12px;margin-bottom:10px}
.item{padding:4px 0;border-bottom:1px solid var(--line);display:flex;gap:8px}
.item:last-child{border:0}
.item .n{color:var(--dim);width:26px;text-align:right;flex:none}
.item.target{color:var(--target);font-weight:600}
.badge{font-size:10px;background:var(--target);color:#1a1206;border-radius:4px;padding:1px 5px;margin-left:6px}
.drop{border:2px dashed var(--line);border-radius:12px;padding:40px;text-align:center;color:var(--dim);
cursor:pointer;margin-bottom:16px}
.drop.hover{border-color:var(--accent);color:var(--fg);background:var(--panel)}
.tv-controls{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin-bottom:14px}
.tv-controls label{display:flex;flex-direction:column;gap:3px;font-size:11px;color:var(--dim)}
.heat{display:grid;gap:1px;margin-top:10px;overflow:auto}
.cell{padding:4px 2px;text-align:center;font-size:10px;border-radius:2px;color:#0a0a0a;font-family:ui-monospace,monospace}
.hexdump{font-family:ui-monospace,monospace;font-size:12px;color:var(--dim);white-space:pre;
background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;max-height:340px;margin-top:14px}
.hint{color:var(--dim);font-size:12px;margin-top:8px}
a{color:var(--accent)}
</style>
</head>
<body>
<header>
<h1>tunie</h1>
<span class="sub" id="meta"></span>
</header>
<nav>
<button data-tab="maps" class="active">Map catalogue</button>
<button data-tab="caps">Tune capabilities</button>
<button data-tab="triumph">Triumph tables</button>
<button data-tab="table">Raw table viewer</button>
</nav>
<main>
<section id="tab-maps" class="tab active">
<div class="controls">
<input type="text" id="q" placeholder="search model, VIN, market…">
<select id="ecu"><option value="">All ECUs</option></select>
<select id="odo">
<option value="">Any odometer</option>
<option value="mechanical odometer">Mechanical odo</option>
<option value="lcd odometer">LCD odo</option>
</select>
<span class="chip" id="mineChip">★ My bike (Keihin · mechanical · Bonneville)</span>
<span class="count" id="mapCount"></span>
</div>
<table id="mapTable">
<thead><tr>
<th data-k="id">Map</th><th data-k="ecu">ECU</th><th data-k="model">Details</th>
</tr></thead>
<tbody></tbody>
</table>
</section>
<section id="tab-caps" class="tab">
<p class="hint">TuneECU's full editable surface, extracted from the APK. The
<span style="color:var(--target)">highlighted</span> devices are the SAI / O2 / exhaust
items this project plans to delete.</p>
<div class="grid-cards" id="caps"></div>
</section>
<section id="tab-triumph" class="tab">
<p class="hint">Drop a real TuneECU Triumph <span class="mono">.hex</span> map (e.g.
<span class="mono">20187Map.hex</span>). It's decoded, unpacked to the flat ROM, and its
fuel/ignition tables are rendered with real RPM/throttle axes — no XDF needed. Drop a
second map to see the difference (e.g. production vs aftermarket enrichment).</p>
<div class="controls" id="tcatRow" style="display:none">
<label style="color:var(--dim);font-size:12px">Catalogue A
<select id="tcatA"><option value="">— pick a downloaded map —</option></select></label>
<label style="color:var(--dim);font-size:12px">Catalogue B (diff)
<select id="tcatB"><option value="">— none —</option></select></label>
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap">
<div class="drop" id="tdropA" style="flex:1;min-width:260px;padding:22px">Map A — drop <span class="mono">.hex</span>
<input type="file" id="tfileA" style="display:none"></div>
<div class="drop" id="tdropB" style="flex:1;min-width:260px;padding:22px">Map B (optional, for diff)
<input type="file" id="tfileB" style="display:none"></div>
</div>
<div id="tBody" style="display:none">
<div class="controls">
<select id="tsel"></select>
<span class="chip" id="tdiffChip">Δ Show A→B difference</span>
<span class="count" id="tinfo"></span>
</div>
<div style="overflow:auto"><table id="tgrid" class="mono" style="font-size:11px"></table></div>
<div class="card" id="tdevices" style="margin-top:16px;max-width:520px">
<h3>Device flags <span class="badge">Map A</span></h3>
<div class="note">Emissions/hardware device flags (1=on, 0=off).
<span style="color:var(--ok)">SAI = confirmed</span>;
<span style="color:var(--target)">O2 = probable</span> (validated as a group vs a
real NO-SAI-NO-O2 map, but not individually isolated).</div>
<div id="tdevlist"></div>
<div class="hint" style="margin-top:10px;color:var(--hot)"><b>⚠ Flags alone are not a tune.</b>
Disabling O2 forces open-loop — the bike runs only on the base fuel map. On a stock map that
can run lean/rough. A proper delete pairs flags-off with fuel enrichment. Prefer flashing a
complete, matching delete map over hand-toggling a stock one.</div>
<button id="tdevExport" class="chip" style="border-color:var(--ok);color:var(--ok);margin-top:10px">⭳ Export edited .hex</button>
<span class="count" id="tdevdirty"></span>
<div class="hint" style="margin-top:8px">Export re-encodes the distribution format only; the ECU-flash
checksum is applied by the flashing tool at write time.</div>
</div>
</div>
</section>
<section id="tab-table" class="tab">
<div class="drop" id="drop">Drop a map file here (<span class="mono">.bin</span> or Intel <span class="mono">.hex</span>) — or click to choose.
<input type="file" id="file" style="display:none">
</div>
<div id="tvBody" style="display:none">
<div class="tv-controls">
<label>Known table
<select id="known"><option value="">— pick / manual —</option></select>
</label>
<label>Offset (hex)<input type="text" id="off" value="0"></label>
<label>Columns<input type="number" id="cols" value="16" min="1"></label>
<label>Rows<input type="number" id="rows" value="16" min="1"></label>
<label>Cell bytes
<select id="cell"><option value="1">1 (8-bit)</option><option value="2" selected>2 (16-bit)</option></select>
</label>
<label>Endian
<select id="endian"><option value="be" selected>big</option><option value="le">little</option></select>
</label>
<label>Scale ×<input type="text" id="scale" value="1"></label>
<label>Checksum off (hex)<input type="text" id="chkoff" value="" placeholder="e.g. d8048"></label>
</div>
<div class="controls">
<span class="chip" id="editChip">✎ Edit mode</span>
<button id="download" class="chip" style="border-color:var(--ok);color:var(--ok)">⭳ Download edited copy</button>
<span class="count" id="dirty"></span>
</div>
<div id="tvInfo" class="hint"></div>
<div id="heat" class="heat"></div>
<div id="hex" class="hexdump"></div>
</div>
<p class="hint"><b>Editing:</b> turn on ✎ Edit mode, click a cell, type a new value, Enter. The bytes
are written in place and — if a checksum offset is set — the running 16-bit checksum word is patched
by <span class="mono">(old − new)</span>, exactly as TuneECU does. Then Download the modified copy.
“Known table” lists the <span id="tgN">0</span> candidate offsets extracted from TuneECU; verify them
against a real ROM before trusting them.</p>
</section>
</main>
<script id="data" type="application/json">/*__DATA__*/</script>
<script>
const DATA = JSON.parse(document.getElementById('data').textContent);
document.getElementById('meta').textContent =
`${DATA.meta.mapCount} maps · ${DATA.meta.source}`;
// ---- tabs ----
document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>{
document.querySelectorAll('nav button').forEach(x=>x.classList.remove('active'));
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
document.getElementById('tab-'+b.dataset.tab).classList.add('active');
});
// ---- maps ----
const maps = DATA.maps;
const ecuSel = document.getElementById('ecu');
[...new Set(maps.map(m=>m.ecu))].sort().forEach(e=>{
const o=document.createElement('option'); o.value=e;
o.textContent='Type '+e+(e==='0'?' (Triumph Keihin)':e==='1'?' (Sagem)':e==='P'?' (Bosch)':'');
ecuSel.appendChild(o);
});
let mineOnly=false;
const q=document.getElementById('q'), odo=document.getElementById('odo'),
tbody=document.querySelector('#mapTable tbody'), mapCount=document.getElementById('mapCount');
document.getElementById('mineChip').onclick=e=>{mineOnly=!mineOnly;e.target.classList.toggle('on',mineOnly);render();};
[q,ecuSel,odo].forEach(el=>el.oninput=render);
function isMine(m){
const b=m.description.join(' ').toLowerCase();
return m.ecu==='0' && b.includes('mechanical odometer') && b.includes('bonneville');
}
function render(){
const needle=q.value.toLowerCase(), e=ecuSel.value, od=odo.value;
let rows=maps.filter(m=>{
const b=m.description.join(' ').toLowerCase();
if(needle && !b.includes(needle) && !m.id.includes(needle)) return false;
if(e && m.ecu!==e) return false;
if(od && !b.includes(od)) return false;
if(mineOnly && !isMine(m)) return false;
return true;
});
tbody.innerHTML='';
rows.slice(0,600).forEach(m=>{
const tr=document.createElement('tr');
if(isMine(m)) tr.className='mine';
tr.innerHTML=`<td class="id mono">${m.id}</td><td class="mono">${m.ecu}</td>`+
`<td><b>${m.description[0]||''}</b><div class="desc">${m.description.slice(1).join('\n')}</div></td>`;
tbody.appendChild(tr);
});
mapCount.textContent=`${rows.length} match${rows.length===1?'':'es'}`+
(rows.length>600?' (showing 600)':'');
}
render();
// ---- capabilities ----
const targets=new Set(DATA.modTargets);
const capWrap=document.getElementById('caps');
Object.values(DATA.definitions).forEach(d=>{
if(!d.items.length) return;
const c=document.createElement('div'); c.className='card';
c.innerHTML=`<h3>${d.label}</h3><div class="note">${d.note}</div>`;
d.items.forEach(it=>{
const isT=targets.has(it.text);
const row=document.createElement('div'); row.className='item'+(isT?' target':'');
row.innerHTML=`<span class="n">${it.i}</span><span>${it.text}${isT?'<span class="badge">mod target</span>':''}</span>`;
c.appendChild(row);
});
capWrap.appendChild(c);
});
// ---- Triumph real-map tables ----
const RD = DATA.romdefs || null;
if(!RD){ document.querySelector('nav button[data-tab=triumph]').style.display='none'; }
else {
const I2={0:0x80808080,1:0x80808080,2:0x84808081,3:0x87848284};
function dcDecode(raw){
const b=new Uint8Array(raw); const i5=b[3]-24;
const i2 = (i5 in I2)?I2[i5] : ((i5+3)<<24)|((i5+1)<<8)|i5|((i5+2)<<16);
const key=((i2 | (b[0]|(b[1]<<8)|(b[2]<<16)|(b[3]<<24)))>>>0);
let prev=0;
for(let i=4;i<b.length;i++){ const c=b[i]; const ks=(key>>>(((i-4)%4)*8))&0xff; b[i]=(prev^c^ks)&0xff; prev=c; }
return b;
}
const le=(b,o,n)=>{let v=0;for(let i=0;i<n;i++)v|=b[o+i]<<(8*i);return v>>>0;};
const be16=(b,o)=>((b[o]<<8)|b[o+1]);
function unpackInfo(dec){
const i21=le(dec,28,2);
if(le(dec,i21+31,2)!==0x6F66) throw new Error('bad unpack marker');
const cnt=dec[i21+33]; const ents=[]; let p=i21+34+cnt*8; const dstart=p;
for(let k=0;k<cnt;k++){ const o=le(dec,i21+34+k*8,4), l=le(dec,i21+38+k*8,4); ents.push([o,l]); }
return {ents, dstart};
}
function flatRom(dec){
const {ents,dstart}=unpackInfo(dec);
let sz=0; ents.forEach(([o,l])=>sz=Math.max(sz,o+l));
const rom=new Uint8Array(sz).fill(0xff); let p=dstart;
ents.forEach(([o,l])=>{ rom.set(dec.subarray(p,p+l), o); p+=l; });
return rom;
}
// Map a flat-ROM offset back to its position in the packed/decoded map.
function flatToDecoded(off, ents, dstart){
let p=dstart;
for(const [o,l] of ents){ if(off>=o && off<o+l) return p+(off-o); p+=l; }
return -1;
}
function resolve(dec){
let sig=(dec[20]<<24|dec[21]<<16|dec[22]<<8|dec[23])>>>0;
if(dec[0]===0x67) sig=((sig&0xFFFF0000)|(((sig&0xFFFF)+dec[25])&0xFFFF))>>>0;
let qd=null;
for(let i=0;i<RD.sa.length/8;i++){ if((RD.sa[i*8]>>>0)===sig){ qd=RD.sa[i*8+1]; break; } }
if(qd===null) throw new Error('signature not in directory');
const fe=RD.ca.slice(qd*48, qd*48+48);
return {fe, base:(fe[0]&0x2F0)<<12, qd};
}
function readTable(rom, off){
const g=[]; for(let r=0;r<RD.rows;r++){ const row=[]; for(let c=0;c<RD.cols;c++) row.push(be16(rom, off+(r*RD.cols+c)*2)); g.push(row); } return g;
}
function loadMap(raw){
const dec=dcDecode(raw); const rom=flatRom(dec); const {fe,base}=resolve(dec);
const {ents,dstart}=unpackInfo(dec);
const rpm=[],thr=[];
for(let i=0;i<RD.rows;i++) rpm.push(be16(rom, base+(fe[RD.rpmFe]&0x7FFFF)+i*2));
for(let i=0;i<RD.cols;i++) thr.push(be16(rom, base+(fe[RD.throttleFe]&0x7FFFF)+i*2));
const tables=RD.tables.map(t=>({...t, off:base+(fe[t.fe]&0x7FFFF)}));
const devBase=base+(fe[RD.deviceFe]&0x7FFFF);
const devices=(RD.devices||[]).map(d=>({...d, off:devBase+d.rel}));
return {raw, dec, rom, base, ents, dstart, fe, rpm, thr, tables, devices,
desc:new TextDecoder().decode(dec.subarray(30,30+le(dec,28,2)))};
}
// Flip a device byte in the flat ROM + packed map, and return a downloadable .hex.
function setDeviceByte(m, off, val){
m.rom[off]=val;
const dpos=flatToDecoded(off, m.ents, m.dstart);
if(dpos>=0) m.dec[dpos]=val;
}
function exportEdited(m){
// re-encode the (edited) decoded map back to the .hex cipher form
const out=dcEncode(m.dec);
const blob=new Blob([out],{type:'application/octet-stream'});
const a=document.createElement('a'); a.href=URL.createObjectURL(blob);
a.download=(m.desc.split('\n')[0]||'map').replace(/\W+/g,'_')+'.edited.hex';
a.click(); URL.revokeObjectURL(a.href);
}
function dcEncode(dec){
const b=Uint8Array.from(dec); const i5=b[3]-24;
const i2=(i5 in I2)?I2[i5]:((i5+3)<<24)|((i5+1)<<8)|i5|((i5+2)<<16);
const key=((i2 | (b[0]|(b[1]<<8)|(b[2]<<16)|(b[3]<<24)))>>>0);
let prev=0;
for(let i=4;i<b.length;i++){ const ks=(key>>>(((i-4)%4)*8))&0xff; const o=(prev^b[i]^ks)&0xff; b[i]=o; prev=o; }
return b;
}
let TA=null, TB=null, tdiff=false;
function wireDrop(dropId, fileId, setter){
const drop=document.getElementById(dropId), inp=document.getElementById(fileId);
drop.onclick=()=>inp.click();
drop.ondragover=e=>{e.preventDefault();drop.classList.add('hover');};
drop.ondragleave=()=>drop.classList.remove('hover');
const go=f=>{ if(!f)return; const r=new FileReader();
r.onload=()=>{ try{ setter(loadMap(r.result), f.name, drop); }catch(e){ drop.textContent=f.name+' — '+e.message; } };
r.readAsArrayBuffer(f); };
drop.ondrop=e=>{e.preventDefault();drop.classList.remove('hover');go(e.dataTransfer.files[0]);};
inp.onchange=e=>go(e.target.files[0]);
}
wireDrop('tdropA','tfileA',(m,name,drop)=>{ TA=m; drop.textContent=name+' — '+m.desc.split('\n')[0]; initTriumph(); });
wireDrop('tdropB','tfileB',(m,name,drop)=>{ TB=m; drop.textContent=name+' — '+m.desc.split('\n')[0]; renderT(); });
// Catalogue dropdowns (only when served over http, so fetch() works).
if(location.protocol.startsWith('http')){
fetch('maps_cache/index.json').then(r=>r.ok?r.json():null).then(list=>{
if(!list||!list.length) return;
document.getElementById('tcatRow').style.display='flex';
const fill=sel=>list.forEach(m=>{ const o=document.createElement('option');
o.value=m.file; o.textContent=`${m.id} — ${(m.desc[0]||'')}${m.desc[1]?' · '+m.desc[1]:''}`;
sel.appendChild(o); });
const A=document.getElementById('tcatA'), B=document.getElementById('tcatB'); fill(A); fill(B);
const pick=(sel,isB)=>{ if(!sel.value) return;
fetch('maps_cache/'+sel.value).then(r=>r.arrayBuffer()).then(buf=>{
const m=loadMap(buf);
if(isB){ TB=m; renderT(); } else { TA=m; initTriumph(); } }); };
A.onchange=()=>pick(A,false); B.onchange=()=>pick(B,true);
}).catch(()=>{});
}
let devDirty=0;
function initTriumph(){
document.getElementById('tBody').style.display='block';
const sel=document.getElementById('tsel'); sel.innerHTML='';
TA.tables.forEach((t,i)=>{ const o=document.createElement('option'); o.value=i;
o.textContent=`${t.name} (0x${t.off.toString(16)})`; sel.appendChild(o); });
sel.onchange=renderT; renderT();
renderDevices();
}
function renderDevices(){
const wrap=document.getElementById('tdevlist'); wrap.innerHTML=''; devDirty=0;
document.getElementById('tdevdirty').textContent='';
if(!TA.devices||!TA.devices.length){ document.getElementById('tdevices').style.display='none'; return; }
document.getElementById('tdevices').style.display='block';
TA.devices.forEach(d=>{
const on=TA.rom[d.off]!==0;
const row=document.createElement('label');
row.className='item'; row.style.cursor='pointer';
const badge=d.confidence==='confirmed'
? '<span class="badge" style="background:var(--ok)">confirmed</span>'
: '<span class="badge">probable</span>';
row.innerHTML=`<input type="checkbox" ${on?'checked':''}> <span>${d.name} ${badge}
<span style="color:var(--dim)">· 0x${d.off.toString(16)} = ${TA.rom[d.off]}</span></span>`;
row.querySelector('input').onchange=e=>{
setDeviceByte(TA, d.off, e.target.checked?1:0);
row.querySelector('span span').textContent=`· 0x${d.off.toString(16)} = ${TA.rom[d.off]}`;
devDirty++; document.getElementById('tdevdirty').textContent=devDirty+' change'+(devDirty===1?'':'s');
};
wrap.appendChild(row);
});
}
document.getElementById('tdevExport').onclick=()=>{ if(TA) exportEdited(TA); };
document.getElementById('tdiffChip').onclick=e=>{ tdiff=!tdiff; e.target.classList.toggle('on',tdiff); renderT(); };
function renderT(){
if(!TA) return;
const idx=+document.getElementById('tsel').value;
const t=TA.tables[idx]; const A=readTable(TA.rom,t.off);
const B=(tdiff&&TB)?readTable(TB.rom, TB.tables[idx].off):null;
let vals=[]; for(let r=0;r<RD.rows;r++)for(let c=0;c<RD.cols;c++) vals.push(B?B[r][c]-A[r][c]:A[r][c]);
const mn=Math.min(...vals), mx=Math.max(...vals), span=(mx-mn)||1;
const g=document.getElementById('tgrid');
let h='<tr><th style="position:sticky;left:0">RPM\TP%</th>';
TA.thr.forEach(v=>h+=`<th>${(v/10).toFixed(0)}</th>`); h+='</tr>';
for(let r=0;r<RD.rows;r++){
h+=`<tr><th style="position:sticky;left:0">${TA.rpm[r]}</th>`;
for(let c=0;c<RD.cols;c++){
const v=B?B[r][c]-A[r][c]:A[r][c]; const tt=(v-mn)/span; const hue=(1-tt)*220;
const txt=B?(v>0?'+'+v:v):v;
h+=`<td style="padding:2px 4px;background:hsl(${hue},70%,${30+tt*28}%);color:#0a0a0a">${txt}</td>`;
}
h+='</tr>';
}
g.innerHTML=h;
document.getElementById('tinfo').textContent = (B?`Δ (B−A) `:'')+
`${t.name} · ${RD.rows}×${RD.cols} · range ${mn}…${mx}`+(B&&!TB?'':'');
}
}
// ---- raw table viewer ----
let BYTES=null;
const drop=document.getElementById('drop'), fileIn=document.getElementById('file');
drop.onclick=()=>fileIn.click();
drop.ondragover=e=>{e.preventDefault();drop.classList.add('hover');};
drop.ondragleave=()=>drop.classList.remove('hover');
drop.ondrop=e=>{e.preventDefault();drop.classList.remove('hover');load(e.dataTransfer.files[0]);};
fileIn.onchange=e=>load(e.target.files[0]);
function load(f){
if(!f) return;
FNAME=f.name;
const r=new FileReader();
r.onload=()=>{
let buf=new Uint8Array(r.result);
if(f.name.toLowerCase().endsWith('.hex')) buf=parseIntelHex(new TextDecoder().decode(buf));
BYTES=buf;
drop.textContent=`${f.name} — ${BYTES.length.toLocaleString()} bytes loaded`;
document.getElementById('tvBody').style.display='block';
draw();
};
r.readAsArrayBuffer(f);
}
function parseIntelHex(txt){
const out=[]; let base=0;
txt.split(/\r?\n/).forEach(l=>{
if(l[0]!==':') return;
const len=parseInt(l.substr(1,2),16), addr=parseInt(l.substr(3,4),16), type=parseInt(l.substr(7,2),16);
if(type===0x04){ base=parseInt(l.substr(9,4),16)<<16; }
else if(type===0x00){
for(let i=0;i<len;i++){ out[base+addr+i]=parseInt(l.substr(9+i*2,2),16); }
}
});
const max=out.length; const b=new Uint8Array(max);
for(let i=0;i<max;i++) b[i]=out[i]||0;
return b;
}
// ---- known-table picklist from extracted geometry ----
let EDIT=false, DIRTY=0, FNAME='map.bin';
const known=document.getElementById('known');
const allTables=[];
(DATA.geometry||[]).forEach(g=>g.tables.forEach(t=>allTables.push({...t,group:g.group})));
document.getElementById('tgN').textContent=allTables.length;
// de-dup by offset for a saner list
const seen=new Set();
allTables.filter(t=>!seen.has(t.offset)&&seen.add(t.offset))
.sort((a,b)=>a.offset-b.offset).forEach(t=>{
const o=document.createElement('option');
o.value=t.offset; o.textContent=`${t.offset_hex} · ${t.length} bytes (grp ${t.group})`;
known.appendChild(o);
});
known.onchange=()=>{
if(!known.value) return;
const t=allTables.find(x=>x.offset==known.value);
document.getElementById('off').value=(+known.value).toString(16);
// guess a square-ish 16-bit layout from the byte length
const cells=Math.floor(t.length/2), c=Math.min(16,cells)||1;
document.getElementById('cols').value=c;
document.getElementById('rows').value=Math.max(1,Math.floor(cells/c));
draw();
};
document.getElementById('editChip').onclick=e=>{EDIT=!EDIT;e.target.classList.toggle('on',EDIT);draw();};
document.getElementById('download').onclick=()=>{
if(!BYTES) return;
const blob=new Blob([BYTES],{type:'application/octet-stream'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob); a.download=FNAME.replace(/(\.\w+)?$/,'.edited$1');
a.click(); URL.revokeObjectURL(a.href);
};
['off','cols','rows','cell','endian','scale','chkoff'].forEach(id=>document.getElementById(id).oninput=draw);
function readCell(p,cw,be){ return cw===1 ? BYTES[p] : (be?(BYTES[p]<<8)|BYTES[p+1]:(BYTES[p+1]<<8)|BYTES[p]); }
function writeCell(p,cw,be,v){
v=Math.max(0,Math.round(v)) & (cw===1?0xff:0xffff);
const old=readCell(p,cw,be);
if(cw===1){ BYTES[p]=v; }
else if(be){ BYTES[p]=v>>8; BYTES[p+1]=v&0xff; } else { BYTES[p+1]=v>>8; BYTES[p]=v&0xff; }
// patch the running 16-bit checksum word, TuneECU-style: chk += (old - new)
const co=parseInt(document.getElementById('chkoff').value,16);
if(!isNaN(co) && cw===2 && co!==p){
let chk=(BYTES[co]<<8)|BYTES[co+1];
chk=(chk + old - v) & 0xffff;
BYTES[co]=chk>>8; BYTES[co+1]=chk&0xff;
}
DIRTY++; document.getElementById('dirty').textContent=DIRTY+' edit'+(DIRTY===1?'':'s');
}
function draw(){
if(!BYTES) return;
const off=parseInt(document.getElementById('off').value,16)||0;
const cols=+document.getElementById('cols').value, rows=+document.getElementById('rows').value;
const cw=+document.getElementById('cell').value, be=document.getElementById('endian').value==='be';
const scale=parseFloat(document.getElementById('scale').value)||1;
const vals=[]; let p=off;
for(let i=0;i<cols*rows;i++){ vals.push(readCell(p,cw,be)*scale); p+=cw; }
const mn=Math.min(...vals), mx=Math.max(...vals), span=mx-mn||1;
const heat=document.getElementById('heat');
heat.style.gridTemplateColumns=`repeat(${cols},1fr)`;
heat.innerHTML='';
vals.forEach((v,i)=>{
const t=(v-mn)/span, hue=(1-t)*220;
const cell=document.createElement('div'); cell.className='cell';
cell.style.background=`hsl(${hue},75%,${35+t*25}%)`;
cell.textContent = Number.isInteger(v)?v:v.toFixed(1);
if(EDIT){
cell.style.cursor='cell'; cell.style.outline='1px solid rgba(255,255,255,.15)';
cell.onclick=()=>{
const raw=prompt(`New value (scaled ×${scale}) for cell r${Math.floor(i/cols)} c${i%cols}:`, v);
if(raw===null) return;
const nv=parseFloat(raw); if(isNaN(nv)) return;
writeCell(off+i*cw, cw, be, nv/scale);
draw();
};
}
heat.appendChild(cell);
});
document.getElementById('tvInfo').textContent=
`range ${mn.toLocaleString()} … ${mx.toLocaleString()} over ${cols}×${rows} cells from 0x${off.toString(16)}`+
(EDIT?' · edit mode ON — click a cell':'');
let dump=''; const start=off & ~0xf;
for(let a=start;a<start+(rows*cols*cw)+16 && a<BYTES.length; a+=16){
let hex='',asc='';
for(let i=0;i<16;i++){ const b=BYTES[a+i]; if(b===undefined)break;
hex+=b.toString(16).padStart(2,'0')+' '; asc+=(b>=32&&b<127)?String.fromCharCode(b):'.'; }
dump+=a.toString(16).padStart(6,'0')+' '+hex.padEnd(48)+' '+asc+'\n';
}
document.getElementById('hex').textContent=dump;
}
</script>
</body>
</html>