Files
samplez/tunie/viewer/template.html
uhryniuk 9ab9feb62f Add map-format reversing + in-browser table editor
Reverse-engineered the Triumph Keihin map format from the TuneECU loader
(l.java zc/sc/Nc) and data classes c/r/s/t.java, and turned the viewer's table
tab into a working editor.

extract_mapdefs.py pulls the table directory out of the decompiled app into
mapdefs.json: r.a/s.a/t.a (calibration directory, 8-int records keyed by the
map's offset-20 signature) -> c.a (48-int calibration metadata) -> c.b (32-int
groups = 16 offset/length pairs each), yielding 413 candidate table offsets.
FORMAT.md documents the header magic (0x18008060 masked), the directory chain,
and the write-back checksum.

The viewer now edits: pick a known table offset (or set it manually), toggle
edit mode, click a cell to change its value, and the bytes are rewritten with
the running 16-bit checksum patched by (old - new) exactly as TuneECU does,
then Download the modified copy. Verified: editing 2016->9999 with the checksum
word at 0 yields 57553 = (0 + 2016 - 9999) & 0xffff.

Geometry offsets are read-confident but not yet validated against a real map
binary; FORMAT.md flags this. Editing/checksum stay local to a downloaded copy;
the flash write path remains out of the read-only tunie tool.
2026-08-10 15:36:23 -05:00

347 lines
16 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="table">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-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);
});
// ---- 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>