More decompiling work
This commit is contained in:
File diff suppressed because it is too large
Load Diff
1867
build/rom_serial_gate.json
Normal file
1867
build/rom_serial_gate.json
Normal file
File diff suppressed because it is too large
Load Diff
105
build/rom_serial_gate.txt
Normal file
105
build/rom_serial_gate.txt
Normal file
@@ -0,0 +1,105 @@
|
||||
H8/536 Serial Gate/Queue State-Machine Reconstruction
|
||||
|
||||
Summary: autonomous serial TX/report queue gate
|
||||
Confidence: high
|
||||
|
||||
Evidence:
|
||||
- loc_3FD3 gate into loc_BAF2: present
|
||||
Requires FAA2 == 0, allows the FAA5.bit7 path only when F9C3 == 0, then requires F9C0 == 0 before BSR loc_BAF2.
|
||||
- H'3FD3: TST.B @H'FAA2
|
||||
- H'3FD7: BNE loc_3FEE
|
||||
- H'3FD9: BTST.B #7, @H'FAA5
|
||||
- H'3FDD: BEQ loc_3FE5
|
||||
- H'3FDF: TST.B @H'F9C3
|
||||
- H'3FE3: BNE loc_3FEE
|
||||
- H'3FE5: TST.B @H'F9C0
|
||||
- H'3FE9: BNE loc_3FEE
|
||||
- H'3FEB: BSR loc_BAF2
|
||||
- loc_BAF2 queue send gate: present
|
||||
F9B5 is compared against F9B0; inequality enters the send path, reads a queued word via the F9B5-derived index around F870, stages F850-F854, and calls BA26 at BB43.
|
||||
- H'BAF2: MOV:G.B @H'F9B5, R1
|
||||
- H'BAF8: CMP:G.B @H'F9B0, R1
|
||||
- H'BAFC: BNE loc_BB00
|
||||
- H'BAFE: BRA loc_BB56
|
||||
- H'BB00: BSET.B #3, @H'FAA2
|
||||
- H'BB08: MOV:G.W @(-H'0790,R0), R0
|
||||
- H'BB1C: MOV:G.B R1, @H'F850
|
||||
- H'BB20: MOV:G.B R5, @H'F852
|
||||
- H'BB2B: MOV:G.B R5, @H'F851
|
||||
- H'BB39: MOV:G.B R4, @H'F854
|
||||
- H'BB3F: MOV:G.B R4, @H'F853
|
||||
- H'BB43: BSR loc_BA26
|
||||
- H'BB46: MOV:G.W #H'01F4, @H'F9C6
|
||||
- H'BB4C: MOV:G.B #H'14, @H'F9C8
|
||||
- H'BB51: MOV:G.B #H'80, @H'FAA3
|
||||
- resend gate/path: present
|
||||
BE9E masks FAA5 with FAA3, waits for F9C6/F9C8 timeout gates, then if FAA3.bit7 remains set clears F9C3 and calls BA26 from BED5.
|
||||
- H'BE9E: MOV:G.B @H'FAA5, R0
|
||||
- H'BEA5: AND.B @H'FAA3, R0
|
||||
- H'BEA9: MOV:G.B R0, @H'FAA3
|
||||
- H'BEAF: CLR.B @H'FAA2
|
||||
- H'BEB5: TST.W @H'F9C6
|
||||
- H'BEBB: TST.B @H'F9C8
|
||||
- H'BEC5: MOV:G.W #H'01F4, @H'F9C6
|
||||
- H'BECB: BTST.B #7, @H'FAA3
|
||||
- H'BED1: CLR.B @H'F9C3
|
||||
- H'BED5: BSR loc_BA26
|
||||
- RX/session maintenance: present
|
||||
F9C5 timeout maintenance clears F9B5/F9B0 and FAA5.bit7; RX command processing uses FAA2 as an in-session latch and paths advance F9B5/F9B0 or clear FAA3/FAA2.
|
||||
- H'3FEF: TST.B @H'F9C5
|
||||
- H'3FF5: CLR.B @H'F9B5
|
||||
- H'3FF9: CLR.B @H'F9B0
|
||||
- H'3FFD: BCLR.B #7, @H'FAA5
|
||||
- H'4007: BSET.B #7, @H'FAA5
|
||||
- H'BBCB: CLR.B @H'F9C3
|
||||
- H'BC0F: TST.B @H'FAA2
|
||||
- H'BC15: BSET.B #7, @H'FAA2
|
||||
- H'BC33: CLR.B @H'FAA2
|
||||
- H'BC5C: BCLR.B #3, @H'FAA2
|
||||
- H'BC63: CLR.B @H'FAA3
|
||||
- H'BCD0: BCLR.B #7, @H'FAA2
|
||||
- H'BCFD: BCLR.B #7, @H'FAA2
|
||||
- H'BD04: BCLR.B #7, @H'FAA2
|
||||
- H'BD6D: ADD:Q.B #1, @H'F9B5
|
||||
- H'BD71: BCLR.B #7, @H'F9B5
|
||||
- H'BD75: CLR.B @H'FAA3
|
||||
- H'BD79: CLR.B @H'FAA2
|
||||
- H'BDC8: ADD:Q.B #1, @H'F9B5
|
||||
- H'BDCC: BCLR.B #7, @H'F9B5
|
||||
- H'BDD0: CLR.B @H'FAA3
|
||||
- H'BDD4: CLR.B @H'FAA2
|
||||
- H'BDF3: ADD:Q.B #1, @H'F9B5
|
||||
- H'BDF7: BCLR.B #7, @H'F9B5
|
||||
- H'BDFB: CLR.B @H'FAA3
|
||||
- H'BDFF: CLR.B @H'FAA2
|
||||
|
||||
State address readers/writers:
|
||||
- H'F9B0: reads=4 writes=1 read/write=4
|
||||
H'3E60 read MOV:G.B @H'F9B0, R1; H'3E7A read_write ADD:Q.B #1, @H'F9B0; H'3E7E read_write BCLR.B #7, @H'F9B0; H'3E82 read MOV:G.B @H'F9B0, R0; H'3FF9 write CLR.B @H'F9B0; H'4059 read MOV:G.B @H'F9B0, R2
|
||||
- H'F9B4: reads=3 writes=0 read/write=4
|
||||
H'280C read CMP:G.B @H'F9B4, R1; H'3EA6 read MOV:G.B @H'F9B4, R1; H'3EC3 read_write ADD:Q.B #1, @H'F9B4; H'3EC7 read_write BCLR.B #5, @H'F9B4; H'BE78 read MOV:G.B @H'F9B4, R1; H'BE95 read_write ADD:Q.B #1, @H'F9B4
|
||||
- H'F9B5: reads=4 writes=1 read/write=6
|
||||
H'3E58 read MOV:G.B @H'F9B5, R0; H'3E8B read CMP:G.B @H'F9B5, R0; H'3FF5 write CLR.B @H'F9B5; H'405F read CMP:G.B @H'F9B5, R2; H'BAF2 read MOV:G.B @H'F9B5, R1; H'BD6D read_write ADD:Q.B #1, @H'F9B5
|
||||
- H'F9B9: reads=3 writes=1 read/write=0
|
||||
H'2806 read MOV:G.B @H'F9B9, R1; H'2822 write MOV:G.B R1, @H'F9B9; H'3E9E read MOV:G.B @H'F9B9, R0; H'BE70 read MOV:G.B @H'F9B9, R3
|
||||
- H'F9C0: reads=3 writes=7 read/write=1
|
||||
H'3FE5 read TST.B @H'F9C0; H'BA26 read TST.B @H'F9C0; H'BA2C write MOV:G.B #H'64, @H'F9C0; H'BAA2 write MOV:G.B #H'1F, @H'F9C0; H'BADA write MOV:G.B #H'09, @H'F9C0; H'BAE1 write MOV:G.B #H'09, @H'F9C0
|
||||
- H'F9C3: reads=6 writes=4 read/write=0
|
||||
H'3FDF read TST.B @H'F9C3; H'4052 read TST.B @H'F9C3; H'BA90 read TST.B @H'F9C3; H'BB77 write CLR.B @H'F9C3; H'BB7D read CMP:G.B #H'05, @H'F9C3; H'BB8A read MOV:G.B @H'F9C3, R1
|
||||
- H'F9C5: reads=2 writes=2 read/write=1
|
||||
H'3FEF read TST.B @H'F9C5; H'BB9E write MOV:G.B #H'14, @H'F9C5; H'BEE4 write CLR.B @H'F9C5; H'BF31 read TST.B @H'F9C5; H'BF37 read_write ADD:Q.B #-1, @H'F9C5
|
||||
- H'F9C6: reads=2 writes=2 read/write=1
|
||||
H'BB46 write MOV:G.W #H'01F4, @H'F9C6; H'BEB5 read TST.W @H'F9C6; H'BEC5 write MOV:G.W #H'01F4, @H'F9C6; H'BF02 read TST.W @H'F9C6; H'BF08 read_write ADD:Q.W #-1, @H'F9C6
|
||||
- H'F9C8: reads=1 writes=1 read/write=1
|
||||
H'BB4C write MOV:G.B #H'14, @H'F9C8; H'BEBB read TST.B @H'F9C8; H'BEC1 read_write ADD:Q.B #-1, @H'F9C8
|
||||
- H'FAA2: reads=6 writes=6 read/write=7
|
||||
H'3FD3 read TST.B @H'FAA2; H'BA84 read BTST.B #3, @H'FAA2; H'BA96 read_write BCLR.B #3, @H'FAA2; H'BB00 read_write BSET.B #3, @H'FAA2; H'BC0F read TST.B @H'FAA2; H'BC15 read_write BSET.B #7, @H'FAA2
|
||||
- H'FAA3: reads=2 writes=8 read/write=0
|
||||
H'BA9A write CLR.B @H'FAA3; H'BB51 write MOV:G.B #H'80, @H'FAA3; H'BC63 write CLR.B @H'FAA3; H'BD75 write CLR.B @H'FAA3; H'BDD0 write CLR.B @H'FAA3; H'BDFB write CLR.B @H'FAA3
|
||||
- H'FAA5: reads=5 writes=0 read/write=2
|
||||
H'3FD9 read BTST.B #7, @H'FAA5; H'3FFD read_write BCLR.B #7, @H'FAA5; H'4007 read_write BSET.B #7, @H'FAA5; H'404C read BTST.B #7, @H'FAA5; H'BA8A read BTST.B #7, @H'FAA5; H'BE2D read BTST.B #7, @H'FAA5
|
||||
|
||||
Caveats:
|
||||
- Observed report indexes 0x0007 and 0x0015 are capture overlays/runtime queue entries; this analyzer does not treat them as statically proven ROM constants.
|
||||
- Queue entries near F870 are reached through RAM-indexed addressing; static JSON proves the access pattern, not the runtime queue contents.
|
||||
- Branch predicates are summarized from local instruction order and targets; this is not an emulator trace.
|
||||
@@ -174,16 +174,36 @@ extern volatile u8 MEM8[0x10000];
|
||||
* evidence: H'BE70
|
||||
* - serial_tx_busy_timer_candidate H'F9C0: reads 2, writes 8
|
||||
* evidence: H'BA26, H'BA2C, H'BAA2, H'BADA, H'BAE1, H'BAE8, H'BE1D, H'BE3E, H'BEEE, H'BEF4
|
||||
* - serial_session_flags_candidate H'FAA2: reads 5, writes 13; bits 3, 7
|
||||
* evidence: H'BA84, H'BA96, H'BB00, H'BC0F, H'BC15, H'BC33, H'BC5C, H'BCD0, H'BCFD, H'BD04, H'BD67, H'BD79, H'BDC2, H'BDD4, H'BDED, H'BDFF, H'BE47, H'BEAF
|
||||
* - serial_pending_mask_candidate H'FAA3: reads 1, writes 9; bits 7
|
||||
* evidence: H'BA9A, H'BB51, H'BC63, H'BD75, H'BDD0, H'BDFB, H'BE43, H'BEA5, H'BEA9, H'BECB
|
||||
* - ... 3 more state-variable candidates
|
||||
* - autonomous_report_period_timer_candidate H'F9C6: reads 1, writes 2
|
||||
* evidence: H'BB46, H'BEB5, H'BEC5
|
||||
* - autonomous_report_resend_countdown_candidate H'F9C8: reads 1, writes 2
|
||||
* evidence: H'BB4C, H'BEBB, H'BEC1
|
||||
* - ... 5 more state-variable candidates
|
||||
* retry/error model candidate:
|
||||
* - checksum path: 0x5A-seeded XOR over RX[0..4] differs from RX[5] -> loc_BE29
|
||||
* - retry path: counter H'FAA6, threshold 2; Candidate retry path clears/consults serial flags, increments FAA6, compares it with 2, and when still below the apparent limit stages a command 0x07 response.
|
||||
* - command 0x07 path: Candidate retransmit/explicit command 0x07 path either copies previous TX frame bytes back to F850-F854 or stages an observed 0x07 response before loc_BA26.
|
||||
* - evidence: H'BBD8, H'BBDC, H'BBE0, H'BBE4, H'BBE8, H'BBEC, H'BBF0, H'BE4D, H'BE56, H'BE5E, H'BE66, H'BE52, H'BE5A, H'BE62, H'BE6A, H'BE29, H'BE2D, H'BE33, H'BE37, H'BE43, H'BE47, H'BE05, H'BE0D, H'BE15, H'BE09, H'BE11, H'BE19, H'BE22
|
||||
* gate/queue state machine candidate:
|
||||
* - main_loop_may_enter_report_builder: FAA2 == 0 && F9C0 == 0 && ((FAA5.bit7 == 0) || (F9C3 == 0)); Main-loop report gate; session must be idle, TX busy timer clear, and RX gate open.
|
||||
* - queue_has_pending_report: F9B5 != F9B0; Queue/pending cursor gate; non-empty state stages through BB43 before loc_BA26.
|
||||
* - periodic_resend_may_fire: (FAA5 & FAA3 & 0x80) != 0 && F9C6 == 0 && F9C8 != 0 after countdown; Resend gate masks pending state with FAA5, checks F9C6/F9C8, then calls BA26 at BED5.
|
||||
* - rx_completion_sets_session_timer: RX completion sets F9C5 (observed reload H'14) after the sixth byte is captured.
|
||||
* - session_timeout_clears_gate_and_queue: When F9C5 is clear, loc_3FEF clears F9B5/F9B0 and clears FAA5.bit7; when nonzero, it sets FAA5.bit7.
|
||||
* - host_ack_can_advance_queue: Commands 0x05/0x06 are modeled as acknowledgement paths that can clear pending state or advance F9B5.; commands H'05, H'06
|
||||
* - caveat: Many panel controls may require host/session traffic before reporting. Observed autonomous call/camera-power indexes are runtime/capture overlays, not ROM constants.
|
||||
* - evidence: H'3FD3, H'3FD7, H'3FD9, H'3FDD, H'3FDF, H'3FE3, H'3FE5, H'3FE9, H'3FEB, H'3FEF, H'3FF3, H'3FF5, H'3FF9, H'3FFD, H'4001, H'4003, H'4005, H'4007, H'BAF2, H'BAF6, H'BAF8, H'BAFC, H'BAFE, H'BB00, H'BB04, H'BB06, H'BB08, H'BB0C, H'BB0E, H'BB11, H'BB13, H'BB15, H'BB17, H'BB19, H'BB1C, H'BB20, H'BB24, H'BB26, H'BB29, H'BB2B, H'BB2F, H'BB33, H'BB35, H'BB39, H'BB3D, H'BB3F, H'BB43, H'BE9E, H'BEA2, H'BEA5, H'BEA9, H'BEAD, H'BEAF, H'BEB3, H'BEB5, H'BEB9, H'BEBB, H'BEBF, H'BEC1, H'BEC5, H'BECB, H'BECF, H'BED1, H'BED5
|
||||
* TX/autonomous report model candidate:
|
||||
* - loc_BB43 -> loc_BA26: bytes 0..2 encode candidate logical index/report id; bytes 3..4 come from current_value_table_candidate; byte5 is 0x5A XOR checksum
|
||||
* - observed overlay candidates: heartbeat_or_idle_report_candidate: 00 00 00 00 80 DA; call_button_report_candidate: 00 00 15 80 00 CF, 00 00 15 00 00 4F; camera_power_report_candidate: 00 00 07 80 00 DD
|
||||
* - caveat: Real captures supplied so far show only heartbeat/idle, call, and camera-power autonomous TX frames. Other panel controls may require a host/device request or state transition before the firmware reports them.
|
||||
* - evidence: H'BB1C, H'BB20, H'BB2B, H'BB39, H'BB3F, H'BB43
|
||||
* heartbeat/periodic resend candidate:
|
||||
* - F9C6 reload H'01F4: Candidate periodic report/heartbeat timer reload.
|
||||
* - F9C8 reload H'14: Candidate periodic resend countdown/retry spacing value.
|
||||
* - FAA3 mask H'80: Candidate bit/mask that marks an autonomous report pending.
|
||||
* - BED5 resend path: Candidate periodic resend path feeding the TX staging/send-builder flow.
|
||||
* - evidence: H'BB46, H'BEC5, H'BB4C, H'BB51, H'BECB, H'BED5
|
||||
*/
|
||||
|
||||
static u8 sci1_rx_candidate_command(void)
|
||||
@@ -213,6 +233,29 @@ static u16 sci1_rx_candidate_logical_index(void)
|
||||
return 0x01FFu;
|
||||
}
|
||||
|
||||
static bool sci1_candidate_main_report_gate_open(void)
|
||||
{
|
||||
bool session_idle = MEM8[0xFAA2u] == 0u;
|
||||
bool rx_gate_open = (MEM8[0xFAA5u] & 0x80u) == 0u || MEM8[0xF9C3u] == 0u;
|
||||
bool tx_timer_clear = MEM8[0xF9C0u] == 0u;
|
||||
|
||||
return session_idle && rx_gate_open && tx_timer_clear;
|
||||
}
|
||||
|
||||
static bool sci1_candidate_report_queue_nonempty(void)
|
||||
{
|
||||
return MEM8[0xF9B5u] != MEM8[0xF9B0u];
|
||||
}
|
||||
|
||||
static bool sci1_candidate_periodic_resend_gate_open(void)
|
||||
{
|
||||
bool pending = (MEM8[0xFAA5u] & MEM8[0xFAA3u] & 0x80u) != 0u;
|
||||
bool period_elapsed = MEM8[0xF9C6u] == 0u && MEM8[0xF9C7u] == 0u;
|
||||
bool resend_countdown_active = MEM8[0xF9C8u] != 0u;
|
||||
|
||||
return pending && period_elapsed && resend_countdown_active;
|
||||
}
|
||||
|
||||
void sci1_process_candidate_protocol_command(void)
|
||||
{
|
||||
u8 command = sci1_rx_candidate_command();
|
||||
|
||||
2991
build/rom_table_xrefs.json
Normal file
2991
build/rom_table_xrefs.json
Normal file
File diff suppressed because it is too large
Load Diff
93
build/rom_table_xrefs.txt
Normal file
93
build/rom_table_xrefs.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Table/Index Cross-Reference Report for build\rom_decompiled.json
|
||||
================================================================
|
||||
|
||||
Static offsets are emitted only when an index register value can be derived from nearby immediate loads in the current JSON. Other indexed accesses are dynamic.
|
||||
|
||||
LCD correlation hints
|
||||
term 'CONNECT': no LCD/text candidate hits in current decompile
|
||||
term 'CONNECT: OK': no LCD/text candidate hits in current decompile
|
||||
term 'CONNECT: NOT ACT': no LCD/text candidate hits in current decompile
|
||||
term 'NOT ACT': no LCD/text candidate hits in current decompile
|
||||
term 'COMM LINK': 2 candidate hit(s): H'77F4 'COMM LINK ITEM-1Xw', H'78F4 'COMM LINK ITEM-2Xx'
|
||||
term 'COMPLETED': 1 candidate hit(s): H'A025 'COMPLETED'
|
||||
display builder xrefs: H'5A91:165, H'5EED:15, H'5E24:14, H'5B88:13, H'5C91:10, H'5D9A:5
|
||||
LCD driver routines: H'3F40 lcd_wait_and_transfer
|
||||
caveat: LCD strings can be builder/script output; absence of a literal term does not disprove runtime composition.
|
||||
|
||||
primary_value_table_candidate H'E000-H'E3FF (negative H'2000; direct H'F900-H'F91F)
|
||||
accesses=31 reads=21 writes=10 dynamic=11
|
||||
static offsets: H'0000, H'0004, H'0006, H'0046, H'0080, H'0102, H'0124, H'0126, H'014E, H'016E, H'0172, H'01EC, H'0220
|
||||
functions: loc_BBAB:5, loc_2650:3, loc_4096:3, loc_1795:2, loc_19DB:2, loc_1A35:2, loc_48FA:2, vec_ad_adi_3D99:2, <no function>:1, loc_1705:1, loc_174D:1, loc_17C9:1
|
||||
- H'170C read offset H'014E -> H'E14E; loc_1705; BTST.W #15, @H'E14E
|
||||
- H'175A read offset H'016E -> H'E16E; loc_174D; BTST.W #13, @H'E16E
|
||||
- H'179C read offset H'0172 -> H'E172; loc_1795; BTST.W #13, @H'E172
|
||||
- H'17A7 read offset H'0220 -> H'E220; loc_1795; BTST.W #15, @H'E220
|
||||
- H'17D0 read offset H'0126 -> H'E126; loc_17C9; BTST.W #12, @H'E126
|
||||
- H'1802 read offset H'0126 -> H'E126; loc_17FB; BTST.W #12, @H'E126
|
||||
- H'183A read offset H'0126 -> H'E126; loc_182D; BTST.W #5, @H'E126
|
||||
- H'189E read offset H'0126 -> H'E126; loc_1891; BTST.W #5, @H'E126
|
||||
- H'18F4 read offset H'0126 -> H'E126; loc_18E7; BTST.W #5, @H'E126
|
||||
- H'19E3 read index dynamic via R3 operand @(-H'2000,R3); loc_19DB; MOV:G.W @(-H'2000,R3), R0
|
||||
- H'1A03 read index dynamic via R3 operand @(-H'2000,R3); loc_19DB; CMP:G.W @(-H'2000,R3), R1
|
||||
- H'1A3D read index dynamic via R3 operand @(-H'2000,R3); loc_1A35; MOV:G.W @(-H'2000,R3), R0
|
||||
- H'1A6B read index dynamic via R3 operand @(-H'2000,R3); loc_1A35; CMP:G.W @(-H'2000,R3), R0
|
||||
- H'2657 read offset H'0124 -> H'E124; loc_2650; MOV:G.W @H'E124, R0
|
||||
- H'266F read offset H'0004 -> H'E004; loc_2650; BTST.W #13, @H'E004
|
||||
- H'268B read offset H'0124 -> H'E124; loc_2650; CMP:G.W @H'E124, R0
|
||||
- H'3DDA read offset H'0102 -> H'E102; vec_ad_adi_3D99; MOV:G.W @H'E102, R0
|
||||
- H'3DFA read offset H'0102 -> H'E102; vec_ad_adi_3D99; CMP:G.W @H'E102, R1
|
||||
- H'3F8C write index dynamic via R0 operand @(-H'2000,R0); <no function>; CLR.W @(-H'2000,R0)
|
||||
- H'402C write offset H'0046 -> H'E046; loc_400C; CLR.W @H'E046
|
||||
- H'4077 write index dynamic via R0 operand @(-H'2000,R0); loc_4075; CLR.W @(-H'2000,R0)
|
||||
- H'4096 write offset H'0000 -> H'E000; loc_4096; MOV:G.W #H'0080, @H'E000
|
||||
- H'409C write offset H'0006 -> H'E006; loc_4096; MOV:G.W #H'8000, @H'E006
|
||||
- H'40A2 write offset H'0080 -> H'E080; loc_4096; MOV:G.W #H'FFFF, @H'E080
|
||||
- H'490F read offset H'01EC -> H'E1EC; loc_48FA; BTST.W #13, @H'E1EC
|
||||
- H'4915 read offset H'01EC -> H'E1EC; loc_48FA; MOV:G.W @H'E1EC, R0
|
||||
- H'BC75 write index dynamic via R4 operand @(-H'2000,R4); loc_BBAB; MOV:G.W R0, @(-H'2000,R4)
|
||||
- H'BC95 write index dynamic via R4 operand @(-H'2000,R4); loc_BBAB; MOV:G.W R0, @(-H'2000,R4)
|
||||
- H'BCEC read index dynamic via R4 operand @(-H'2000,R4); loc_BBAB; MOV:G.W @(-H'2000,R4), R0
|
||||
- H'BD1A write index dynamic via R4 operand @(-H'2000,R4); loc_BBAB; MOV:G.W R0, @(-H'2000,R4)
|
||||
- H'BD35 write index dynamic via R4 operand @(-H'2000,R4); loc_BBAB; MOV:G.W R0, @(-H'2000,R4)
|
||||
|
||||
secondary_value_table_candidate H'E400-H'E7FF (negative H'1C00; direct H'F940-H'F95F)
|
||||
accesses=8 reads=6 writes=2 dynamic=8
|
||||
functions: loc_1A35:2, loc_1A9C:2, <no function>:1, loc_19A2:1, loc_4075:1, loc_BBAB:1
|
||||
- H'19AA read index dynamic via R3 operand @(-H'1C00,R3); loc_19A2; MOV:G.W @(-H'1C00,R3), R0
|
||||
- H'1A4B read index dynamic via R3 operand @(-H'1C00,R3); loc_1A35; MOV:G.W @(-H'1C00,R3), R1
|
||||
- H'1A5B read index dynamic via R3 operand @(-H'1C00,R3); loc_1A35; MOV:G.W @(-H'1C00,R3), R1
|
||||
- H'1A81 read index dynamic via R3 operand @(-H'1C00,R3); <no function>; AND.W @(-H'1C00,R3), R1
|
||||
- H'1AB4 read index dynamic via R3 operand @(-H'1C00,R3); loc_1A9C; BTST.W R0, @(-H'1C00,R3)
|
||||
- H'1AC1 read index dynamic via R3 operand @(-H'1C00,R3); loc_1A9C; BTST.W R0, @(-H'1C00,R3)
|
||||
- H'407B write index dynamic via R0 operand @(-H'1C00,R0); loc_4075; CLR.W @(-H'1C00,R0)
|
||||
- H'BDE5 write index dynamic via R4 operand @(-H'1C00,R4); loc_BBAB; MOV:G.W R0, @(-H'1C00,R4)
|
||||
|
||||
current_value_table_candidate H'E800-H'EBFF (negative H'1800; direct H'F920-H'F93F)
|
||||
accesses=14 reads=1 writes=13 dynamic=8
|
||||
static offsets: H'0000, H'0006, H'0080, H'0102, H'0124, H'01EC
|
||||
functions: loc_4096:3, loc_BBAB:3, <no function>:1, loc_15E0:1, loc_19DB:1, loc_1A35:1, loc_2650:1, loc_4075:1, loc_48FA:1, loc_BAF2:1
|
||||
- H'15ED write offset H'0102 -> H'E902; loc_15E0; MOV:G.W R1, @H'E902
|
||||
- H'1A09 write index dynamic via R3 operand @(-H'1800,R3); loc_19DB; MOV:G.W R1, @(-H'1800,R3)
|
||||
- H'1A71 write index dynamic via R3 operand @(-H'1800,R3); loc_1A35; MOV:G.W R0, @(-H'1800,R3)
|
||||
- H'2691 write offset H'0124 -> H'E924; loc_2650; MOV:G.W R0, @H'E924
|
||||
- H'3F90 write index dynamic via R0 operand @(-H'1800,R0); <no function>; CLR.W @(-H'1800,R0)
|
||||
- H'407F write index dynamic via R0 operand @(-H'1800,R0); loc_4075; CLR.W @(-H'1800,R0)
|
||||
- H'40A8 write offset H'0000 -> H'E800; loc_4096; MOV:G.W #H'0080, @H'E800
|
||||
- H'40AE write offset H'0006 -> H'E806; loc_4096; MOV:G.W #H'8000, @H'E806
|
||||
- H'40B4 write offset H'0080 -> H'E880; loc_4096; MOV:G.W #H'FFFF, @H'E880
|
||||
- H'491D write offset H'01EC -> H'E9EC; loc_48FA; MOV:G.W R0, @H'E9EC
|
||||
- H'BB35 read index dynamic via R0 operand @(-H'1800,R0); loc_BAF2; MOV:G.W @(-H'1800,R0), R4
|
||||
- H'BC79 write index dynamic via R4 operand @(-H'1800,R4); loc_BBAB; MOV:G.W R0, @(-H'1800,R4)
|
||||
- H'BC99 write index dynamic via R4 operand @(-H'1800,R4); loc_BBAB; MOV:G.W R0, @(-H'1800,R4)
|
||||
- H'BD1E write index dynamic via R4 operand @(-H'1800,R4); loc_BBAB; MOV:G.W R0, @(-H'1800,R4)
|
||||
|
||||
flag_table_candidate H'EC00-H'EFFF (negative H'1400; direct H'F980-H'F99F)
|
||||
accesses=6 reads=0 writes=6 dynamic=5
|
||||
static offsets: H'0200
|
||||
functions: loc_BBAB:5, loc_4075:1
|
||||
- H'4088 write offset H'0200 -> H'EE00; loc_4075; CLR.W @(-H'1400,R0)
|
||||
- H'BC82 write index dynamic via R5 operand @(-H'1400,R5); loc_BBAB; BSET.B #7, @(-H'1400,R5)
|
||||
- H'BC9D write index dynamic via R5 operand @(-H'1400,R5); loc_BBAB; BSET.B #7, @(-H'1400,R5)
|
||||
- H'BD22 write index dynamic via R5 operand @(-H'1400,R5); loc_BBAB; BSET.B #7, @(-H'1400,R5)
|
||||
- H'BD39 write index dynamic via R5 operand @(-H'1400,R5); loc_BBAB; BSET.B #7, @(-H'1400,R5)
|
||||
- H'BDE9 write index dynamic via R5 operand @(-H'1400,R5); loc_BBAB; BSET.B #6, @(-H'1400,R5)
|
||||
534
h8536/protocol_capture.py
Normal file
534
h8536/protocol_capture.py
Normal file
@@ -0,0 +1,534 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping, TextIO
|
||||
|
||||
try: # Keep this module useful even when copied away from the decompiler tree.
|
||||
from . import protocol_trace as _protocol_trace
|
||||
except ImportError: # pragma: no cover - exercised only outside package imports.
|
||||
_protocol_trace = None
|
||||
|
||||
|
||||
CHECKSUM_SEED = getattr(_protocol_trace, "CHECKSUM_SEED", 0x5A)
|
||||
FRAME_LENGTH = getattr(_protocol_trace, "FRAME_LENGTH", 6)
|
||||
CAPTURE_LINE_RE = re.compile(
|
||||
r"^\s*(?P<time>\d{1,2}:\d{2}:\d{2}(?:\.\d{1,6})?)\s+"
|
||||
r"(?P<direction>RX|TX)\s+"
|
||||
r"(?P<count>\d+)\s+bytes?\s+"
|
||||
r"(?P<byte_text>.*?)\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
HEX_BYTE_RE = re.compile(r"\b[0-9A-Fa-f]{2}\b")
|
||||
|
||||
_FALLBACK_OBSERVED_TX_REPORT_CANDIDATES: dict[tuple[int, int], dict[str, str]] = {
|
||||
(0x0000, 0x0080): {
|
||||
"name_candidate": "heartbeat_alive_candidate",
|
||||
},
|
||||
(0x0015, 0x8000): {
|
||||
"name_candidate": "call_button_candidate",
|
||||
"state_candidate": "active",
|
||||
},
|
||||
(0x0015, 0x0000): {
|
||||
"name_candidate": "call_button_candidate",
|
||||
"state_candidate": "inactive",
|
||||
},
|
||||
(0x0007, 0x8000): {
|
||||
"name_candidate": "cam_power_button_candidate",
|
||||
"state_candidate": "active",
|
||||
},
|
||||
}
|
||||
OBSERVED_TX_REPORT_CANDIDATES = getattr(
|
||||
_protocol_trace,
|
||||
"OBSERVED_TX_REPORT_CANDIDATES",
|
||||
_FALLBACK_OBSERVED_TX_REPORT_CANDIDATES,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaptureChunk:
|
||||
chunk_index: int
|
||||
timestamp: str
|
||||
timestamp_ms: int
|
||||
analyzer_direction: str
|
||||
device_direction: str
|
||||
declared_count: int
|
||||
bytes: tuple[int, ...]
|
||||
raw_line: str
|
||||
|
||||
|
||||
def checksum_for(frame_prefix: Iterable[int]) -> int:
|
||||
if _protocol_trace is not None and hasattr(_protocol_trace, "checksum_for"):
|
||||
return int(_protocol_trace.checksum_for(frame_prefix))
|
||||
value = CHECKSUM_SEED
|
||||
for byte in frame_prefix:
|
||||
value ^= byte & 0xFF
|
||||
return value & 0xFF
|
||||
|
||||
|
||||
def parse_capture_text(text: str) -> list[CaptureChunk]:
|
||||
chunks: list[CaptureChunk] = []
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
match = CAPTURE_LINE_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
byte_values = tuple(int(token, 16) for token in HEX_BYTE_RE.findall(match.group("byte_text")))
|
||||
analyzer_direction = match.group("direction").lower()
|
||||
chunks.append(
|
||||
CaptureChunk(
|
||||
chunk_index=len(chunks),
|
||||
timestamp=match.group("time"),
|
||||
timestamp_ms=_timestamp_ms(match.group("time")),
|
||||
analyzer_direction=analyzer_direction,
|
||||
device_direction=_device_direction(analyzer_direction),
|
||||
declared_count=int(match.group("count")),
|
||||
bytes=byte_values,
|
||||
raw_line=raw_line,
|
||||
)
|
||||
)
|
||||
if len(byte_values) != int(match.group("count")):
|
||||
# Preserve the chunk and expose the mismatch in analysis instead of dropping capture evidence.
|
||||
continue
|
||||
return chunks
|
||||
|
||||
|
||||
def analyze_capture_text(text: str) -> dict[str, Any]:
|
||||
return analyze_capture_chunks(parse_capture_text(text))
|
||||
|
||||
|
||||
def analyze_capture_chunks(chunks: Iterable[CaptureChunk]) -> dict[str, Any]:
|
||||
chunk_list = list(chunks)
|
||||
frames = _recombine_frames(chunk_list)
|
||||
groups = _repeated_groups(frames)
|
||||
gate_session_hints = _gate_session_hints(frames)
|
||||
return {
|
||||
"kind": "h8536_protocol_capture",
|
||||
"frame_length": FRAME_LENGTH,
|
||||
"checksum_model": {
|
||||
"algorithm": "xor",
|
||||
"seed": CHECKSUM_SEED,
|
||||
"seed_hex": _h8(CHECKSUM_SEED),
|
||||
"covered_offsets": [0, 1, 2, 3, 4],
|
||||
"checksum_offset": 5,
|
||||
},
|
||||
"chunks": [_chunk_dict(chunk) for chunk in chunk_list],
|
||||
"chunk_count": len(chunk_list),
|
||||
"frames": frames,
|
||||
"frame_count": len(frames),
|
||||
"repeated_groups": groups,
|
||||
"repeated_group_count": len(groups),
|
||||
"gate_session_hints": gate_session_hints,
|
||||
"direction_note": (
|
||||
"Capture RX is analyzer-perspective receive; these bytes are device-perspective TX."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def format_text_report(analysis: Mapping[str, Any]) -> str:
|
||||
lines = [
|
||||
"H8/536 capture log",
|
||||
(
|
||||
f"chunks={analysis.get('chunk_count', 0)} "
|
||||
f"frames={analysis.get('frame_count', 0)} "
|
||||
f"repeated_groups={analysis.get('repeated_group_count', 0)}"
|
||||
),
|
||||
]
|
||||
for frame in analysis.get("frames", []):
|
||||
label = ""
|
||||
report = frame.get("report_candidate") or {}
|
||||
candidate = report.get("observed_candidate") or {}
|
||||
if candidate.get("name_candidate"):
|
||||
label = f" {candidate['name_candidate']}"
|
||||
if candidate.get("state_candidate"):
|
||||
label += f" state={candidate['state_candidate']}"
|
||||
split = " split" if frame.get("source_chunk_count", 0) > 1 else ""
|
||||
lines.append(
|
||||
(
|
||||
f"[{frame['frame_index']:04d}] {frame['timestamp']} "
|
||||
f"{frame['analyzer_direction'].upper()}=>device:{frame['device_direction']} "
|
||||
f"bytes={' '.join(frame['bytes_hex'])} checksum=ok{split} "
|
||||
f"index={report.get('index_hex')} value={report.get('value_hex')}{label}"
|
||||
)
|
||||
)
|
||||
for group in analysis.get("repeated_groups", []):
|
||||
cadence = group.get("cadence_ms") or {}
|
||||
cadence_text = "n/a"
|
||||
if cadence.get("average") is not None:
|
||||
cadence_text = (
|
||||
f"avg={cadence['average']:.1f}ms "
|
||||
f"min={cadence['min']}ms max={cadence['max']}ms"
|
||||
)
|
||||
lines.append(
|
||||
(
|
||||
f"group {group['bytes']} count={group['count']} "
|
||||
f"span={group['span_ms']}ms cadence={cadence_text}"
|
||||
)
|
||||
)
|
||||
hints = analysis.get("gate_session_hints") or {}
|
||||
names = hints.get("observed_autonomous_report_names") or []
|
||||
if names:
|
||||
lines.append("observed autonomous report candidates: " + ", ".join(names))
|
||||
heartbeat = hints.get("heartbeat_cadence_ms") or {}
|
||||
if heartbeat.get("count"):
|
||||
cadence_text = "n/a"
|
||||
if heartbeat.get("average") is not None:
|
||||
cadence_text = (
|
||||
f"avg={heartbeat['average']:.1f}ms "
|
||||
f"min={heartbeat['min']}ms max={heartbeat['max']}ms"
|
||||
)
|
||||
lines.append(f"heartbeat cadence count={heartbeat['count']} cadence={cadence_text}")
|
||||
for transition in hints.get("active_inactive_transitions", []):
|
||||
lines.append(
|
||||
(
|
||||
f"transition index={transition['index_hex']} "
|
||||
f"{transition['from_state']}->{transition['to_state']} "
|
||||
f"{transition['from_timestamp']}..{transition['to_timestamp']}"
|
||||
)
|
||||
)
|
||||
for interruption in hints.get("heartbeat_interruptions", []):
|
||||
interrupted_names = ", ".join(
|
||||
item["name_candidate"] for item in interruption.get("interrupted_by", [])
|
||||
)
|
||||
lines.append(
|
||||
(
|
||||
f"heartbeat gap {interruption['from_timestamp']}..{interruption['to_timestamp']} "
|
||||
f"gap={interruption['gap_ms']}ms interrupted_by={interrupted_names}"
|
||||
)
|
||||
)
|
||||
if hints.get("caveat"):
|
||||
lines.append(f"caveat: {hints['caveat']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, *, stdin: TextIO | None = None, stdout: TextIO | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze timestamped H8/536 serial capture logs and recombine 6-byte frames."
|
||||
)
|
||||
parser.add_argument("input", nargs="?", help="Capture log path. Use '-' or omit to read stdin.")
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON instead of text.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
stdin = stdin or sys.stdin
|
||||
stdout = stdout or sys.stdout
|
||||
if args.input and args.input != "-":
|
||||
text = Path(args.input).read_text(encoding="utf-8")
|
||||
else:
|
||||
text = stdin.read()
|
||||
|
||||
analysis = analyze_capture_text(text)
|
||||
if args.json:
|
||||
json.dump(analysis, stdout, indent=2, sort_keys=True)
|
||||
stdout.write("\n")
|
||||
else:
|
||||
stdout.write(format_text_report(analysis))
|
||||
stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _recombine_frames(chunks: list[CaptureChunk]) -> list[dict[str, Any]]:
|
||||
buffers: dict[str, list[dict[str, Any]]] = {}
|
||||
frames: list[dict[str, Any]] = []
|
||||
for chunk in chunks:
|
||||
key = chunk.analyzer_direction
|
||||
stream = buffers.setdefault(key, [])
|
||||
for offset, byte in enumerate(chunk.bytes):
|
||||
stream.append({"byte": byte, "chunk": chunk, "offset": offset})
|
||||
_drain_valid_frames(stream, frames)
|
||||
return frames
|
||||
|
||||
|
||||
def _drain_valid_frames(stream: list[dict[str, Any]], frames: list[dict[str, Any]]) -> None:
|
||||
while len(stream) >= FRAME_LENGTH:
|
||||
candidate = stream[:FRAME_LENGTH]
|
||||
values = [int(item["byte"]) for item in candidate]
|
||||
if checksum_for(values[:5]) == values[5]:
|
||||
frames.append(_frame_dict(len(frames), candidate))
|
||||
del stream[:FRAME_LENGTH]
|
||||
continue
|
||||
|
||||
realigned = False
|
||||
for start in range(1, len(stream) - FRAME_LENGTH + 1):
|
||||
window = stream[start : start + FRAME_LENGTH]
|
||||
values = [int(item["byte"]) for item in window]
|
||||
if checksum_for(values[:5]) == values[5]:
|
||||
del stream[:start]
|
||||
realigned = True
|
||||
break
|
||||
if not realigned:
|
||||
break
|
||||
|
||||
|
||||
def _frame_dict(frame_index: int, items: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
values = [int(item["byte"]) for item in items]
|
||||
chunks = [item["chunk"] for item in items]
|
||||
first: CaptureChunk = chunks[0]
|
||||
source_chunk_indexes = sorted({chunk.chunk_index for chunk in chunks})
|
||||
return {
|
||||
"frame_index": frame_index,
|
||||
"timestamp": first.timestamp,
|
||||
"timestamp_ms": first.timestamp_ms,
|
||||
"analyzer_direction": first.analyzer_direction,
|
||||
"device_direction": first.device_direction,
|
||||
"bytes": values,
|
||||
"bytes_hex": [_h8(value) for value in values],
|
||||
"checksum": {
|
||||
"valid": True,
|
||||
"expected": values[5],
|
||||
"expected_hex": _h8(values[5]),
|
||||
"actual": values[5],
|
||||
"actual_hex": _h8(values[5]),
|
||||
},
|
||||
"source_chunk_indexes": source_chunk_indexes,
|
||||
"source_chunk_count": len(source_chunk_indexes),
|
||||
"report_candidate": _tx_report_candidate(values),
|
||||
}
|
||||
|
||||
|
||||
def _tx_report_candidate(frame: list[int]) -> dict[str, Any]:
|
||||
index = (frame[0] << 16) | (frame[1] << 8) | frame[2]
|
||||
value = (frame[3] << 8) | frame[4]
|
||||
candidate = OBSERVED_TX_REPORT_CANDIDATES.get((index, value))
|
||||
return {
|
||||
"encoding": "observed_tx_index_value_report_candidate",
|
||||
"confidence": "observed_candidate" if candidate else "unknown",
|
||||
"index": index,
|
||||
"index_hex": f"0x{index:06X}" if index > 0xFFFF else _h16(index),
|
||||
"value": value,
|
||||
"value_hex": _h16(value),
|
||||
"observed_candidate": dict(candidate) if candidate else None,
|
||||
"caveat": "Observed TX report names are capture labels, not proven protocol facts.",
|
||||
}
|
||||
|
||||
|
||||
def _repeated_groups(frames: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
by_bytes: dict[tuple[int, ...], list[Mapping[str, Any]]] = {}
|
||||
for frame in frames:
|
||||
by_bytes.setdefault(tuple(frame["bytes"]), []).append(frame)
|
||||
|
||||
groups: list[dict[str, Any]] = []
|
||||
for values, members in by_bytes.items():
|
||||
if len(members) < 2:
|
||||
continue
|
||||
timestamps = [int(member["timestamp_ms"]) for member in members]
|
||||
deltas = [right - left for left, right in zip(timestamps, timestamps[1:])]
|
||||
groups.append(
|
||||
{
|
||||
"bytes": " ".join(_h8(value) for value in values),
|
||||
"count": len(members),
|
||||
"frame_indexes": [member["frame_index"] for member in members],
|
||||
"first_timestamp": members[0]["timestamp"],
|
||||
"last_timestamp": members[-1]["timestamp"],
|
||||
"span_ms": timestamps[-1] - timestamps[0],
|
||||
"cadence_ms": {
|
||||
"samples": deltas,
|
||||
"average": (sum(deltas) / len(deltas)) if deltas else None,
|
||||
"min": min(deltas) if deltas else None,
|
||||
"max": max(deltas) if deltas else None,
|
||||
},
|
||||
}
|
||||
)
|
||||
return sorted(groups, key=lambda group: (-int(group["count"]), str(group["bytes"])))
|
||||
|
||||
|
||||
def _gate_session_hints(frames: list[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
observed = [_observed_report_frame(frame) for frame in frames]
|
||||
observed = [item for item in observed if item is not None]
|
||||
by_name: dict[str, list[dict[str, Any]]] = {}
|
||||
for item in observed:
|
||||
by_name.setdefault(str(item["name_candidate"]), []).append(item)
|
||||
|
||||
observed_reports = []
|
||||
for name, members in sorted(by_name.items()):
|
||||
observed_reports.append(
|
||||
{
|
||||
"name_candidate": name,
|
||||
"count": len(members),
|
||||
"first_timestamp": members[0]["timestamp"],
|
||||
"last_timestamp": members[-1]["timestamp"],
|
||||
"frame_indexes": [member["frame_index"] for member in members],
|
||||
"indexes_hex": sorted({str(member["index_hex"]) for member in members}),
|
||||
"values_hex": sorted({str(member["value_hex"]) for member in members}),
|
||||
"states": sorted(
|
||||
{
|
||||
str(member["state_candidate"])
|
||||
for member in members
|
||||
if member.get("state_candidate")
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
heartbeat_frames = [
|
||||
item for item in observed if item.get("name_candidate") == "heartbeat_alive_candidate"
|
||||
]
|
||||
heartbeat_timestamps = [int(item["timestamp_ms"]) for item in heartbeat_frames]
|
||||
heartbeat_deltas = [
|
||||
right - left for left, right in zip(heartbeat_timestamps, heartbeat_timestamps[1:])
|
||||
]
|
||||
|
||||
return {
|
||||
"observed_autonomous_report_names": sorted(by_name),
|
||||
"observed_reports": observed_reports,
|
||||
"active_inactive_transitions": _active_inactive_transitions(observed),
|
||||
"heartbeat_cadence_ms": {
|
||||
"count": len(heartbeat_frames),
|
||||
"samples": heartbeat_deltas,
|
||||
"average": (sum(heartbeat_deltas) / len(heartbeat_deltas)) if heartbeat_deltas else None,
|
||||
"min": min(heartbeat_deltas) if heartbeat_deltas else None,
|
||||
"max": max(heartbeat_deltas) if heartbeat_deltas else None,
|
||||
},
|
||||
"heartbeat_interruptions": _heartbeat_interruptions(observed),
|
||||
"caveat": (
|
||||
"Missing autonomous reports for other controls may reflect host/session gating "
|
||||
"or capture timing, not proof that local control state did not change."
|
||||
),
|
||||
"evidence_scope": "capture_side_observation_only",
|
||||
}
|
||||
|
||||
|
||||
def _observed_report_frame(frame: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
report = frame.get("report_candidate") or {}
|
||||
candidate = report.get("observed_candidate") or {}
|
||||
name = candidate.get("name_candidate")
|
||||
if not name:
|
||||
return None
|
||||
return {
|
||||
"frame_index": frame.get("frame_index"),
|
||||
"timestamp": frame.get("timestamp"),
|
||||
"timestamp_ms": frame.get("timestamp_ms"),
|
||||
"analyzer_direction": frame.get("analyzer_direction"),
|
||||
"device_direction": frame.get("device_direction"),
|
||||
"name_candidate": name,
|
||||
"state_candidate": candidate.get("state_candidate"),
|
||||
"index": report.get("index"),
|
||||
"index_hex": report.get("index_hex"),
|
||||
"value": report.get("value"),
|
||||
"value_hex": report.get("value_hex"),
|
||||
}
|
||||
|
||||
|
||||
def _active_inactive_transitions(observed: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
by_index: dict[int, list[Mapping[str, Any]]] = {}
|
||||
for item in observed:
|
||||
state = item.get("state_candidate")
|
||||
index = item.get("index")
|
||||
if state not in {"active", "inactive"} or not isinstance(index, int):
|
||||
continue
|
||||
by_index.setdefault(index, []).append(item)
|
||||
|
||||
transitions: list[dict[str, Any]] = []
|
||||
for index, members in sorted(by_index.items()):
|
||||
previous: Mapping[str, Any] | None = None
|
||||
for member in sorted(members, key=lambda item: int(item.get("frame_index") or 0)):
|
||||
if previous is not None and previous.get("state_candidate") != member.get("state_candidate"):
|
||||
transitions.append(
|
||||
{
|
||||
"index": index,
|
||||
"index_hex": member.get("index_hex"),
|
||||
"name_candidate": member.get("name_candidate"),
|
||||
"from_state": previous.get("state_candidate"),
|
||||
"to_state": member.get("state_candidate"),
|
||||
"from_timestamp": previous.get("timestamp"),
|
||||
"to_timestamp": member.get("timestamp"),
|
||||
"from_frame_index": previous.get("frame_index"),
|
||||
"to_frame_index": member.get("frame_index"),
|
||||
}
|
||||
)
|
||||
previous = member
|
||||
return transitions
|
||||
|
||||
|
||||
def _heartbeat_interruptions(observed: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
interruptions: list[dict[str, Any]] = []
|
||||
heartbeat_positions = [
|
||||
index
|
||||
for index, item in enumerate(observed)
|
||||
if item.get("name_candidate") == "heartbeat_alive_candidate"
|
||||
]
|
||||
for left, right in zip(heartbeat_positions, heartbeat_positions[1:]):
|
||||
between = [
|
||||
item
|
||||
for item in observed[left + 1 : right]
|
||||
if item.get("name_candidate") != "heartbeat_alive_candidate"
|
||||
]
|
||||
if not between:
|
||||
continue
|
||||
start = observed[left]
|
||||
end = observed[right]
|
||||
interruptions.append(
|
||||
{
|
||||
"from_frame_index": start.get("frame_index"),
|
||||
"to_frame_index": end.get("frame_index"),
|
||||
"from_timestamp": start.get("timestamp"),
|
||||
"to_timestamp": end.get("timestamp"),
|
||||
"gap_ms": int(end.get("timestamp_ms") or 0) - int(start.get("timestamp_ms") or 0),
|
||||
"interrupted_by": [
|
||||
{
|
||||
"frame_index": item.get("frame_index"),
|
||||
"timestamp": item.get("timestamp"),
|
||||
"name_candidate": item.get("name_candidate"),
|
||||
"state_candidate": item.get("state_candidate"),
|
||||
"index_hex": item.get("index_hex"),
|
||||
"value_hex": item.get("value_hex"),
|
||||
}
|
||||
for item in between
|
||||
],
|
||||
}
|
||||
)
|
||||
return interruptions
|
||||
|
||||
|
||||
def _chunk_dict(chunk: CaptureChunk) -> dict[str, Any]:
|
||||
return {
|
||||
"chunk_index": chunk.chunk_index,
|
||||
"timestamp": chunk.timestamp,
|
||||
"timestamp_ms": chunk.timestamp_ms,
|
||||
"analyzer_direction": chunk.analyzer_direction,
|
||||
"device_direction": chunk.device_direction,
|
||||
"declared_count": chunk.declared_count,
|
||||
"byte_count": len(chunk.bytes),
|
||||
"count_matches": chunk.declared_count == len(chunk.bytes),
|
||||
"bytes": list(chunk.bytes),
|
||||
"bytes_hex": [_h8(byte) for byte in chunk.bytes],
|
||||
}
|
||||
|
||||
|
||||
def _device_direction(analyzer_direction: str) -> str:
|
||||
if analyzer_direction == "rx":
|
||||
return "tx"
|
||||
if analyzer_direction == "tx":
|
||||
return "rx"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _timestamp_ms(value: str) -> int:
|
||||
head, _, fraction = value.partition(".")
|
||||
hours, minutes, seconds = [int(part) for part in head.split(":")]
|
||||
millis = int((fraction + "000")[:3]) if fraction else 0
|
||||
return ((hours * 60 + minutes) * 60 + seconds) * 1000 + millis
|
||||
|
||||
|
||||
def _h8(value: int) -> str:
|
||||
return f"0x{value & 0xFF:02X}"
|
||||
|
||||
|
||||
def _h16(value: int) -> str:
|
||||
return f"0x{value & 0xFFFF:04X}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CaptureChunk",
|
||||
"analyze_capture_chunks",
|
||||
"analyze_capture_text",
|
||||
"checksum_for",
|
||||
"format_text_report",
|
||||
"main",
|
||||
"parse_capture_text",
|
||||
]
|
||||
504
h8536/protocol_trace.py
Normal file
504
h8536/protocol_trace.py
Normal file
@@ -0,0 +1,504 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping, TextIO
|
||||
|
||||
|
||||
CHECKSUM_SEED = 0x5A
|
||||
FRAME_LENGTH = 6
|
||||
VALID_DIRECTIONS = {"rx", "tx", "auto"}
|
||||
OBSERVED_TX_REPORT_CANDIDATES = {
|
||||
(0x0000, 0x0080): {
|
||||
"name_candidate": "heartbeat_alive_candidate",
|
||||
},
|
||||
(0x0007, 0x8000): {
|
||||
"name_candidate": "cam_power_button_candidate",
|
||||
"state_candidate": "active",
|
||||
},
|
||||
(0x0015, 0x8000): {
|
||||
"name_candidate": "call_button_candidate",
|
||||
"state_candidate": "active",
|
||||
},
|
||||
(0x0015, 0x0000): {
|
||||
"name_candidate": "call_button_candidate",
|
||||
"state_candidate": "inactive",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ByteEvent:
|
||||
value: int
|
||||
direction_hint: str | None = None
|
||||
|
||||
|
||||
def checksum_for(frame_prefix: Iterable[int]) -> int:
|
||||
value = CHECKSUM_SEED
|
||||
for byte in frame_prefix:
|
||||
value ^= byte & 0xFF
|
||||
return value & 0xFF
|
||||
|
||||
|
||||
def decode_trace(
|
||||
data: bytes | Iterable[int | ByteEvent],
|
||||
*,
|
||||
direction: str = "auto",
|
||||
semantics_path: str | Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if direction not in VALID_DIRECTIONS:
|
||||
raise ValueError(f"direction must be one of {sorted(VALID_DIRECTIONS)}")
|
||||
|
||||
events = _byte_events(data)
|
||||
semantics = load_semantics(semantics_path)
|
||||
frames: list[dict[str, Any]] = []
|
||||
previous_valid: dict[str, dict[str, Any] | None] = {"rx": None, "tx": None}
|
||||
|
||||
complete_len = (len(events) // FRAME_LENGTH) * FRAME_LENGTH
|
||||
for frame_index, offset in enumerate(range(0, complete_len, FRAME_LENGTH)):
|
||||
chunk = events[offset : offset + FRAME_LENGTH]
|
||||
resolved_direction = _frame_direction(chunk, direction)
|
||||
frame = _decode_frame(
|
||||
[event.value for event in chunk],
|
||||
frame_index=frame_index,
|
||||
byte_offset=offset,
|
||||
direction=resolved_direction,
|
||||
semantics=semantics,
|
||||
previous_valid=previous_valid,
|
||||
)
|
||||
frames.append(frame)
|
||||
if frame["checksum"]["valid"] and resolved_direction in previous_valid:
|
||||
previous_valid[resolved_direction] = frame
|
||||
|
||||
trailing = [event.value for event in events[complete_len:]]
|
||||
return {
|
||||
"kind": "h8536_protocol_trace",
|
||||
"frame_length": FRAME_LENGTH,
|
||||
"checksum_model": {
|
||||
"algorithm": "xor",
|
||||
"seed": CHECKSUM_SEED,
|
||||
"seed_hex": _h8(CHECKSUM_SEED),
|
||||
"covered_offsets": [0, 1, 2, 3, 4],
|
||||
"checksum_offset": 5,
|
||||
},
|
||||
"direction_mode": direction,
|
||||
"semantics": {
|
||||
"loaded": semantics["loaded"],
|
||||
"path": str(semantics["path"]) if semantics["path"] else None,
|
||||
"command_effect_count": len(semantics["command_effects"]),
|
||||
"response_schema_count": len(semantics["response_schemas"]),
|
||||
"caveat": (
|
||||
"Semantic names are evidence-backed candidates imported from decompiler output; "
|
||||
"trace decoding does not make them protocol facts."
|
||||
),
|
||||
},
|
||||
"frames": frames,
|
||||
"trailing_bytes": [_h8(byte) for byte in trailing],
|
||||
"trailing_byte_count": len(trailing),
|
||||
}
|
||||
|
||||
|
||||
def parse_byte_text(text: str, *, direction_hint: str | None = None) -> list[ByteEvent]:
|
||||
events: list[ByteEvent] = []
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
line_direction = direction_hint
|
||||
lowered = line.lower()
|
||||
for prefix in ("rx:", "tx:"):
|
||||
if lowered.startswith(prefix):
|
||||
line_direction = prefix[:2]
|
||||
line = line[len(prefix) :].strip()
|
||||
break
|
||||
for token in _tokens(line):
|
||||
events.extend(_events_from_token(token, line_direction))
|
||||
return events
|
||||
|
||||
|
||||
def load_semantics(path: str | Path | None = None) -> dict[str, Any]:
|
||||
candidate = Path(path) if path else Path("build") / "rom_decompiled.json"
|
||||
if not candidate.exists():
|
||||
return _empty_semantics(candidate)
|
||||
|
||||
try:
|
||||
with candidate.open("r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return _empty_semantics(candidate)
|
||||
|
||||
serial = payload.get("serial_protocol")
|
||||
if not isinstance(serial, Mapping):
|
||||
serial = payload.get("serial_semantics")
|
||||
if not isinstance(serial, Mapping):
|
||||
return _empty_semantics(candidate)
|
||||
|
||||
protocol = _first_protocol(serial)
|
||||
command_effects = _mapping_by_command(
|
||||
_list_value(protocol.get("command_effects")) or _list_value(serial.get("command_effects"))
|
||||
)
|
||||
response_schemas = _list_value(protocol.get("response_schema")) or _list_value(
|
||||
serial.get("response_schema")
|
||||
)
|
||||
return {
|
||||
"loaded": True,
|
||||
"path": candidate,
|
||||
"command_effects": command_effects,
|
||||
"response_schemas": response_schemas,
|
||||
}
|
||||
|
||||
|
||||
def format_text_report(decoded: Mapping[str, Any]) -> str:
|
||||
lines = [
|
||||
"H8/536 protocol trace",
|
||||
(
|
||||
f"frames={len(decoded.get('frames', []))} "
|
||||
f"trailing={decoded.get('trailing_byte_count', 0)} "
|
||||
f"semantics={'loaded' if decoded.get('semantics', {}).get('loaded') else 'not-loaded'}"
|
||||
),
|
||||
]
|
||||
for frame in decoded.get("frames", []):
|
||||
checksum = frame["checksum"]
|
||||
status = "ok" if checksum["valid"] else f"bad expected {checksum['expected_hex']}"
|
||||
direction = frame.get("direction") or "unknown"
|
||||
prefix = (
|
||||
f"[{frame['frame_index']:04d}] {direction:<7} off={frame['byte_offset']:04d} "
|
||||
f"bytes={' '.join(frame['bytes_hex'])} checksum={status} "
|
||||
)
|
||||
if direction == "tx":
|
||||
report = frame["report"]
|
||||
candidate = report.get("observed_candidate")
|
||||
suffix = ""
|
||||
if candidate:
|
||||
name = candidate.get("name_candidate")
|
||||
state = candidate.get("state_candidate")
|
||||
suffix = f" observed_candidate={name}" if name else " observed_candidate"
|
||||
if state:
|
||||
suffix += f" state={state}"
|
||||
lines.append(
|
||||
(
|
||||
f"{prefix}report_index={report['index_hex']} "
|
||||
f"value={report['value_hex']}{suffix}"
|
||||
)
|
||||
)
|
||||
else:
|
||||
command = frame["command"]
|
||||
name = command.get("name_candidate")
|
||||
suffix = f" {name}" if name else ""
|
||||
lines.append(
|
||||
(
|
||||
f"{prefix}cmd={command['value_hex']}{suffix} "
|
||||
f"index={frame['index']['combined']} value={frame['payload_value']['word_be_hex']}"
|
||||
)
|
||||
)
|
||||
for annotation in frame.get("stateful_annotations", []):
|
||||
lines.append(f" candidate: {annotation['kind']} - {annotation['summary']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, *, stdin: TextIO | None = None, stdout: TextIO | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Decode H8/536 serial byte captures into 6-byte protocol frames."
|
||||
)
|
||||
parser.add_argument("bytes", nargs="*", help="Byte tokens, e.g. 00 01 02 03 04 5E or rx:00010203045E")
|
||||
parser.add_argument("-i", "--input", help="Input file. Use '-' or omit byte args to read stdin.")
|
||||
parser.add_argument("--direction", choices=sorted(VALID_DIRECTIONS), default="auto")
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON instead of text.")
|
||||
parser.add_argument(
|
||||
"--semantics",
|
||||
default=None,
|
||||
help="Decompiler JSON path. Defaults to build/rom_decompiled.json when present.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
stdin = stdin or sys.stdin
|
||||
stdout = stdout or sys.stdout
|
||||
events: list[ByteEvent] = []
|
||||
if args.input:
|
||||
if args.input == "-":
|
||||
events.extend(parse_byte_text(stdin.read()))
|
||||
else:
|
||||
events.extend(parse_byte_text(Path(args.input).read_text(encoding="utf-8")))
|
||||
if args.bytes:
|
||||
events.extend(parse_byte_text(" ".join(args.bytes)))
|
||||
if not events and not args.input:
|
||||
events.extend(parse_byte_text(stdin.read()))
|
||||
|
||||
decoded = decode_trace(events, direction=args.direction, semantics_path=args.semantics)
|
||||
if args.json:
|
||||
json.dump(decoded, stdout, indent=2, sort_keys=True)
|
||||
stdout.write("\n")
|
||||
else:
|
||||
stdout.write(format_text_report(decoded))
|
||||
stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _decode_frame(
|
||||
frame: list[int],
|
||||
*,
|
||||
frame_index: int,
|
||||
byte_offset: int,
|
||||
direction: str | None,
|
||||
semantics: Mapping[str, Any],
|
||||
previous_valid: Mapping[str, dict[str, Any] | None],
|
||||
) -> dict[str, Any]:
|
||||
expected = checksum_for(frame[:5])
|
||||
actual = frame[5]
|
||||
command_value = frame[0] & 0x07
|
||||
command_effect = semantics["command_effects"].get(command_value, {})
|
||||
is_tx_report = direction == "tx"
|
||||
decoded = {
|
||||
"frame_index": frame_index,
|
||||
"byte_offset": byte_offset,
|
||||
"direction": direction,
|
||||
"bytes": frame,
|
||||
"bytes_hex": [_h8(byte) for byte in frame],
|
||||
"checksum": {
|
||||
"algorithm": "xor",
|
||||
"seed": CHECKSUM_SEED,
|
||||
"expected": expected,
|
||||
"expected_hex": _h8(expected),
|
||||
"actual": actual,
|
||||
"actual_hex": _h8(actual),
|
||||
"valid": expected == actual,
|
||||
},
|
||||
"command": {
|
||||
"applicable": not is_tx_report,
|
||||
"source_byte": frame[0],
|
||||
"source_byte_hex": _h8(frame[0]),
|
||||
"mask": 0x07,
|
||||
"value": command_value,
|
||||
"value_hex": _h8(command_value),
|
||||
"name_candidate": None if is_tx_report else command_effect.get("name_candidate"),
|
||||
"effect_candidate": None if is_tx_report else command_effect or None,
|
||||
"caveat": "TX frames are decoded as report frames; byte0 is not treated as a command."
|
||||
if is_tx_report
|
||||
else None,
|
||||
},
|
||||
"index": {
|
||||
"byte1": frame[1],
|
||||
"byte1_hex": _h8(frame[1]),
|
||||
"byte1_low3": frame[1] & 0x07,
|
||||
"byte1_low3_hex": _h8(frame[1] & 0x07),
|
||||
"byte2": frame[2],
|
||||
"byte2_hex": _h8(frame[2]),
|
||||
"combined": ((frame[1] & 0x07) << 8) | frame[2],
|
||||
"combined_hex": _h16(((frame[1] & 0x07) << 8) | frame[2]),
|
||||
},
|
||||
"payload_value": {
|
||||
"byte3": frame[3],
|
||||
"byte3_hex": _h8(frame[3]),
|
||||
"byte4": frame[4],
|
||||
"byte4_hex": _h8(frame[4]),
|
||||
"word_be": (frame[3] << 8) | frame[4],
|
||||
"word_be_hex": _h16((frame[3] << 8) | frame[4]),
|
||||
"word_le": (frame[4] << 8) | frame[3],
|
||||
"word_le_hex": _h16((frame[4] << 8) | frame[3]),
|
||||
},
|
||||
"report": _tx_report(frame) if is_tx_report else None,
|
||||
"response_schema_candidates": []
|
||||
if is_tx_report
|
||||
else _response_schema_candidates(semantics, command_value),
|
||||
"stateful_annotations": [],
|
||||
}
|
||||
decoded["stateful_annotations"] = _stateful_annotations(decoded, previous_valid)
|
||||
return decoded
|
||||
|
||||
|
||||
def _tx_report(frame: list[int]) -> dict[str, Any]:
|
||||
index = (frame[0] << 16) | (frame[1] << 8) | frame[2]
|
||||
value = (frame[3] << 8) | frame[4]
|
||||
candidate = OBSERVED_TX_REPORT_CANDIDATES.get((index, value))
|
||||
return {
|
||||
"encoding": "observed_tx_index_value_report_candidate",
|
||||
"confidence": "observed_candidate",
|
||||
"index_source_offsets": [0, 1, 2],
|
||||
"index": index,
|
||||
"index_hex": f"0x{index:06X}" if index > 0xFFFF else _h16(index),
|
||||
"index_bytes_hex": [_h8(frame[0]), _h8(frame[1]), _h8(frame[2])],
|
||||
"value_source_offsets": [3, 4],
|
||||
"value": value,
|
||||
"value_hex": _h16(value),
|
||||
"observed_candidate": dict(candidate) if candidate else None,
|
||||
"caveat": "TX report names are capture-observed candidates, not ROM-derived protocol facts.",
|
||||
}
|
||||
|
||||
|
||||
def _stateful_annotations(
|
||||
frame: Mapping[str, Any],
|
||||
previous_valid: Mapping[str, dict[str, Any] | None],
|
||||
) -> list[dict[str, Any]]:
|
||||
annotations: list[dict[str, Any]] = []
|
||||
if frame.get("direction") == "tx":
|
||||
return annotations
|
||||
if frame["command"]["value"] != 0x07:
|
||||
return annotations
|
||||
|
||||
direction = frame.get("direction")
|
||||
same = previous_valid.get(direction) if direction in previous_valid else None
|
||||
opposite_direction = "tx" if direction == "rx" else "rx" if direction == "tx" else None
|
||||
opposite = previous_valid.get(opposite_direction) if opposite_direction else None
|
||||
|
||||
annotation = {
|
||||
"kind": "retransmit_or_error_candidate",
|
||||
"confidence": "candidate",
|
||||
"summary": "cmd 0x07 is associated with retry/error handling in decompiler semantics.",
|
||||
"evidence": ["command_low3 == 0x07"],
|
||||
"previous_valid_same_direction": _previous_summary(same),
|
||||
"previous_valid_opposite_direction": _previous_summary(opposite),
|
||||
}
|
||||
if same and same.get("bytes") == frame.get("bytes"):
|
||||
annotation["evidence"].append("matches previous valid frame in same direction")
|
||||
if opposite and opposite.get("bytes") == frame.get("bytes"):
|
||||
annotation["evidence"].append("matches previous valid frame in opposite direction")
|
||||
annotations.append(annotation)
|
||||
return annotations
|
||||
|
||||
|
||||
def _previous_summary(frame: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not frame:
|
||||
return None
|
||||
return {
|
||||
"frame_index": frame["frame_index"],
|
||||
"direction": frame.get("direction"),
|
||||
"bytes_hex": frame["bytes_hex"],
|
||||
"command": frame["command"]["value_hex"],
|
||||
"checksum_valid": frame["checksum"]["valid"],
|
||||
}
|
||||
|
||||
|
||||
def _byte_events(data: bytes | Iterable[int | ByteEvent]) -> list[ByteEvent]:
|
||||
if isinstance(data, bytes):
|
||||
return [ByteEvent(byte) for byte in data]
|
||||
events: list[ByteEvent] = []
|
||||
for item in data:
|
||||
if isinstance(item, ByteEvent):
|
||||
events.append(item)
|
||||
else:
|
||||
value = int(item)
|
||||
if not 0 <= value <= 0xFF:
|
||||
raise ValueError(f"byte out of range: {value}")
|
||||
events.append(ByteEvent(value))
|
||||
return events
|
||||
|
||||
|
||||
def _frame_direction(chunk: list[ByteEvent], mode: str) -> str | None:
|
||||
if mode in {"rx", "tx"}:
|
||||
return mode
|
||||
hints = {event.direction_hint for event in chunk if event.direction_hint in {"rx", "tx"}}
|
||||
if len(hints) == 1:
|
||||
return next(iter(hints))
|
||||
return None
|
||||
|
||||
|
||||
def _tokens(text: str) -> list[str]:
|
||||
return [token for token in text.replace(",", " ").replace(";", " ").split() if token]
|
||||
|
||||
|
||||
def _events_from_token(token: str, direction_hint: str | None) -> list[ByteEvent]:
|
||||
lowered = token.lower()
|
||||
for prefix in ("rx:", "tx:"):
|
||||
if lowered.startswith(prefix):
|
||||
return _events_from_token(token[len(prefix) :], prefix[:2])
|
||||
value_text = token.strip()
|
||||
if value_text.upper().startswith("H'"):
|
||||
value_text = "0x" + value_text[2:]
|
||||
if (
|
||||
not value_text.lower().startswith("0x")
|
||||
and len(value_text) > 2
|
||||
and len(value_text) % 2 == 0
|
||||
and all(char in "0123456789abcdefABCDEF" for char in value_text)
|
||||
):
|
||||
return [
|
||||
ByteEvent(int(value_text[index : index + 2], 16), direction_hint)
|
||||
for index in range(0, len(value_text), 2)
|
||||
]
|
||||
if value_text.lower().startswith("0x"):
|
||||
value = int(value_text, 16)
|
||||
else:
|
||||
value = int(value_text, 16)
|
||||
if not 0 <= value <= 0xFF:
|
||||
raise ValueError(f"byte out of range: {token}")
|
||||
return [ByteEvent(value, direction_hint)]
|
||||
|
||||
|
||||
def _empty_semantics(path: Path) -> dict[str, Any]:
|
||||
return {"loaded": False, "path": path, "command_effects": {}, "response_schemas": []}
|
||||
|
||||
|
||||
def _first_protocol(serial: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
protocols = serial.get("protocol_semantics")
|
||||
if isinstance(protocols, list):
|
||||
for protocol in protocols:
|
||||
if isinstance(protocol, Mapping):
|
||||
return protocol
|
||||
return serial
|
||||
|
||||
|
||||
def _list_value(value: Any) -> list[Any]:
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _mapping_by_command(items: list[Any]) -> dict[int, Mapping[str, Any]]:
|
||||
output: dict[int, Mapping[str, Any]] = {}
|
||||
for item in items:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
value = item.get("command_value", item.get("command"))
|
||||
if isinstance(value, int):
|
||||
output[value] = item
|
||||
return output
|
||||
|
||||
|
||||
def _response_schema_candidates(semantics: Mapping[str, Any], command: int) -> list[Mapping[str, Any]]:
|
||||
matches: list[Mapping[str, Any]] = []
|
||||
for schema in semantics.get("response_schemas", []):
|
||||
if not isinstance(schema, Mapping):
|
||||
continue
|
||||
constants = _schema_constants(schema)
|
||||
if command in constants:
|
||||
matches.append(
|
||||
{
|
||||
"response_id": schema.get("response_id"),
|
||||
"call_address_hex": schema.get("call_address_hex"),
|
||||
"matched_command_byte_candidate": _h8(command),
|
||||
"caveat": "Matched schema constants are candidates from decompiler output.",
|
||||
}
|
||||
)
|
||||
return matches
|
||||
|
||||
|
||||
def _schema_constants(value: Any) -> set[int]:
|
||||
constants: set[int] = set()
|
||||
if isinstance(value, Mapping):
|
||||
for key, item in value.items():
|
||||
if key in {"value", "constant", "constant_value"} and isinstance(item, int):
|
||||
constants.add(item & 0x07)
|
||||
constants.update(_schema_constants(item))
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
constants.update(_schema_constants(item))
|
||||
return constants
|
||||
|
||||
|
||||
def _h8(value: int) -> str:
|
||||
return f"0x{value & 0xFF:02X}"
|
||||
|
||||
|
||||
def _h16(value: int) -> str:
|
||||
return f"0x{value & 0xFFFF:04X}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ByteEvent",
|
||||
"checksum_for",
|
||||
"decode_trace",
|
||||
"format_text_report",
|
||||
"load_semantics",
|
||||
"main",
|
||||
"parse_byte_text",
|
||||
]
|
||||
391
h8536/serial_gate.py
Normal file
391
h8536/serial_gate.py
Normal file
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .formatting import h16, label_for
|
||||
|
||||
|
||||
JsonObject = dict[str, Any]
|
||||
|
||||
KEY_STATE_ADDRESSES: tuple[int, ...] = (
|
||||
0xF9B0,
|
||||
0xF9B4,
|
||||
0xF9B5,
|
||||
0xF9B9,
|
||||
0xF9C0,
|
||||
0xF9C3,
|
||||
0xF9C5,
|
||||
0xF9C6,
|
||||
0xF9C8,
|
||||
0xFAA2,
|
||||
0xFAA3,
|
||||
0xFAA5,
|
||||
)
|
||||
|
||||
DEFAULT_INPUT = Path("build/rom_decompiled.json")
|
||||
CAPTURE_OVERLAY_CAVEAT = (
|
||||
"Observed report indexes 0x0007 and 0x0015 are capture overlays/runtime queue "
|
||||
"entries; this analyzer does not treat them as statically proven ROM constants."
|
||||
)
|
||||
|
||||
|
||||
def load_serial_gate_input(path: Path) -> JsonObject:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
if not isinstance(payload, dict) or "instructions" not in payload:
|
||||
raise ValueError(f"{path} does not look like h8536_decompiler JSON output")
|
||||
return payload
|
||||
|
||||
|
||||
def analyze_serial_gate(payload: dict[str, Any]) -> JsonObject:
|
||||
instructions = _instruction_sequence(payload.get("instructions"))
|
||||
labels = _collect_labels(payload, instructions)
|
||||
by_address = {int(ins["address"]): ins for ins in instructions if "address" in ins}
|
||||
|
||||
evidence = {
|
||||
"scheduler_gate_loc_3FD3": _scheduler_gate(by_address),
|
||||
"queue_send_gate_loc_BAF2": _queue_send_gate(by_address),
|
||||
"resend_gate_path": _resend_gate_path(by_address),
|
||||
"rx_session_maintenance": _rx_session_maintenance(by_address),
|
||||
}
|
||||
access_summary = _state_access_summary(instructions, labels)
|
||||
|
||||
return {
|
||||
"kind": "serial_gate",
|
||||
"summary": {
|
||||
"state_machine_candidate": "autonomous serial TX/report queue gate",
|
||||
"confidence": _confidence(evidence),
|
||||
"basis": "address-driven static evidence from decompiler JSON",
|
||||
},
|
||||
"state_addresses": [
|
||||
{"address": address, "address_hex": h16(address), "symbol": f"ram_{address:04X}"}
|
||||
for address in KEY_STATE_ADDRESSES
|
||||
],
|
||||
"evidence": evidence,
|
||||
"state_accesses": access_summary,
|
||||
"caveats": [
|
||||
CAPTURE_OVERLAY_CAVEAT,
|
||||
"Queue entries near F870 are reached through RAM-indexed addressing; static JSON proves the access pattern, not the runtime queue contents.",
|
||||
"Branch predicates are summarized from local instruction order and targets; this is not an emulator trace.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def format_text_report(analysis: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
"H8/536 Serial Gate/Queue State-Machine Reconstruction",
|
||||
"",
|
||||
f"Summary: {analysis['summary']['state_machine_candidate']}",
|
||||
f"Confidence: {analysis['summary']['confidence']}",
|
||||
"",
|
||||
"Evidence:",
|
||||
]
|
||||
|
||||
for key, section in analysis.get("evidence", {}).items():
|
||||
title = str(section.get("title", key)).rstrip(".")
|
||||
status = "present" if section.get("present") else "missing"
|
||||
lines.append(f"- {title}: {status}")
|
||||
summary = section.get("summary")
|
||||
if summary:
|
||||
lines.append(f" {summary}")
|
||||
for item in section.get("items", []):
|
||||
lines.append(f" - {item['address_hex']}: {item['text']}")
|
||||
|
||||
lines.extend(["", "State address readers/writers:"])
|
||||
for entry in analysis.get("state_accesses", []):
|
||||
lines.append(
|
||||
f"- {entry['address_hex']}: reads={entry['read_count']} "
|
||||
f"writes={entry['write_count']} read/write={entry['read_write_count']}"
|
||||
)
|
||||
samples = entry.get("sample_accesses", [])
|
||||
if samples:
|
||||
sample_text = "; ".join(f"{sample['address_hex']} {sample['access']} {sample['text']}" for sample in samples)
|
||||
lines.append(f" {sample_text}")
|
||||
|
||||
lines.extend(["", "Caveats:"])
|
||||
for caveat in analysis.get("caveats", []):
|
||||
lines.append(f"- {caveat}")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def write_serial_gate_report(input_path: Path, output_path: Path, *, as_json: bool = False) -> JsonObject:
|
||||
analysis = analyze_serial_gate(load_serial_gate_input(input_path))
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if as_json:
|
||||
output_path.write_text(json.dumps(analysis, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
else:
|
||||
output_path.write_text(format_text_report(analysis), encoding="utf-8")
|
||||
return analysis
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, stdout: Any | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Summarize H8/536 autonomous serial TX/report gates and queue state.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"input",
|
||||
nargs="?",
|
||||
type=Path,
|
||||
default=DEFAULT_INPUT,
|
||||
help="structured JSON emitted by h8536_decompiler.py",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="emit structured JSON instead of readable text")
|
||||
parser.add_argument("--out", type=Path, default=None, help="write report to this path")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
stream = stdout
|
||||
if stream is None:
|
||||
import sys
|
||||
|
||||
stream = sys.stdout
|
||||
|
||||
analysis = analyze_serial_gate(load_serial_gate_input(args.input))
|
||||
if args.json:
|
||||
rendered = json.dumps(analysis, indent=2, sort_keys=True) + "\n"
|
||||
else:
|
||||
rendered = format_text_report(analysis)
|
||||
|
||||
if args.out:
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(rendered, encoding="utf-8")
|
||||
print(f"wrote {args.out}", file=stream)
|
||||
else:
|
||||
print(rendered, end="", file=stream)
|
||||
return 0
|
||||
|
||||
|
||||
def _scheduler_gate(by_address: dict[int, JsonObject]) -> JsonObject:
|
||||
addresses = [0x3FD3, 0x3FD7, 0x3FD9, 0x3FDD, 0x3FDF, 0x3FE3, 0x3FE5, 0x3FE9, 0x3FEB]
|
||||
items = _items(by_address, addresses)
|
||||
return {
|
||||
"title": "loc_3FD3 gate into loc_BAF2",
|
||||
"present": _has_all(by_address, (0x3FD3, 0x3FD9, 0x3FDF, 0x3FE5, 0x3FEB)),
|
||||
"summary": (
|
||||
"Requires FAA2 == 0, allows the FAA5.bit7 path only when F9C3 == 0, "
|
||||
"then requires F9C0 == 0 before BSR loc_BAF2."
|
||||
),
|
||||
"items": items,
|
||||
"required_addresses_hex": [h16(address) for address in addresses],
|
||||
}
|
||||
|
||||
|
||||
def _queue_send_gate(by_address: dict[int, JsonObject]) -> JsonObject:
|
||||
addresses = [
|
||||
0xBAF2,
|
||||
0xBAF8,
|
||||
0xBAFC,
|
||||
0xBAFE,
|
||||
0xBB00,
|
||||
0xBB08,
|
||||
0xBB1C,
|
||||
0xBB20,
|
||||
0xBB2B,
|
||||
0xBB39,
|
||||
0xBB3F,
|
||||
0xBB43,
|
||||
0xBB46,
|
||||
0xBB4C,
|
||||
0xBB51,
|
||||
]
|
||||
return {
|
||||
"title": "loc_BAF2 queue send gate",
|
||||
"present": _has_all(by_address, (0xBAF2, 0xBAF8, 0xBB08, 0xBB1C, 0xBB39, 0xBB43)),
|
||||
"summary": (
|
||||
"F9B5 is compared against F9B0; inequality enters the send path, reads a queued "
|
||||
"word via the F9B5-derived index around F870, stages F850-F854, and calls BA26 at BB43."
|
||||
),
|
||||
"items": _items(by_address, addresses),
|
||||
"queue_table_candidate": {
|
||||
"base_address_hex": h16(0xF870),
|
||||
"index_address_hex": h16(0xF9B5),
|
||||
"evidence_address_hex": h16(0xBB08),
|
||||
"addressing_text": _text(by_address, 0xBB08),
|
||||
},
|
||||
"staging_addresses_hex": [h16(address) for address in range(0xF850, 0xF855)],
|
||||
"send_subroutine_hex": h16(0xBA26),
|
||||
"send_call_address_hex": h16(0xBB43),
|
||||
}
|
||||
|
||||
|
||||
def _resend_gate_path(by_address: dict[int, JsonObject]) -> JsonObject:
|
||||
addresses = [0xBE9E, 0xBEA5, 0xBEA9, 0xBEAF, 0xBEB5, 0xBEBB, 0xBEC5, 0xBECB, 0xBED1, 0xBED5]
|
||||
return {
|
||||
"title": "resend gate/path",
|
||||
"present": _has_all(by_address, (0xBE9E, 0xBEA5, 0xBEB5, 0xBEBB, 0xBECB, 0xBED5)),
|
||||
"summary": (
|
||||
"BE9E masks FAA5 with FAA3, waits for F9C6/F9C8 timeout gates, then if FAA3.bit7 "
|
||||
"remains set clears F9C3 and calls BA26 from BED5."
|
||||
),
|
||||
"items": _items(by_address, addresses),
|
||||
"resend_call_address_hex": h16(0xBED5),
|
||||
"send_subroutine_hex": h16(0xBA26),
|
||||
}
|
||||
|
||||
|
||||
def _rx_session_maintenance(by_address: dict[int, JsonObject]) -> JsonObject:
|
||||
addresses = [
|
||||
0x3FEF,
|
||||
0x3FF5,
|
||||
0x3FF9,
|
||||
0x3FFD,
|
||||
0x4007,
|
||||
0xBBCB,
|
||||
0xBC0F,
|
||||
0xBC15,
|
||||
0xBC33,
|
||||
0xBC5C,
|
||||
0xBC63,
|
||||
0xBCD0,
|
||||
0xBCFD,
|
||||
0xBD04,
|
||||
0xBD6D,
|
||||
0xBD71,
|
||||
0xBD75,
|
||||
0xBD79,
|
||||
0xBDC8,
|
||||
0xBDCC,
|
||||
0xBDD0,
|
||||
0xBDD4,
|
||||
0xBDF3,
|
||||
0xBDF7,
|
||||
0xBDFB,
|
||||
0xBDFF,
|
||||
]
|
||||
return {
|
||||
"title": "RX/session maintenance",
|
||||
"present": _has_all(by_address, (0x3FEF, 0x3FF5, 0xBBCB, 0xBC15, 0xBD6D, 0xBD79)),
|
||||
"summary": (
|
||||
"F9C5 timeout maintenance clears F9B5/F9B0 and FAA5.bit7; RX command processing "
|
||||
"uses FAA2 as an in-session latch and paths advance F9B5/F9B0 or clear FAA3/FAA2."
|
||||
),
|
||||
"items": _items(by_address, addresses),
|
||||
}
|
||||
|
||||
|
||||
def _state_access_summary(instructions: list[JsonObject], labels: dict[int, str]) -> list[JsonObject]:
|
||||
result: list[JsonObject] = []
|
||||
for state_address in KEY_STATE_ADDRESSES:
|
||||
accesses = []
|
||||
for ins in instructions:
|
||||
if state_address not in _reference_addresses(ins):
|
||||
continue
|
||||
access = _access_kind(ins, state_address)
|
||||
accesses.append(
|
||||
{
|
||||
"address": int(ins["address"]),
|
||||
"address_hex": h16(int(ins["address"])),
|
||||
"function": _function_label_for_address(int(ins["address"]), labels),
|
||||
"access": access,
|
||||
"text": str(ins.get("text", "")),
|
||||
}
|
||||
)
|
||||
result.append(
|
||||
{
|
||||
"address": state_address,
|
||||
"address_hex": h16(state_address),
|
||||
"read_count": sum(1 for access in accesses if access["access"] == "read"),
|
||||
"write_count": sum(1 for access in accesses if access["access"] == "write"),
|
||||
"read_write_count": sum(1 for access in accesses if access["access"] == "read_write"),
|
||||
"accesses": accesses,
|
||||
"sample_accesses": accesses[:6],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _instruction_sequence(raw: Any) -> list[JsonObject]:
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return sorted(
|
||||
[item for item in raw if isinstance(item, dict) and isinstance(item.get("address"), int)],
|
||||
key=lambda item: int(item["address"]),
|
||||
)
|
||||
|
||||
|
||||
def _collect_labels(payload: dict[str, Any], instructions: list[JsonObject]) -> dict[int, str]:
|
||||
labels: dict[int, str] = {}
|
||||
nodes = payload.get("call_graph", {}).get("nodes", []) if isinstance(payload.get("call_graph"), dict) else []
|
||||
if isinstance(nodes, list):
|
||||
for node in nodes:
|
||||
if isinstance(node, dict) and isinstance(node.get("start"), int) and node.get("label"):
|
||||
labels[int(node["start"])] = str(node["label"])
|
||||
return labels
|
||||
|
||||
|
||||
def _items(by_address: dict[int, JsonObject], addresses: list[int]) -> list[JsonObject]:
|
||||
return [
|
||||
{
|
||||
"address": address,
|
||||
"address_hex": h16(address),
|
||||
"text": _text(by_address, address),
|
||||
"present": address in by_address,
|
||||
"targets_hex": [h16(target) for target in by_address.get(address, {}).get("targets", []) if isinstance(target, int)],
|
||||
}
|
||||
for address in addresses
|
||||
]
|
||||
|
||||
|
||||
def _has_all(by_address: dict[int, JsonObject], addresses: tuple[int, ...]) -> bool:
|
||||
return all(address in by_address for address in addresses)
|
||||
|
||||
|
||||
def _text(by_address: dict[int, JsonObject], address: int) -> str:
|
||||
return str(by_address.get(address, {}).get("text", "<missing>"))
|
||||
|
||||
|
||||
def _reference_addresses(ins: JsonObject) -> set[int]:
|
||||
addresses: set[int] = set()
|
||||
refs = ins.get("references", [])
|
||||
if isinstance(refs, list):
|
||||
for ref in refs:
|
||||
if isinstance(ref, dict) and isinstance(ref.get("address"), int):
|
||||
addresses.add(int(ref["address"]))
|
||||
text = str(ins.get("text", ""))
|
||||
for match in re.finditer(r"@H'([0-9A-Fa-f]{4})", text):
|
||||
addresses.add(int(match.group(1), 16))
|
||||
return addresses
|
||||
|
||||
|
||||
def _access_kind(ins: JsonObject, address: int) -> str:
|
||||
mnemonic = str(ins.get("mnemonic", "")).upper()
|
||||
operands = str(ins.get("operands", ""))
|
||||
target = f"@H'{address:04X}"
|
||||
upper_operands = operands.upper()
|
||||
|
||||
if mnemonic.startswith(("TST", "CMP", "BTST")):
|
||||
return "read"
|
||||
if mnemonic.startswith("CLR"):
|
||||
return "write"
|
||||
if mnemonic.startswith(("BSET", "BCLR", "ADD", "SUB", "INC", "DEC")):
|
||||
return "read_write"
|
||||
if mnemonic.startswith("MOV") and "," in upper_operands:
|
||||
_src, dest = [part.strip() for part in upper_operands.rsplit(",", 1)]
|
||||
return "write" if target in dest else "read"
|
||||
if mnemonic.startswith(("AND", "OR", "XOR")) and "," in upper_operands:
|
||||
_src, dest = [part.strip() for part in upper_operands.rsplit(",", 1)]
|
||||
return "read_write" if target in dest else "read"
|
||||
return "read"
|
||||
|
||||
|
||||
def _function_label_for_address(address: int, labels: dict[int, str]) -> str:
|
||||
starts = [start for start in labels if start <= address]
|
||||
if not starts:
|
||||
return label_for(address)
|
||||
return labels[max(starts)]
|
||||
|
||||
|
||||
def _confidence(evidence: dict[str, JsonObject]) -> str:
|
||||
present_count = sum(1 for section in evidence.values() if section.get("present"))
|
||||
if present_count == len(evidence):
|
||||
return "high"
|
||||
if present_count >= 2:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -404,6 +404,9 @@ def _semantics_lines(
|
||||
lines.extend(_table_map_comment_lines(_table_map_list(protocol), opts, prefix=" * "))
|
||||
lines.extend(_state_variable_comment_lines(protocol.get("state_variable_candidates"), opts, prefix=" * "))
|
||||
lines.extend(_retry_error_comment_lines(protocol.get("retry_error_model"), opts, prefix=" * "))
|
||||
lines.extend(_gate_queue_comment_lines(protocol.get("gate_queue_model"), opts, prefix=" * "))
|
||||
lines.extend(_tx_report_comment_lines(protocol.get("tx_report_model"), opts, prefix=" * "))
|
||||
lines.extend(_periodic_resend_comment_lines(protocol.get("periodic_resend_model"), opts, prefix=" * "))
|
||||
lines.append(" */")
|
||||
lines.append("")
|
||||
|
||||
@@ -436,6 +439,11 @@ def _semantics_lines(
|
||||
" return 0x01FFu;",
|
||||
"}",
|
||||
"",
|
||||
],
|
||||
)
|
||||
lines.extend(_gate_queue_predicate_function_lines(protocol.get("gate_queue_model")))
|
||||
lines.extend(
|
||||
[
|
||||
"void sci1_process_candidate_protocol_command(void)",
|
||||
"{",
|
||||
" u8 command = sci1_rx_candidate_command();",
|
||||
@@ -605,6 +613,134 @@ def _retry_error_comment_lines(
|
||||
return lines
|
||||
|
||||
|
||||
def _gate_queue_comment_lines(
|
||||
value: object,
|
||||
opts: SerialPseudocodeOptions,
|
||||
*,
|
||||
prefix: str,
|
||||
) -> list[str]:
|
||||
if not isinstance(value, dict):
|
||||
return []
|
||||
lines = [f"{prefix}gate/queue state machine candidate:"]
|
||||
for predicate in _object_list(value.get("predicates")):
|
||||
name = predicate.get("name") or "predicate_candidate"
|
||||
condition = _comment_text(str(predicate.get("condition_candidate") or "condition unknown"))
|
||||
summary = _comment_text(str(predicate.get("summary") or "candidate gate"))
|
||||
lines.append(f"{prefix}- {name}: {condition}; {summary}")
|
||||
for effect in _object_list(value.get("session_effects")):
|
||||
name = effect.get("name") or "session_effect_candidate"
|
||||
summary = _comment_text(str(effect.get("summary") or "candidate session effect"))
|
||||
commands = ", ".join(str(item) for item in effect.get("command_values_hex", []) if item)
|
||||
suffix = f"; commands {commands}" if commands else ""
|
||||
lines.append(f"{prefix}- {name}: {summary}{suffix}")
|
||||
caveat = str(value.get("caveat") or "").strip()
|
||||
if caveat:
|
||||
lines.append(f"{prefix}- caveat: {_comment_text(caveat)}")
|
||||
evidence = _hex_join(value.get("evidence_addresses_hex"))
|
||||
if opts.include_evidence and evidence:
|
||||
lines.append(f"{prefix}- evidence: {evidence}")
|
||||
return lines
|
||||
|
||||
|
||||
def _gate_queue_predicate_function_lines(value: object) -> list[str]:
|
||||
if not isinstance(value, dict):
|
||||
return []
|
||||
return [
|
||||
"static bool sci1_candidate_main_report_gate_open(void)",
|
||||
"{",
|
||||
" bool session_idle = MEM8[0xFAA2u] == 0u;",
|
||||
" bool rx_gate_open = (MEM8[0xFAA5u] & 0x80u) == 0u || MEM8[0xF9C3u] == 0u;",
|
||||
" bool tx_timer_clear = MEM8[0xF9C0u] == 0u;",
|
||||
"",
|
||||
" return session_idle && rx_gate_open && tx_timer_clear;",
|
||||
"}",
|
||||
"",
|
||||
"static bool sci1_candidate_report_queue_nonempty(void)",
|
||||
"{",
|
||||
" return MEM8[0xF9B5u] != MEM8[0xF9B0u];",
|
||||
"}",
|
||||
"",
|
||||
"static bool sci1_candidate_periodic_resend_gate_open(void)",
|
||||
"{",
|
||||
" bool pending = (MEM8[0xFAA5u] & MEM8[0xFAA3u] & 0x80u) != 0u;",
|
||||
" bool period_elapsed = MEM8[0xF9C6u] == 0u && MEM8[0xF9C7u] == 0u;",
|
||||
" bool resend_countdown_active = MEM8[0xF9C8u] != 0u;",
|
||||
"",
|
||||
" return pending && period_elapsed && resend_countdown_active;",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def _tx_report_comment_lines(
|
||||
value: object,
|
||||
opts: SerialPseudocodeOptions,
|
||||
*,
|
||||
prefix: str,
|
||||
) -> list[str]:
|
||||
if not isinstance(value, dict):
|
||||
return []
|
||||
entry = value.get("entry_label") or value.get("entry_address_hex") or "TX report path"
|
||||
source = _comment_text(str(value.get("value_source_candidate") or "current value table"))
|
||||
lines = [f"{prefix}TX/autonomous report model candidate:"]
|
||||
lines.append(f"{prefix}- {entry} -> loc_BA26: bytes 0..2 encode candidate logical index/report id; bytes 3..4 come from {source}; byte5 is 0x5A XOR checksum")
|
||||
overlay = _object_list(value.get("observed_capture_overlay_candidates"))
|
||||
if overlay:
|
||||
observed = []
|
||||
for item in overlay[:3]:
|
||||
name = item.get("name_candidate") or "observed_report_candidate"
|
||||
frames = ", ".join(str(frame) for frame in item.get("observed_frames_hex", []) if frame)
|
||||
if frames:
|
||||
observed.append(f"{name}: {frames}")
|
||||
if observed:
|
||||
lines.append(f"{prefix}- observed overlay candidates: {_comment_text('; '.join(observed))}")
|
||||
caveat = str(value.get("observed_autonomous_output_caveat") or value.get("caveat") or "").strip()
|
||||
if caveat:
|
||||
lines.append(f"{prefix}- caveat: {_comment_text(caveat)}")
|
||||
evidence = _hex_join(value.get("evidence_addresses_hex"))
|
||||
if opts.include_evidence and evidence:
|
||||
lines.append(f"{prefix}- evidence: {evidence}")
|
||||
return lines
|
||||
|
||||
|
||||
def _periodic_resend_comment_lines(
|
||||
value: object,
|
||||
opts: SerialPseudocodeOptions,
|
||||
*,
|
||||
prefix: str,
|
||||
) -> list[str]:
|
||||
if not isinstance(value, dict):
|
||||
return []
|
||||
lines = [f"{prefix}heartbeat/periodic resend candidate:"]
|
||||
period = value.get("period_timer")
|
||||
if isinstance(period, dict):
|
||||
lines.append(
|
||||
f"{prefix}- F9C6 reload {period.get('reload_value_hex', '?')}: "
|
||||
f"{_comment_text(str(period.get('summary') or 'period timer'))}",
|
||||
)
|
||||
countdown = value.get("resend_countdown")
|
||||
if isinstance(countdown, dict):
|
||||
lines.append(
|
||||
f"{prefix}- F9C8 reload {countdown.get('reload_value_hex', '?')}: "
|
||||
f"{_comment_text(str(countdown.get('summary') or 'resend countdown'))}",
|
||||
)
|
||||
pending = value.get("pending_mask")
|
||||
if isinstance(pending, dict):
|
||||
lines.append(
|
||||
f"{prefix}- FAA3 mask {pending.get('mask_hex', '?')}: "
|
||||
f"{_comment_text(str(pending.get('summary') or 'pending mask'))}",
|
||||
)
|
||||
resend = value.get("resend_path")
|
||||
if isinstance(resend, dict):
|
||||
lines.append(
|
||||
f"{prefix}- BED5 resend path: {_comment_text(str(resend.get('summary') or 'candidate resend path'))}",
|
||||
)
|
||||
evidence = _hex_join(value.get("evidence_addresses_hex"))
|
||||
if opts.include_evidence and evidence:
|
||||
lines.append(f"{prefix}- evidence: {evidence}")
|
||||
return lines
|
||||
|
||||
|
||||
def _command_effect_switch_lines(command: JsonObject) -> list[str]:
|
||||
effects = _object_list(command.get("effects"))[:3]
|
||||
lines = []
|
||||
|
||||
@@ -21,6 +21,14 @@ TX_CHECKSUM_ADDRESS = TX_FRAME_END
|
||||
|
||||
SEND_BUILDER_ADDRESS = 0xBA26
|
||||
SEND_BUILDER_LABEL = "loc_BA26"
|
||||
AUTONOMOUS_TX_REPORT_CALL = 0xBB43
|
||||
AUTONOMOUS_TX_REPORT_LABEL = "loc_BB43"
|
||||
MAIN_REPORT_GATE_ENTRY = 0x3FD3
|
||||
MAIN_REPORT_GATE_CALL = 0x3FEB
|
||||
SESSION_GATE_ENTRY = 0x3FEF
|
||||
QUEUE_REPORT_ENTRY = 0xBAF2
|
||||
RESEND_GATE_ENTRY = 0xBE9E
|
||||
PERIODIC_RESEND_ENTRY = 0xBED5
|
||||
INDEX_DECODER_ADDRESS = 0x622B
|
||||
INDEX_DECODER_LABEL = "loc_622B"
|
||||
CHECKSUM_SEED = 0x5A
|
||||
@@ -74,8 +82,29 @@ STATE_VARIABLES = {
|
||||
0xF9B5: "event_queue_write_or_pending_cursor_candidate",
|
||||
0xF9B9: "event_queue_base_or_current_slot_candidate",
|
||||
0xF9C0: "serial_tx_busy_timer_candidate",
|
||||
0xF9C6: "autonomous_report_period_timer_candidate",
|
||||
0xF9C8: "autonomous_report_resend_countdown_candidate",
|
||||
}
|
||||
|
||||
OBSERVED_TX_REPORT_OVERLAY = [
|
||||
{
|
||||
"logical_index": 0x0000,
|
||||
"name_candidate": "heartbeat_or_idle_report_candidate",
|
||||
"observed_frames_hex": ["00 00 00 00 80 DA"],
|
||||
"observed_period_ms_candidate": 700,
|
||||
},
|
||||
{
|
||||
"logical_index": 0x0015,
|
||||
"name_candidate": "call_button_report_candidate",
|
||||
"observed_frames_hex": ["00 00 15 80 00 CF", "00 00 15 00 00 4F"],
|
||||
},
|
||||
{
|
||||
"logical_index": 0x0007,
|
||||
"name_candidate": "camera_power_report_candidate",
|
||||
"observed_frames_hex": ["00 00 07 80 00 DD"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def analyze_serial_semantics(payload: Mapping[str, Any]) -> JsonObject:
|
||||
"""Infer conservative SCI1 frame/command semantics from decompiler JSON."""
|
||||
@@ -100,6 +129,9 @@ def analyze_serial_semantics(payload: Mapping[str, Any]) -> JsonObject:
|
||||
"table_map_candidates": [],
|
||||
"state_variable_candidates": [],
|
||||
"retry_error_model": None,
|
||||
"gate_queue_model": None,
|
||||
"tx_report_model": None,
|
||||
"periodic_resend_model": None,
|
||||
"confidence": "low",
|
||||
"confidence_score": 0.0,
|
||||
"caveat": "No protocol semantics are emitted without both RX and TX serial reconstruction candidates.",
|
||||
@@ -113,6 +145,9 @@ def analyze_serial_semantics(payload: Mapping[str, Any]) -> JsonObject:
|
||||
logical_tables = _logical_table_map_candidates(ordered)
|
||||
state_variables = _state_variable_candidates(ordered)
|
||||
retry_error_model = _retry_error_model(ordered, responses)
|
||||
gate_queue_model = _gate_queue_model(ordered, commands)
|
||||
tx_report_model = _tx_report_model(ordered, responses)
|
||||
periodic_resend_model = _periodic_resend_model(ordered, responses)
|
||||
evidence = _top_level_evidence(ordered, dispatch, responses, rx_candidate, tx_candidate)
|
||||
|
||||
confidence_score = _confidence_score(frame_supported, dispatch, responses, commands)
|
||||
@@ -164,6 +199,9 @@ def analyze_serial_semantics(payload: Mapping[str, Any]) -> JsonObject:
|
||||
"rx_fields": _rx_field_candidates(ordered, dispatch),
|
||||
"response_builders": _response_builder_aliases(responses),
|
||||
"retry_error_model": retry_error_model,
|
||||
"gate_queue_model": gate_queue_model,
|
||||
"tx_report_model": tx_report_model,
|
||||
"periodic_resend_model": periodic_resend_model,
|
||||
"evidence": evidence,
|
||||
}
|
||||
return {
|
||||
@@ -181,6 +219,9 @@ def analyze_serial_semantics(payload: Mapping[str, Any]) -> JsonObject:
|
||||
"table_map_candidates": protocol["table_map_candidates"],
|
||||
"state_variable_candidates": protocol["state_variable_candidates"],
|
||||
"retry_error_model": protocol["retry_error_model"],
|
||||
"gate_queue_model": protocol["gate_queue_model"],
|
||||
"tx_report_model": protocol["tx_report_model"],
|
||||
"periodic_resend_model": protocol["periodic_resend_model"],
|
||||
"confidence": protocol["confidence"],
|
||||
"confidence_score": protocol["confidence_score"],
|
||||
"caveat": protocol["caveat"],
|
||||
@@ -1576,6 +1617,324 @@ def _retry_error_model(ordered: list[JsonObject], responses: list[JsonObject]) -
|
||||
}
|
||||
|
||||
|
||||
def _gate_queue_model(ordered: list[JsonObject], commands: list[JsonObject]) -> JsonObject | None:
|
||||
evidence = _dedupe_ints(
|
||||
_addresses_in_ranges(ordered, [(MAIN_REPORT_GATE_ENTRY, MAIN_REPORT_GATE_CALL)], MAIN_REPORT_GATE_ENTRY, MAIN_REPORT_GATE_CALL)
|
||||
+ _addresses_in_ranges(ordered, [(SESSION_GATE_ENTRY, 0x4007)], SESSION_GATE_ENTRY, 0x4007)
|
||||
+ _addresses_in_ranges(ordered, [(QUEUE_REPORT_ENTRY, AUTONOMOUS_TX_REPORT_CALL)], QUEUE_REPORT_ENTRY, AUTONOMOUS_TX_REPORT_CALL)
|
||||
+ _addresses_in_ranges(ordered, [(RESEND_GATE_ENTRY, PERIODIC_RESEND_ENTRY)], RESEND_GATE_ENTRY, PERIODIC_RESEND_ENTRY)
|
||||
)
|
||||
command_ack_values = [
|
||||
int(command["command_value"])
|
||||
for command in commands
|
||||
if command.get("command_value") in {0x05, 0x06}
|
||||
]
|
||||
if not evidence and not command_ack_values:
|
||||
return None
|
||||
|
||||
return {
|
||||
"kind": "serial_gate_queue_state_machine_candidate",
|
||||
"summary": (
|
||||
"Conservative model for autonomous report gating, queue cursor comparison, "
|
||||
"periodic resend, and RX/session side effects."
|
||||
),
|
||||
"predicates": [
|
||||
{
|
||||
"name": "main_loop_may_enter_report_builder",
|
||||
"entry_label": "loc_3FD3",
|
||||
"target_label": "loc_BAF2",
|
||||
"condition_candidate": (
|
||||
"FAA2 == 0 && F9C0 == 0 && ((FAA5.bit7 == 0) || (F9C3 == 0))"
|
||||
),
|
||||
"summary": "Main-loop report gate; session must be idle, TX busy timer clear, and RX gate open.",
|
||||
"state_addresses_hex": [_h16(0xFAA2), _h16(0xFAA5), _h16(0xF9C3), _h16(0xF9C0)],
|
||||
"evidence_addresses": _addresses_in_ranges(
|
||||
ordered,
|
||||
[(MAIN_REPORT_GATE_ENTRY, MAIN_REPORT_GATE_CALL)],
|
||||
MAIN_REPORT_GATE_ENTRY,
|
||||
MAIN_REPORT_GATE_CALL,
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "queue_has_pending_report",
|
||||
"entry_label": "loc_BAF2",
|
||||
"condition_candidate": "F9B5 != F9B0",
|
||||
"summary": "Queue/pending cursor gate; non-empty state stages through BB43 before loc_BA26.",
|
||||
"state_addresses_hex": [_h16(0xF9B5), _h16(0xF9B0)],
|
||||
"staging_path": ["loc_BAF2", "loc_BB43", "loc_BA26"],
|
||||
"evidence_addresses": _addresses_in_ranges(
|
||||
ordered,
|
||||
[(QUEUE_REPORT_ENTRY, AUTONOMOUS_TX_REPORT_CALL)],
|
||||
QUEUE_REPORT_ENTRY,
|
||||
AUTONOMOUS_TX_REPORT_CALL,
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "periodic_resend_may_fire",
|
||||
"entry_label": "loc_BE9E",
|
||||
"target_label": "loc_BED5",
|
||||
"condition_candidate": (
|
||||
"(FAA5 & FAA3 & 0x80) != 0 && F9C6 == 0 && F9C8 != 0 after countdown"
|
||||
),
|
||||
"summary": "Resend gate masks pending state with FAA5, checks F9C6/F9C8, then calls BA26 at BED5.",
|
||||
"state_addresses_hex": [_h16(0xFAA5), _h16(0xFAA3), _h16(0xF9C6), _h16(0xF9C8)],
|
||||
"evidence_addresses": _addresses_in_ranges(
|
||||
ordered,
|
||||
[(RESEND_GATE_ENTRY, PERIODIC_RESEND_ENTRY)],
|
||||
RESEND_GATE_ENTRY,
|
||||
PERIODIC_RESEND_ENTRY,
|
||||
),
|
||||
},
|
||||
],
|
||||
"session_effects": [
|
||||
{
|
||||
"name": "rx_completion_sets_session_timer",
|
||||
"summary": "RX completion sets F9C5 (observed reload H'14) after the sixth byte is captured.",
|
||||
"state_addresses_hex": [_h16(0xF9C5)],
|
||||
"evidence_addresses": _state_immediate_evidence(ordered, 0xF9C5, 0x14),
|
||||
},
|
||||
{
|
||||
"name": "session_timeout_clears_gate_and_queue",
|
||||
"entry_label": "loc_3FEF",
|
||||
"summary": "When F9C5 is clear, loc_3FEF clears F9B5/F9B0 and clears FAA5.bit7; when nonzero, it sets FAA5.bit7.",
|
||||
"state_addresses_hex": [_h16(0xF9C5), _h16(0xF9B5), _h16(0xF9B0), _h16(0xFAA5)],
|
||||
"evidence_addresses": _addresses_in_ranges(
|
||||
ordered,
|
||||
[(SESSION_GATE_ENTRY, 0x4007)],
|
||||
SESSION_GATE_ENTRY,
|
||||
0x4007,
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "host_ack_can_advance_queue",
|
||||
"summary": "Commands 0x05/0x06 are modeled as acknowledgement paths that can clear pending state or advance F9B5.",
|
||||
"command_values_hex": [_h16(value, width=2) for value in command_ack_values],
|
||||
"state_addresses_hex": [_h16(0xF9B5)],
|
||||
"evidence_addresses": _dedupe_ints(
|
||||
addr
|
||||
for command in commands
|
||||
if command.get("command_value") in {0x05, 0x06}
|
||||
for addr in command.get("evidence_addresses", [])
|
||||
if isinstance(addr, int)
|
||||
),
|
||||
},
|
||||
],
|
||||
"caveat": (
|
||||
"Many panel controls may require host/session traffic before reporting. Observed "
|
||||
"autonomous call/camera-power indexes are runtime/capture overlays, not ROM constants."
|
||||
),
|
||||
"confidence": "candidate-medium",
|
||||
"evidence_addresses": evidence,
|
||||
"evidence_addresses_hex": _hlist(evidence),
|
||||
}
|
||||
|
||||
|
||||
def _tx_report_model(ordered: list[JsonObject], responses: list[JsonObject]) -> JsonObject | None:
|
||||
report_responses = [
|
||||
response for response in responses
|
||||
if response.get("call_address") == AUTONOMOUS_TX_REPORT_CALL
|
||||
]
|
||||
if not report_responses:
|
||||
report_responses = [
|
||||
response for response in responses
|
||||
if _response_reads_current_value_table(response)
|
||||
and not _response_reads_rx_frame(response)
|
||||
]
|
||||
if not report_responses:
|
||||
return None
|
||||
|
||||
response_ids = [
|
||||
str(response["id"])
|
||||
for response in report_responses
|
||||
if isinstance(response.get("id"), str)
|
||||
]
|
||||
evidence = _dedupe_ints(
|
||||
addr
|
||||
for response in report_responses
|
||||
for addr in response.get("evidence_addresses", [])
|
||||
if isinstance(addr, int)
|
||||
)
|
||||
byte_roles = [
|
||||
{
|
||||
"offset": 0,
|
||||
"field_candidate": "encoded_logical_index_or_report_id_byte0",
|
||||
"source_candidate": "computed from candidate logical index/report id",
|
||||
},
|
||||
{
|
||||
"offset": 1,
|
||||
"field_candidate": "encoded_logical_index_or_report_id_byte1",
|
||||
"source_candidate": "computed from candidate logical index/report id",
|
||||
},
|
||||
{
|
||||
"offset": 2,
|
||||
"field_candidate": "encoded_logical_index_or_report_id_byte2",
|
||||
"source_candidate": "computed from candidate logical index/report id",
|
||||
},
|
||||
{
|
||||
"offset": 3,
|
||||
"field_candidate": "current_value_hi",
|
||||
"source_candidate": "current_value_table_candidate high byte",
|
||||
"table_candidate": "current_value_table_candidate",
|
||||
},
|
||||
{
|
||||
"offset": 4,
|
||||
"field_candidate": "current_value_lo",
|
||||
"source_candidate": "current_value_table_candidate low byte",
|
||||
"table_candidate": "current_value_table_candidate",
|
||||
},
|
||||
{
|
||||
"offset": 5,
|
||||
"field_candidate": "checksum",
|
||||
"source_candidate": "0x5A XOR TX[0..4]",
|
||||
},
|
||||
]
|
||||
return {
|
||||
"kind": "bb43_to_ba26_tx_report_model_candidate",
|
||||
"direction": "device_to_host_autonomous_report_candidate",
|
||||
"entry_label": AUTONOMOUS_TX_REPORT_LABEL,
|
||||
"entry_address": AUTONOMOUS_TX_REPORT_CALL,
|
||||
"entry_address_hex": _h16(AUTONOMOUS_TX_REPORT_CALL),
|
||||
"send_builder": SEND_BUILDER_LABEL,
|
||||
"send_builder_address": SEND_BUILDER_ADDRESS,
|
||||
"send_builder_address_hex": _h16(SEND_BUILDER_ADDRESS),
|
||||
"response_candidates": _dedupe_strings(response_ids),
|
||||
"summary": (
|
||||
"TX report bytes 0..2 are computed encoded logical index/report id bytes, "
|
||||
"bytes 3..4 come from current_value_table_candidate, and byte5 is the "
|
||||
"0x5A XOR checksum."
|
||||
),
|
||||
"byte_roles": byte_roles,
|
||||
"value_source_candidate": "current_value_table_candidate",
|
||||
"checksum_formula": "checksum = 0x5A ^ byte0 ^ byte1 ^ byte2 ^ byte3 ^ byte4",
|
||||
"observed_capture_overlay_candidates": OBSERVED_TX_REPORT_OVERLAY,
|
||||
"observed_autonomous_output_caveat": (
|
||||
"Real captures supplied so far show only heartbeat/idle, call, and camera-power "
|
||||
"autonomous TX frames. Other panel controls may require a host/device request or "
|
||||
"state transition before the firmware reports them."
|
||||
),
|
||||
"confidence": "candidate-medium",
|
||||
"caveat": (
|
||||
"This is a TX/report model for the BB43 -> BA26 path, separate from RX command "
|
||||
"dispatch. Observed report names are a capture overlay candidate only, not hard-coded "
|
||||
"source truth."
|
||||
),
|
||||
"evidence_addresses": evidence,
|
||||
"evidence_addresses_hex": _hlist(evidence),
|
||||
}
|
||||
|
||||
|
||||
def _periodic_resend_model(ordered: list[JsonObject], responses: list[JsonObject]) -> JsonObject | None:
|
||||
del responses
|
||||
period_evidence = _state_immediate_evidence(ordered, 0xF9C6, 0x01F4)
|
||||
countdown_evidence = _state_immediate_evidence(ordered, 0xF9C8, 0x14)
|
||||
pending_evidence = _state_immediate_evidence(ordered, 0xFAA3, 0x80)
|
||||
pending_evidence = _dedupe_ints(pending_evidence + _state_bit_evidence(ordered, 0xFAA3, 7))
|
||||
resend_evidence = [
|
||||
int(ins["address"])
|
||||
for ins in ordered
|
||||
if PERIODIC_RESEND_ENTRY <= int(ins.get("address", -1)) <= SERIAL_HANDLER_END
|
||||
]
|
||||
resend_send_evidence = [
|
||||
int(ins["address"])
|
||||
for ins in ordered
|
||||
if PERIODIC_RESEND_ENTRY <= int(ins.get("address", -1)) <= SERIAL_HANDLER_END
|
||||
and (_is_send_builder_call(ins) or _has_ref_in_range(ins, TX_STAGING_START, TX_FRAME_END))
|
||||
]
|
||||
evidence = _dedupe_ints(period_evidence + countdown_evidence + pending_evidence + resend_send_evidence)
|
||||
if not evidence and not resend_evidence:
|
||||
return None
|
||||
return {
|
||||
"kind": "autonomous_periodic_resend_model_candidate",
|
||||
"period_timer": {
|
||||
"address": 0xF9C6,
|
||||
"address_hex": _h16(0xF9C6),
|
||||
"reload_value_candidate": 0x01F4,
|
||||
"reload_value_hex": _h16(0x01F4),
|
||||
"summary": "Candidate periodic report/heartbeat timer reload.",
|
||||
"evidence_addresses": period_evidence,
|
||||
"evidence_addresses_hex": _hlist(period_evidence),
|
||||
},
|
||||
"resend_countdown": {
|
||||
"address": 0xF9C8,
|
||||
"address_hex": _h16(0xF9C8),
|
||||
"reload_value_candidate": 0x14,
|
||||
"reload_value_hex": _h16(0x14, width=2),
|
||||
"summary": "Candidate periodic resend countdown/retry spacing value.",
|
||||
"evidence_addresses": countdown_evidence,
|
||||
"evidence_addresses_hex": _hlist(countdown_evidence),
|
||||
},
|
||||
"pending_mask": {
|
||||
"address": 0xFAA3,
|
||||
"address_hex": _h16(0xFAA3),
|
||||
"mask_candidate": 0x80,
|
||||
"mask_hex": _h16(0x80, width=2),
|
||||
"summary": "Candidate bit/mask that marks an autonomous report pending.",
|
||||
"evidence_addresses": pending_evidence,
|
||||
"evidence_addresses_hex": _hlist(pending_evidence),
|
||||
},
|
||||
"resend_path": {
|
||||
"entry_label": "loc_BED5",
|
||||
"entry_address": PERIODIC_RESEND_ENTRY,
|
||||
"entry_address_hex": _h16(PERIODIC_RESEND_ENTRY),
|
||||
"summary": "Candidate periodic resend path feeding the TX staging/send-builder flow.",
|
||||
"evidence_addresses": _dedupe_ints(resend_send_evidence or resend_evidence),
|
||||
"evidence_addresses_hex": _hlist(resend_send_evidence or resend_evidence),
|
||||
},
|
||||
"evidence_addresses": evidence,
|
||||
"evidence_addresses_hex": _hlist(evidence),
|
||||
"confidence": "candidate-medium" if evidence else "candidate-low",
|
||||
"caveat": (
|
||||
"Timer and resend roles are inferred from constants/state references around F9C6, "
|
||||
"F9C8, FAA3, and loc_BED5; exact scheduling units remain candidate phrasing."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _response_reads_current_value_table(response: Mapping[str, Any]) -> bool:
|
||||
schema = response.get("schema")
|
||||
if not isinstance(schema, Mapping):
|
||||
return False
|
||||
return any(
|
||||
isinstance(item, Mapping)
|
||||
and isinstance(item.get("source"), Mapping)
|
||||
and item["source"].get("kind") == "table"
|
||||
and item["source"].get("name_candidate") == "current_value_table_candidate"
|
||||
for item in schema.get("bytes", [])
|
||||
)
|
||||
|
||||
|
||||
def _response_reads_rx_frame(response: Mapping[str, Any]) -> bool:
|
||||
schema = response.get("schema")
|
||||
if not isinstance(schema, Mapping):
|
||||
return False
|
||||
return any(
|
||||
isinstance(item, Mapping)
|
||||
and isinstance(item.get("source"), Mapping)
|
||||
and item["source"].get("kind") == "rx_frame_byte"
|
||||
for item in schema.get("bytes", [])
|
||||
)
|
||||
|
||||
|
||||
def _state_immediate_evidence(ordered: list[JsonObject], state_address: int, value: int) -> list[int]:
|
||||
evidence = []
|
||||
for ins in ordered:
|
||||
if not _has_ref_in_range(ins, state_address, state_address):
|
||||
continue
|
||||
source, _destination = _source_destination_operands(str(ins.get("operands", "")))
|
||||
if _parse_immediate(source) == value:
|
||||
evidence.append(int(ins["address"]))
|
||||
return _dedupe_ints(evidence)
|
||||
|
||||
|
||||
def _state_bit_evidence(ordered: list[JsonObject], state_address: int, bit: int) -> list[int]:
|
||||
return _dedupe_ints(
|
||||
int(ins["address"])
|
||||
for ins in ordered
|
||||
if _has_ref_in_range(ins, state_address, state_address)
|
||||
and _bit_number_from_instruction(ins) == bit
|
||||
)
|
||||
|
||||
|
||||
def _send_builder_candidate(
|
||||
ordered: list[JsonObject],
|
||||
responses: list[JsonObject],
|
||||
@@ -1687,6 +2046,28 @@ def _top_level_evidence(
|
||||
"response_count": len(responses),
|
||||
}
|
||||
)
|
||||
tx_report_responses = [
|
||||
response for response in responses
|
||||
if response.get("call_address") == AUTONOMOUS_TX_REPORT_CALL
|
||||
]
|
||||
if tx_report_responses:
|
||||
addresses = _dedupe_ints(
|
||||
addr
|
||||
for response in tx_report_responses
|
||||
for addr in response.get("evidence_addresses", [])
|
||||
if isinstance(addr, int)
|
||||
)
|
||||
evidence.append(
|
||||
{
|
||||
"kind": "bb43_autonomous_tx_report_path",
|
||||
"summary": (
|
||||
"BB43 stages a candidate device-to-host report before loc_BA26; this is "
|
||||
"separate from RX command dispatch."
|
||||
),
|
||||
"addresses": addresses,
|
||||
"addresses_hex": _hlist(addresses),
|
||||
}
|
||||
)
|
||||
rx_payload_reads = [
|
||||
int(ins["address"])
|
||||
for ins in ordered
|
||||
|
||||
830
h8536/table_xrefs.py
Normal file
830
h8536/table_xrefs.py
Normal file
@@ -0,0 +1,830 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Iterable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .formatting import h16, label_for
|
||||
from .serial_semantics import DIRECT_TABLE_TO_LOGICAL_OFFSET, LOGICAL_TABLES
|
||||
|
||||
|
||||
JsonObject = dict[str, Any]
|
||||
|
||||
|
||||
TABLES: tuple[JsonObject, ...] = (
|
||||
{
|
||||
"name": "primary_value_table_candidate",
|
||||
"logical_base_address": 0xE000,
|
||||
"logical_range_end": 0xE3FF,
|
||||
"negative_offset": 0x2000,
|
||||
"element_candidate": "word_value",
|
||||
"direct_addresses": [0xF900],
|
||||
"direct_range_end": 0xF91F,
|
||||
},
|
||||
{
|
||||
"name": "secondary_value_table_candidate",
|
||||
"logical_base_address": 0xE400,
|
||||
"logical_range_end": 0xE7FF,
|
||||
"negative_offset": 0x1C00,
|
||||
"element_candidate": "word_value",
|
||||
"direct_addresses": [0xF940],
|
||||
"direct_range_end": 0xF95F,
|
||||
},
|
||||
{
|
||||
"name": "current_value_table_candidate",
|
||||
"logical_base_address": 0xE800,
|
||||
"logical_range_end": 0xEBFF,
|
||||
"negative_offset": 0x1800,
|
||||
"element_candidate": "word_value",
|
||||
"direct_addresses": [0xF920],
|
||||
"direct_range_end": 0xF93F,
|
||||
},
|
||||
{
|
||||
"name": "flag_table_candidate",
|
||||
"logical_base_address": 0xEC00,
|
||||
"logical_range_end": 0xEFFF,
|
||||
"negative_offset": 0x1400,
|
||||
"element_candidate": "bit_flags",
|
||||
"direct_addresses": [0xF980],
|
||||
"direct_range_end": 0xF99F,
|
||||
},
|
||||
)
|
||||
|
||||
_TABLE_BY_NEGATIVE_OFFSET = {int(item["negative_offset"]): item for item in TABLES}
|
||||
_TABLE_BY_DIRECT_ADDRESS = {
|
||||
address: item
|
||||
for item in TABLES
|
||||
for address in item["direct_addresses"]
|
||||
}
|
||||
LCD_CORRELATION_TERMS = (
|
||||
"CONNECT",
|
||||
"CONNECT: OK",
|
||||
"CONNECT: NOT ACT",
|
||||
"NOT ACT",
|
||||
"COMM LINK",
|
||||
"COMPLETED",
|
||||
)
|
||||
|
||||
|
||||
def load_table_xref_input(path: Path) -> JsonObject:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
if not isinstance(payload, dict) or "instructions" not in payload:
|
||||
raise ValueError(f"{path} does not look like h8536_decompiler JSON output")
|
||||
return payload
|
||||
|
||||
|
||||
def analyze_table_xrefs(payload: Mapping[str, Any]) -> JsonObject:
|
||||
instructions = _instruction_sequence(payload.get("instructions"))
|
||||
functions = _function_ranges(payload)
|
||||
semantic_accesses = _semantic_access_locations(payload)
|
||||
accesses_by_table = {str(table["name"]): [] for table in TABLES}
|
||||
|
||||
for index, ins in enumerate(instructions):
|
||||
for access in _logical_operand_accesses(instructions, index, functions, semantic_accesses):
|
||||
accesses_by_table.setdefault(str(access["table"]), []).append(access)
|
||||
for access in _direct_address_accesses(ins, functions, semantic_accesses):
|
||||
accesses_by_table.setdefault(str(access["table"]), []).append(access)
|
||||
|
||||
tables: list[JsonObject] = []
|
||||
for table in TABLES:
|
||||
name = str(table["name"])
|
||||
accesses = sorted(accesses_by_table.get(name, []), key=lambda item: int(item["instruction_address"]))
|
||||
reads = sum(1 for access in accesses if access["access"] == "read")
|
||||
writes = sum(1 for access in accesses if access["access"] == "write")
|
||||
read_write = sum(1 for access in accesses if access["access"] == "read_write_candidate")
|
||||
dynamic = sum(1 for access in accesses if access.get("index") == "dynamic")
|
||||
static_offsets = sorted(
|
||||
{
|
||||
int(access["offset"])
|
||||
for access in accesses
|
||||
if isinstance(access.get("offset"), int)
|
||||
}
|
||||
)
|
||||
tables.append(
|
||||
{
|
||||
"name": name,
|
||||
"logical_base_address": table["logical_base_address"],
|
||||
"logical_base_address_hex": h16(int(table["logical_base_address"])),
|
||||
"logical_range_end": table["logical_range_end"],
|
||||
"logical_range_end_hex": h16(int(table["logical_range_end"])),
|
||||
"negative_offset": table["negative_offset"],
|
||||
"negative_offset_hex": h16(int(table["negative_offset"])),
|
||||
"element_candidate": table["element_candidate"],
|
||||
"direct_addresses": table["direct_addresses"],
|
||||
"direct_addresses_hex": [h16(int(address)) for address in table["direct_addresses"]],
|
||||
"direct_range_end": table["direct_range_end"],
|
||||
"direct_range_end_hex": h16(int(table["direct_range_end"])),
|
||||
"access_count": len(accesses),
|
||||
"read_count": reads,
|
||||
"write_count": writes,
|
||||
"read_write_candidate_count": read_write,
|
||||
"dynamic_index_count": dynamic,
|
||||
"static_offsets": static_offsets,
|
||||
"static_offsets_hex": [h16(offset) for offset in static_offsets],
|
||||
"functions": _summarize_functions(accesses),
|
||||
"accesses": accesses,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"kind": "table_xrefs",
|
||||
"tables": tables,
|
||||
"summary": {
|
||||
"table_count": len(tables),
|
||||
"access_count": sum(int(table["access_count"]) for table in tables),
|
||||
"dynamic_index_count": sum(int(table["dynamic_index_count"]) for table in tables),
|
||||
"source_instruction_count": len(instructions),
|
||||
},
|
||||
"lcd_correlation": _lcd_correlation_hints(payload),
|
||||
"caveat": (
|
||||
"Static offsets are emitted only when an index register value can be derived from "
|
||||
"nearby immediate loads in the current JSON. Other indexed accesses are dynamic."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def generate_table_xref_report(payload: Mapping[str, Any], *, source_name: str = "") -> str:
|
||||
analysis = analyze_table_xrefs(payload)
|
||||
lines: list[str] = []
|
||||
suffix = f" for {source_name}" if source_name else ""
|
||||
lines.append(f"Table/Index Cross-Reference Report{suffix}")
|
||||
lines.append("=" * len(lines[0]))
|
||||
lines.append("")
|
||||
lines.append(str(analysis["caveat"]))
|
||||
lines.append("")
|
||||
lines.extend(_format_lcd_correlation_lines(analysis.get("lcd_correlation")))
|
||||
if lines[-1] != "":
|
||||
lines.append("")
|
||||
|
||||
for table in analysis["tables"]:
|
||||
name = str(table["name"])
|
||||
direct = ", ".join(str(item) for item in table["direct_addresses_hex"])
|
||||
lines.append(
|
||||
f"{name} {table['logical_base_address_hex']}-{table['logical_range_end_hex']} "
|
||||
f"(negative {table['negative_offset_hex']}; direct {direct}-{table['direct_range_end_hex']})"
|
||||
)
|
||||
lines.append(
|
||||
f" accesses={table['access_count']} reads={table['read_count']} "
|
||||
f"writes={table['write_count']} dynamic={table['dynamic_index_count']}"
|
||||
)
|
||||
offsets = table.get("static_offsets_hex") or []
|
||||
if offsets:
|
||||
lines.append(f" static offsets: {', '.join(str(item) for item in offsets[:16])}")
|
||||
function_summaries = table.get("functions") or []
|
||||
if function_summaries:
|
||||
joined = ", ".join(
|
||||
f"{item['label']}:{item['access_count']}" for item in function_summaries[:12]
|
||||
)
|
||||
lines.append(f" functions: {joined}")
|
||||
accesses = table.get("accesses")
|
||||
if isinstance(accesses, list) and accesses:
|
||||
for access in accesses[:80]:
|
||||
lines.append(f" - {_format_access_line(access)}")
|
||||
if len(accesses) > 80:
|
||||
lines.append(f" - ... {len(accesses) - 80} more accesses omitted")
|
||||
else:
|
||||
lines.append(" no references found in current JSON")
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _format_lcd_correlation_lines(value: Any) -> list[str]:
|
||||
if not isinstance(value, Mapping):
|
||||
return []
|
||||
lines = ["LCD correlation hints"]
|
||||
for hit in value.get("term_hits", []):
|
||||
if not isinstance(hit, Mapping):
|
||||
continue
|
||||
term = hit.get("term")
|
||||
count = int(hit.get("hit_count", 0))
|
||||
if count:
|
||||
samples = ", ".join(
|
||||
f"{item['address_hex']} {item['trimmed']!r}"
|
||||
for item in hit.get("hits", [])[:4]
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
lines.append(f" term {term!r}: {count} candidate hit(s): {samples}")
|
||||
else:
|
||||
lines.append(f" term {term!r}: no LCD/text candidate hits in current decompile")
|
||||
builders = value.get("display_builder_targets", [])
|
||||
if isinstance(builders, list) and builders:
|
||||
parts = [
|
||||
f"{item['target_hex']}:{item['xref_count']}"
|
||||
for item in builders[:8]
|
||||
if isinstance(item, Mapping)
|
||||
]
|
||||
lines.append(f" display builder xrefs: {', '.join(parts)}")
|
||||
routines = value.get("lcd_driver_routines", [])
|
||||
if isinstance(routines, list) and routines:
|
||||
parts = [
|
||||
f"{item['start_hex']} {item['role_hint']}"
|
||||
for item in routines[:4]
|
||||
if isinstance(item, Mapping)
|
||||
]
|
||||
lines.append(f" LCD driver routines: {', '.join(parts)}")
|
||||
lines.append(
|
||||
" caveat: LCD strings can be builder/script output; absence of a literal term does not disprove runtime composition."
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def write_table_xrefs(input_path: Path, output_path: Path, *, as_json: bool = False) -> None:
|
||||
payload = load_table_xref_input(input_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if as_json:
|
||||
analysis = analyze_table_xrefs(payload)
|
||||
analysis["source"] = str(input_path)
|
||||
output_path.write_text(json.dumps(analysis, indent=2), encoding="utf-8")
|
||||
else:
|
||||
output_path.write_text(generate_table_xref_report(payload, source_name=str(input_path)), encoding="utf-8")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate table/index cross-references for candidate serial protocol data tables.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"input",
|
||||
nargs="?",
|
||||
type=Path,
|
||||
default=Path("build/rom_decompiled.json"),
|
||||
help="structured JSON emitted by h8536_decompiler.py",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
type=Path,
|
||||
default=Path("build/rom_table_xrefs.txt"),
|
||||
help="table cross-reference report output path",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="write structured JSON instead of text")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
write_table_xrefs(args.input, args.out, as_json=args.json)
|
||||
print(f"wrote {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
def _logical_operand_accesses(
|
||||
instructions: list[JsonObject],
|
||||
index: int,
|
||||
functions: list[JsonObject],
|
||||
semantic_accesses: Mapping[int, list[JsonObject]],
|
||||
) -> list[JsonObject]:
|
||||
ins = instructions[index]
|
||||
accesses: list[JsonObject] = []
|
||||
operands = str(ins.get("operands", ""))
|
||||
for operand in _negative_indexed_operands(operands):
|
||||
table = _TABLE_BY_NEGATIVE_OFFSET.get(int(operand["negative_offset"]))
|
||||
if table is None:
|
||||
continue
|
||||
register = str(operand["index_register"])
|
||||
known = _nearby_register_value(instructions, index, register)
|
||||
offset: int | str = known if known is not None else "dynamic"
|
||||
logical_address: int | None = None
|
||||
if isinstance(offset, int):
|
||||
logical_address = (int(table["logical_base_address"]) + offset) & 0xFFFF
|
||||
access = _base_access(ins, functions, semantic_accesses)
|
||||
access.update(
|
||||
{
|
||||
"table": table["name"],
|
||||
"table_base_address": table["logical_base_address"],
|
||||
"table_base_address_hex": h16(int(table["logical_base_address"])),
|
||||
"kind": "logical_negative_indexed_access",
|
||||
"operand": operand["operand"],
|
||||
"negative_offset": operand["negative_offset"],
|
||||
"negative_offset_hex": h16(int(operand["negative_offset"])),
|
||||
"index_register": register,
|
||||
"index": offset,
|
||||
"offset": offset,
|
||||
"access": _operand_access_kind(ins, str(operand["operand"])),
|
||||
}
|
||||
)
|
||||
if logical_address is not None:
|
||||
access["logical_address"] = logical_address
|
||||
access["logical_address_hex"] = h16(logical_address)
|
||||
accesses.append(access)
|
||||
return accesses
|
||||
|
||||
|
||||
def _direct_address_accesses(
|
||||
ins: Mapping[str, Any],
|
||||
functions: list[JsonObject],
|
||||
semantic_accesses: Mapping[int, list[JsonObject]],
|
||||
) -> list[JsonObject]:
|
||||
accesses: list[JsonObject] = []
|
||||
refs = _references(ins)
|
||||
for address in refs:
|
||||
logical_table = _table_for_logical_address(address)
|
||||
if logical_table is not None:
|
||||
accesses.append(
|
||||
_direct_logical_address_access(ins, logical_table, address, functions, semantic_accesses),
|
||||
)
|
||||
continue
|
||||
|
||||
direct_table = _table_for_direct_candidate_address(address)
|
||||
if direct_table is not None:
|
||||
accesses.append(
|
||||
_direct_candidate_address_access(ins, direct_table, address, functions, semantic_accesses),
|
||||
)
|
||||
return accesses
|
||||
|
||||
|
||||
def _direct_logical_address_access(
|
||||
ins: Mapping[str, Any],
|
||||
table: Mapping[str, Any],
|
||||
address: int,
|
||||
functions: list[JsonObject],
|
||||
semantic_accesses: Mapping[int, list[JsonObject]],
|
||||
) -> JsonObject:
|
||||
base = int(table["logical_base_address"])
|
||||
offset = address - base
|
||||
access = _base_access(ins, functions, semantic_accesses)
|
||||
access.update(
|
||||
{
|
||||
"table": table["name"],
|
||||
"table_base_address": base,
|
||||
"table_base_address_hex": h16(base),
|
||||
"kind": "direct_logical_address_access",
|
||||
"direct_address": address,
|
||||
"direct_address_hex": h16(address),
|
||||
"logical_address": address,
|
||||
"logical_address_hex": h16(address),
|
||||
"index": offset,
|
||||
"offset": offset,
|
||||
"offset_hex": h16(offset),
|
||||
"access": _access_direction(ins, address) or "read_write_candidate",
|
||||
}
|
||||
)
|
||||
return access
|
||||
|
||||
|
||||
def _direct_candidate_address_access(
|
||||
ins: Mapping[str, Any],
|
||||
table: Mapping[str, Any],
|
||||
address: int,
|
||||
functions: list[JsonObject],
|
||||
semantic_accesses: Mapping[int, list[JsonObject]],
|
||||
) -> JsonObject:
|
||||
base = min(int(item) for item in table["direct_addresses"])
|
||||
offset = address - base
|
||||
access = _base_access(ins, functions, semantic_accesses)
|
||||
logical_offset = DIRECT_TABLE_TO_LOGICAL_OFFSET.get(base)
|
||||
access.update(
|
||||
{
|
||||
"table": table["name"],
|
||||
"table_base_address": table["logical_base_address"],
|
||||
"table_base_address_hex": h16(int(table["logical_base_address"])),
|
||||
"kind": "direct_candidate_address_access",
|
||||
"direct_address": address,
|
||||
"direct_address_hex": h16(address),
|
||||
"direct_base_address": base,
|
||||
"direct_base_address_hex": h16(base),
|
||||
"index": offset,
|
||||
"offset": offset,
|
||||
"offset_hex": h16(offset),
|
||||
"access": _access_direction(ins, address) or "read_write_candidate",
|
||||
}
|
||||
)
|
||||
if logical_offset is not None:
|
||||
access["semantic_negative_offset"] = logical_offset
|
||||
access["semantic_negative_offset_hex"] = h16(logical_offset)
|
||||
return access
|
||||
|
||||
|
||||
def _lcd_correlation_hints(payload: Mapping[str, Any]) -> JsonObject:
|
||||
lcd_text = payload.get("lcd_text")
|
||||
strings = []
|
||||
if isinstance(lcd_text, Mapping) and isinstance(lcd_text.get("strings"), list):
|
||||
strings = [item for item in lcd_text["strings"] if isinstance(item, Mapping)]
|
||||
|
||||
term_hits = []
|
||||
for term in LCD_CORRELATION_TERMS:
|
||||
hits = []
|
||||
upper_term = term.upper()
|
||||
for item in strings:
|
||||
text = f"{item.get('text', '')} {item.get('trimmed', '')}".upper()
|
||||
if upper_term not in text:
|
||||
continue
|
||||
hits.append(_lcd_string_summary(item))
|
||||
term_hits.append(
|
||||
{
|
||||
"term": term,
|
||||
"hit_count": len(hits),
|
||||
"hits": hits[:24],
|
||||
"status": "candidate_hits" if hits else "not_found",
|
||||
}
|
||||
)
|
||||
|
||||
builder_targets: dict[int, JsonObject] = {}
|
||||
for item in strings:
|
||||
for xref in item.get("xrefs", []):
|
||||
if not isinstance(xref, Mapping):
|
||||
continue
|
||||
following = xref.get("following_bsr")
|
||||
if not isinstance(following, Mapping) or not isinstance(following.get("target"), int):
|
||||
continue
|
||||
target = int(following["target"])
|
||||
record = builder_targets.setdefault(
|
||||
target,
|
||||
{
|
||||
"target": target,
|
||||
"target_hex": h16(target),
|
||||
"xref_count": 0,
|
||||
"examples": [],
|
||||
},
|
||||
)
|
||||
record["xref_count"] = int(record["xref_count"]) + 1
|
||||
examples = record["examples"]
|
||||
if isinstance(examples, list) and len(examples) < 8:
|
||||
examples.append(
|
||||
{
|
||||
"text_address": item.get("address"),
|
||||
"text_address_hex": h16(int(item["address"])) if isinstance(item.get("address"), int) else None,
|
||||
"trimmed": item.get("trimmed"),
|
||||
"xref_address": xref.get("address"),
|
||||
"xref_address_hex": h16(int(xref["address"])) if isinstance(xref.get("address"), int) else None,
|
||||
}
|
||||
)
|
||||
|
||||
lcd_driver = payload.get("lcd_driver")
|
||||
routines = []
|
||||
if isinstance(lcd_driver, Mapping) and isinstance(lcd_driver.get("routines"), list):
|
||||
for routine in lcd_driver["routines"]:
|
||||
if not isinstance(routine, Mapping) or not isinstance(routine.get("start"), int):
|
||||
continue
|
||||
routines.append(
|
||||
{
|
||||
"start": routine["start"],
|
||||
"start_hex": h16(int(routine["start"])),
|
||||
"end": routine.get("end"),
|
||||
"end_hex": h16(int(routine["end"])) if isinstance(routine.get("end"), int) else None,
|
||||
"role_hint": routine.get("role_hint"),
|
||||
"roles": routine.get("roles", []),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"terms": list(LCD_CORRELATION_TERMS),
|
||||
"term_hits": term_hits,
|
||||
"display_builder_targets": sorted(
|
||||
builder_targets.values(),
|
||||
key=lambda item: (-int(item["xref_count"]), int(item["target"])),
|
||||
),
|
||||
"lcd_driver_routines": routines,
|
||||
"caveat": (
|
||||
"This is a static correlation helper. It reports text/script candidates and LCD driver "
|
||||
"routines in the same decompile; it does not prove a protocol field directly causes a string."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _lcd_string_summary(item: Mapping[str, Any]) -> JsonObject:
|
||||
address = item.get("address")
|
||||
return {
|
||||
"address": address,
|
||||
"address_hex": h16(int(address)) if isinstance(address, int) else None,
|
||||
"text": item.get("text"),
|
||||
"trimmed": item.get("trimmed"),
|
||||
"confidence": item.get("confidence"),
|
||||
"xref_count": item.get("xref_count", 0),
|
||||
}
|
||||
|
||||
|
||||
def _base_access(
|
||||
ins: Mapping[str, Any],
|
||||
functions: list[JsonObject],
|
||||
semantic_accesses: Mapping[int, list[JsonObject]],
|
||||
) -> JsonObject:
|
||||
address = int(ins["address"])
|
||||
function = _function_for_address(functions, address)
|
||||
access: JsonObject = {
|
||||
"instruction_address": address,
|
||||
"instruction_address_hex": h16(address),
|
||||
"mnemonic": str(ins.get("mnemonic", "")),
|
||||
"operands": str(ins.get("operands", "")),
|
||||
"instruction": str(ins.get("text") or _instruction_text(ins)),
|
||||
"references": _references(ins),
|
||||
"references_hex": [h16(ref) for ref in _references(ins)],
|
||||
"targets": _targets(ins),
|
||||
"targets_hex": [h16(target) for target in _targets(ins)],
|
||||
"label": _label_for_instruction(ins),
|
||||
"semantic_candidates": semantic_accesses.get(address, []),
|
||||
}
|
||||
if function:
|
||||
access["function_start"] = function["start"]
|
||||
access["function_start_hex"] = h16(int(function["start"]))
|
||||
access["function_label"] = function["label"]
|
||||
return access
|
||||
|
||||
|
||||
def _semantic_access_locations(payload: Mapping[str, Any]) -> dict[int, list[JsonObject]]:
|
||||
locations: dict[int, list[JsonObject]] = {}
|
||||
semantics = payload.get("serial_semantics")
|
||||
if not isinstance(semantics, Mapping):
|
||||
return locations
|
||||
sources: list[Any] = []
|
||||
protocols = semantics.get("protocol_semantics")
|
||||
if isinstance(protocols, list):
|
||||
sources.extend(protocols)
|
||||
sources.append(semantics)
|
||||
for source in sources:
|
||||
if not isinstance(source, Mapping):
|
||||
continue
|
||||
for item in _table_candidate_items(source.get("table_map_candidates")):
|
||||
for access in _table_candidate_items(item.get("accesses")):
|
||||
address = access.get("instruction_address")
|
||||
if isinstance(address, int):
|
||||
locations.setdefault(address, []).append(
|
||||
{
|
||||
"name_candidate": item.get("name_candidate"),
|
||||
"kind": item.get("kind"),
|
||||
"confidence": item.get("confidence"),
|
||||
}
|
||||
)
|
||||
return locations
|
||||
|
||||
|
||||
def _table_candidate_items(value: Any) -> list[Mapping[str, Any]]:
|
||||
if isinstance(value, Mapping):
|
||||
return [item for item in value.values() if isinstance(item, Mapping)]
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if isinstance(item, Mapping)]
|
||||
return []
|
||||
|
||||
|
||||
def _format_access_line(access: Mapping[str, Any]) -> str:
|
||||
function = access.get("function_label") or "<no function>"
|
||||
operand = access.get("operand") or access.get("direct_address_hex")
|
||||
index = access.get("index")
|
||||
if index == "dynamic":
|
||||
index_text = f"index dynamic via {access.get('index_register')} operand {operand}"
|
||||
else:
|
||||
index_text = f"offset {h16(int(index or 0))}"
|
||||
if access.get("logical_address_hex"):
|
||||
index_text += f" -> {access['logical_address_hex']}"
|
||||
elif access.get("direct_address_hex"):
|
||||
index_text += f" at {access['direct_address_hex']}"
|
||||
return (
|
||||
f"{access['instruction_address_hex']} {access['access']} {index_text}; "
|
||||
f"{function}; {access['instruction']}"
|
||||
)
|
||||
|
||||
|
||||
def _summarize_functions(accesses: Iterable[Mapping[str, Any]]) -> list[JsonObject]:
|
||||
summaries: dict[int, JsonObject] = {}
|
||||
for access in accesses:
|
||||
start = access.get("function_start")
|
||||
if not isinstance(start, int):
|
||||
start = -1
|
||||
summary = summaries.setdefault(
|
||||
start,
|
||||
{
|
||||
"start": start if start >= 0 else None,
|
||||
"start_hex": h16(start) if start >= 0 else None,
|
||||
"label": access.get("function_label") or "<no function>",
|
||||
"access_count": 0,
|
||||
"reads": 0,
|
||||
"writes": 0,
|
||||
},
|
||||
)
|
||||
summary["access_count"] = int(summary["access_count"]) + 1
|
||||
if access.get("access") == "read":
|
||||
summary["reads"] = int(summary["reads"]) + 1
|
||||
elif access.get("access") == "write":
|
||||
summary["writes"] = int(summary["writes"]) + 1
|
||||
return sorted(summaries.values(), key=lambda item: (-int(item["access_count"]), str(item["label"])))
|
||||
|
||||
|
||||
def _function_ranges(payload: Mapping[str, Any]) -> list[JsonObject]:
|
||||
call_graph = payload.get("call_graph")
|
||||
if not isinstance(call_graph, Mapping):
|
||||
return []
|
||||
nodes = call_graph.get("nodes")
|
||||
if not isinstance(nodes, list):
|
||||
return []
|
||||
ranges: list[JsonObject] = []
|
||||
for node in nodes:
|
||||
if not isinstance(node, Mapping):
|
||||
continue
|
||||
start = node.get("start")
|
||||
end = node.get("end")
|
||||
if isinstance(start, int) and isinstance(end, int):
|
||||
ranges.append({"start": start, "end": end, "label": str(node.get("label") or label_for(start))})
|
||||
return sorted(ranges, key=lambda item: int(item["start"]))
|
||||
|
||||
|
||||
def _function_for_address(functions: list[JsonObject], address: int) -> JsonObject | None:
|
||||
for function in functions:
|
||||
if int(function["start"]) <= address <= int(function["end"]):
|
||||
return function
|
||||
return None
|
||||
|
||||
|
||||
def _nearby_register_value(instructions: list[JsonObject], index: int, register: str) -> int | None:
|
||||
register = register.upper()
|
||||
for prior_index in range(index - 1, max(-1, index - 10), -1):
|
||||
prior = instructions[prior_index]
|
||||
source, destination = _source_destination_operands(str(prior.get("operands", "")))
|
||||
if destination.upper() != register:
|
||||
continue
|
||||
value = _parse_immediate(source)
|
||||
if value is not None:
|
||||
return value
|
||||
if _writes_register(prior, register):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _writes_register(ins: Mapping[str, Any], register: str) -> bool:
|
||||
_source, destination = _source_destination_operands(str(ins.get("operands", "")))
|
||||
return destination.upper() == register
|
||||
|
||||
|
||||
def _instruction_sequence(value: object) -> list[JsonObject]:
|
||||
if isinstance(value, Mapping):
|
||||
values: Iterable[Any] = value.values()
|
||||
elif isinstance(value, list):
|
||||
values = value
|
||||
else:
|
||||
values = []
|
||||
return sorted(
|
||||
[item for item in values if isinstance(item, dict) and isinstance(item.get("address"), int)],
|
||||
key=lambda item: int(item["address"]),
|
||||
)
|
||||
|
||||
|
||||
def _label_for_instruction(ins: Mapping[str, Any]) -> str | None:
|
||||
address = int(ins["address"])
|
||||
for key in ("label", "target_label"):
|
||||
value = ins.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
if _targets(ins):
|
||||
return label_for(address)
|
||||
return None
|
||||
|
||||
|
||||
def _instruction_text(ins: Mapping[str, Any]) -> str:
|
||||
operands = str(ins.get("operands", ""))
|
||||
return f"{ins.get('mnemonic', '')} {operands}".strip()
|
||||
|
||||
|
||||
def _references(ins: Mapping[str, Any]) -> list[int]:
|
||||
refs = ins.get("references", [])
|
||||
if not isinstance(refs, list):
|
||||
return []
|
||||
output: list[int] = []
|
||||
for ref in refs:
|
||||
if isinstance(ref, Mapping) and isinstance(ref.get("address"), int):
|
||||
output.append(int(ref["address"]))
|
||||
elif isinstance(ref, int):
|
||||
output.append(ref)
|
||||
return output
|
||||
|
||||
|
||||
def _targets(ins: Mapping[str, Any]) -> list[int]:
|
||||
targets = ins.get("targets", [])
|
||||
if not isinstance(targets, list):
|
||||
return []
|
||||
return [int(target) for target in targets if isinstance(target, int)]
|
||||
|
||||
|
||||
def _negative_indexed_operands(operands: str) -> list[JsonObject]:
|
||||
matches: list[JsonObject] = []
|
||||
for match in re.finditer(r"@\(-H'([0-9A-Fa-f]+),\s*(R[0-7])\)", operands):
|
||||
offset = int(match.group(1), 16) & 0xFFFF
|
||||
if offset not in LOGICAL_TABLES:
|
||||
continue
|
||||
matches.append(
|
||||
{
|
||||
"operand": match.group(0),
|
||||
"negative_offset": offset,
|
||||
"index_register": match.group(2).upper(),
|
||||
}
|
||||
)
|
||||
return matches
|
||||
|
||||
|
||||
def _table_for_logical_address(address: int) -> Mapping[str, Any] | None:
|
||||
for table in TABLES:
|
||||
if int(table["logical_base_address"]) <= address <= int(table["logical_range_end"]):
|
||||
return table
|
||||
return None
|
||||
|
||||
|
||||
def _table_for_direct_candidate_address(address: int) -> Mapping[str, Any] | None:
|
||||
for table in TABLES:
|
||||
direct_addresses = [int(item) for item in table["direct_addresses"]]
|
||||
if min(direct_addresses) <= address <= int(table["direct_range_end"]):
|
||||
return table
|
||||
return None
|
||||
|
||||
|
||||
def _operand_access_kind(ins: Mapping[str, Any], operand: str) -> str:
|
||||
root = _mnemonic_root(str(ins.get("mnemonic", "")))
|
||||
source, destination = _source_destination_operands(str(ins.get("operands", "")))
|
||||
if root in {"BTST", "CMP", "CMP:E", "CMP:G", "CMP:I", "TST"}:
|
||||
return "read"
|
||||
if root in {"BCLR", "BNOT", "BSET", "CLR", "INC", "INC:G", "NEG", "NOT"}:
|
||||
return "write"
|
||||
if operand in destination and operand not in source:
|
||||
return "write"
|
||||
if operand in source and operand not in destination:
|
||||
return "read"
|
||||
if root in {"ADD:Q", "ADD:G", "ADDS", "ADDX", "AND", "OR", "SUB", "SUBS", "SUBX", "XOR"}:
|
||||
return "write"
|
||||
return "read_write_candidate"
|
||||
|
||||
|
||||
def _access_direction(ins: Mapping[str, Any], address: int) -> str | None:
|
||||
root = _mnemonic_root(str(ins.get("mnemonic", "")))
|
||||
if root in {"BTST", "CMP", "CMP:E", "CMP:G", "CMP:I", "MOVFPE", "TST"}:
|
||||
return "read"
|
||||
if root in {"BCLR", "BNOT", "BSET", "CLR", "INC", "INC:G", "NEG", "NOT"}:
|
||||
return "write"
|
||||
if root in {"ADD:Q", "ADD:G", "ADDS", "ADDX", "AND", "OR", "SUB", "SUBS", "SUBX", "XOR"}:
|
||||
return "write"
|
||||
if root in {"MOV:G", "MOV:S", "MOVTPE"}:
|
||||
source, destination = _source_destination_operands(str(ins.get("operands", "")))
|
||||
if _operand_mentions_address(destination, address):
|
||||
return "write"
|
||||
if _operand_mentions_address(source, address):
|
||||
return "read"
|
||||
if address in _references(ins):
|
||||
if destination.startswith("@") and not _operand_mentions_any_reference(source, _references(ins)):
|
||||
return "write"
|
||||
if source.startswith("@") and not _operand_mentions_any_reference(destination, _references(ins)):
|
||||
return "read"
|
||||
if root in {"MOV:L", "MOV:F"}:
|
||||
return "read"
|
||||
if root == "STC":
|
||||
return "write"
|
||||
if root == "LDC":
|
||||
return "read"
|
||||
return None
|
||||
|
||||
|
||||
def _source_destination_operands(operands: str) -> tuple[str, str]:
|
||||
depth = 0
|
||||
split_at: int | None = None
|
||||
for index, char in enumerate(operands):
|
||||
if char in "({":
|
||||
depth += 1
|
||||
elif char in ")}" and depth:
|
||||
depth -= 1
|
||||
elif char == "," and depth == 0:
|
||||
split_at = index
|
||||
if split_at is None:
|
||||
operand = operands.strip()
|
||||
return "", operand
|
||||
return operands[:split_at].strip(), operands[split_at + 1 :].strip()
|
||||
|
||||
|
||||
def _parse_immediate(operand: str) -> int | None:
|
||||
text = operand.strip()
|
||||
if text.startswith("#"):
|
||||
text = text[1:].strip()
|
||||
try:
|
||||
if text.upper().startswith("H'"):
|
||||
return int(text[2:], 16) & 0xFFFF
|
||||
if text.upper().startswith("0X"):
|
||||
return int(text, 16) & 0xFFFF
|
||||
if text.upper().startswith("$"):
|
||||
return int(text[1:], 16) & 0xFFFF
|
||||
return int(text, 10) & 0xFFFF
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _operand_mentions_any_reference(operand: str, references: list[int]) -> bool:
|
||||
return any(_operand_mentions_address(operand, address) for address in references)
|
||||
|
||||
|
||||
def _operand_mentions_address(operand: str, address: int) -> bool:
|
||||
operand_upper = operand.upper().replace(" ", "")
|
||||
negative = (0x10000 - address) & 0xFFFF
|
||||
return (
|
||||
f"H'{address:04X}" in operand_upper
|
||||
or f"0X{address:04X}" in operand_upper
|
||||
or f"${address:04X}" in operand_upper
|
||||
or f"-H'{negative:04X}" in operand_upper
|
||||
or f"-0X{negative:04X}" in operand_upper
|
||||
or f"-${negative:04X}" in operand_upper
|
||||
)
|
||||
|
||||
|
||||
def _mnemonic_root(mnemonic: str) -> str:
|
||||
return mnemonic.rsplit(".", 1)[0].upper()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"analyze_table_xrefs",
|
||||
"generate_table_xref_report",
|
||||
"load_table_xref_input",
|
||||
"main",
|
||||
"write_table_xrefs",
|
||||
]
|
||||
8
h8536_protocol_capture.py
Normal file
8
h8536_protocol_capture.py
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compatibility wrapper for the H8/536 protocol capture-log analyzer CLI."""
|
||||
|
||||
from h8536.protocol_capture import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
8
h8536_protocol_trace.py
Normal file
8
h8536_protocol_trace.py
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compatibility wrapper for the H8/536 protocol trace decoder CLI."""
|
||||
|
||||
from h8536.protocol_trace import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
8
h8536_serial_gate.py
Normal file
8
h8536_serial_gate.py
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compatibility wrapper for the H8/536 serial gate analyzer CLI."""
|
||||
|
||||
from h8536.serial_gate import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
5
h8536_table_xrefs.py
Normal file
5
h8536_table_xrefs.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from h8536.table_xrefs import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
145
tests/test_protocol_capture.py
Normal file
145
tests/test_protocol_capture.py
Normal 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()
|
||||
154
tests/test_protocol_trace.py
Normal file
154
tests/test_protocol_trace.py
Normal 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
130
tests/test_serial_gate.py
Normal 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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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
157
tests/test_table_xrefs.py
Normal 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()
|
||||
Reference in New Issue
Block a user