1
0

More decompiling work

This commit is contained in:
Aiden
2026-05-25 17:32:00 +10:00
parent 56829b6e0b
commit 07f48c76e0
22 changed files with 9837 additions and 5 deletions

View File

@@ -0,0 +1,145 @@
import io
import json
import unittest
from h8536.protocol_capture import analyze_capture_text, format_text_report, main, parse_capture_text
class ProtocolCaptureTest(unittest.TestCase):
def test_parses_timestamped_capture_chunks(self):
chunks = parse_capture_text("16:06:15.502 RX 006 bytes 00 00 15 80 00 CF\n")
self.assertEqual(len(chunks), 1)
self.assertEqual(chunks[0].timestamp_ms, 57975502)
self.assertEqual(chunks[0].analyzer_direction, "rx")
self.assertEqual(chunks[0].device_direction, "tx")
self.assertEqual(chunks[0].bytes, (0x00, 0x00, 0x15, 0x80, 0x00, 0xCF))
def test_recombines_user_split_rx_chunks_into_valid_call_frame(self):
analysis = analyze_capture_text(
"16:06:15.502 RX 003 bytes 00 00 15\n"
"16:06:15.506 RX 003 bytes 80 00 CF\n"
)
self.assertEqual(analysis["frame_count"], 1)
frame = analysis["frames"][0]
self.assertEqual(frame["source_chunk_indexes"], [0, 1])
self.assertEqual(frame["analyzer_direction"], "rx")
self.assertEqual(frame["device_direction"], "tx")
self.assertEqual(frame["report_candidate"]["index"], 0x15)
self.assertEqual(frame["report_candidate"]["value"], 0x8000)
self.assertEqual(
frame["report_candidate"]["observed_candidate"]["name_candidate"],
"call_button_candidate",
)
self.assertEqual(frame["report_candidate"]["observed_candidate"]["state_candidate"], "active")
def test_recombines_split_chunks_with_multiple_frames_and_labels_known_reports(self):
analysis = analyze_capture_text(
"16:06:15.500 RX 004 bytes 00 00 00 00\n"
"16:06:15.502 RX 005 bytes 80 DA 00 00 07\n"
"16:06:15.504 RX 003 bytes 80 00 DD\n"
"16:06:15.600 RX 006 bytes 00 00 00 00 80 DA\n"
)
self.assertEqual(analysis["frame_count"], 3)
names = [
frame["report_candidate"]["observed_candidate"]["name_candidate"]
for frame in analysis["frames"]
]
self.assertEqual(
names,
[
"heartbeat_alive_candidate",
"cam_power_button_candidate",
"heartbeat_alive_candidate",
],
)
self.assertEqual(analysis["repeated_group_count"], 1)
group = analysis["repeated_groups"][0]
self.assertEqual(group["count"], 2)
self.assertEqual(group["cadence_ms"]["samples"], [100])
def test_text_report_mentions_split_label_and_cadence(self):
report = format_text_report(
analyze_capture_text(
"16:06:15.502 RX 003 bytes 00 00 15\n"
"16:06:15.506 RX 003 bytes 80 00 CF\n"
"16:06:15.602 RX 006 bytes 00 00 15 80 00 CF\n"
)
)
self.assertIn("call_button_candidate state=active", report)
self.assertIn("checksum=ok split", report)
self.assertIn("cadence=avg=100.0ms", report)
def test_gate_session_hints_summarize_reports_transitions_and_heartbeat_interruptions(self):
analysis = analyze_capture_text(
"16:06:15.500 RX 006 bytes 00 00 00 00 80 DA\n"
"16:06:15.550 RX 006 bytes 00 00 15 80 00 CF\n"
"16:06:15.600 RX 006 bytes 00 00 00 00 80 DA\n"
"16:06:15.650 RX 006 bytes 00 00 15 00 00 4F\n"
"16:06:15.700 RX 006 bytes 00 00 00 00 80 DA\n"
)
hints = analysis["gate_session_hints"]
self.assertEqual(
hints["observed_autonomous_report_names"],
["call_button_candidate", "heartbeat_alive_candidate"],
)
reports = {item["name_candidate"]: item for item in hints["observed_reports"]}
self.assertEqual(reports["heartbeat_alive_candidate"]["count"], 3)
self.assertEqual(reports["heartbeat_alive_candidate"]["first_timestamp"], "16:06:15.500")
self.assertEqual(reports["heartbeat_alive_candidate"]["last_timestamp"], "16:06:15.700")
self.assertEqual(reports["call_button_candidate"]["states"], ["active", "inactive"])
self.assertEqual(hints["heartbeat_cadence_ms"]["samples"], [100, 100])
self.assertEqual(hints["heartbeat_cadence_ms"]["average"], 100.0)
self.assertEqual(len(hints["heartbeat_interruptions"]), 2)
self.assertEqual(
hints["heartbeat_interruptions"][0]["interrupted_by"][0]["name_candidate"],
"call_button_candidate",
)
self.assertEqual(len(hints["active_inactive_transitions"]), 1)
transition = hints["active_inactive_transitions"][0]
self.assertEqual(transition["index_hex"], "0x0015")
self.assertEqual(transition["from_state"], "active")
self.assertEqual(transition["to_state"], "inactive")
self.assertEqual(hints["evidence_scope"], "capture_side_observation_only")
self.assertIn("host/session gating", hints["caveat"])
def test_text_report_mentions_gate_session_hints_and_caveat(self):
report = format_text_report(
analyze_capture_text(
"16:06:15.500 RX 006 bytes 00 00 00 00 80 DA\n"
"16:06:15.550 RX 006 bytes 00 00 15 80 00 CF\n"
"16:06:15.600 RX 006 bytes 00 00 00 00 80 DA\n"
"16:06:15.650 RX 006 bytes 00 00 15 00 00 4F\n"
)
)
self.assertIn(
"observed autonomous report candidates: call_button_candidate, heartbeat_alive_candidate",
report,
)
self.assertIn("heartbeat cadence count=2 cadence=avg=100.0ms", report)
self.assertIn("transition index=0x0015 active->inactive", report)
self.assertIn("heartbeat gap 16:06:15.500..16:06:15.600", report)
self.assertIn("caveat: Missing autonomous reports", report)
def test_cli_json_output(self):
output = io.StringIO()
rc = main(
["--json", "-"],
stdin=io.StringIO("16:06:15.502 RX 006 bytes 00 00 15 80 00 CF\n"),
stdout=output,
)
self.assertEqual(rc, 0)
payload = json.loads(output.getvalue())
self.assertEqual(payload["frames"][0]["report_candidate"]["index"], 0x15)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,154 @@
import io
import json
import tempfile
import unittest
from pathlib import Path
from h8536.protocol_trace import (
checksum_for,
decode_trace,
format_text_report,
main,
parse_byte_text,
)
def frame(prefix: list[int]) -> list[int]:
return prefix + [checksum_for(prefix)]
class ProtocolTraceTest(unittest.TestCase):
def test_decodes_six_byte_frame_fields_and_checksum(self):
decoded = decode_trace(frame([0x08, 0x85, 0x34, 0x12, 0xAB]), direction="rx")
only = decoded["frames"][0]
self.assertTrue(only["checksum"]["valid"])
self.assertEqual(only["direction"], "rx")
self.assertEqual(only["command"]["value"], 0)
self.assertEqual(only["index"]["byte1_low3"], 5)
self.assertEqual(only["index"]["combined"], 0x0534)
self.assertEqual(only["payload_value"]["word_be"], 0x12AB)
self.assertEqual(only["payload_value"]["word_le"], 0xAB12)
def test_reports_bad_checksum_and_trailing_bytes(self):
decoded = decode_trace([0x01, 0x02, 0x03, 0x04, 0x05, 0x00, 0x99])
only = decoded["frames"][0]
self.assertFalse(only["checksum"]["valid"])
self.assertEqual(only["checksum"]["expected"], checksum_for([1, 2, 3, 4, 5]))
self.assertEqual(decoded["trailing_bytes"], ["0x99"])
def test_loads_semantic_command_names_from_decompiler_json(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "rom.json"
path.write_text(
json.dumps(
{
"serial_semantics": {
"protocol_semantics": [
{
"command_effects": [
{
"command_value": 1,
"name_candidate": "read_value",
}
],
"response_schema": [],
}
]
}
}
),
encoding="utf-8",
)
decoded = decode_trace(frame([0x09, 0, 1, 2, 3]), semantics_path=path)
self.assertTrue(decoded["semantics"]["loaded"])
self.assertEqual(decoded["frames"][0]["command"]["name_candidate"], "read_value")
def test_marks_rx_cmd_7_as_retransmit_or_error_candidate_with_previous_frame(self):
prior = frame([0x01, 0, 0, 0, 0])
retry = frame([0x07, 0, 0, 0, 0])
decoded = decode_trace(prior + retry, direction="rx")
annotation = decoded["frames"][1]["stateful_annotations"][0]
self.assertEqual(annotation["kind"], "retransmit_or_error_candidate")
self.assertEqual(annotation["previous_valid_same_direction"]["frame_index"], 0)
def test_decodes_observed_tx_frames_as_reports(self):
samples = [
([0x00, 0x00, 0x00, 0x00, 0x80, 0xDA], 0x0000, 0x0080, "heartbeat_alive_candidate", None),
([0x00, 0x00, 0x15, 0x80, 0x00, 0xCF], 0x0015, 0x8000, "call_button_candidate", "active"),
([0x00, 0x00, 0x15, 0x00, 0x00, 0x4F], 0x0015, 0x0000, "call_button_candidate", "inactive"),
([0x00, 0x00, 0x07, 0x80, 0x00, 0xDD], 0x0007, 0x8000, "cam_power_button_candidate", "active"),
]
decoded = decode_trace([byte for sample, *_ in samples for byte in sample], direction="tx")
for actual, (_, index, value, name, state) in zip(decoded["frames"], samples):
self.assertTrue(actual["checksum"]["valid"])
self.assertFalse(actual["command"]["applicable"])
self.assertIsNone(actual["command"]["name_candidate"])
self.assertEqual(actual["response_schema_candidates"], [])
self.assertEqual(actual["stateful_annotations"], [])
self.assertEqual(actual["report"]["index"], index)
self.assertEqual(actual["report"]["value"], value)
self.assertEqual(actual["report"]["observed_candidate"]["name_candidate"], name)
self.assertEqual(actual["report"]["observed_candidate"].get("state_candidate"), state)
def test_tx_text_report_does_not_render_cmd_or_set_value_acked(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "rom.json"
path.write_text(
json.dumps(
{
"serial_semantics": {
"command_effects": [
{
"command_value": 0,
"name_candidate": "set_value_acked",
}
]
}
}
),
encoding="utf-8",
)
decoded = decode_trace(
[0x00, 0x00, 0x15, 0x80, 0x00, 0xCF],
direction="tx",
semantics_path=path,
)
text = format_text_report(decoded)
self.assertIn("report_index=0x0015", text)
self.assertIn("observed_candidate=call_button_candidate", text)
self.assertNotIn("cmd=", text)
self.assertNotIn("set_value_acked", text)
def test_auto_direction_uses_rx_tx_prefixes(self):
events = parse_byte_text(
"rx: 01 00 00 00 00 5B\n"
"tx: 04 00 00 00 00 5E\n"
)
decoded = decode_trace(events, direction="auto")
self.assertEqual(decoded["frames"][0]["direction"], "rx")
self.assertEqual(decoded["frames"][1]["direction"], "tx")
def test_cli_json_output(self):
output = io.StringIO()
rc = main(["--json", "01", "00", "00", "00", "00", "5B"], stdout=output)
self.assertEqual(rc, 0)
payload = json.loads(output.getvalue())
self.assertEqual(payload["frames"][0]["command"]["value"], 1)
if __name__ == "__main__":
unittest.main()

130
tests/test_serial_gate.py Normal file
View File

@@ -0,0 +1,130 @@
import io
import json
import tempfile
import unittest
from pathlib import Path
from h8536.serial_gate import analyze_serial_gate, format_text_report, main
def ins(
address: int,
text: str,
mnemonic: str | None = None,
operands: str = "",
references: list[int] | None = None,
targets: list[int] | None = None,
) -> dict[str, object]:
refs = [{"address": ref, "symbol": f"ram_{ref:04X}", "region": "on_chip_ram", "kind": "ram"} for ref in references or []]
return {
"address": address,
"text": text,
"mnemonic": mnemonic or text.split()[0],
"operands": operands or (text.split(" ", 1)[1] if " " in text else ""),
"kind": "call" if text.startswith("BSR") else "normal",
"targets": targets or [],
"references": refs,
}
def fixture_payload() -> dict[str, object]:
rows = [
ins(0x3FD3, "TST.B @H'FAA2", references=[0xFAA2]),
ins(0x3FD7, "BNE loc_3FEE", targets=[0x3FEE]),
ins(0x3FD9, "BTST.B #7, @H'FAA5", references=[0xFAA5]),
ins(0x3FDD, "BEQ loc_3FE5", targets=[0x3FE5]),
ins(0x3FDF, "TST.B @H'F9C3", references=[0xF9C3]),
ins(0x3FE3, "BNE loc_3FEE", targets=[0x3FEE]),
ins(0x3FE5, "TST.B @H'F9C0", references=[0xF9C0]),
ins(0x3FE9, "BNE loc_3FEE", targets=[0x3FEE]),
ins(0x3FEB, "BSR loc_BAF2", targets=[0xBAF2]),
ins(0x3FEF, "TST.B @H'F9C5", references=[0xF9C5]),
ins(0x3FF5, "CLR.B @H'F9B5", references=[0xF9B5]),
ins(0x3FF9, "CLR.B @H'F9B0", references=[0xF9B0]),
ins(0xBAF2, "MOV:G.B @H'F9B5, R1", references=[0xF9B5]),
ins(0xBAF8, "CMP:G.B @H'F9B0, R1", references=[0xF9B0]),
ins(0xBAFC, "BNE loc_BB00", targets=[0xBB00]),
ins(0xBAFE, "BRA loc_BB56", targets=[0xBB56]),
ins(0xBB00, "BSET.B #3, @H'FAA2", references=[0xFAA2]),
ins(0xBB08, "MOV:G.W @(-H'0790,R0), R0"),
ins(0xBB1C, "MOV:G.B R1, @H'F850", references=[0xF850]),
ins(0xBB20, "MOV:G.B R5, @H'F852", references=[0xF852]),
ins(0xBB2B, "MOV:G.B R5, @H'F851", references=[0xF851]),
ins(0xBB39, "MOV:G.B R4, @H'F854", references=[0xF854]),
ins(0xBB3F, "MOV:G.B R4, @H'F853", references=[0xF853]),
ins(0xBB43, "BSR loc_BA26", targets=[0xBA26]),
ins(0xBB46, "MOV:G.W #H'01F4, @H'F9C6", references=[0xF9C6]),
ins(0xBB4C, "MOV:G.B #H'14, @H'F9C8", references=[0xF9C8]),
ins(0xBB51, "MOV:G.B #H'80, @H'FAA3", references=[0xFAA3]),
ins(0xBBCB, "CLR.B @H'F9C3", references=[0xF9C3]),
ins(0xBC0F, "TST.B @H'FAA2", references=[0xFAA2]),
ins(0xBC15, "BSET.B #7, @H'FAA2", references=[0xFAA2]),
ins(0xBD6D, "ADD:Q.B #1, @H'F9B5", references=[0xF9B5]),
ins(0xBD71, "BCLR.B #7, @H'F9B5", references=[0xF9B5]),
ins(0xBD75, "CLR.B @H'FAA3", references=[0xFAA3]),
ins(0xBD79, "CLR.B @H'FAA2", references=[0xFAA2]),
ins(0xBE9E, "MOV:G.B @H'FAA5, R0", references=[0xFAA5]),
ins(0xBEA5, "AND.B @H'FAA3, R0", references=[0xFAA3]),
ins(0xBEA9, "MOV:G.B R0, @H'FAA3", references=[0xFAA3]),
ins(0xBEAF, "CLR.B @H'FAA2", references=[0xFAA2]),
ins(0xBEB5, "TST.W @H'F9C6", references=[0xF9C6]),
ins(0xBEBB, "TST.B @H'F9C8", references=[0xF9C8]),
ins(0xBEC5, "MOV:G.W #H'01F4, @H'F9C6", references=[0xF9C6]),
ins(0xBECB, "BTST.B #7, @H'FAA3", references=[0xFAA3]),
ins(0xBED1, "CLR.B @H'F9C3", references=[0xF9C3]),
ins(0xBED5, "BSR loc_BA26", targets=[0xBA26]),
]
return {
"call_graph": {"nodes": [{"start": 0x3FD3, "label": "loc_3FD3"}, {"start": 0xBAF2, "label": "loc_BAF2"}]},
"instructions": rows,
}
class SerialGateTest(unittest.TestCase):
def test_reconstructs_requested_gate_evidence(self):
analysis = analyze_serial_gate(fixture_payload())
self.assertEqual(analysis["summary"]["confidence"], "high")
self.assertTrue(analysis["evidence"]["scheduler_gate_loc_3FD3"]["present"])
self.assertIn("FAA2 == 0", analysis["evidence"]["scheduler_gate_loc_3FD3"]["summary"])
self.assertTrue(analysis["evidence"]["queue_send_gate_loc_BAF2"]["present"])
self.assertEqual(analysis["evidence"]["queue_send_gate_loc_BAF2"]["queue_table_candidate"]["base_address_hex"], "H'F870")
self.assertEqual(analysis["evidence"]["queue_send_gate_loc_BAF2"]["send_call_address_hex"], "H'BB43")
self.assertTrue(analysis["evidence"]["resend_gate_path"]["present"])
self.assertEqual(analysis["evidence"]["resend_gate_path"]["resend_call_address_hex"], "H'BED5")
self.assertTrue(analysis["evidence"]["rx_session_maintenance"]["present"])
self.assertTrue(any("0x0007" in caveat and "0x0015" in caveat for caveat in analysis["caveats"]))
def test_summarizes_key_state_readers_and_writers(self):
analysis = analyze_serial_gate(fixture_payload())
accesses = {entry["address"]: entry for entry in analysis["state_accesses"]}
self.assertGreaterEqual(accesses[0xF9B5]["read_count"], 1)
self.assertGreaterEqual(accesses[0xF9B5]["read_write_count"], 1)
self.assertGreaterEqual(accesses[0xFAA3]["write_count"], 1)
self.assertIn("sample_accesses", accesses[0xFAA2])
def test_text_report_mentions_caveat_and_gate(self):
text = format_text_report(analyze_serial_gate(fixture_payload()))
self.assertIn("loc_3FD3 gate into loc_BAF2", text)
self.assertIn("capture overlays/runtime queue entries", text)
self.assertIn("H'F9B5", text)
def test_cli_json_output_and_out_file(self):
with tempfile.TemporaryDirectory() as tmp:
input_path = Path(tmp) / "rom.json"
output_path = Path(tmp) / "gate.json"
input_path.write_text(json.dumps(fixture_payload()), encoding="utf-8")
stdout = io.StringIO()
rc = main(["--json", "--out", str(output_path), str(input_path)], stdout=stdout)
self.assertEqual(rc, 0)
self.assertIn("wrote", stdout.getvalue())
payload = json.loads(output_path.read_text(encoding="utf-8"))
self.assertEqual(payload["kind"], "serial_gate")
if __name__ == "__main__":
unittest.main()

View File

@@ -270,6 +270,80 @@ class SerialPseudocodeTest(unittest.TestCase):
},
"evidence_addresses_hex": ["H'BBD6", "H'BE29"],
},
"gate_queue_model": {
"predicates": [
{
"name": "main_loop_may_enter_report_builder",
"condition_candidate": "FAA2 == 0 && F9C0 == 0 && ((FAA5.bit7 == 0) || (F9C3 == 0))",
"summary": "loc_3FD3 gates loc_BAF2.",
},
{
"name": "queue_has_pending_report",
"condition_candidate": "F9B5 != F9B0",
"summary": "Queue non-empty path stages through BB43/BA26.",
},
{
"name": "periodic_resend_may_fire",
"condition_candidate": "(FAA5 & FAA3 & 0x80) != 0 && F9C6 == 0 && F9C8 != 0",
"summary": "BE9E/BED5 resend gate.",
},
],
"session_effects": [
{
"name": "rx_completion_sets_session_timer",
"summary": "RX completion sets F9C5.",
},
{
"name": "session_timeout_clears_gate_and_queue",
"summary": "loc_3FEF clears F9B5/F9B0 and clears or sets FAA5.",
},
{
"name": "host_ack_can_advance_queue",
"summary": "Commands 0x05/0x06 can ack or advance F9B5.",
"command_values_hex": ["H'05", "H'06"],
},
],
"caveat": "Many panel controls may require host/session traffic before reporting; observed autonomous call/cam-power indexes are runtime/capture overlays, not ROM constants.",
"evidence_addresses_hex": ["H'3FD3", "H'3FEB", "H'BAF2", "H'BB43", "H'BE9E", "H'BED5"],
},
"tx_report_model": {
"entry_label": "loc_BB43",
"value_source_candidate": "current_value_table_candidate",
"observed_capture_overlay_candidates": [
{
"name_candidate": "heartbeat_or_idle_report_candidate",
"observed_frames_hex": ["00 00 00 00 80 DA"],
},
{
"name_candidate": "call_button_report_candidate",
"observed_frames_hex": ["00 00 15 80 00 CF", "00 00 15 00 00 4F"],
},
{
"name_candidate": "camera_power_report_candidate",
"observed_frames_hex": ["00 00 07 80 00 DD"],
},
],
"observed_autonomous_output_caveat": "Observed autonomous output is limited to heartbeat/call/cam-power; other controls may require host/device requests first.",
"evidence_addresses_hex": ["H'BB20", "H'BB43"],
},
"periodic_resend_model": {
"period_timer": {
"reload_value_hex": "H'01F4",
"summary": "Candidate periodic report/heartbeat timer reload.",
},
"resend_countdown": {
"reload_value_hex": "H'14",
"summary": "Candidate periodic resend countdown.",
},
"pending_mask": {
"mask_hex": "H'80",
"summary": "Candidate autonomous report pending mask.",
},
"resend_path": {
"summary": "Candidate periodic resend path feeding TX staging.",
},
"evidence_addresses_hex": ["H'BE90", "H'BED5"],
},
}
]
}
@@ -287,6 +361,19 @@ class SerialPseudocodeTest(unittest.TestCase):
self.assertIn("serial_session_flags_candidate H'FAA2: reads 1, writes 2; bits 7", text)
self.assertIn("retry/error model candidate:", text)
self.assertIn("checksum path: 0x5A-seeded XOR over RX[0..4] differs from RX[5] -> loc_BE29", text)
self.assertIn("gate/queue state machine candidate:", text)
self.assertIn("main_loop_may_enter_report_builder: FAA2 == 0 && F9C0 == 0", text)
self.assertIn("queue_has_pending_report: F9B5 != F9B0", text)
self.assertIn("host_ack_can_advance_queue: Commands 0x05/0x06 can ack or advance F9B5", text)
self.assertIn("static bool sci1_candidate_main_report_gate_open(void)", text)
self.assertIn("static bool sci1_candidate_report_queue_nonempty(void)", text)
self.assertIn("static bool sci1_candidate_periodic_resend_gate_open(void)", text)
self.assertIn("TX/autonomous report model candidate:", text)
self.assertIn("loc_BB43 -> loc_BA26: bytes 0..2 encode candidate logical index/report id; bytes 3..4 come from current_value_table_candidate", text)
self.assertIn("heartbeat_or_idle_report_candidate: 00 00 00 00 80 DA", text)
self.assertIn("heartbeat/periodic resend candidate:", text)
self.assertIn("F9C6 reload H'01F4", text)
self.assertIn("BED5 resend path", text)
self.assertIn("candidate effect: table_write_candidate; target primary_value_table_candidate", text)
def test_tx_only_option_omits_rx_functions(self):

