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.
This commit is contained in:
@@ -70,7 +70,8 @@
|
||||
<nav>
|
||||
<button data-tab="maps" class="active">Map catalogue</button>
|
||||
<button data-tab="caps">Tune capabilities</button>
|
||||
<button data-tab="table">Table viewer</button>
|
||||
<button data-tab="triumph">Triumph tables</button>
|
||||
<button data-tab="table">Raw table viewer</button>
|
||||
</nav>
|
||||
<main>
|
||||
|
||||
@@ -101,6 +102,33 @@
|
||||
<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">
|
||||
@@ -211,7 +239,119 @@ Object.values(DATA.definitions).forEach(d=>{
|
||||
capWrap.appendChild(c);
|
||||
});
|
||||
|
||||
// ---- table viewer ----
|
||||
// ---- 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();
|
||||
|
||||
Reference in New Issue
Block a user