Files
samplez/tunie/viewer/template.html
uhryniuk 173f6fa0c4 Locate Keihin tables + render real fuel maps in the viewer
Reversed the inner map format from l.java (Nc/Lb) and validated it against the
stock reference maps:

- reconstruct_rom.py: decoded map -> unpack directory -> 384KB flat ROM. In the
  flat ROM, production (20187) vs aftermarket (20188) differ only 1.4% (vs 96%
  packed), i.e. real fuel enrichment.
- table_map.py + TABLES.md: fe = c.a[Qd*48]; base = (fe[0]&0x2F0)<<12 = 0x50000;
  table = base + fe[k]. Located 9 tables (main/low-throttle fuel per cylinder,
  base/idle fuel, ignition by gear x4), 32 RPM rows x 20 throttle cols, with real
  axes (RPM fe[8], throttle fe[27]) and the AFR curve (fe[2], 128=lambda 1.00).
  Validated: main-fuel delta is uniformly richer in the aftermarket map.

Viewer now has a "Triumph tables" tab: drop a real .hex (or pick two) and it
decodes, unpacks, and renders the fuel/ignition tables as heatmaps with real
axes, plus an A->B difference view. build_viewer.py embeds the c.a/s.a directory
so any map resolves in-browser.

Catalogue dropdown: download_maps.py fetches map .hex files into maps_cache/;
serve.py serves the viewer over http so the dropdown can fetch them (drag-and-drop
still works on file://).

Proprietary map binaries (*.hex, *.dec.bin, maps_cache/) are gitignored — code and
docs only.
2026-08-11 07:00:19 -05:00

487 lines
23 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>
</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 flatRom(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=[];
for(let k=0;k<cnt;k++) ents.push([le(dec,i21+34+k*8,4), le(dec,i21+38+k*8,4)]);
let sz=0; ents.forEach(([o,l])=>sz=Math.max(sz,o+l));
const rom=new Uint8Array(sz).fill(0xff); let p=i21+34+cnt*8;
ents.forEach(([o,l])=>{ rom.set(dec.subarray(p,p+l), o); p+=l; });
return rom;
}
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 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)}));
return {rom, base, rpm, thr, tables, desc:new TextDecoder().decode(dec.subarray(30,30+le(dec,28,2)))};
}
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(()=>{});
}
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();
}
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>