View File

@@ -103,8 +103,20 @@ def planned_semantics_payload() -> dict:
instruction(0xBF14, "BEQ", "loc_C100", targets=[0xC100]),
instruction(0xBF18, "CMP:E.B", "#H'06, R0"),
instruction(0xBF1C, "BEQ", "loc_C600", targets=[0xC600]),
instruction(0xBF1E, "CMP:E.B", "#H'05, R0"),
instruction(0xBF1F, "BEQ", "loc_C500", targets=[0xC500]),
instruction(0xBF20, "CMP:E.B", "#H'07, R0"),
instruction(0xBF24, "BEQ", "loc_C700", targets=[0xC700]),
instruction(0x3FD3, "TST.B", "@H'FAA2", [0xFAA2]),
instruction(0x3FD9, "BTST.B", "#7, @H'FAA5", [0xFAA5]),
instruction(0x3FDF, "TST.B", "@H'F9C3", [0xF9C3]),
instruction(0x3FE5, "TST.B", "@H'F9C0", [0xF9C0]),
instruction(0x3FEB, "BSR", "loc_BAF2", targets=[0xBAF2]),
instruction(0x3FEF, "TST.B", "@H'F9C5", [0xF9C5]),
instruction(0x3FF5, "CLR.B", "@H'F9B5", [0xF9B5]),
instruction(0x3FF9, "CLR.B", "@H'F9B0", [0xF9B0]),
instruction(0x3FFD, "BCLR.B", "#7, @H'FAA5", [0xFAA5]),
instruction(0x4007, "BSET.B", "#7, @H'FAA5", [0xFAA5]),
instruction(0xC000, "MOV:G.B", "@H'F861, R1", [0xF861]),
instruction(0xC004, "MOV:G.B", "@H'F862, R2", [0xF862]),
instruction(0xC008, "BSR", "loc_622B", targets=[0x622B]),
@@ -130,6 +142,9 @@ def planned_semantics_payload() -> dict:
instruction(0xC124, "MOV:G.W", "R3, @H'F853", [0xF853]),
instruction(0xC128, "MOV:G.B", "@H'F9B5, R6", [0xF9B5]),
instruction(0xC12C, "BSR", "loc_BA26", targets=[0xBA26]),
instruction(0xC500, "ADD:Q.B", "#1, @H'F9B5", [0xF9B5]),
instruction(0xC504, "BCLR.B", "#7, @H'F9B5", [0xF9B5]),
instruction(0xC508, "BCLR.B", "#7, @H'FAA3", [0xFAA3]),
instruction(0xC600, "MOV:G.B", "@H'F861, R1", [0xF861]),
instruction(0xC604, "MOV:G.B", "@H'F862, R2", [0xF862]),
instruction(0xC608, "BSR", "loc_622B", targets=[0x622B]),
@@ -143,6 +158,25 @@ def planned_semantics_payload() -> dict:
instruction(0xC70C, "BSR", "loc_BA26", targets=[0xBA26]),
instruction(0xC800, "CMP:E.B", "@H'F865, R7", [0xF865]),
instruction(0xC804, "BNE", "loc_C700", targets=[0xC700]),
instruction(0xBB20, "MOV:G.B", "#H'00, @H'F850", [0xF850]),
instruction(0xBB24, "MOV:G.B", "#H'00, @H'F851", [0xF851]),
instruction(0xBB28, "MOV:G.B", "#H'15, @H'F852", [0xF852]),
instruction(0xBB2C, "MOV:G.W", "@(-H'1800,R4), R0"),
instruction(0xBB30, "MOV:G.W", "R0, @H'F853", [0xF853]),
instruction(0xBB43, "BSR", "loc_BA26", targets=[0xBA26]),
instruction(0xBE90, "MOV:G.W", "#H'01F4, @H'F9C6", [0xF9C6]),
instruction(0xBE94, "MOV:G.B", "#H'14, @H'F9C8", [0xF9C8]),
instruction(0xBE98, "MOV:G.B", "#H'80, @H'FAA3", [0xFAA3]),
instruction(0xBE9C, "MOV:G.B", "#H'01, @H'FAA2", [0xFAA2]),
instruction(0xBEA0, "MOV:G.B", "@H'F9B5, R1", [0xF9B5]),
instruction(0xBEA4, "MOV:G.B", "#H'01, @H'F9C0", [0xF9C0]),
instruction(0xBE9E, "MOV:G.B", "@H'FAA5, R0", [0xFAA5]),
instruction(0xBEA5, "AND.B", "@H'FAA3, R0", [0xFAA3]),
instruction(0xBEB5, "TST.W", "@H'F9C6", [0xF9C6]),
instruction(0xBEBB, "TST.B", "@H'F9C8", [0xF9C8]),
instruction(0xBED5, "MOV:G.B", "@H'F858, R0", [0xF858]),
instruction(0xBED9, "MOV:G.B", "R0, @H'F850", [0xF850]),
instruction(0xBEE0, "BSR", "loc_BA26", targets=[0xBA26]),
]
)
@@ -296,6 +330,53 @@ class SerialSemanticsTest(unittest.TestCase):
self.assertIn("0x07", retry_text)
self.assertIn("checksum_error_response", retry_text)
def test_tx_report_model_separates_autonomous_reports_from_rx_commands(self):
semantics = only_semantics(self, planned_semantics_payload())
report = semantics["tx_report_model"]
report_text = semantic_text(report)
self.assertEqual(report["direction"], "device_to_host_autonomous_report_candidate")
self.assertIn("bb43", report_text)
self.assertIn("ba26", report_text)
self.assertIn("bytes 0..2", report_text)
self.assertIn("current_value_table", report_text)
self.assertIn("00 00 15 80 00 cf", report_text)
self.assertIn("host/device request", report_text)
def test_periodic_resend_model_marks_heartbeat_constants(self):
semantics = only_semantics(self, planned_semantics_payload())
periodic = semantics["periodic_resend_model"]
periodic_text = semantic_text(periodic)
self.assertIn("f9c6", periodic_text)
self.assertIn("01f4", periodic_text)
self.assertIn("f9c8", periodic_text)
self.assertIn("14", periodic_text)
self.assertIn("faa3", periodic_text)
self.assertIn("80", periodic_text)
self.assertIn("bed5", periodic_text)
def test_gate_queue_model_surfaces_autonomous_tx_state_machine(self):
semantics = only_semantics(self, planned_semantics_payload())
gate = semantics["gate_queue_model"]
gate_text = semantic_text(gate)
self.assertIn("3fd3", gate_text)
self.assertIn("baf2", gate_text)
self.assertIn("faa2 == 0", gate_text)
self.assertIn("f9c0 == 0", gate_text)
self.assertIn("f9b5 != f9b0", gate_text)
self.assertIn("bb43", gate_text)
self.assertIn("be9e", gate_text)
self.assertIn("bed5", gate_text)
self.assertIn("f9c5", gate_text)
self.assertIn("commands 0x05", gate_text)
self.assertIn("0x06", gate_text)
self.assertIn("not rom constants", gate_text)
def test_missing_serial_reconstruction_candidates_emit_no_protocol_semantics(self):
payload = {
"serial_reconstruction": {"candidates": []},

157
tests/test_table_xrefs.py Normal file
View File

@@ -0,0 +1,157 @@
import json
import tempfile
import unittest
from pathlib import Path
from h8536.table_xrefs import analyze_table_xrefs, generate_table_xref_report, write_table_xrefs
def reference(address: int) -> dict:
return {"address": address}
def instruction(
address: int,
mnemonic: str,
operands: str = "",
references: list[int] | None = None,
text: str | None = None,
) -> dict:
return {
"address": address,
"mnemonic": mnemonic,
"operands": operands,
"text": text or f"{mnemonic} {operands}".strip(),
"references": [reference(item) for item in (references or [])],
"targets": [],
}
def payload() -> dict:
return {
"call_graph": {
"nodes": [
{"start": 0xC000, "end": 0xC0FF, "label": "loc_C000"},
{"start": 0xD000, "end": 0xD0FF, "label": "loc_D000"},
],
},
"serial_semantics": {
"table_map_candidates": [
{
"kind": "logical_table_map_candidate",
"name_candidate": "primary_value_table_candidate",
"confidence": "candidate-medium",
"accesses": [{"instruction_address": 0xC004}],
}
],
},
"lcd_text": {
"strings": [
{
"address": 0x77F4,
"text": "COMM LINK ITEM-1",
"trimmed": "COMM LINK ITEM-1",
"confidence": "high",
"xrefs": [
{
"address": 0x7804,
"following_bsr": {"address": 0x7807, "target": 0x5A91},
}
],
"xref_count": 1,
}
],
},
"lcd_driver": {
"routines": [
{
"start": 0x3F40,
"end": 0x3F74,
"role_hint": "lcd_wait_and_transfer",
"roles": ["lcd_data_write"],
}
],
},
"instructions": [
instruction(0xC000, "MOV:G.W", "#H'0006, R3"),
instruction(0xC004, "MOV:G.W", "@(-H'2000,R3), R0"),
instruction(0xC008, "MOV:G.W", "R1, @(-H'1800,R4)"),
instruction(0xD000, "MOV:G.W", "@H'F900, R2", [0xF900]),
instruction(0xD004, "BSET.B", "#7, @(-H'1400,R5)"),
instruction(0xD008, "CMP:G.W", "@H'E124, R0", [0xE124]),
instruction(0xD00C, "MOV:G.W", "R3, @H'F922", [0xF922]),
],
}
class TableXrefsTest(unittest.TestCase):
def test_reports_logical_direct_static_and_dynamic_accesses(self):
analysis = analyze_table_xrefs(payload())
tables = {table["name"]: table for table in analysis["tables"]}
primary = tables["primary_value_table_candidate"]
self.assertEqual(primary["access_count"], 3)
self.assertEqual(primary["read_count"], 3)
self.assertEqual(primary["static_offsets"], [0, 6, 0x124])
static_access = primary["accesses"][0]
self.assertEqual(static_access["index"], 6)
self.assertEqual(static_access["logical_address"], 0xE006)
self.assertEqual(static_access["function_label"], "loc_C000")
self.assertEqual(static_access["semantic_candidates"][0]["confidence"], "candidate-medium")
self.assertEqual(primary["accesses"][2]["kind"], "direct_logical_address_access")
self.assertEqual(primary["accesses"][2]["logical_address"], 0xE124)
current = tables["current_value_table_candidate"]
self.assertEqual(current["access_count"], 2)
self.assertEqual(current["dynamic_index_count"], 1)
self.assertEqual(current["accesses"][0]["index"], "dynamic")
self.assertEqual(current["accesses"][0]["index_register"], "R4")
self.assertEqual(current["accesses"][1]["direct_address"], 0xF922)
self.assertEqual(current["accesses"][1]["offset"], 2)
self.assertEqual(current["write_count"], 2)
flags = tables["flag_table_candidate"]
self.assertEqual(flags["write_count"], 1)
self.assertEqual(flags["dynamic_index_count"], 1)
lcd_terms = {
item["term"]: item
for item in analysis["lcd_correlation"]["term_hits"]
}
self.assertEqual(lcd_terms["CONNECT"]["hit_count"], 0)
self.assertEqual(lcd_terms["COMM LINK"]["hit_count"], 1)
self.assertEqual(
analysis["lcd_correlation"]["display_builder_targets"][0]["target"],
0x5A91,
)
def test_text_report_names_dynamic_registers_and_functions(self):
text = generate_table_xref_report(payload(), source_name="sample.json")
self.assertIn("Table/Index Cross-Reference Report for sample.json", text)
self.assertIn("primary_value_table_candidate H'E000", text)
self.assertIn("offset H'0006 -> H'E006", text)
self.assertIn("offset H'0124 -> H'E124", text)
self.assertIn("offset H'0002 at H'F922", text)
self.assertIn("index dynamic via R4", text)
self.assertIn("term 'CONNECT': no LCD/text candidate hits", text)
self.assertIn("term 'COMM LINK': 1 candidate", text)
self.assertIn("display builder xrefs: H'5A91:1", text)
self.assertIn("loc_C000", text)
def test_write_json_output(self):
with tempfile.TemporaryDirectory() as tmp:
input_path = Path(tmp) / "rom.json"
output_path = Path(tmp) / "xrefs.json"
input_path.write_text(json.dumps(payload()), encoding="utf-8")
write_table_xrefs(input_path, output_path, as_json=True)
written = json.loads(output_path.read_text(encoding="utf-8"))
self.assertEqual(written["kind"], "table_xrefs")
self.assertEqual(written["summary"]["access_count"], 6)
self.assertEqual(written["source"], str(input_path))
if __name__ == "__main__":
unittest.main()