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.
487 lines
23 KiB
HTML
487 lines
23 KiB
HTML
<!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>
|