Files
samplez/tunie/viewer/template.html
uhryniuk eaa804fc35 Add validated SAI/O2 device-flag checkbox editor
Reversed the device-flag mechanism (l.java lc() + ee bit logic) and validated the
flag locations against a real reference map: 20188Map2009AIRBOXBONNY (explicitly
"NO SAI, NO O2 SENSORS"). Diffing its flat ROM vs stock 20188 isolated exactly
three byte-boolean flags that flip 1->0:

  0x53801  SAI            (= base + fe[33] + 0x00)
  0x53818  O2 sensor      (+ 0x17)
  0x53819  O2 sensor (2)  (+ 0x18)

1 = enabled, 0 = disabled. Neither stock map could reveal these (both have SAI+O2
on); the delete map was the key. See research/reference-maps/DEVICES.md.

Viewer: the Triumph-tables tab now has a Device flags panel — checkboxes reflect
the loaded map's real state (stock: all on; delete map: all off), toggling flips
the byte, and "Export edited .hex" re-encodes the distribution format. The
decode->edit->encode round-trip is byte-exact (verified). The caXX header bytes
are map-ID metadata, not a cal checksum; the ECU-flash checksum is applied at
write time.
2026-08-11 08:04:13 -05:00

555 lines
26 KiB
HTML
Raw 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">Toggle emissions/hardware devices. Validated against a real
NO-SAI-NO-O2 map. Uncheck to delete; then Export the edited map.</div>
<div id="tdevlist"></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 map's distribution format.
The separate 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';
row.innerHTML=`<input type="checkbox" ${on?'checked':''}> <span>${d.name}
<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>