387 lines
13 KiB
Python
387 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable, Mapping
|
|
from difflib import SequenceMatcher
|
|
|
|
from .formatting import h16
|
|
from .model import Instruction
|
|
from .rom import Rom
|
|
|
|
|
|
DISPLAY_PRINTABLE = set(range(0x20, 0x7F))
|
|
DISPLAY_PUNCTUATION = set(b" ./:-+,%()[]")
|
|
DEFAULT_SEARCH_TERMS = ("CONNECT",)
|
|
MOV_IW_FIRST_OPCODE = 0x58
|
|
MOV_IW_LAST_OPCODE = 0x5F
|
|
|
|
|
|
def analyze_lcd_text(
|
|
rom: Rom,
|
|
instructions: Mapping[int, Instruction] | Iterable[Instruction] | None = None,
|
|
*,
|
|
start: int | None = None,
|
|
end: int | None = None,
|
|
search_terms: Iterable[str] = DEFAULT_SEARCH_TERMS,
|
|
max_candidates: int = 240,
|
|
) -> dict[str, object]:
|
|
"""Find likely fixed-width LCD/menu strings and their nearby raw xrefs.
|
|
|
|
The firmware stores some display text as inline menu/script records rather
|
|
than as plain null-terminated strings. This pass scans the ROM bytes
|
|
directly, then correlates likely text fields with decoded and raw immediate
|
|
address loads such as ``MOV:I.W #H'63D4, R0``.
|
|
"""
|
|
|
|
lower = 0 if start is None else max(0, start)
|
|
upper = rom.end if end is None else min(rom.end, end)
|
|
candidates = _unique_candidates(
|
|
[
|
|
*_ff_terminated_candidates(rom, lower, upper),
|
|
*_printable_run_candidates(rom, lower, upper),
|
|
],
|
|
)
|
|
candidates = sorted(candidates, key=lambda item: (-float(item["score"]), int(item["address"])))[:max_candidates]
|
|
candidates.sort(key=lambda item: int(item["address"]))
|
|
|
|
instruction_list = _instruction_sequence(instructions)
|
|
xrefs_by_address = _xref_map(rom, candidates, instruction_list)
|
|
for candidate in candidates:
|
|
xrefs = xrefs_by_address.get(int(candidate["address"]), [])
|
|
if xrefs:
|
|
candidate["xrefs"] = xrefs
|
|
candidate["xref_count"] = len(xrefs)
|
|
|
|
regions = _group_regions(candidates)
|
|
searches = [_search_term(rom, candidates, term) for term in search_terms]
|
|
return {
|
|
"strings": candidates,
|
|
"regions": regions,
|
|
"searches": searches,
|
|
"notes": [
|
|
"LCD text scan is byte-oriented and conservative; strings may be inline script fields.",
|
|
"Raw xrefs include MOV:I.W immediates to the string address and nearby record prefixes.",
|
|
],
|
|
}
|
|
|
|
|
|
def lcd_text_comment_for_instruction(analysis: Mapping[str, object] | None, address: int) -> str:
|
|
if not analysis:
|
|
return ""
|
|
for candidate in analysis.get("strings", []):
|
|
if not isinstance(candidate, Mapping):
|
|
continue
|
|
for xref in candidate.get("xrefs", []):
|
|
if isinstance(xref, Mapping) and int(xref.get("address", -1)) == address:
|
|
text = str(candidate.get("trimmed") or candidate.get("text") or "").strip()
|
|
return f"LCD text xref {h16(int(candidate['address']))} {text!r}"
|
|
return ""
|
|
|
|
|
|
def _instruction_sequence(
|
|
instructions: Mapping[int, Instruction] | Iterable[Instruction] | None,
|
|
) -> list[Instruction]:
|
|
if instructions is None:
|
|
return []
|
|
values = instructions.values() if isinstance(instructions, Mapping) else instructions
|
|
return sorted(values, key=lambda ins: ins.address)
|
|
|
|
|
|
def _ff_terminated_candidates(rom: Rom, start: int, end: int) -> list[dict[str, object]]:
|
|
candidates: list[dict[str, object]] = []
|
|
address = start
|
|
while address < end:
|
|
if rom.u8(address) not in DISPLAY_PRINTABLE:
|
|
address += 1
|
|
continue
|
|
text_start = address
|
|
raw = bytearray()
|
|
while address < end and rom.u8(address) in DISPLAY_PRINTABLE:
|
|
raw.append(rom.u8(address))
|
|
address += 1
|
|
ff_count = 0
|
|
cursor = address
|
|
while cursor < end and rom.u8(cursor) == 0xFF:
|
|
ff_count += 1
|
|
cursor += 1
|
|
if ff_count and _looks_like_lcd_text(raw):
|
|
candidates.append(_candidate(text_start, raw, "ff_terminated", ff_count=ff_count))
|
|
address = max(cursor, address + 1)
|
|
return candidates
|
|
|
|
|
|
def _printable_run_candidates(rom: Rom, start: int, end: int) -> list[dict[str, object]]:
|
|
candidates: list[dict[str, object]] = []
|
|
address = start
|
|
while address < end:
|
|
if rom.u8(address) not in DISPLAY_PRINTABLE:
|
|
address += 1
|
|
continue
|
|
text_start = address
|
|
raw = bytearray()
|
|
while address < end and rom.u8(address) in DISPLAY_PRINTABLE:
|
|
raw.append(rom.u8(address))
|
|
address += 1
|
|
if _looks_like_lcd_text(raw, allow_long=True):
|
|
candidates.append(_candidate(text_start, raw, "printable_run", ff_count=0))
|
|
address += 1
|
|
return candidates
|
|
|
|
|
|
def _candidate(address: int, raw: bytearray, kind: str, *, ff_count: int) -> dict[str, object]:
|
|
text = raw.decode("ascii", errors="replace")
|
|
trimmed = text.strip()
|
|
score = _display_score(raw, kind, ff_count)
|
|
payload: dict[str, object] = {
|
|
"address": address,
|
|
"length": len(raw),
|
|
"text": text,
|
|
"trimmed": trimmed,
|
|
"kind": kind,
|
|
"score": round(score, 3),
|
|
"confidence": _confidence(score),
|
|
}
|
|
if ff_count:
|
|
payload["ff_terminators"] = ff_count
|
|
if len(raw) > 32:
|
|
payload["segments"] = _fixed_width_segments(text)
|
|
return payload
|
|
|
|
|
|
def _unique_candidates(candidates: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
by_key: dict[tuple[int, int], dict[str, object]] = {}
|
|
for candidate in candidates:
|
|
key = (int(candidate["address"]), int(candidate["length"]))
|
|
existing = by_key.get(key)
|
|
if existing is None or float(candidate["score"]) > float(existing["score"]):
|
|
by_key[key] = candidate
|
|
return list(by_key.values())
|
|
|
|
|
|
def _looks_like_lcd_text(raw: bytearray, *, allow_long: bool = False) -> bool:
|
|
if len(raw) < 4:
|
|
return False
|
|
if len(raw) > 80 and not allow_long:
|
|
return False
|
|
trimmed = bytes(raw).strip()
|
|
if len(trimmed) < 2:
|
|
return False
|
|
good = sum(1 for value in trimmed if _display_char_score(value) > 0)
|
|
return good / max(len(trimmed), 1) >= 0.78
|
|
|
|
|
|
def _display_char_score(value: int) -> float:
|
|
if 0x41 <= value <= 0x5A or 0x30 <= value <= 0x39:
|
|
return 1.0
|
|
if value == 0x20:
|
|
return 0.8
|
|
if value in DISPLAY_PUNCTUATION:
|
|
return 0.7
|
|
if 0x61 <= value <= 0x7A:
|
|
return 0.35
|
|
return 0.0
|
|
|
|
|
|
def _display_score(raw: bytearray, kind: str, ff_count: int) -> float:
|
|
trimmed = bytes(raw).strip()
|
|
if not trimmed:
|
|
return 0.0
|
|
char_score = sum(_display_char_score(value) for value in trimmed) / len(trimmed)
|
|
length_bonus = 0.15 if len(raw) in {8, 10, 16, 17, 18, 19, 20} else 0.0
|
|
terminator_bonus = min(ff_count, 3) * 0.08
|
|
kind_bonus = 0.08 if kind == "ff_terminated" else 0.0
|
|
long_penalty = 0.2 if len(raw) > 40 else 0.0
|
|
return max(0.0, char_score + length_bonus + terminator_bonus + kind_bonus - long_penalty)
|
|
|
|
|
|
def _confidence(score: float) -> str:
|
|
if score >= 1.05:
|
|
return "high"
|
|
if score >= 0.82:
|
|
return "medium"
|
|
return "low"
|
|
|
|
|
|
def _fixed_width_segments(text: str) -> list[dict[str, object]]:
|
|
segments: list[dict[str, object]] = []
|
|
for width in (10, 16, 18, 20):
|
|
if len(text) < width * 2:
|
|
continue
|
|
chunks = [text[index : index + width] for index in range(0, len(text), width)]
|
|
useful = [chunk.strip() for chunk in chunks if len(chunk.strip()) >= 2]
|
|
if len(useful) >= 2:
|
|
segments.append({"width": width, "chunks": chunks})
|
|
return segments[:3]
|
|
|
|
|
|
def _xref_map(
|
|
rom: Rom,
|
|
candidates: list[dict[str, object]],
|
|
instructions: list[Instruction],
|
|
) -> dict[int, list[dict[str, object]]]:
|
|
addresses = [int(candidate["address"]) for candidate in candidates]
|
|
target_to_address: dict[int, int] = {}
|
|
for address in addresses:
|
|
for delta in range(-4, 5):
|
|
target = address + delta
|
|
if 0 <= target <= 0xFFFF:
|
|
target_to_address.setdefault(target, address)
|
|
|
|
xrefs: dict[int, list[dict[str, object]]] = {address: [] for address in addresses}
|
|
_add_decoded_xrefs(xrefs, target_to_address, instructions)
|
|
_add_raw_mov_iw_xrefs(xrefs, target_to_address, rom)
|
|
return {address: refs for address, refs in xrefs.items() if refs}
|
|
|
|
|
|
def _add_decoded_xrefs(
|
|
xrefs: dict[int, list[dict[str, object]]],
|
|
target_to_address: Mapping[int, int],
|
|
instructions: list[Instruction],
|
|
) -> None:
|
|
for ins in instructions:
|
|
for target, candidate_address in target_to_address.items():
|
|
needle = f"H'{target:04X}"
|
|
if needle in ins.operands:
|
|
xrefs[candidate_address].append(
|
|
{
|
|
"address": ins.address,
|
|
"kind": "decoded_operand",
|
|
"target": target,
|
|
"delta": target - candidate_address,
|
|
"instruction": ins.text,
|
|
},
|
|
)
|
|
|
|
|
|
def _add_raw_mov_iw_xrefs(
|
|
xrefs: dict[int, list[dict[str, object]]],
|
|
target_to_address: Mapping[int, int],
|
|
rom: Rom,
|
|
) -> None:
|
|
data = rom.data
|
|
for address in range(0, max(len(data) - 2, 0)):
|
|
opcode = data[address]
|
|
if not MOV_IW_FIRST_OPCODE <= opcode <= MOV_IW_LAST_OPCODE:
|
|
continue
|
|
target = (data[address + 1] << 8) | data[address + 2]
|
|
candidate_address = target_to_address.get(target)
|
|
if candidate_address is None:
|
|
continue
|
|
register = f"R{opcode - MOV_IW_FIRST_OPCODE}"
|
|
xref: dict[str, object] = {
|
|
"address": address,
|
|
"kind": "raw_mov_iw",
|
|
"target": target,
|
|
"delta": target - candidate_address,
|
|
"register": register,
|
|
"instruction": f"MOV:I.W #{h16(target)}, {register}",
|
|
}
|
|
bsr = _following_bsr(data, address + 3)
|
|
if bsr:
|
|
xref["following_bsr"] = bsr
|
|
xrefs[candidate_address].append(xref)
|
|
|
|
|
|
def _following_bsr(data: bytes, address: int) -> dict[str, object] | None:
|
|
if address + 2 >= len(data) or data[address] != 0x1E:
|
|
return None
|
|
displacement = (data[address + 1] << 8) | data[address + 2]
|
|
if displacement & 0x8000:
|
|
displacement -= 0x10000
|
|
target = (address + 3 + displacement) & 0xFFFF
|
|
return {"address": address, "target": target, "instruction": f"BSR {h16(target)}"}
|
|
|
|
|
|
def _group_regions(candidates: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
regions: list[dict[str, object]] = []
|
|
current: list[dict[str, object]] = []
|
|
previous_end: int | None = None
|
|
for candidate in candidates:
|
|
address = int(candidate["address"])
|
|
length = int(candidate["length"])
|
|
if current and previous_end is not None and address - previous_end > 0x80:
|
|
_append_region(regions, current)
|
|
current = []
|
|
current.append(candidate)
|
|
previous_end = address + length
|
|
if current:
|
|
_append_region(regions, current)
|
|
return regions
|
|
|
|
|
|
def _append_region(regions: list[dict[str, object]], candidates: list[dict[str, object]]) -> None:
|
|
if len(candidates) < 2:
|
|
return
|
|
start = int(candidates[0]["address"])
|
|
end = max(int(item["address"]) + int(item["length"]) for item in candidates)
|
|
regions.append(
|
|
{
|
|
"start": start,
|
|
"end": end,
|
|
"count": len(candidates),
|
|
"samples": [str(item.get("trimmed") or item.get("text")) for item in candidates[:8]],
|
|
},
|
|
)
|
|
|
|
|
|
def _search_term(rom: Rom, candidates: list[dict[str, object]], term: str) -> dict[str, object]:
|
|
raw = term.encode("ascii", errors="ignore")
|
|
literal_hits = _literal_hits(rom.data, raw)
|
|
folded = term.upper()
|
|
candidate_hits = [
|
|
{
|
|
"address": int(candidate["address"]),
|
|
"text": candidate["text"],
|
|
"trimmed": candidate["trimmed"],
|
|
}
|
|
for candidate in candidates
|
|
if folded in str(candidate.get("text", "")).upper()
|
|
]
|
|
near_matches = _near_matches(candidates, term)
|
|
return {
|
|
"term": term,
|
|
"literal_hits": literal_hits,
|
|
"candidate_hits": candidate_hits,
|
|
"near_matches": near_matches,
|
|
"status": "found" if literal_hits or candidate_hits else "not_found",
|
|
}
|
|
|
|
|
|
def _literal_hits(data: bytes, needle: bytes) -> list[int]:
|
|
if not needle:
|
|
return []
|
|
hits: list[int] = []
|
|
start = 0
|
|
upper_data = data.upper()
|
|
upper_needle = needle.upper()
|
|
while True:
|
|
index = upper_data.find(upper_needle, start)
|
|
if index < 0:
|
|
return hits
|
|
hits.append(index)
|
|
start = index + 1
|
|
|
|
|
|
def _near_matches(candidates: list[dict[str, object]], term: str) -> list[dict[str, object]]:
|
|
normalized_term = _normalize_text(term)
|
|
matches: list[dict[str, object]] = []
|
|
for candidate in candidates:
|
|
normalized = _normalize_text(str(candidate.get("trimmed") or candidate.get("text") or ""))
|
|
if not normalized:
|
|
continue
|
|
ratio = SequenceMatcher(None, normalized_term, normalized).ratio()
|
|
if ratio >= 0.34:
|
|
matches.append(
|
|
{
|
|
"address": int(candidate["address"]),
|
|
"text": candidate["text"],
|
|
"trimmed": candidate["trimmed"],
|
|
"score": round(ratio, 3),
|
|
},
|
|
)
|
|
matches.sort(key=lambda item: (-float(item["score"]), int(item["address"])))
|
|
return matches[:12]
|
|
|
|
|
|
def _normalize_text(text: str) -> str:
|
|
return "".join(char for char in text.upper() if "A" <= char <= "Z" or "0" <= char <= "9")
|