reccmp: HTML refactor and diff address display (#581)

* reccmp: HTML refactor and diff address display

* Restore the @@ range indicator
This commit is contained in:
MS
2024-02-20 02:56:33 -05:00
committed by GitHub
parent ba8f2b1c0f
commit 9c71209fb9
8 changed files with 878 additions and 240 deletions

605
tools/reccmp/reccmp.js Normal file
View File

@@ -0,0 +1,605 @@
// reccmp.js
/* global data */
// Unwrap array of functions into a dictionary with address as the key.
const dataDict = Object.fromEntries(data.map(row => [row.address, row]));
function getDataByAddr(addr) {
return dataDict[addr];
}
//
// Pure functions
//
function formatAsm(entries, addrOption) {
const output = [];
const createTh = (text) => {
const th = document.createElement('th');
th.innerText = text;
return th;
};
const createTd = (text, className = '') => {
const td = document.createElement('td');
td.innerText = text;
td.className = className;
return td;
};
entries.forEach(obj => {
// These won't all be present. You get "both" for an equal node
// and orig/recomp for a diff.
const { both = [], orig = [], recomp = [] } = obj;
output.push(...both.map(([addr, line, recompAddr]) => {
const tr = document.createElement('tr');
tr.appendChild(createTh(addr));
tr.appendChild(createTh(recompAddr));
tr.appendChild(createTd(line));
return tr;
}));
output.push(...orig.map(([addr, line]) => {
const tr = document.createElement('tr');
tr.appendChild(createTh(addr));
tr.appendChild(createTh(''));
tr.appendChild(createTd(`-${line}`, 'diffneg'));
return tr;
}));
output.push(...recomp.map(([addr, line]) => {
const tr = document.createElement('tr');
tr.appendChild(createTh(''));
tr.appendChild(createTh(addr));
tr.appendChild(createTd(`+${line}`, 'diffpos'));
return tr;
}));
});
return output;
}
function getMatchPercentText(row) {
if ('stub' in row) {
return 'stub';
}
if ('effective' in row) {
return '100.00%*';
}
return (row.matching * 100).toFixed(2) + '%';
}
// Helper for this set/remove attribute block
function setBooleanAttribute(element, attribute, value) {
if (value) {
element.setAttribute(attribute, '');
} else {
element.removeAttribute(attribute);
}
}
//
// Global state
//
class ListingState {
constructor() {
this._query = '';
this._sortCol = 'address';
this._filterType = 1;
this.sortDesc = false;
this.hidePerfect = false;
this.hideStub = false;
}
get filterType() {
return parseInt(this._filterType);
}
set filterType(value) {
value = parseInt(value);
if (value >= 1 && value <= 3) {
this._filterType = value;
}
}
get query() {
return this._query;
}
set query(value) {
// Normalize search string
this._query = value.toLowerCase().trim();
}
get sortCol() {
return this._sortCol;
}
set sortCol(column) {
if (column === this._sortCol) {
this.sortDesc = !this.sortDesc;
} else {
this._sortCol = column;
}
}
}
const StateProxy = {
set(obj, prop, value) {
if (prop === 'onsort') {
this._onsort = value;
return true;
}
if (prop === 'onfilter') {
this._onfilter = value;
return true;
}
obj[prop] = value;
if (prop === 'sortCol' || prop === 'sortDesc') {
this._onsort();
} else {
this._onfilter();
}
return true;
}
};
const appState = new Proxy(new ListingState(), StateProxy);
//
// Stateful functions
//
function addrShouldAppear(addr) {
// Destructuring sets defaults for optional values from this object.
const {
effective = false,
stub = false,
diff = '',
name,
address,
matching
} = getDataByAddr(addr);
if (appState.hidePerfect && (effective || matching >= 1)) {
return false;
}
if (appState.hideStub && stub) {
return false;
}
if (appState.query === '') {
return true;
}
// Name/addr search
if (appState.filterType === 1) {
return (
address.includes(appState.query) ||
name.toLowerCase().includes(appState.query)
);
}
// no diff for review.
if (diff === '') {
return false;
}
// special matcher for combined diff
const anyLineMatch = ([addr, line]) => line.toLowerCase().trim().includes(appState.query);
// Flatten all diff groups for the search
const diffs = diff.map(([slug, subgroups]) => subgroups).flat();
for (const subgroup of diffs) {
const { both = [], orig = [], recomp = [] } = subgroup;
// If search includes context
if (appState.filterType === 2 && both.some(anyLineMatch)) {
return true;
}
if (orig.some(anyLineMatch) || recomp.some(anyLineMatch)) {
return true;
}
}
return false;
}
// Row comparator function, using our chosen sort column and direction.
// -1 (A before B)
// 1 (B before A)
// 0 (equal)
function rowSortOrder(addrA, addrB) {
const objA = getDataByAddr(addrA);
const objB = getDataByAddr(addrB);
const valA = objA[appState.sortCol];
const valB = objB[appState.sortCol];
if (valA > valB) {
return appState.sortDesc ? -1 : 1;
} else if (valA < valB) {
return appState.sortDesc ? 1 : -1;
}
return 0;
}
//
// Custom elements
//
// Sets sort indicator arrow based on element attributes.
class SortIndicator extends window.HTMLElement {
static observedAttributes = ['data-sort'];
attributeChangedCallback(name, oldValue, newValue) {
if (newValue === null) {
this.textContent = '';
} else {
this.innerHTML = newValue === 'asc' ? '&#9650;' : '&#9660;';
}
}
}
// Wrapper for <tr>
class FuncRow extends window.HTMLTableRowElement {
static observedAttributes = ['expanded'];
constructor() {
super();
this.onclick = evt => (this.expanded = !this.expanded);
}
connectedCallback() {
if (this.address === null) {
return;
}
if (this.querySelector('td')) {
return;
}
const obj = getDataByAddr(this.address);
const td0 = document.createElement('td');
const td1 = document.createElement('td');
const td2 = document.createElement('td');
td0.innerText = `${obj.address}`;
td1.innerText = `${obj.name}`;
td2.innerText = getMatchPercentText(obj);
this.appendChild(td0);
this.appendChild(td1);
this.appendChild(td2);
}
get address() {
return this.getAttribute('data-address');
}
get expanded() {
return this.getAttribute('expanded') !== null;
}
set expanded(value) {
setBooleanAttribute(this, 'expanded', value);
}
attributeChangedCallback(name, oldValue, newValue) {
if (name !== 'expanded') {
return;
}
if (this.onchangeExpand) {
this.onchangeExpand(this.expanded);
}
}
}
// Wrapper for <tr>
// Displays asm diff for the given @data-address value.
class FuncRowChild extends window.HTMLTableRowElement {
constructor() {
super();
const td = document.createElement('td');
td.setAttribute('colspan', 3);
this.appendChild(td);
}
connectedCallback() {
const td = this.querySelector('td');
const addr = this.getAttribute('data-address');
const obj = getDataByAddr(addr);
if ('stub' in obj) {
const diff = document.createElement('div');
diff.className = 'identical';
diff.innerText = 'Stub. No diff.';
td.appendChild(diff);
} else if (obj.diff.length === 0) {
const diff = document.createElement('div');
diff.className = 'identical';
diff.innerText = 'Identical function - no diff';
td.appendChild(diff);
} else {
const dd = new DiffDisplay();
dd.option = '1';
dd.address = addr;
td.appendChild(dd);
}
}
}
class DiffDisplayOptions extends window.HTMLElement {
static observedAttributes = ['data-option'];
connectedCallback() {
if (this.shadowRoot !== null) {
return;
}
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
fieldset {
align-items: center;
display: flex;
margin-bottom: 20px;
}
label {
margin-right: 10px;
user-select: none;
}
</style>
<fieldset>
<legend>Address display:</legend>
<input type="radio" id="showNone" name="addrDisplay" value=0>
<label for="showNone">None</label>
<input type="radio" id="showOrig" name="addrDisplay" value=1>
<label for="showOrig">Original</label>
<input type="radio" id="showBoth" name="addrDisplay" value=2>
<label for="showBoth">Both</label>
</fieldset>`;
shadow.querySelectorAll('input[type=radio]').forEach(radio => {
const checked = this.option === radio.getAttribute('value');
setBooleanAttribute(radio, 'checked', checked);
radio.addEventListener('change', evt => (this.option = evt.target.value));
});
}
set option(value) {
this.setAttribute('data-option', parseInt(value));
}
get option() {
return this.getAttribute('data-option') ?? 1;
}
attributeChangedCallback(name, oldValue, newValue) {
if (name !== 'data-option') {
return;
}
this.dispatchEvent(new Event('change'));
}
}
class DiffDisplay extends window.HTMLElement {
static observedAttributes = ['data-option'];
connectedCallback() {
if (this.querySelector('diff-display-options') !== null) {
return;
}
const optControl = new DiffDisplayOptions();
optControl.option = this.option;
optControl.addEventListener('change', evt => (this.option = evt.target.option));
this.appendChild(optControl);
const div = document.createElement('div');
const obj = getDataByAddr(this.address);
const createSingleCellRow = (text, className) => {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.setAttribute('colspan', 3);
td.textContent = text;
td.className = className;
tr.appendChild(td);
return tr;
};
const groups = obj.diff;
groups.forEach(([slug, subgroups]) => {
const secondTable = document.createElement('table');
secondTable.classList.add('diffTable', 'showOrig');
const tbody = document.createElement('tbody');
secondTable.appendChild(tbody);
tbody.appendChild(createSingleCellRow('---', 'diffneg'));
tbody.appendChild(createSingleCellRow('+++', 'diffpos'));
tbody.appendChild(createSingleCellRow(slug, 'diffslug'));
const diffs = formatAsm(subgroups, this.option);
for (const el of diffs) {
tbody.appendChild(el);
}
div.appendChild(secondTable);
});
this.appendChild(div);
}
get address() {
return this.getAttribute('data-address');
}
set address(value) {
this.setAttribute('data-address', value);
}
get option() {
return this.getAttribute('data-option') ?? 1;
}
set option(value) {
this.setAttribute('data-option', value);
}
}
// Main application.
class ListingTable extends window.HTMLElement {
constructor() {
super();
// Redraw the table on any changes.
appState.onsort = () => this.sortRows();
appState.onfilter = () => this.filterRows();
const input = this.querySelector('input[type=search]');
input.oninput = evt => (appState.query = evt.target.value);
const hidePerf = this.querySelector('input#cbHidePerfect');
hidePerf.onchange = evt => (appState.hidePerfect = evt.target.checked);
hidePerf.checked = appState.hidePerfect;
const hideStub = this.querySelector('input#cbHideStub');
hideStub.onchange = evt => (appState.hideStub = evt.target.checked);
hideStub.checked = appState.hideStub;
this.querySelectorAll('input[name=filterType]').forEach(radio => {
const checked = appState.filterType === parseInt(radio.getAttribute('value'));
setBooleanAttribute(radio, 'checked', checked);
radio.onchange = evt => (appState.filterType = radio.getAttribute('value'));
});
}
setRowExpand(address, shouldExpand) {
const tbody = this.querySelector('tbody');
const funcrow = tbody.querySelector(`tr.funcrow[data-address="${address}"]`);
if (funcrow === null) {
return;
}
const existing = tbody.querySelector(`tr.diffRow[data-address="${address}"]`);
if (shouldExpand) {
if (existing === null) {
const diffrow = document.createElement('tr', { is: 'func-row-child' });
diffrow.className = 'diffRow';
diffrow.setAttribute('data-address', address);
// Insert the diff row after the parent func row.
tbody.insertBefore(diffrow, funcrow.nextSibling);
}
} else {
if (existing !== null) {
tbody.removeChild(existing);
}
}
}
connectedCallback() {
const thead = this.querySelector('thead');
const headers = thead.querySelectorAll('th');
headers.forEach(th => {
const col = th.getAttribute('data-col');
if (col) {
th.onclick = evt => (appState.sortCol = col);
}
});
const tbody = this.querySelector('tbody');
for (const row of data) {
const tr = document.createElement('tr', { is: 'func-row' });
tr.className = 'funcrow';
tr.setAttribute('data-address', row.address);
tr.onchangeExpand = shouldExpand => this.setRowExpand(row.address, shouldExpand);
tbody.appendChild(tr);
}
this.sortRows();
this.filterRows();
}
sortRows() {
const thead = this.querySelector('thead');
const headers = thead.querySelectorAll('th');
// Update sort indicator
headers.forEach(th => {
const col = th.getAttribute('data-col');
const indicator = th.querySelector('sort-indicator');
if (appState.sortCol === col) {
indicator.setAttribute('data-sort', appState.sortDesc ? 'desc' : 'asc');
} else {
indicator.removeAttribute('data-sort');
}
});
// Select only the function rows and the diff child row.
// Exclude any nested tables used to *display* the diffs.
const tbody = this.querySelector('tbody');
const rows = tbody.querySelectorAll('tr.funcrow[data-address], tr.diffRow[data-address]');
// Sort all rows according to chosen order
const newRows = Array.from(rows);
newRows.sort((rowA, rowB) => {
const addrA = rowA.getAttribute('data-address');
const addrB = rowB.getAttribute('data-address');
// Diff row always sorts after its parent row
if (addrA === addrB && rowB.className === 'diffRow') {
return -1;
}
return rowSortOrder(addrA, addrB);
});
// Replace existing rows with updated order
newRows.forEach(row => tbody.appendChild(row));
}
filterRows() {
const tbody = this.querySelector('tbody');
const rows = tbody.querySelectorAll('tr.funcrow[data-address], tr.diffRow[data-address]');
rows.forEach(row => {
const addr = row.getAttribute('data-address');
const hidden = !addrShouldAppear(addr);
setBooleanAttribute(row, 'hidden', hidden);
});
// Update row count
this.querySelector('#rowcount').textContent = `${tbody.querySelectorAll('tr.funcrow:not([hidden])').length}`;
}
}
window.onload = () => {
window.customElements.define('listing-table', ListingTable);
window.customElements.define('diff-display', DiffDisplay);
window.customElements.define('diff-display-options', DiffDisplayOptions);
window.customElements.define('sort-indicator', SortIndicator);
window.customElements.define('func-row', FuncRow, { extends: 'tr' });
window.customElements.define('func-row-child', FuncRowChild, { extends: 'tr' });
};

View File

@@ -10,7 +10,7 @@ from datetime import datetime
from isledecomp import (
Bin,
get_file_in_script_dir,
print_diff,
print_combined_diff,
diff_json,
percent_string,
)
@@ -48,8 +48,12 @@ def gen_json(json_file: str, orig_file: str, data):
def gen_html(html_file, data):
js_path = get_file_in_script_dir("reccmp.js")
with open(js_path, "r", encoding="utf-8") as f:
reccmp_js = f.read()
output_data = Renderer().render_path(
get_file_in_script_dir("template.html"), {"data": data}
get_file_in_script_dir("template.html"), {"data": data, "reccmp_js": reccmp_js}
)
with open(html_file, "w", encoding="utf-8") as htmlfile:
@@ -106,7 +110,7 @@ def print_match_verbose(match, show_both_addrs: bool = False, is_plain: bool = F
f"{addrs}: {match.name} Effective 100%% match. (Differs in register allocation only)\n\n{ok_text} (still differs in register allocation)\n\n"
)
else:
print_diff(match.udiff, is_plain)
print_combined_diff(match.udiff, is_plain, show_both_addrs)
print(
f"\n{match.name} is only {percenttext} similar to the original, diff above"
@@ -282,7 +286,7 @@ def main():
html_obj["effective"] = True
if match.udiff is not None:
html_obj["diff"] = "\n".join(match.udiff)
html_obj["diff"] = match.udiff
if match.is_stub:
html_obj["stub"] = True

View File

@@ -43,15 +43,15 @@
background: #404040 !important;
}
.funcrow:nth-child(odd), #listing th {
.funcrow:nth-child(odd of :not([hidden])), #listing > thead th {
background: #282828;
}
.funcrow:nth-child(even) {
.funcrow:nth-child(even of :not([hidden])) {
background: #383838;
}
#listing td, #listing th {
.funcrow > td, .diffRow > td, #listing > thead th {
border: 1px #f0f0f0 solid;
padding: 0.5em;
word-break: break-all !important;
@@ -65,233 +65,109 @@
color: #80FF80;
}
.diffslug {
color: #8080FF;
}
.identical {
font-style: italic;
text-align: center;
}
#sortind {
sort-indicator {
margin: 0 0.5em;
}
.filters {
align-items: top;
display: flex;
font-size: 10pt;
text-align: center;
justify-content: space-between;
margin: 0.5em 0 1em 0;
}
.filters > fieldset {
/* checkbox and radio buttons v-aligned with text */
align-items: center;
display: flex;
}
.filters > fieldset > label {
margin-right: 10px;
}
table.diffTable {
border-collapse: collapse;
}
table.diffTable:not(:last-child) {
/* visual gap *between* diff context groups */
margin-bottom: 40px;
}
table.diffTable td, table.diffTable th {
border: 0 none;
padding: 0 10px 0 0;
}
table.diffTable th {
/* don't break address if asm line is long */
word-break: keep-all;
}
diff-display[data-option="0"] th:nth-child(1) {
display: none;
}
diff-display[data-option="0"] th:nth-child(2),
diff-display[data-option="1"] th:nth-child(2) {
display: none;
}
label {
user-select: none;
}
</style>
<script>
var data = {{{data}}};
function diffCssClass(firstChar) {
return firstChar === '-' ? 'diffneg' : (firstChar === '+' ? 'diffpos' : '');
}
function asmLineToDiv(line) {
const diff_line = document.createElement('div');
diff_line.className = diffCssClass(line[0]);
diff_line.innerText = line;
return diff_line;
}
function formatAsm(asm) {
var lines = asm.split('\n');
return lines.filter(line => line.length > 0)
.map(asmLineToDiv);
}
function rowClick() {
if (this.dataset.expanded === 'true') {
this.nextSibling.remove();
this.dataset.expanded = false;
} else {
var row = this.parentNode.insertBefore(document.createElement('tr'), this.nextSibling);
row.classList.add('diff');
var decCel = row.appendChild(document.createElement('td'));
decCel.colSpan = 3;
var diff = data[this.dataset.index].diff;
const stub = "stub" in data[this.dataset.index];
if (stub) {
diff = document.createElement('div');
diff.className = 'identical';
diff.innerText = 'Stub. No diff.';
decCel.appendChild(diff);
} else if (diff == '') {
diff = document.createElement('div');
diff.className = 'identical';
diff.innerText = 'Identical function - no diff';
decCel.appendChild(diff);
} else {
diff = formatAsm(diff);
for (const el of diff) {
decCel.appendChild(el);
}
}
this.dataset.expanded = true;
}
}
function closeAllDiffs() {
const collection = document.getElementsByClassName("diff");
for (var ele of collection) {
ele.remove();
}
}
const filterOptions = { text: '', hidePerfect: false, hideStub: false };
function filter() {
closeAllDiffs();
var ltext = filterOptions.text.toLowerCase();
const collection = document.getElementsByClassName("funcrow");
var searchCount = 0;
for (var ele of collection) {
var eledata = data[ele.dataset.index];
const stubOk = (!filterOptions.hideStub || !("stub" in eledata));
const textOk = (ltext == ''
|| eledata.address.toLowerCase().includes(ltext)
|| eledata.name.toLowerCase().includes(ltext));
const perfOk = (!filterOptions.hidePerfect || (eledata.matching < 1));
if (stubOk && textOk && perfOk) {
ele.style.display = '';
searchCount++;
} else {
ele.style.display = 'none';
}
}
}
var lastSortedCol = -1;
var ascending = true;
function sortByColumn(column) {
closeAllDiffs();
if (column == lastSortedCol) {
ascending = !ascending;
}
lastSortedCol = column;
const collection = document.getElementsByClassName("funcrow");
var newOrder = [];
for (var ele of collection) {
var inserted = false;
for (var i = 0; i < newOrder.length; i++) {
var cmpEle = newOrder[i];
var ourCol = ele.childNodes[column];
var cmpCol = cmpEle.childNodes[column];
if ((cmpCol.dataset.value > ourCol.dataset.value) == ascending) {
newOrder.splice(i, 0, ele);
inserted = true;
break;
}
}
if (!inserted) {
newOrder.push(ele);
}
}
for (var i = 1; i < newOrder.length; i++) {
newOrder[i - 1].after(newOrder[i]);
}
var sortIndicator = document.getElementById('sortind');
if (!sortIndicator) {
sortIndicator = document.createElement('span');
sortIndicator.id = 'sortind';
}
sortIndicator.innerHTML = ascending ? '&#9650;' : '&#9660;';
var th = document.getElementById('listingheader').childNodes[column];
th.appendChild(sortIndicator);
}
document.addEventListener("DOMContentLoaded", () => {
var listing = document.getElementById('listing');
const headers = listing.getElementsByTagName('th');
var headerCount = 0;
for (const header of headers) {
header.addEventListener('click', function(){
sortByColumn(this.dataset.column, true);
});
header.dataset.column = headerCount;
headerCount++;
}
data.forEach((element, index) => {
var row = listing.appendChild(document.createElement('tr'));
var addrCel = row.appendChild(document.createElement('td'));
var nameCel = row.appendChild(document.createElement('td'));
var matchCel = row.appendChild(document.createElement('td'));
addrCel.innerText = addrCel.dataset.value = element.address;
nameCel.innerText = nameCel.dataset.value = element.name;
if ("stub" in element) {
matchCel.innerHTML = 'stub'
matchCel.dataset.value = -1;
} else {
var effectiveNote = (element.matching == 1 && element.diff != '') ? '*' : '';
matchCel.innerHTML = (element.matching * 100).toFixed(2) + '%' + effectiveNote;
matchCel.dataset.value = element.matching;
}
row.classList.add('funcrow');
row.addEventListener('click', rowClick);
row.dataset.index = index;
row.dataset.expanded = false;
});
var search = document.getElementById('search');
search.addEventListener('input', function (evt) {
filterOptions.text = search.value;
filter();
});
const cbHidePerfect = document.getElementById('cbHidePerfect');
cbHidePerfect.addEventListener('change', evt => {
filterOptions.hidePerfect = evt.target.checked;
filter();
})
const cbHideStub = document.querySelector('#cbHideStub');
cbHideStub.addEventListener('change', evt => {
filterOptions.hideStub = evt.target.checked;
filter();
})
sortByColumn(0);
});
<script>var data = {{{data}}};</script>
<script>{{{reccmp_js}}}</script>
</script>
</head>
<body>
<div class="main">
<h1>Decompilation Status</h1>
<input id="search" type="search" placeholder="Search for offset or function name...">
<div class="filters">
<label for="cbHidePerfect">Hide 100% match</label>
<input type="checkbox" id="cbHidePerfect" />
<label for="cbHideStub">Hide stubs</label>
<input type="checkbox" id="cbHideStub" />
</div>
<table id="listing">
<tr id='listingheader'><th style='width: 20%'>Address</th><th style="width:60%">Name</th><th style='width: 20%'>Matching</th></tr>
</table>
<listing-table>
<input id="search" type="search" placeholder="Search for offset or function name...">
<div class="filters">
<fieldset>
<legend>Options:</legend>
<input type="checkbox" id="cbHidePerfect" />
<label for="cbHidePerfect">Hide 100% match</label>
<input type="checkbox" id="cbHideStub" />
<label for="cbHideStub">Hide stubs</label>
</fieldset>
<fieldset>
<legend>Search filters on:</legend>
<input type="radio" name="filterType" id="filterName" value=1 checked />
<label for="filterName">Name/address</label>
<input type="radio" name="filterType" id="filterAsm" value=2 />
<label for="filterAsm">Asm output</label>
<input type="radio" name="filterType" id="filterDiff" value=3 />
<label for="filterDiff">Asm diffs only</label>
</fieldset>
</div>
<p>Results: <span id="rowcount"></span></p>
<table id="listing">
<thead>
<tr>
<th data-col="address" style="width: 20%">Address<sort-indicator/></th>
<th data-col="name" style="width: 60%">Name<sort-indicator/></th>
<th data-col="matching" style="width: 20%">Matching<sort-indicator/></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</listing-table>
</div>
</body>
</html>