From ccc40b022ce7a729a7fb4a1731afd4acaaf38719 Mon Sep 17 00:00:00 2001 From: Ricardo Baratto Date: Wed, 28 Oct 2026 21:47:48 -0400 Subject: [PATCH 1/3] APP: make disassembly metrics available when only disassembly was done If normal analysis is done, the disassembly metrics are part of the r/_metrics.xml file. However, if only disassembly is done (chkx analyze -d), that file does not exist, and instead the metrics are saved to s/_disassembly.xml. This change creates a dataclass to represent the disassembly metrics, AppDisassemblyMetrics, which gets the information from a passed xnode. AppResultMetrics now uses that class to provide the metrics it exposes from the disassembly. AppAccess also gains a method, disassembly_metrics, which tries the two possible locations where they can be found. --- chb/app/AppAccess.py | 24 ++++++++++++++++- chb/app/AppResultMetrics.py | 53 ++++++++++++++++++++++++++++++------- chb/util/fileutil.py | 10 ++++++- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/chb/app/AppAccess.py b/chb/app/AppAccess.py index ea519394..68581eef 100644 --- a/chb/app/AppAccess.py +++ b/chb/app/AppAccess.py @@ -47,7 +47,7 @@ from chb.app.AppCfgInfo import AppCfgInfo from chb.app.AppResultData import AppResultData -from chb.app.AppResultMetrics import AppResultMetrics +from chb.app.AppResultMetrics import AppResultMetrics, DisassemblyMetrics from chb.app.ASMCallgraph import ASMCallgraph from chb.app.BDictionary import BDictionary from chb.app.CallbackTables import CallbackTables @@ -712,6 +712,28 @@ def result_metrics(self) -> AppResultMetrics: h = UF.get_resultmetrics_xheader(self.path, self.filename) return AppResultMetrics(self.filename, x, h) + # Disassembly Metrics ----------------------------------------------------------- + + @property + def disassembly_metrics(self) -> DisassemblyMetrics: + """Returns metrics for the disassembly process + + Depending on whether full analysis was done or only disassembly + (chkx analyze -d) was done they will be found in two different files: + - full analysis: + r/_metrics.xml + - disaseembly only: + s/_disassembly.xml + """ + try: + result_metrics = self.result_metrics + return result_metrics.disassembly + except UF.CHBFileNotFoundError: + # If only disassembly was done and not full analysis, the disassembly + # stats are in a different file. + x = UF.get_disassembly_statistics_xnode(self.path, self.filename) + return DisassemblyMetrics(x) + # User data ----------------------------------------------------------- @property diff --git a/chb/app/AppResultMetrics.py b/chb/app/AppResultMetrics.py index f907fa16..8ebe5063 100644 --- a/chb/app/AppResultMetrics.py +++ b/chb/app/AppResultMetrics.py @@ -27,6 +27,7 @@ # SOFTWARE. # ------------------------------------------------------------------------------ +from dataclasses import dataclass, field import xml.etree.ElementTree as ET from typing import Any, Callable, Dict, List, Optional @@ -61,6 +62,39 @@ ] +@dataclass +class DisassemblyMetrics: + xnode: ET.Element + all_overlap: int = field(init=False) + coverage: int = field(init=False) + datablocks: int = field(init=False) + function_count: int = field(init=False) + instruction_count: int = field(init=False) + jumptables: int = field(init=False) + overlap: int = field(init=False) + pcoverage: float = field(init=False) + unknown_instrs: int = field(init=False) + + def __post_init__(self) -> None: + self.function_count = self._int_field("functions") + self.instruction_count = self._int_field("instrs") + self.unknown_instrs = self._int_field("unknown-instrs") + + self.all_overlap = self._int_field("alloverlap") + self.overlap = self._int_field("overlap") + + self.coverage = self._int_field("coverage") + self.pcoverage = float(self.xnode.get('pcoverage', "0.0")) + + self.datablocks = self._int_field("datablocks") + self.jumptables = self._int_field("jumptables") + + + def _int_field(self, field_name: str) -> int: + return int(self.xnode.get(field_name, "0")) + + + class AppResultMetrics: def __init__( @@ -75,7 +109,7 @@ def __init__( self._calls: Optional[ET.Element] = None self._jumps: Optional[ET.Element] = None self._precision: Optional[ET.Element] = None - self._disassembly: Optional[ET.Element] = None + self._disassembly: Optional[DisassemblyMetrics] = None self._functions: Dict[str, AppResultFunctionMetrics] = {} self._imports: Optional[ET.Element] = None self._ccmetrics: Optional[ET.Element] = None @@ -173,17 +207,18 @@ def cc_metrics(self) -> ET.Element: return self._ccmetrics @property - def disassembly(self) -> ET.Element: + def disassembly(self) -> DisassemblyMetrics: if self._disassembly is None: - self._disassembly = self.xnode.find("disassembly") - if self._disassembly is None: + disassembly_node = self.xnode.find("disassembly") + if disassembly_node is None: raise UF.CHBError("Disassembly is missing from resultmetrics") + self._disassembly = DisassemblyMetrics(disassembly_node) return self._disassembly @property def imports(self) -> ET.Element: if self._imports is None: - self._imports = self.disassembly.find("imports") + self._imports = self.disassembly.xnode.find("imports") if self._imports is None: raise UF.CHBError( "Imports is missing from disassembly in resultmetrics") @@ -323,19 +358,19 @@ def no_summary(self) -> int: @property def instruction_count(self) -> int: - return int(self.disassembly.get('instrs', "0")) + return self.disassembly.instruction_count @property def function_count(self) -> int: - return int(self.disassembly.get('functions', "0")) + return self.disassembly.function_count @property def unknown_instructions(self) -> int: - return int(self.disassembly.get('unknown-instrs', '0')) + return self.disassembly.unknown_instrs @property def pcoverage(self) -> float: - return float(self.disassembly.get('pcoverage', "0.0")) + return self.disassembly.pcoverage def time_share(self, count: int) -> Dict[str, float]: """Return a list of the most time-intensive functions with %time.""" diff --git a/chb/util/fileutil.py b/chb/util/fileutil.py index bf09b9fb..0796a5b7 100644 --- a/chb/util/fileutil.py +++ b/chb/util/fileutil.py @@ -1056,8 +1056,16 @@ def get_disassembly_statistics_filename(path: str, xfile: str) -> str: def get_disassembly_statistics_xnode(path: str, xfile: str) -> ET.Element: + """This file/node is only found when we run disassembly only (chkx analyze -d) + + The node will be at <--root--> + """ filename = get_disassembly_statistics_filename(path, xfile) - return get_chb_xnode(filename, "disassembly") + disassembly_status_node = get_chb_xnode(filename, "disassembly-status") + disassembly_node = disassembly_status_node.find("disassembly") + if disassembly_node is None: + raise CHBXmlRootElementNotFoundError(filename, "disassembly") + return disassembly_node def get_features_filename(path: str, xfile: str) -> str: From 622800a55631eff2b43adc5dbb288623a92019e8 Mon Sep 17 00:00:00 2001 From: Ricardo Baratto Date: Thu, 29 Oct 2026 20:40:18 -0400 Subject: [PATCH 2/3] add 'ddata stats' command to get the disassembly statistics it uses the DisassemblyMetrics class to either print the information or save it to json. The DisassemblyMetrics dataclass gained a to_json_result method to support this. Along the way I realized that the AppResultMetrics to_json_result method was asking the disasembly stats to be converted to int/float when they are already of that type --- chb/app/AppResultMetrics.py | 25 +++++++++++++++++++++---- chb/cmdline/chkx | 8 ++++++++ chb/cmdline/commandutil.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/chb/app/AppResultMetrics.py b/chb/app/AppResultMetrics.py index 8ebe5063..4093e6ef 100644 --- a/chb/app/AppResultMetrics.py +++ b/chb/app/AppResultMetrics.py @@ -94,6 +94,23 @@ def _int_field(self, field_name: str) -> int: return int(self.xnode.get(field_name, "0")) + def to_json_result(self) -> JSONResult: + content: Dict[str, Any] = {} + content["function_count"] = self.function_count + content["instruction_count"] = self.instruction_count + content["unknown-instructions"] = self.unknown_instrs + + content["all-overlap"] = self.all_overlap + content["overlap"] = self.overlap + + content["coverage"] = self.coverage + content["pcoverage"] = self.pcoverage + + content["datablocks"] = self.datablocks + content["jumptables"] = self.jumptables + + return JSONResult("disassembly-stats", content, "ok") + class AppResultMetrics: @@ -445,10 +462,10 @@ def f(fn: AppResultFunctionMetrics) -> None: def to_json_result(self, includefns: bool = False) -> JSONResult: content: Dict[str, Any] = {} - content["instructions"] = int(self.instruction_count) - content["unknowninstrs"] = int(self.unknown_instructions) - content["functions"] = int(self.function_count) - content["functioncoverage"] = float(self.pcoverage) + content["instructions"] = self.instruction_count + content["unknowninstrs"] = self.unknown_instructions + content["functions"] = self.function_count + content["functioncoverage"] = self.pcoverage content["espprecision"] = self.esp_precision content["readsprecision"] = self.reads_precision content["writesprecision"] = self.writes_precision diff --git a/chb/cmdline/chkx b/chb/cmdline/chkx index bce4bf71..a5861706 100755 --- a/chb/cmdline/chkx +++ b/chb/cmdline/chkx @@ -608,6 +608,14 @@ def parse() -> argparse.Namespace: ddatacmd.set_defaults(func=ddatacommand) ddataparsers = ddatacmd.add_subparsers(title="show options") + # -- disassembly stats -- + ddatastats = ddataparsers.add_parser("stats") + ddatastats.add_argument("xname", help="name of executable") + ddatastats.add_argument("-o", "--output", + help="Optional output file where stats should " + "be saved as json") + ddatastats.set_defaults(func=UCC.ddata_stats) + # -- ddata globalvars -- ddatagvars = ddataparsers.add_parser("globalvars") ddatagvars.add_argument("xname", help="name of executable") diff --git a/chb/cmdline/commandutil.py b/chb/cmdline/commandutil.py index 3844579e..99d3ed73 100644 --- a/chb/cmdline/commandutil.py +++ b/chb/cmdline/commandutil.py @@ -2810,6 +2810,38 @@ def show_function_semantics_table(args: argparse.Namespace) -> NoReturn: exit(0) +def ddata_stats(args: argparse.Namespace) -> NoReturn: + """Prints disassembly statistics""" + # arguments + xname: str = str(args.xname) + + try: + (path, xfile) = get_path_filename(xname) + except UF.CHBError as e: + print(str(e.wrap())) + exit(1) + + xinfo = XI.XInfo() + xinfo.load(path, xfile) + + app = get_app(path, xfile, xinfo) + metrics = app.disassembly_metrics + + if args.output: + jsonok = JU.jsonok("disassembly-stats", metrics.to_json_result().content) + with open(args.output, "w") as fp: + json.dump(jsonok, fp, indent=4) + else: + print("Instructions : %d" % metrics.instruction_count) + print("Unknown instructions : %d" % metrics.unknown_instrs) + print("Functions : %d (coverage: %.2f%%)" % (metrics.function_count, metrics.pcoverage)) + print("Function overlap : %d (counting multiples: %d)" % (metrics.overlap, metrics.all_overlap)) + print("Jumptables : %d" % (metrics.jumptables)) + print("Data blocks : %d" % (metrics.datablocks)) + + exit(0) + + def ddata_gvars(args: argparse.Namespace) -> NoReturn: # arguments From 0a6eeb15538238d48e029c04fd368c00a8417eb1 Mon Sep 17 00:00:00 2001 From: Ricardo Baratto Date: Mon, 2 Nov 2026 09:17:03 -0500 Subject: [PATCH 3/3] Bump version after addition of disassembly stats --- chb/app/CHVersion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chb/app/CHVersion.py b/chb/app/CHVersion.py index b2cfed15..503f0487 100644 --- a/chb/app/CHVersion.py +++ b/chb/app/CHVersion.py @@ -1,3 +1,3 @@ -chbversion: str = "0.3.20261020" +chbversion: str = "0.3.20261102" minimum_required_chb_version = "0.6.0_20260802"