Sprinter: добавить отладку C-исходников и интеграцию VS Code
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Карта оптимизированного кода SDCC и пакет отладки Sprinter."""
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Сборочные операции sdbg: уникальные debug-символы без изменения инструкций."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from .macros import anchor_symbols, extract
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def write_json(path: Path, data) -> None:
|
||||
temporary = path.with_suffix(path.suffix + '.tmp')
|
||||
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def normalize(assembly: str, adb: str, module: str):
|
||||
"""Меняет только имена debug-записей; сохраняет число строк .asm."""
|
||||
match = re.search(r'^\s*\.module\s+(\S+)', assembly, re.M)
|
||||
if not match:
|
||||
raise ValueError('В asm отсутствует .module')
|
||||
old = match.group(1)
|
||||
# F/XF/L связывают статики, функции и локальные с единицей трансляции.
|
||||
def rename(text):
|
||||
text = re.sub(r'\b(X?F)' + re.escape(old) + r'(?=\$)',
|
||||
lambda m: m[1] + module, text)
|
||||
return re.sub(r'\bL' + re.escape(old) + r'(?=[.$])', 'L' + module, text)
|
||||
# Не трогаем .ascii и инструкции: строковый литерал может содержать
|
||||
# текст, похожий на debug-символ. Переименовываем только определения
|
||||
# и объявления символов, сгенерированные SDCC.
|
||||
symbol_line = re.compile(r'^\s*(?:\.globl\s+|(?:X?F|L)' + re.escape(old) + r'[$.])')
|
||||
assembly = ''.join(rename(line) if symbol_line.match(line) else line
|
||||
for line in assembly.splitlines(keepends=True))
|
||||
adb = rename(adb)
|
||||
assembly = re.sub(r'(?m)^(\s*\.module\s+)\S+', lambda m: m[1] + module, assembly)
|
||||
adb = re.sub(r'(?m)^M:.*$', 'M:' + module, adb)
|
||||
markers, active, output = {}, {}, []
|
||||
for number, line in enumerate(assembly.splitlines(keepends=True), 1):
|
||||
definition = re.match(r'\s*(C\$[^\s=]+)\s*=\s*\.', line)
|
||||
if definition:
|
||||
original = definition[1]
|
||||
parts = original.split('$')
|
||||
if len(parts) != 5:
|
||||
raise ValueError('Неизвестная форма CDB: ' + original)
|
||||
unique = f'C${module}_{len(markers)}${parts[2]}${parts[3]}${parts[4]}'
|
||||
active[original] = unique
|
||||
markers[unique] = {'original': original, 'file': parts[1],
|
||||
'line': int(parts[2]), 'asm_line': number}
|
||||
line = line.replace(original, unique)
|
||||
elif re.match(r'\s*\.globl\s+C\$', line):
|
||||
original = line.split()[1]
|
||||
if original not in active:
|
||||
raise ValueError('CDB .globl без определения: ' + original)
|
||||
line = line.replace(original, active[original])
|
||||
output.append(line)
|
||||
return ''.join(output), adb, markers
|
||||
|
||||
|
||||
def compile_unit(sdcc: Path, assembler: Path, source: Path, output: Path, flags, debug=True):
|
||||
"""-S и те же опции ассемблера, которые SDCC 4.5 использует с --debug."""
|
||||
source = source.resolve()
|
||||
asm, adb = output.with_suffix('.asm'), output.with_suffix('.adb')
|
||||
compile_flags = [*flags, '-DSPRINTER_SDBG_ANCHORS=1'] if debug else flags
|
||||
# SDCC -E кодирует внутренний разделитель строк inline asm байтом 0x87;
|
||||
# для списка #line нужны только ASCII-пути. Само описание логов извлекаем
|
||||
# отдельным metadata-проходом без asm-развёртки.
|
||||
preprocessed = subprocess.check_output([str(sdcc), *compile_flags, '-E', str(source)],
|
||||
text=True, errors='replace')
|
||||
dependencies = {str(source)}
|
||||
for filename in re.findall(r'^#(?:line)?\s*\d+\s+"([^"]+)"', preprocessed, re.M):
|
||||
path = Path(filename).resolve()
|
||||
if path.is_file(): dependencies.add(str(path))
|
||||
hashes = {name: digest(Path(name)) for name in dependencies}
|
||||
if not debug:
|
||||
subprocess.run([str(sdcc), *flags, '-c', '-o', str(output), str(source)], check=True)
|
||||
if any(digest(Path(name)) != sha for name, sha in hashes.items()):
|
||||
raise ValueError('Исходники изменились во время компиляции')
|
||||
write_json(output.with_suffix('.sdbg-input.json'), {'dependency_hashes': hashes})
|
||||
return
|
||||
metadata = subprocess.check_output([str(sdcc), *compile_flags,
|
||||
'-DSPRINTER_SDBG_METADATA=1', '-E', str(source)], text=True)
|
||||
macros = extract(metadata, dependencies)
|
||||
subprocess.run([str(sdcc), *compile_flags, '--debug', '-S', '-o', str(asm), str(source)], check=True)
|
||||
if any(digest(Path(name)) != sha for name, sha in hashes.items()):
|
||||
raise ValueError('Исходники изменились во время компиляции')
|
||||
original = asm.read_text()
|
||||
# Включаем output stem: один исходник может собираться в разные банки.
|
||||
module = 'sdbg_' + hashlib.sha256((str(source) + ':' + output.stem).encode()).hexdigest()[:16]
|
||||
assembly, descriptions, markers = normalize(original, adb.read_text(), module)
|
||||
assembly, log_macros = anchor_symbols(assembly, module, macros)
|
||||
asm.write_text(assembly)
|
||||
adb.write_text(descriptions)
|
||||
subprocess.run([str(assembler), '-plosgffwy', str(output), str(asm)], check=True)
|
||||
# Комментарии SDCC сохраняют фактические пути строк, включая заголовки.
|
||||
sources = {}
|
||||
for name in dependencies:
|
||||
sources.setdefault(Path(name).name, set()).add(name)
|
||||
for filename in re.findall(r'^;(.+?):\d+:', original, re.M):
|
||||
path = Path(filename).resolve()
|
||||
if path.is_file():
|
||||
sources.setdefault(path.name, set()).add(str(path))
|
||||
write_json(output.with_suffix('.sdbg-unit.json'), {
|
||||
'module': module, 'source': str(source), 'source_sha256': digest(source),
|
||||
'asm': asm.name, 'adb': adb.name, 'object': output.name,
|
||||
'flags': compile_flags, 'markers': markers, 'log_macros': log_macros,
|
||||
'dependency_hashes': hashes,
|
||||
'sources': {k: sorted(v) for k, v in sources.items()},
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Честный минимальный Debug Adapter Protocol поверх постоянной sdbg-сессии."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .server import rpc_call
|
||||
from .session import SessionError
|
||||
|
||||
|
||||
class DapEngine:
|
||||
def __init__(self, rpc=None):
|
||||
self.rpc_path = None
|
||||
self._rpc_override = rpc
|
||||
self.attached = None
|
||||
self.event_sequence = 0
|
||||
|
||||
def rpc(self, method, arguments=None, timeout=10):
|
||||
if self._rpc_override is not None:
|
||||
return self._rpc_override(method, arguments or {})
|
||||
if not self.rpc_path:
|
||||
raise SessionError('DAP ещё не подключён к session server')
|
||||
return rpc_call(self.rpc_path, method, arguments, timeout)
|
||||
|
||||
def handle(self, command: str, arguments: dict) -> tuple[dict, list[tuple[str, dict]]]:
|
||||
events = []
|
||||
if command == 'initialize':
|
||||
body = {
|
||||
'supportsConfigurationDoneRequest': True,
|
||||
'supportsFunctionBreakpoints': True,
|
||||
'supportsInstructionBreakpoints': False,
|
||||
'supportsEvaluateForHovers': True,
|
||||
'supportsSetVariable': False,
|
||||
'supportsStepBack': False,
|
||||
'supportsRestartRequest': False,
|
||||
'supportsTerminateRequest': False,
|
||||
'supportsDisassembleRequest': False,
|
||||
'supportsSteppingGranularity': True,
|
||||
}
|
||||
return body, events
|
||||
if command == 'attach':
|
||||
if self._rpc_override is None:
|
||||
path = arguments.get('socket')
|
||||
if not isinstance(path, str) or not path:
|
||||
raise SessionError('В attach требуется socket session server')
|
||||
self.rpc_path = path
|
||||
self.attached = self.rpc('status')
|
||||
self.event_sequence = self.attached['event_sequence']
|
||||
events.append(('initialized', {}))
|
||||
return {'buildId': self.attached['build_id']}, events
|
||||
if command == 'configurationDone':
|
||||
location = self.rpc('where')
|
||||
events.append(('stopped', {'reason': 'entry', 'threadId': 1,
|
||||
'allThreadsStopped': True,
|
||||
'description': self._description(location)}))
|
||||
return {}, events
|
||||
if command == 'disconnect':
|
||||
return {}, events
|
||||
if command == 'threads':
|
||||
return {'threads': [{'id': 1, 'name': 'Sprinter Z80'}]}, events
|
||||
if command == 'stackTrace':
|
||||
location = self.rpc('where')
|
||||
return {'stackFrames': [self._frame(location)], 'totalFrames': 1}, events
|
||||
if command == 'scopes':
|
||||
return {'scopes': [
|
||||
{'name': 'Registers', 'variablesReference': 1, 'expensive': False},
|
||||
{'name': 'Globals/statics', 'variablesReference': 2, 'expensive': False},
|
||||
]}, events
|
||||
if command == 'variables':
|
||||
reference = int(arguments.get('variablesReference', 0))
|
||||
if reference == 1:
|
||||
values = self.rpc('registers')['registers']
|
||||
variables = [{'name': name, 'value': f'{value:#x}',
|
||||
'variablesReference': 0} for name, value in sorted(values.items())]
|
||||
elif reference == 2:
|
||||
variables = self._global_variables()
|
||||
else:
|
||||
variables = []
|
||||
return {'variables': variables}, events
|
||||
if command == 'evaluate':
|
||||
expression = arguments.get('expression', '')
|
||||
if not expression.isidentifier():
|
||||
raise SessionError('MVP evaluate принимает только имя переменной')
|
||||
value = self.rpc('read_variable', {'name': expression})
|
||||
return {'result': str(value['value']), 'type': value['type'],
|
||||
'variablesReference': 0, 'memoryReference': hex(value['link_address'])}, events
|
||||
if command == 'setBreakpoints':
|
||||
source = arguments.get('source', {})
|
||||
path = source.get('path')
|
||||
if not path:
|
||||
raise SessionError('Для source breakpoint нужен полный path')
|
||||
specifications = [{name: item[name] for name in
|
||||
('line','condition','hitCondition','logMessage') if name in item}
|
||||
for item in arguments.get('breakpoints', [])]
|
||||
result = self.rpc('set_source_breakpoints',
|
||||
{'file': path, 'breakpoints': specifications})
|
||||
return {'breakpoints': [self._breakpoint(item) for item in result['breakpoints']]}, events
|
||||
if command == 'setFunctionBreakpoints':
|
||||
names = [item['name'] for item in arguments.get('breakpoints', [])]
|
||||
result = self.rpc('set_function_breakpoints', {'names': names})
|
||||
return {'breakpoints': [self._breakpoint(item) for item in result['breakpoints']]}, events
|
||||
if command == 'continue':
|
||||
self.rpc('continue')
|
||||
return {'allThreadsContinued': True}, events
|
||||
if command == 'pause':
|
||||
self.rpc('pause')
|
||||
return {}, events
|
||||
if command in ('next', 'stepIn'):
|
||||
if arguments.get('granularity') == 'instruction':
|
||||
self.rpc('step')
|
||||
else:
|
||||
self.rpc('source_step', {
|
||||
'kind': 'over' if command == 'next' else 'into'})
|
||||
return {}, events
|
||||
if command == 'stepOut':
|
||||
self.rpc('source_step', {'kind': 'out'})
|
||||
return {}, events
|
||||
raise SessionError('DAP-команда пока не поддерживается: ' + command)
|
||||
|
||||
def poll_events(self, timeout=1):
|
||||
previous = self.event_sequence
|
||||
result = self.rpc('events', {'after': self.event_sequence, 'timeout': timeout},
|
||||
timeout=timeout + 2)
|
||||
self.event_sequence = result['last']
|
||||
translated = []
|
||||
gap = max(0, result.get('first', previous + 1) - previous - 1)
|
||||
if gap:
|
||||
translated.append(('output', {'category': 'stderr',
|
||||
'output': f'sdbg: пропущено {gap} событий журнала; '
|
||||
'уменьшите частоту logpoint\n'}))
|
||||
for event in result['events']:
|
||||
if event['event'] == 'stopped':
|
||||
translated.append(('stopped', {'reason': event['body'].get('reason', 'breakpoint'),
|
||||
'threadId': 1, 'allThreadsStopped': True,
|
||||
'description': self._description(
|
||||
event['body'].get('location', {}))}))
|
||||
elif event['event'] == 'continued':
|
||||
translated.append(('continued', {'threadId': 1, 'allThreadsContinued': True}))
|
||||
elif event['event'] == 'invalidated':
|
||||
translated.append(('terminated', {'restart': False}))
|
||||
elif event['event'] == 'output':
|
||||
translated.append(('output', {'category': event['body'].get('category','console'),
|
||||
'output': event['body']['output']}))
|
||||
return translated, result['closed']
|
||||
|
||||
@staticmethod
|
||||
def _description(location):
|
||||
status = location.get('status', 'unknown')
|
||||
bank = location.get('function', {}).get('bank') if location.get('function') else None
|
||||
suffix = '' if bank is None else f', bank {bank}'
|
||||
return f'{status}{suffix}, PC={location.get("pc", 0):#x}'
|
||||
|
||||
@staticmethod
|
||||
def _frame(location):
|
||||
function = location.get('function') or {}
|
||||
sources = location.get('sources') or []
|
||||
source = sources[0] if sources else None
|
||||
frame = {
|
||||
'id': 1,
|
||||
'name': function.get('name', '<unknown>') +
|
||||
(' [ambiguous]' if location.get('status') == 'ambiguous' else ''),
|
||||
'line': source['line'] if source else 1,
|
||||
'column': 1,
|
||||
'instructionPointerReference': hex(location.get('link_address', location.get('pc', 0))),
|
||||
}
|
||||
if source:
|
||||
frame['source'] = {'name': Path(source['file']).name, 'path': source['file']}
|
||||
return frame
|
||||
|
||||
@staticmethod
|
||||
def _breakpoint(item):
|
||||
locations = item.get('locations', [])
|
||||
first = locations[0] if locations else {}
|
||||
result = {'id': item['id'], 'verified': bool(item.get('verified', locations))}
|
||||
line = item.get('line', first.get('line'))
|
||||
if line is not None:
|
||||
result['line'] = line
|
||||
if first.get('link_address') is not None:
|
||||
result['instructionReference'] = hex(first['link_address'])
|
||||
if len(locations) > 1:
|
||||
result['message'] = f'Разрешено в {len(locations)} адресов/банков'
|
||||
return result
|
||||
|
||||
def _global_variables(self):
|
||||
result = []
|
||||
for variable in self.rpc('variables'):
|
||||
label = variable['name']
|
||||
if variable.get('module'):
|
||||
label += '@' + variable['module']
|
||||
try:
|
||||
value = self.rpc('read_variable', {'name': variable['name'],
|
||||
'module': variable.get('module')})
|
||||
text = str(value['value'])
|
||||
except SessionError as error:
|
||||
text = '<unavailable: ' + str(error) + '>'
|
||||
result.append({'name': label, 'value': text, 'type': variable['type'],
|
||||
'variablesReference': 0,
|
||||
'memoryReference': hex(variable['link_address'])})
|
||||
return result
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Строгий разбор Intel HEX для проверки образа, загруженного в MAME."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def read_ihx(path: Path) -> dict[int, int]:
|
||||
"""Возвращает разреженный образ и отвергает битые/конфликтующие записи."""
|
||||
memory: dict[int, int] = {}
|
||||
base = 0
|
||||
eof = False
|
||||
for number, raw in enumerate(Path(path).read_text().splitlines(), 1):
|
||||
if not raw.startswith(':'):
|
||||
raise ValueError(f'Intel HEX, строка {number}: отсутствует двоеточие')
|
||||
try:
|
||||
record = bytes.fromhex(raw[1:])
|
||||
except ValueError as error:
|
||||
raise ValueError(f'Intel HEX, строка {number}: неверные hex-цифры') from error
|
||||
if len(record) < 5 or len(record) != record[0] + 5:
|
||||
raise ValueError(f'Intel HEX, строка {number}: неверная длина')
|
||||
if sum(record) & 0xff:
|
||||
raise ValueError(f'Intel HEX, строка {number}: неверная контрольная сумма')
|
||||
length = record[0]
|
||||
address = int.from_bytes(record[1:3], 'big')
|
||||
kind = record[3]
|
||||
data = record[4:4 + length]
|
||||
if eof:
|
||||
raise ValueError(f'Intel HEX, строка {number}: данные после EOF')
|
||||
if kind == 0:
|
||||
for offset, value in enumerate(data):
|
||||
absolute = base + address + offset
|
||||
if absolute in memory and memory[absolute] != value:
|
||||
raise ValueError(f'Intel HEX: конфликт по адресу {absolute:#x}')
|
||||
memory[absolute] = value
|
||||
elif kind == 1:
|
||||
if length or address:
|
||||
raise ValueError(f'Intel HEX, строка {number}: неверная EOF-запись')
|
||||
eof = True
|
||||
elif kind == 2:
|
||||
if length != 2 or address:
|
||||
raise ValueError(f'Intel HEX, строка {number}: неверная segment-запись')
|
||||
base = int.from_bytes(data, 'big') << 4
|
||||
elif kind == 4:
|
||||
if length != 2 or address:
|
||||
raise ValueError(f'Intel HEX, строка {number}: неверная linear-запись')
|
||||
base = int.from_bytes(data, 'big') << 16
|
||||
elif kind not in (3, 5):
|
||||
raise ValueError(f'Intel HEX, строка {number}: неизвестный тип {kind}')
|
||||
if not eof:
|
||||
raise ValueError('Intel HEX: отсутствует EOF')
|
||||
return memory
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Извлекает авторские логи из активного препроцессорного потока SDCC."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import bisect
|
||||
import re
|
||||
import string
|
||||
from pathlib import Path
|
||||
|
||||
_DIRECTIVE = re.compile(r'^#(?:line)?\s+(\d+)\s+"([^"]+)"')
|
||||
_CALL = re.compile(r'\bSDBG_METADATA_(LOGIF|LOG)\s*\(')
|
||||
_IDENT = re.compile(r'[A-Za-z_][A-Za-z_0-9]*\Z')
|
||||
_LITERAL = re.compile(r'"(?:\\.|[^"\\])*"')
|
||||
|
||||
|
||||
def _calls(text: str):
|
||||
"""Находит metadata-вызовы вне строк/символьных литералов C."""
|
||||
position = 0
|
||||
while position < len(text):
|
||||
char = text[position]
|
||||
if char in ('"', "'"):
|
||||
quote = char
|
||||
position += 1
|
||||
while position < len(text):
|
||||
if text[position] == '\\': position += 2
|
||||
elif text[position] == quote:
|
||||
position += 1
|
||||
break
|
||||
else: position += 1
|
||||
continue
|
||||
match = _CALL.match(text, position)
|
||||
if match:
|
||||
yield match
|
||||
position = match.end()
|
||||
else:
|
||||
position += 1
|
||||
|
||||
|
||||
def _arguments(text: str, start: int) -> list[str]:
|
||||
"""Делит аргументы вызова после `(`, не путая запятые в строках."""
|
||||
args, begin, depth, quote, escape = [], start, 1, False, False
|
||||
for position in range(start, len(text)):
|
||||
char = text[position]
|
||||
if quote:
|
||||
if escape: escape = False
|
||||
elif char == '\\': escape = True
|
||||
elif char == '"': quote = False
|
||||
continue
|
||||
if char == '"': quote = True
|
||||
elif char == '(': depth += 1
|
||||
elif char == ')':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
args.append(text[begin:position].strip())
|
||||
return args
|
||||
elif char == ',' and depth == 1:
|
||||
args.append(text[begin:position].strip())
|
||||
begin = position + 1
|
||||
raise ValueError('Незакрытый вызов SDBG_LOG в препроцессорном потоке')
|
||||
|
||||
|
||||
def _message(value: str) -> str:
|
||||
literals = []
|
||||
position = 0
|
||||
while position < len(value):
|
||||
while position < len(value) and value[position].isspace(): position += 1
|
||||
if position == len(value): break
|
||||
match = _LITERAL.match(value, position)
|
||||
if not match:
|
||||
raise ValueError('SDBG_LOG: сообщение должно быть строковым литералом')
|
||||
try:
|
||||
decoded = ast.literal_eval(match.group())
|
||||
except (ValueError, SyntaxError) as error:
|
||||
raise ValueError('SDBG_LOG: неверный строковый литерал') from error
|
||||
if not isinstance(decoded, str):
|
||||
raise ValueError('SDBG_LOG: нужен обычный строковый литерал')
|
||||
literals.append(decoded)
|
||||
position = match.end()
|
||||
message = ''.join(literals)
|
||||
if not message or len(message) > 1024:
|
||||
raise ValueError('SDBG_LOG: сообщение должно содержать 1..1024 символа')
|
||||
validate_log_message(message)
|
||||
return message
|
||||
|
||||
|
||||
def validate_log_message(message: str) -> None:
|
||||
"""Одна грамматика для C-макроса и DAP logMessage."""
|
||||
if not isinstance(message, str) or not message or len(message) > 1024:
|
||||
raise ValueError('logMessage должен содержать 1..1024 символа')
|
||||
try:
|
||||
fields = list(string.Formatter().parse(message))
|
||||
except ValueError as error:
|
||||
raise ValueError('Неверные фигурные скобки logMessage') from error
|
||||
for _, name, spec, conversion in fields:
|
||||
if name is not None and (not _IDENT.fullmatch(name) or spec or conversion):
|
||||
raise ValueError('В logMessage разрешены только подстановки {variable}')
|
||||
|
||||
|
||||
def extract(preprocessed: str, dependencies: set[str]) -> list[dict]:
|
||||
"""Возвращает активные вызовы и проверенные исходные пути/строки."""
|
||||
lines = preprocessed.splitlines(keepends=True)
|
||||
offsets, indexed = [], []
|
||||
offset, filename, number = 0, None, 0
|
||||
for line in lines:
|
||||
offsets.append(offset)
|
||||
directive = _DIRECTIVE.match(line)
|
||||
if directive:
|
||||
filename, number = directive[2], int(directive[1])
|
||||
indexed.append((None, 0))
|
||||
else:
|
||||
indexed.append((filename, number))
|
||||
number += 1
|
||||
offset += len(line)
|
||||
found, tags = [], set()
|
||||
for match in _calls(preprocessed):
|
||||
index = bisect.bisect_right(offsets, match.start()) - 1
|
||||
filename, number = indexed[index]
|
||||
if not filename or number < 1:
|
||||
raise ValueError('SDBG_LOG: препроцессор не сохранил позицию исходника')
|
||||
source = str(Path(filename).resolve())
|
||||
if source not in dependencies:
|
||||
raise ValueError('SDBG_LOG: вызов вне проверенных исходников: ' + source)
|
||||
args = _arguments(preprocessed, match.end())
|
||||
expected = 3 if match[1] == 'LOGIF' else 2
|
||||
if len(args) != expected:
|
||||
raise ValueError('SDBG_LOG: неверное число аргументов')
|
||||
tag = args[0]
|
||||
if not _IDENT.fullmatch(tag) or tag in tags:
|
||||
raise ValueError('SDBG_LOG: tag должен быть уникальным идентификатором TU: ' + tag)
|
||||
tags.add(tag)
|
||||
condition = args[1] if expected == 3 else None
|
||||
if condition is not None and not _IDENT.fullmatch(condition):
|
||||
raise ValueError('SDBG_LOGIF: пока поддержано только имя переменной')
|
||||
found.append({'tag': tag, 'message': _message(args[-1]),
|
||||
'condition': condition, 'source': source, 'line': number})
|
||||
return found
|
||||
|
||||
|
||||
def anchor_symbols(assembly: str, module: str, macros: list[dict]) -> tuple[str, list[dict]]:
|
||||
"""Уникализирует asm-символы TU и требует ровно один якорь на macro tag."""
|
||||
descriptions = []
|
||||
for macro in macros:
|
||||
old = '_spr_sdbg_log_' + macro['tag']
|
||||
new = '_spr_sdbg_log_' + module + '_' + macro['tag']
|
||||
definition = re.compile(r'(?m)^(\s*)' + re.escape(old) + r'(\s*=\s*\.\s*)$')
|
||||
declaration = re.compile(r'(?m)^(\s*\.globl\s+)' + re.escape(old) + r'(\s*)$')
|
||||
if len(definition.findall(assembly)) != 1 or len(declaration.findall(assembly)) != 1:
|
||||
raise ValueError('SDBG_LOG: якорь отсутствует или развёрнут повторно: ' + macro['tag'])
|
||||
assembly = definition.sub(lambda m: m[1] + new + m[2], assembly)
|
||||
assembly = declaration.sub(lambda m: m[1] + new + m[2], assembly)
|
||||
descriptions.append({**macro, 'symbol': new})
|
||||
return assembly, descriptions
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Проверенная оффлайновая карта SDCC; неизвестные диапазоны не угадываются."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import asdict, dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from .build import digest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Location:
|
||||
link_address: int
|
||||
logical_address: int
|
||||
section: str
|
||||
bank: int | None
|
||||
window: int | None
|
||||
|
||||
|
||||
class DebugMap:
|
||||
def __init__(self, directory):
|
||||
self.directory = Path(directory).resolve()
|
||||
self.manifest = json.loads((self.directory/'manifest.json').read_text())
|
||||
if self.manifest['schema_version'] != 1:
|
||||
raise ValueError('Неподдержанная версия пакета')
|
||||
for name, expected in self.manifest['artifacts'].items():
|
||||
path = (self.directory/name).resolve()
|
||||
if not path.is_relative_to(self.directory) or digest(path) != expected:
|
||||
raise ValueError('Повреждённый артефакт: ' + name)
|
||||
self.stale_sources = [name for name, value in self.manifest['sources'].items()
|
||||
if not Path(name).is_file() or digest(Path(name)) != value['sha256']]
|
||||
stem = Path(self.manifest['executable']).stem
|
||||
self.symbols = {}
|
||||
for row in (self.directory/(stem+'.noi')).read_text().splitlines():
|
||||
match = re.fullmatch(r'DEF (\S+) (0x[0-9A-Fa-f]+)', row)
|
||||
if match:
|
||||
self.symbols[match[1]] = int(match[2], 16)
|
||||
self.sections = []
|
||||
for row in (self.directory/(stem+'.map')).read_text().splitlines():
|
||||
match = re.match(r'^(\S+)\s+([0-9A-F]{8})\s+([0-9A-F]{8})\s+=', row)
|
||||
if match and int(match[3],16):
|
||||
item = (match[1], int(match[2],16), int(match[3],16))
|
||||
if item not in self.sections: self.sections.append(item)
|
||||
self.units = {u['module']: u for u in self.manifest['units']}
|
||||
self.instructions = {}
|
||||
self.markers = []
|
||||
self.functions = []
|
||||
self.variables = []
|
||||
self.logpoints = []
|
||||
self.diagnostics = []
|
||||
self._load((self.directory/(stem+'.cdb')).read_text().splitlines())
|
||||
|
||||
def verify_executable(self):
|
||||
path = Path(self.manifest['executable_path'])
|
||||
if not path.is_file() or digest(path) != self.manifest.get('executable_sha256', self.manifest['build_id']):
|
||||
raise ValueError('EXE не соответствует пакету')
|
||||
|
||||
def location(self, address):
|
||||
sections = [s for s in self.sections if s[1] <= address < s[1]+s[2]]
|
||||
if len(sections) != 1:
|
||||
raise ValueError(f'Неоднозначная/неизвестная секция адреса {address:#x}')
|
||||
section = sections[0][0]
|
||||
match = re.fullmatch(r'_?BANK(\d+)', section)
|
||||
bank = int(match[1]) if match else None
|
||||
if address > 0xffff and (bank is None or address >> 16 != bank):
|
||||
raise ValueError(f'Неподдержанное размещение {section}: {address:#x}')
|
||||
logical = address & 0xffff
|
||||
return asdict(Location(address, logical, section, bank, logical >> 14))
|
||||
|
||||
def _load(self, records):
|
||||
addresses, declarations = {}, []
|
||||
module = None
|
||||
for record in records:
|
||||
if record.startswith('M:'): module = record[2:]
|
||||
elif record.startswith('L:'):
|
||||
name, value = record[2:].rsplit(':', 1)
|
||||
address = int(value, 16)
|
||||
if name in addresses and addresses[name] != address:
|
||||
raise ValueError('Конфликт отладочного символа: ' + name)
|
||||
addresses[name] = address
|
||||
elif record.startswith(('F:', 'S:')):
|
||||
declarations.append((module, record))
|
||||
elif record and not record.startswith('T:'):
|
||||
self.diagnostics.append('Неизвестная запись: '+record)
|
||||
# A$ у ассемблера использует basename файла, а не .module.
|
||||
for unit in self.units.values():
|
||||
asm = (self.directory/unit['asm']).read_text().splitlines()
|
||||
sizes = {}
|
||||
listing = (self.directory/Path(unit['asm']).with_suffix('.lst')).read_text()
|
||||
for row in listing.splitlines():
|
||||
match = re.match(r'^\s+[0-9A-F]{6,8}\s+(.+?)\s+\[\s*\d+\]\s+(\d+)\s', row)
|
||||
if match:
|
||||
sizes[int(match[2])] = len(re.findall(r'[0-9A-F]{2}', match[1]))
|
||||
prefix = 'A$'+Path(unit['asm']).stem+'$'
|
||||
for symbol, address in addresses.items():
|
||||
if symbol.startswith(prefix):
|
||||
line = int(symbol[len(prefix):])
|
||||
if line in sizes and sizes[line] > 0:
|
||||
self.instructions[address] = {
|
||||
**self.location(address), 'size': sizes[line],
|
||||
'asm': unit['asm'], 'asm_line': line, 'text': asm[line-1].strip(),
|
||||
'module': unit['module'],
|
||||
}
|
||||
for symbol, marker in unit['markers'].items():
|
||||
if symbol not in addresses:
|
||||
raise ValueError('Отсутствует linked CDB-маркер: '+symbol)
|
||||
paths = unit['sources'].get(Path(marker['file']).name, [])
|
||||
if len(paths) > 1:
|
||||
raise ValueError('Неоднозначный путь debug-записи: ' + marker['file'])
|
||||
self.markers.append({**self.location(addresses[symbol]),
|
||||
'line': marker['line'], 'sources': paths,
|
||||
'module': unit['module']})
|
||||
for module, record in declarations:
|
||||
match = re.match(r'([FS]):([^($]+\$[^($]+\$[^($]+\$[^($]+)\(\{(\d+)\}(.+)\),([A-Z]),', record)
|
||||
if not match: continue
|
||||
kind, key, size, ctype, space = match.groups()
|
||||
parts = key.split('$')
|
||||
if kind == 'F':
|
||||
startkey = '$'.join(parts[:2])+'$0$0'
|
||||
start, last = addresses.get(startkey), addresses.get('X'+startkey)
|
||||
if start is None or last not in self.instructions: continue
|
||||
end = last + self.instructions[last]['size']
|
||||
if end <= start: continue
|
||||
function = {'name': parts[1], 'module': module, 'start': start,
|
||||
'end': end, **self.location(start)}
|
||||
if function not in self.functions: self.functions.append(function)
|
||||
elif space == 'E' and parts[0].startswith(('G','F')) and not ctype.startswith('DF,'):
|
||||
address = addresses.get(key)
|
||||
if address is None and parts[0] == 'G': address = self.symbols.get('_'+parts[1])
|
||||
if address is None: continue
|
||||
supported = bool(re.fullmatch(r'S[ICL]:[SU]', ctype) or ctype.startswith('DG,'))
|
||||
variable = {'name': parts[1], 'module': module if parts[0] != 'G' else None,
|
||||
'size': int(size), 'type': ctype,
|
||||
'signed': ctype.endswith(':S') and not ctype.startswith('D'),
|
||||
'supported': supported, **self.location(address)}
|
||||
if variable not in self.variables: self.variables.append(variable)
|
||||
self.functions.sort(key=lambda f: f['start'])
|
||||
self.markers.sort(key=lambda m: m['link_address'])
|
||||
for unit in self.units.values():
|
||||
for macro in unit.get('log_macros', []):
|
||||
address = self.symbols.get(macro['symbol'])
|
||||
if address is None:
|
||||
raise ValueError('Связанный SDBG_LOG-якорь отсутствует: ' + macro['tag'])
|
||||
try:
|
||||
location = self.location(address)
|
||||
verified = address in self.instructions and self.function_at(address) is not None
|
||||
reason = None if verified else 'Якорь не совпал с началом исполняемой инструкции'
|
||||
except ValueError as error:
|
||||
location, verified, reason = {}, False, str(error)
|
||||
self.logpoints.append({**macro, **location, 'module': unit['module'],
|
||||
'verified': verified, 'reason': reason})
|
||||
|
||||
def function_at(self, address):
|
||||
found = [f for f in self.functions if f['start'] <= address < f['end']]
|
||||
return found[0] if len(found) == 1 else None
|
||||
|
||||
def addr2line(self, address):
|
||||
function = self.function_at(address)
|
||||
instruction = next((v for k,v in self.instructions.items()
|
||||
if k <= address < k+v['size']), None)
|
||||
if not function or not instruction:
|
||||
return {'address': address, 'status': 'unknown', 'function': function}
|
||||
markers = [m for m in self.markers if m['module'] == function['module']
|
||||
and function['start'] <= m['link_address'] <= instruction['link_address']]
|
||||
nearest = max((m['link_address'] for m in markers), default=None)
|
||||
sources = []
|
||||
for marker in markers:
|
||||
if marker['link_address'] == nearest:
|
||||
for source in marker['sources']:
|
||||
item = {'file': source, 'line': marker['line']}
|
||||
if item not in sources: sources.append(item)
|
||||
return {'status': 'mapped' if len(sources) == 1 else 'ambiguous' if sources else 'unknown',
|
||||
'instruction': instruction, 'function': function, 'sources': sources, 'stale_source': any(s['file'] in self.stale_sources for s in sources)}
|
||||
|
||||
def line_locations(self, filename, line):
|
||||
exact = str(Path(filename).resolve())
|
||||
known = self.manifest['sources']
|
||||
candidates = [exact] if exact in known else [p for p in known if Path(p).name == filename]
|
||||
if len(candidates) > 1: raise ValueError('Неоднозначный source; укажите полный путь')
|
||||
result = []
|
||||
for marker in self.markers:
|
||||
address = marker['link_address']
|
||||
if marker['line'] == line and any(p in candidates for p in marker['sources']):
|
||||
if address in self.instructions and self.function_at(address):
|
||||
item = {**marker, 'function': self.function_at(address)['name']}
|
||||
if item not in result: result.append(item)
|
||||
stale = any(p in self.stale_sources for p in candidates)
|
||||
return {'status': 'stale' if stale else 'verified' if result else 'unverified',
|
||||
'locations': result, 'stale_source': stale}
|
||||
|
||||
def source_text(self, filename, line):
|
||||
source = self.manifest['sources'][filename]
|
||||
text = (self.directory/source['snapshot']).read_text(errors='replace').splitlines()
|
||||
return text[line-1] if 0 < line <= len(text) else None
|
||||
@@ -0,0 +1,464 @@
|
||||
"""Постоянный владелец DebugSession и локальный JSON-RPC для адаптеров."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import socketserver
|
||||
import string
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .session import DebugSession, SessionError
|
||||
from .transport import BridgeError
|
||||
from .macros import validate_log_message
|
||||
|
||||
|
||||
class SessionController:
|
||||
def __init__(self, session: DebugSession):
|
||||
self.session = session
|
||||
self.lock = threading.RLock()
|
||||
self.changed = threading.Condition()
|
||||
self.events = deque(maxlen=1024)
|
||||
self.dropped_events = 0
|
||||
self.sequence = 0
|
||||
self.running = False
|
||||
self.source_step = None
|
||||
self.closed = False
|
||||
self.source_breakpoints: dict[str, list[int]] = {}
|
||||
self.function_breakpoints: list[int] = []
|
||||
self.breakpoint_info: dict[int, dict] = {}
|
||||
self.attached = session.attach()
|
||||
self.mame_console = bool(self.attached.get('capabilities', {}).get('console_print'))
|
||||
self._emit('stopped', {'reason': 'entry', 'location': self.attached['location']})
|
||||
self._install_macro_logs()
|
||||
self.poller = threading.Thread(target=self._poll, name='sdbg-poller', daemon=True)
|
||||
self.poller.start()
|
||||
|
||||
def _install_macro_logs(self) -> None:
|
||||
"""Авторские точки принадлежат пакету сборки, не DAP source-набору."""
|
||||
created = []
|
||||
try:
|
||||
for macro in getattr(self.session.model, 'logpoints', []):
|
||||
if not macro['verified'] or macro['source'] in self.session.model.stale_sources:
|
||||
self._emit('output', {'category': 'stderr',
|
||||
'output': 'sdbg: SDBG_LOG ' + macro['tag'] +
|
||||
' не активирован: ' + str(macro.get('reason') or 'устаревший исходник') + '\n'})
|
||||
continue
|
||||
self._validate_log_message(macro['message'])
|
||||
item = self.session.break_anchor(macro, enabled=False)
|
||||
created.append(item['id'])
|
||||
self.breakpoint_info[item['id']] = {
|
||||
'kind': 'log', 'message': macro['message'],
|
||||
'condition': macro['condition'], 'module': macro['module'],
|
||||
'tag': macro['tag'], 'locations': item['locations'], 'hits': 0,
|
||||
}
|
||||
if created:
|
||||
self.session.activate_breakpoints()
|
||||
except BaseException:
|
||||
for identifier in created:
|
||||
self.breakpoint_info.pop(identifier, None)
|
||||
try: self.session.clear_breakpoint(identifier)
|
||||
except BaseException: pass
|
||||
raise
|
||||
|
||||
def _emit(self, name: str, body: dict) -> None:
|
||||
with self.changed:
|
||||
if len(self.events) == self.events.maxlen:
|
||||
self.dropped_events += 1
|
||||
self.sequence += 1
|
||||
self.events.append({'seq': self.sequence, 'event': name, 'body': body})
|
||||
self.changed.notify_all()
|
||||
|
||||
def _poll(self) -> None:
|
||||
while not self.closed:
|
||||
if not self.running:
|
||||
time.sleep(.02)
|
||||
continue
|
||||
try:
|
||||
with self.lock:
|
||||
snapshot = self.session.bridge.request('snapshot')
|
||||
if snapshot['state'] == 'stopped':
|
||||
location = self.session.where(snapshot)
|
||||
if self.source_step is not None:
|
||||
self._source_step_stopped(location)
|
||||
elif self._handle_logpoints(location):
|
||||
self.session.bridge.request('continue')
|
||||
continue
|
||||
else:
|
||||
self.running = False
|
||||
self._emit('stopped', {'reason': 'breakpoint', 'location': location})
|
||||
elif snapshot['state'] == 'invalidated':
|
||||
self.running = False
|
||||
self.source_step = None
|
||||
self.closed = True
|
||||
self._emit('invalidated', {'reason': 'reset_or_load'})
|
||||
except (BridgeError, SessionError, ValueError, OSError) as error:
|
||||
self.running = False
|
||||
self.source_step = None
|
||||
self.closed = True
|
||||
self._emit('invalidated', {'reason': str(error)})
|
||||
time.sleep(.01)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
self.poller.join(timeout=1)
|
||||
self.session.bridge.close()
|
||||
|
||||
def call(self, method: str, arguments: dict) -> object:
|
||||
if method == 'events':
|
||||
after = int(arguments.get('after', 0))
|
||||
timeout = min(max(float(arguments.get('timeout', 0)), 0), 30)
|
||||
deadline = time.monotonic() + timeout
|
||||
with self.changed:
|
||||
while self.sequence <= after and not self.closed and time.monotonic() < deadline:
|
||||
self.changed.wait(deadline - time.monotonic())
|
||||
return {'events': [event for event in self.events if event['seq'] > after],
|
||||
'first': self.events[0]['seq'] if self.events else self.sequence + 1,
|
||||
'last': self.sequence, 'lost': self.dropped_events,
|
||||
'closed': self.closed}
|
||||
with self.lock:
|
||||
if self.closed:
|
||||
raise SessionError('Сессия закрыта или инвалидирована')
|
||||
if method == 'status':
|
||||
return {**self.attached, 'running': self.running,
|
||||
'event_sequence': self.sequence}
|
||||
if method == 'snapshot':
|
||||
return self.session.bridge.request('snapshot')
|
||||
if method == 'mame_console_tail':
|
||||
return self.session.bridge.request(
|
||||
'console_tail', count=int(arguments.get('count', 40)))
|
||||
if method == 'input_key':
|
||||
if not self.running and arguments['down'] is not False:
|
||||
raise SessionError('Нажатие возможно только при running CPU')
|
||||
return self.session.bridge.request(
|
||||
'key', tag=arguments['tag'], mask=int(arguments['mask']),
|
||||
down=arguments['down'])
|
||||
if method == 'where':
|
||||
return self.session.where()
|
||||
if method == 'registers':
|
||||
mapping = self.session.refresh()
|
||||
return {'generation': mapping.generation, 'registers': mapping.registers,
|
||||
'bank_pages': mapping.bank_pages}
|
||||
if method == 'variables':
|
||||
return self.session.model.variables
|
||||
if method == 'read_variable':
|
||||
return self.session.read_variable(arguments['name'], arguments.get('module'))
|
||||
if method == 'break_line':
|
||||
return self.session.break_line(arguments['file'], int(arguments['line']))
|
||||
if method == 'break_function':
|
||||
return self.session.break_function(arguments['name'])
|
||||
if method == 'clear_breakpoint':
|
||||
return self.session.clear_breakpoint(int(arguments['id']))
|
||||
if method == 'set_source_breakpoints':
|
||||
specifications = arguments.get('breakpoints')
|
||||
if specifications is None:
|
||||
specifications = [{'line': line} for line in arguments.get('lines', [])]
|
||||
return self._set_source_breakpoints(arguments['file'], specifications)
|
||||
if method == 'set_function_breakpoints':
|
||||
return self._set_function_breakpoints(arguments.get('names', []))
|
||||
if method == 'continue':
|
||||
if self.running:
|
||||
raise SessionError('CPU уже выполняется; сначала Pause')
|
||||
result = self.session.bridge.request('continue')
|
||||
self.running = True
|
||||
self._emit('continued', {})
|
||||
return result
|
||||
if method == 'pause':
|
||||
self.source_step = None
|
||||
self.session.bridge.request('pause')
|
||||
location = self.session.where(self.session.bridge.wait_stopped())
|
||||
self.running = False
|
||||
self._emit('stopped', {'reason': 'pause', 'location': location})
|
||||
return location
|
||||
if method == 'step':
|
||||
if self.running:
|
||||
raise SessionError('CPU уже выполняется; сначала Pause')
|
||||
self.session.bridge.request('step')
|
||||
self.running = True
|
||||
self._emit('continued', {'reason': 'step'})
|
||||
location = self.session.where(self.session.bridge.wait_stopped())
|
||||
self.running = False
|
||||
self._emit('stopped', {'reason': 'step', 'location': location})
|
||||
return location
|
||||
if method == 'source_step':
|
||||
return self._source_step(arguments.get('kind', 'into'))
|
||||
raise SessionError('Неизвестный RPC-метод: ' + method)
|
||||
|
||||
@staticmethod
|
||||
def _source_identity(location: dict) -> frozenset[tuple[str, int]]:
|
||||
return frozenset((item['file'], int(item['line']))
|
||||
for item in location.get('sources', []))
|
||||
|
||||
def _has_stop_breakpoint(self, location: dict) -> bool:
|
||||
address = location.get('link_address')
|
||||
return any(info['kind'] == 'stop' and
|
||||
any(item.get('link_address') == address for item in info['locations'])
|
||||
for info in self.breakpoint_info.values())
|
||||
|
||||
def _source_step(self, kind: str) -> dict:
|
||||
commands = {'into': 'step', 'over': 'step_over', 'out': 'step_out'}
|
||||
if kind not in commands:
|
||||
raise SessionError('Неизвестный вид source step')
|
||||
if self.running:
|
||||
raise SessionError('CPU уже выполняется; сначала Pause')
|
||||
start = self.session.where()
|
||||
if start.get('stale_source'):
|
||||
raise SessionError('Исходник изменён после сборки; пересоберите программу')
|
||||
initial = self._source_identity(start)
|
||||
if not initial:
|
||||
raise SessionError('Текущий PC не имеет проверенной C-позиции')
|
||||
self.source_step = {
|
||||
'initial': initial, 'kind': kind, 'command': commands[kind],
|
||||
'instructions': 1, 'location': start,
|
||||
}
|
||||
try:
|
||||
self.session.bridge.request(commands[kind])
|
||||
except BaseException:
|
||||
self.source_step = None
|
||||
raise
|
||||
if kind == 'out':
|
||||
# Первый out выходит из машинного frame. У банкового вызова
|
||||
# дальше идём через over до первой позиции C вызывающей функции.
|
||||
self.source_step['command'] = 'step_over'
|
||||
self.running = True
|
||||
self._emit('continued', {'reason': 'step'})
|
||||
return {'accepted': True}
|
||||
|
||||
def _source_step_stopped(self, location: dict) -> None:
|
||||
step = self.source_step
|
||||
self._handle_logpoints(location)
|
||||
reason = None
|
||||
if self._has_stop_breakpoint(location):
|
||||
reason = 'breakpoint'
|
||||
elif self._source_identity(location) and self._source_identity(location) != step['initial']:
|
||||
reason = 'step'
|
||||
elif step['instructions'] >= 512:
|
||||
reason = 'step'
|
||||
self._emit('output', {'category': 'stderr',
|
||||
'output': 'sdbg: source step достиг лимита; CPU остановлен\n'})
|
||||
if reason is not None:
|
||||
self.source_step = None
|
||||
self.running = False
|
||||
self._emit('stopped', {'reason': reason, 'location': location})
|
||||
return
|
||||
# Машинный over может ждать клавишу сколь угодно долго. Пока CPU
|
||||
# выполняется, этот автомат не занимает session lock и не ставит
|
||||
# таймер; пользователь может направить ввод в MAME или нажать Pause.
|
||||
self.session.bridge.request(step['command'])
|
||||
step['instructions'] += 1
|
||||
step['location'] = location
|
||||
|
||||
def _set_source_breakpoints(self, filename: str, specifications) -> dict:
|
||||
filename = str(Path(filename).resolve())
|
||||
requested = []
|
||||
for value in specifications:
|
||||
if not isinstance(value, dict):
|
||||
raise SessionError('Описание breakpoint должно быть объектом')
|
||||
line = int(value['line'])
|
||||
if line <= 0 or any(item['line'] == line for item in requested):
|
||||
raise SessionError('Номер строки должен быть положительным и уникальным')
|
||||
if value.get('condition') or value.get('hitCondition'):
|
||||
raise SessionError('Условия и hitCondition пока не поддержаны')
|
||||
message = value.get('logMessage')
|
||||
if message is not None:
|
||||
self._validate_log_message(message)
|
||||
requested.append({'line': line, 'logMessage': message})
|
||||
created = []
|
||||
results = []
|
||||
try:
|
||||
for specification in requested:
|
||||
line = specification['line']
|
||||
item = self.session.break_line(filename, line, enabled=False)
|
||||
created.append(item['id'])
|
||||
kind = 'log' if specification['logMessage'] is not None else 'stop'
|
||||
self.breakpoint_info[item['id']] = {
|
||||
'kind': kind, 'message': specification['logMessage'],
|
||||
'locations': item['locations'], 'hits': 0,
|
||||
}
|
||||
results.append({'line': line, 'verified': True,
|
||||
'logMessage': specification['logMessage'], **item})
|
||||
except BaseException:
|
||||
for identifier in created:
|
||||
self.breakpoint_info.pop(identifier, None)
|
||||
try:
|
||||
self.session.clear_breakpoint(identifier)
|
||||
except BaseException:
|
||||
pass
|
||||
raise
|
||||
previous = self.source_breakpoints.get(filename, [])
|
||||
for identifier in previous:
|
||||
self.breakpoint_info.pop(identifier, None)
|
||||
self.session.clear_breakpoint(identifier)
|
||||
self.source_breakpoints[filename] = created
|
||||
self.session.activate_breakpoints()
|
||||
return {'file': filename, 'breakpoints': results}
|
||||
|
||||
def _set_function_breakpoints(self, names) -> dict:
|
||||
requested = []
|
||||
for value in names:
|
||||
name = str(value)
|
||||
if not name or name in requested:
|
||||
raise SessionError('Имя функции должно быть непустым и уникальным')
|
||||
requested.append(name)
|
||||
created = []
|
||||
results = []
|
||||
try:
|
||||
for name in requested:
|
||||
item = self.session.break_function(name, enabled=False)
|
||||
created.append(item['id'])
|
||||
self.breakpoint_info[item['id']] = {
|
||||
'kind': 'stop', 'message': None, 'locations': item['locations'], 'hits': 0}
|
||||
results.append({'name': name, 'verified': True, **item})
|
||||
except BaseException:
|
||||
for identifier in created:
|
||||
self.breakpoint_info.pop(identifier, None)
|
||||
try:
|
||||
self.session.clear_breakpoint(identifier)
|
||||
except BaseException:
|
||||
pass
|
||||
raise
|
||||
for identifier in self.function_breakpoints:
|
||||
self.breakpoint_info.pop(identifier, None)
|
||||
self.session.clear_breakpoint(identifier)
|
||||
self.function_breakpoints = created
|
||||
self.session.activate_breakpoints()
|
||||
return {'breakpoints': results}
|
||||
|
||||
@staticmethod
|
||||
def _validate_log_message(message: str) -> None:
|
||||
try:
|
||||
validate_log_message(message)
|
||||
except ValueError as error:
|
||||
raise SessionError(str(error)) from error
|
||||
|
||||
def _read_log_variable(self, name: str, module: str | None) -> dict:
|
||||
if module is None:
|
||||
return self.session.read_variable(name)
|
||||
candidates = [item for item in self.session.model.variables
|
||||
if item['name'] == name and item['module'] in (None, module)]
|
||||
if len(candidates) != 1:
|
||||
raise SessionError('Переменная не найдена или имя неоднозначно')
|
||||
return self.session.read_variable(name, candidates[0]['module'])
|
||||
|
||||
def _render_log_message(self, message: str, module: str | None = None) -> str:
|
||||
output = []
|
||||
for literal, name, _, _ in string.Formatter().parse(message):
|
||||
output.append(literal)
|
||||
if name is not None:
|
||||
try:
|
||||
output.append(str(self._read_log_variable(name, module)['value']))
|
||||
except SessionError as error:
|
||||
output.append('<unavailable: ' + str(error) + '>')
|
||||
return ''.join(output)
|
||||
|
||||
def _handle_logpoints(self, location: dict) -> bool:
|
||||
address = location.get('link_address')
|
||||
matched_logs = []
|
||||
matched_any_log = False
|
||||
matched_stop = False
|
||||
for info in self.breakpoint_info.values():
|
||||
if not any(item.get('link_address') == address for item in info['locations']):
|
||||
continue
|
||||
if info['kind'] == 'stop':
|
||||
matched_stop = True
|
||||
else:
|
||||
matched_any_log = True
|
||||
condition = info.get('condition')
|
||||
if condition is not None:
|
||||
try:
|
||||
if not self._read_log_variable(condition, info.get('module'))['value']:
|
||||
continue
|
||||
except SessionError as error:
|
||||
if not info.get('condition_warned'):
|
||||
info['condition_warned'] = True
|
||||
self._emit('output', {'category': 'stderr',
|
||||
'output': 'sdbg: SDBG_LOGIF ' + info.get('tag', '') +
|
||||
': ' + str(error) + '\n'})
|
||||
continue
|
||||
info['hits'] += 1
|
||||
matched_logs.append(info)
|
||||
for info in matched_logs:
|
||||
rendered = self._render_log_message(info['message'], info.get('module'))
|
||||
self._emit('output', {'category': 'console',
|
||||
'output': rendered + '\n',
|
||||
'location': location, 'hit': info['hits'],
|
||||
'tag': info.get('tag')})
|
||||
if self.mame_console:
|
||||
try:
|
||||
self.session.bridge.request('console_print', text=rendered)
|
||||
except (BridgeError, OSError) as error:
|
||||
if not info.get('console_warned'):
|
||||
info['console_warned'] = True
|
||||
self._emit('output', {'category': 'stderr',
|
||||
'output': 'sdbg: MAME console: ' + str(error) + '\n'})
|
||||
return matched_any_log and not matched_stop
|
||||
|
||||
|
||||
class _ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
class SessionRpcServer:
|
||||
def __init__(self, path, controller: SessionController):
|
||||
self.path = Path(path)
|
||||
self.controller = controller
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if self.path.exists():
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX) as probe:
|
||||
probe.connect(str(self.path))
|
||||
except OSError:
|
||||
self.path.unlink()
|
||||
else:
|
||||
raise OSError('RPC socket уже занят: ' + str(self.path))
|
||||
controller_ref = controller
|
||||
|
||||
class Handler(socketserver.StreamRequestHandler):
|
||||
def handle(self):
|
||||
raw = self.rfile.readline(1_048_577)
|
||||
response = {'id': None, 'ok': False}
|
||||
try:
|
||||
if len(raw) > 1_048_576:
|
||||
raise ValueError('RPC-запрос слишком велик')
|
||||
request = json.loads(raw)
|
||||
if not isinstance(request, dict) or not isinstance(request.get('method'), str):
|
||||
raise ValueError('Неверная структура RPC-запроса')
|
||||
arguments = request.get('arguments', {})
|
||||
if not isinstance(arguments, dict):
|
||||
raise ValueError('RPC arguments должен быть объектом')
|
||||
response = {'id': request.get('id'), 'ok': True,
|
||||
'result': controller_ref.call(request['method'], arguments)}
|
||||
except (BridgeError, SessionError, ValueError, TypeError, OSError, KeyError) as error:
|
||||
response.update(error=str(error))
|
||||
self.wfile.write((json.dumps(response, ensure_ascii=False) + '\n').encode())
|
||||
|
||||
self.server = _ThreadedUnixServer(str(self.path), Handler)
|
||||
self.path.chmod(0o600)
|
||||
|
||||
def serve_forever(self):
|
||||
try:
|
||||
self.server.serve_forever(poll_interval=.1)
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
self.server.server_close()
|
||||
self.path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def rpc_call(path, method: str, arguments=None, timeout=10):
|
||||
request = {'id': 1, 'method': method, 'arguments': arguments or {}}
|
||||
with socket.socket(socket.AF_UNIX) as client:
|
||||
client.settimeout(timeout)
|
||||
client.connect(str(path))
|
||||
client.sendall((json.dumps(request, ensure_ascii=False) + '\n').encode())
|
||||
file = client.makefile('rb')
|
||||
raw = file.readline(1_048_577)
|
||||
if not raw:
|
||||
raise SessionError('RPC server закрыл соединение без ответа')
|
||||
response = json.loads(raw)
|
||||
if not response.get('ok'):
|
||||
raise SessionError(response.get('error', 'Неизвестная RPC-ошибка'))
|
||||
return response['result']
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Высокоуровневая source-debug сессия поверх проверенной карты и IPC."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .image import read_ihx
|
||||
from .model import DebugMap
|
||||
from .transport import FileBridge
|
||||
|
||||
|
||||
class SessionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MappingSnapshot:
|
||||
generation: int
|
||||
registers: dict[str, int]
|
||||
bank_pages: dict[int, int]
|
||||
|
||||
|
||||
class DebugSession:
|
||||
"""Связывает build identity, состояние CPU и логические операции IDE."""
|
||||
|
||||
def __init__(self, model: DebugMap, bridge: FileBridge):
|
||||
self.model = model
|
||||
self.bridge = bridge
|
||||
self.mapping: MappingSnapshot | None = None
|
||||
self.resident_pages: dict[int, int] = {}
|
||||
self.breakpoints: dict[int, list[int]] = {}
|
||||
self._next_breakpoint = 1
|
||||
stem = Path(model.manifest['executable']).stem
|
||||
self.image = read_ihx(model.directory / (stem + '.ihx'))
|
||||
|
||||
def attach(self) -> dict:
|
||||
"""Проверяет пакет, остановку CPU, резидентный код и bank mapping."""
|
||||
try:
|
||||
self.model.verify_executable()
|
||||
hello = self.bridge.handshake()
|
||||
snapshot = self.bridge.wait_stopped()
|
||||
mapping = self._mapping(snapshot)
|
||||
if mapping.bank_pages and not hello.get('capabilities', {}).get('bank_guard'):
|
||||
raise SessionError('Backend не поддерживает безопасные банковские точки')
|
||||
self._verify_loaded_code(mapping)
|
||||
except (ValueError, OSError) as error:
|
||||
raise SessionError(str(error)) from error
|
||||
self.mapping = mapping
|
||||
self._remember_resident_pages(mapping)
|
||||
return {
|
||||
'build_id': self.model.manifest['build_id'],
|
||||
'state': snapshot['state'],
|
||||
'protocol': hello['protocol'],
|
||||
'capabilities': hello.get('capabilities', {}),
|
||||
'generation': mapping.generation,
|
||||
'pc': mapping.registers['PC'],
|
||||
'bank_pages': mapping.bank_pages,
|
||||
'stale_sources': self.model.stale_sources,
|
||||
'location': self.where(snapshot),
|
||||
}
|
||||
|
||||
def refresh(self) -> MappingSnapshot:
|
||||
snapshot = self.bridge.wait_stopped()
|
||||
self.mapping = self._mapping(snapshot)
|
||||
self._remember_resident_pages(self.mapping)
|
||||
self._verify_loaded_code(self.mapping)
|
||||
return self.mapping
|
||||
|
||||
def _remember_resident_pages(self, mapping: MappingSnapshot) -> None:
|
||||
bank_values = set(mapping.bank_pages.values())
|
||||
windows = {instruction['window'] for instruction in self.model.instructions.values()
|
||||
if instruction['bank'] is not None}
|
||||
for window in windows:
|
||||
value = self._page(mapping, window)
|
||||
if value is not None and value not in bank_values:
|
||||
self.resident_pages[window] = value
|
||||
|
||||
@staticmethod
|
||||
def _page(mapping: MappingSnapshot, window: int) -> int | None:
|
||||
"""Sprinter state PG может включать старшие флаги; port хранит byte."""
|
||||
value = mapping.registers.get(f'PG{window}')
|
||||
return None if value is None else value & 0xff
|
||||
|
||||
def _mapping(self, snapshot: dict) -> MappingSnapshot:
|
||||
registers = snapshot.get('registers')
|
||||
if snapshot.get('state') != 'stopped' or not isinstance(registers, dict):
|
||||
raise SessionError('Backend не предоставил регистры остановленного CPU')
|
||||
if 'PC' not in registers:
|
||||
raise SessionError('Backend не предоставил PC')
|
||||
maximum = max((location['bank'] or 0 for location in
|
||||
[*self.model.functions, *self.model.variables,
|
||||
*self.model.instructions.values()]), default=0)
|
||||
pages: dict[int, int] = {}
|
||||
table = self.model.symbols.get('_bank_pages')
|
||||
if maximum:
|
||||
if table is None:
|
||||
raise SessionError('В банковской сборке отсутствует _bank_pages')
|
||||
data = bytes.fromhex(self.bridge.request(
|
||||
'memory', address=table, length=maximum + 1)['hex'])
|
||||
pages = {bank: data[bank] for bank in range(1, maximum + 1)}
|
||||
if any(value == 0 for value in pages.values()) or \
|
||||
len(set(pages.values())) != len(pages):
|
||||
raise SessionError('_bank_pages ещё не готова или содержит нули/дубликаты')
|
||||
windows = {location['window'] for location in
|
||||
[*self.model.functions, *self.model.variables]
|
||||
if location['bank'] is not None}
|
||||
missing = [window for window in windows if f'PG{window}' not in registers]
|
||||
if missing:
|
||||
raise SessionError('Backend не предоставил PG для банковских окон')
|
||||
generation = self.bridge.generation
|
||||
if generation is None:
|
||||
raise SessionError('Backend не предоставил generation')
|
||||
return MappingSnapshot(generation, dict(registers), pages)
|
||||
|
||||
def _expected_ranges(self, mapping: MappingSnapshot):
|
||||
expected: dict[int, int] = {}
|
||||
active_windows = {instruction['window'] for instruction in self.model.instructions.values()
|
||||
if instruction['bank'] is not None and
|
||||
self._page(mapping, instruction['window']) ==
|
||||
mapping.bank_pages[instruction['bank']]}
|
||||
for instruction in self.model.instructions.values():
|
||||
bank = instruction['bank']
|
||||
if bank is not None:
|
||||
if self._page(mapping, instruction['window']) != mapping.bank_pages[bank]:
|
||||
continue
|
||||
elif instruction['window'] in active_windows:
|
||||
# Физическая страница банка закрыла весь resident window.
|
||||
continue
|
||||
start = instruction['link_address']
|
||||
logical = instruction['logical_address']
|
||||
for offset in range(instruction['size']):
|
||||
if start + offset not in self.image:
|
||||
raise SessionError(f'В IHX нет инструкции по адресу {start + offset:#x}')
|
||||
value = self.image[start + offset]
|
||||
if logical + offset in expected and expected[logical + offset] != value:
|
||||
raise SessionError(f'Неоднозначный ожидаемый байт {logical + offset:#x}')
|
||||
expected[logical + offset] = value
|
||||
return expected
|
||||
|
||||
def _verify_loaded_code(self, mapping: MappingSnapshot) -> None:
|
||||
expected = self._expected_ranges(mapping)
|
||||
addresses = sorted(expected)
|
||||
ranges: list[tuple[int, int]] = []
|
||||
for address in addresses:
|
||||
if not ranges or address != ranges[-1][1] or address - ranges[-1][0] >= 4096:
|
||||
ranges.append((address, address + 1))
|
||||
else:
|
||||
ranges[-1] = (ranges[-1][0], address + 1)
|
||||
for start, end in ranges:
|
||||
actual = bytes.fromhex(self.bridge.request(
|
||||
'memory', address=start, length=end - start)['hex'])
|
||||
wanted = bytes(expected[address] for address in range(start, end))
|
||||
if actual != wanted:
|
||||
mismatch = next(i for i, pair in enumerate(zip(actual, wanted))
|
||||
if pair[0] != pair[1])
|
||||
raise SessionError(
|
||||
f'Образ в MAME не соответствует build по адресу {start + mismatch:#x}')
|
||||
|
||||
def _link_address(self, pc: int, mapping: MappingSnapshot) -> int:
|
||||
candidates = []
|
||||
for instruction in self.model.instructions.values():
|
||||
if instruction['bank'] is None:
|
||||
continue
|
||||
if not (instruction['logical_address'] <= pc <
|
||||
instruction['logical_address'] + instruction['size']):
|
||||
continue
|
||||
if self._page(mapping, instruction['window']) == mapping.bank_pages[instruction['bank']]:
|
||||
candidates.append(instruction['link_address'] +
|
||||
pc - instruction['logical_address'])
|
||||
candidates = sorted(set(candidates))
|
||||
if len(candidates) > 1:
|
||||
raise SessionError(f'Неоднозначное банковское отображение PC={pc:#x}')
|
||||
return candidates[0] if candidates else pc
|
||||
|
||||
def where(self, snapshot: dict | None = None) -> dict:
|
||||
mapping = self._mapping(snapshot) if snapshot is not None else self.refresh()
|
||||
if snapshot is not None:
|
||||
self._verify_loaded_code(mapping)
|
||||
pc = mapping.registers['PC']
|
||||
link = self._link_address(pc, mapping)
|
||||
return {'pc': pc, 'link_address': link, **self.model.addr2line(link)}
|
||||
|
||||
def _guard(self, location: dict, mapping: MappingSnapshot) -> dict:
|
||||
bank = location['bank']
|
||||
window = location['window']
|
||||
banked_window = any(instruction['bank'] is not None and
|
||||
instruction['window'] == window
|
||||
for instruction in self.model.instructions.values())
|
||||
if bank is not None:
|
||||
return {'window': window, 'page': mapping.bank_pages[bank]}
|
||||
if not banked_window:
|
||||
return {}
|
||||
if window not in self.resident_pages:
|
||||
raise SessionError('Физическая страница resident window ещё не установлена')
|
||||
return {'window': window, 'page': self.resident_pages[window]}
|
||||
|
||||
def _install(self, locations: list[dict], enabled: bool = True) -> dict:
|
||||
if not locations:
|
||||
raise SessionError('Для точки остановки нет исполняемых адресов')
|
||||
mapping = self.refresh()
|
||||
ids = []
|
||||
conditions = []
|
||||
try:
|
||||
for location in locations:
|
||||
arguments = self._guard(location, mapping)
|
||||
if not enabled:
|
||||
arguments['enabled'] = False
|
||||
result = self.bridge.request(
|
||||
'breakpoint', address=location['logical_address'],
|
||||
**arguments)
|
||||
ids.append(result['id'])
|
||||
conditions.append(result.get('condition', ''))
|
||||
except BaseException:
|
||||
for identifier in ids:
|
||||
try:
|
||||
self.bridge.request('clear', id=identifier)
|
||||
except BaseException:
|
||||
pass
|
||||
raise
|
||||
logical = self._next_breakpoint
|
||||
self._next_breakpoint += 1
|
||||
self.breakpoints[logical] = ids
|
||||
return {'id': logical, 'backend_ids': ids, 'conditions': conditions,
|
||||
'locations': locations}
|
||||
|
||||
def break_line(self, filename: str, line: int, enabled: bool = True) -> dict:
|
||||
resolved = self.model.line_locations(filename, line)
|
||||
if resolved['stale_source']:
|
||||
raise SessionError('Исходник изменён после сборки; пересоберите программу')
|
||||
return self._install(resolved['locations'], enabled)
|
||||
|
||||
def break_function(self, name: str, enabled: bool = True) -> dict:
|
||||
locations = [function for function in self.model.functions
|
||||
if function['name'] == name]
|
||||
return self._install(locations, enabled)
|
||||
|
||||
def break_anchor(self, location: dict, enabled: bool = True) -> dict:
|
||||
if not location.get('verified') or location.get('source') in self.model.stale_sources:
|
||||
raise SessionError('SDBG_LOG-якорь не проверен или исходник устарел')
|
||||
return self._install([location], enabled)
|
||||
|
||||
def activate_breakpoints(self) -> dict:
|
||||
return self.bridge.request('activate_breakpoints')
|
||||
|
||||
def deactivate_breakpoints(self) -> dict:
|
||||
return self.bridge.request('deactivate_breakpoints')
|
||||
|
||||
def clear_breakpoint(self, identifier: int) -> dict:
|
||||
ids = self.breakpoints.pop(identifier, None)
|
||||
if ids is None:
|
||||
raise SessionError('Неизвестная логическая точка остановки')
|
||||
for backend_id in ids:
|
||||
self.bridge.request('clear', id=backend_id)
|
||||
return {'cleared': identifier, 'backend_ids': ids}
|
||||
|
||||
def read_variable(self, name: str, module: str | None = None) -> dict:
|
||||
matches = [variable for variable in self.model.variables
|
||||
if variable['name'] == name and
|
||||
(module is None or variable['module'] == module)]
|
||||
if len(matches) != 1:
|
||||
raise SessionError('Переменная не найдена или имя неоднозначно')
|
||||
variable = matches[0]
|
||||
if not variable['supported'] or variable['size'] not in (1, 2, 4):
|
||||
raise SessionError('Тип переменной пока не поддерживается')
|
||||
mapping = self.refresh()
|
||||
if variable['bank'] is not None:
|
||||
if self._page(mapping, variable['window']) != mapping.bank_pages[variable['bank']]:
|
||||
raise SessionError('Банк переменной сейчас не отображён')
|
||||
else:
|
||||
bank_pages = set(mapping.bank_pages.values())
|
||||
if self._page(mapping, variable['window']) in bank_pages:
|
||||
raise SessionError('Resident-страница переменной сейчас закрыта банком')
|
||||
data = bytes.fromhex(self.bridge.request(
|
||||
'memory', address=variable['logical_address'], length=variable['size'])['hex'])
|
||||
value = int.from_bytes(data, 'little', signed=variable['signed'])
|
||||
return {**variable, 'value': value, 'hex': data.hex()}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Один владелец файлового backend; timeout инвалидирует канал mutations."""
|
||||
from __future__ import annotations
|
||||
import fcntl
|
||||
import json
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from .build import write_json
|
||||
|
||||
|
||||
class BridgeError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class FileBridge:
|
||||
def __init__(self, directory, session, timeout=5):
|
||||
self.directory = Path(directory)
|
||||
self.session = session
|
||||
self.timeout = timeout
|
||||
self.generation = None
|
||||
self.invalid = False
|
||||
self._mutex = threading.Lock()
|
||||
self._owner = (self.directory/'owner.lock').open('a')
|
||||
try:
|
||||
fcntl.flock(self._owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
self._owner.close()
|
||||
raise BridgeError('У backend уже есть управляющая сессия')
|
||||
|
||||
def close(self):
|
||||
with self._mutex:
|
||||
self._owner.close()
|
||||
|
||||
def request(self, command, **args):
|
||||
with self._mutex:
|
||||
if self._owner.closed:
|
||||
raise BridgeError('Канал закрыт')
|
||||
if self.invalid:
|
||||
raise BridgeError('Канал инвалидирован после timeout; требуется новая сессия')
|
||||
identity = str(uuid.uuid4().int)
|
||||
request = self.directory/f'req_{identity}.json'
|
||||
response = self.directory/f'resp_{identity}.json'
|
||||
write_json(request, {'session': self.session, 'generation': self.generation,
|
||||
'command': command, 'args': args})
|
||||
deadline = time.monotonic()+self.timeout
|
||||
while time.monotonic() < deadline:
|
||||
if response.exists():
|
||||
try:
|
||||
value = json.loads(response.read_text())
|
||||
if not isinstance(value, dict) or not {'session', 'generation', 'ok'} <= value.keys():
|
||||
raise ValueError('Неверная структура ответа')
|
||||
except (OSError, ValueError) as error:
|
||||
self.invalid = True
|
||||
raise BridgeError('Повреждённый ответ backend: ' + str(error)) from error
|
||||
response.unlink()
|
||||
if value['session'] != self.session:
|
||||
self.invalid = True
|
||||
raise BridgeError('Ответ от другой сессии')
|
||||
self.generation = value['generation']
|
||||
if not value['ok']:
|
||||
raise BridgeError(value['error'])
|
||||
return value['result']
|
||||
time.sleep(.005)
|
||||
self.invalid = True
|
||||
request.unlink(missing_ok=True)
|
||||
raise BridgeError('Timeout: результат команды неизвестен; автоматический повтор запрещён')
|
||||
|
||||
def wait_stopped(self, timeout=5):
|
||||
deadline = time.monotonic()+timeout
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = self.request('snapshot')
|
||||
if snapshot['state']=='stopped':
|
||||
return snapshot
|
||||
if snapshot['state']=='invalidated':
|
||||
raise BridgeError('Сессия MAME инвалидирована reset/load')
|
||||
time.sleep(.005)
|
||||
raise BridgeError('CPU не остановился за отведённое время')
|
||||
|
||||
def handshake(self):
|
||||
response = self.request('hello')
|
||||
if response['protocol'] != 1:
|
||||
raise BridgeError('Неподдержанная версия протокола')
|
||||
return response
|
||||
Reference in New Issue
Block a user