- LSLexer: tokenisation complete du format LS Fanuc - LSParser: AST pour programmes LS (mouvements, CALL, JMP, WAIT, IF) - tp_dump: analyseur hexa TP avec header parsing et comparaison - tp_format: structures du format binaire TP (V9.30) - CLI: interface en ligne de commande (lex, parse, compile) - Tests unitaires pour lexer et parser
329 lines
12 KiB
Python
329 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
tp_dump — Analyseur hexadécimal de fichiers TP Fanuc
|
|
|
|
Outil d'analyse et de visualisation du format binaire TP Fanuc.
|
|
Utile pour la rétro-ingénierie du format en comparant des paires
|
|
de fichiers LS/TP.
|
|
|
|
Usage:
|
|
tp_dump fichier.tp # Analyse complète
|
|
tp_dump fichier.tp --header-only # Header uniquement
|
|
tp_dump fichier.tp --instructions # Décodage des instructions
|
|
tp_dump fichier.tp --positions # Données de positions
|
|
tp_dump fichier.tp --hex # Dump hexadécimal complet
|
|
tp_dump fichier.tp --compare f2.tp # Comparaison de deux TP
|
|
"""
|
|
|
|
import sys
|
|
import struct
|
|
import argparse
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
# Ajouter le répertoire parent au path pour l'import
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from ls2tp.tp_format import (
|
|
TPHeader, TPInstruction, TPPosition, TPFile,
|
|
TPOpcode, PositionType, ConfigFlags,
|
|
identify_tp_version, is_valid_tp,
|
|
)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# Affichage formaté
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
class Colors:
|
|
"""Codes ANSI pour la coloration."""
|
|
HEADER = '\033[1;36m' # Cyan bold
|
|
SECTION = '\033[1;33m' # Jaune bold
|
|
VALUE = '\033[1;32m' # Vert bold
|
|
WARN = '\033[1;31m' # Rouge bold
|
|
DIM = '\033[2m' # Dim
|
|
RESET = '\033[0m'
|
|
HEX_ADDR = '\033[2;34m' # Bleu dim
|
|
HEX_ASCII = '\033[2;37m' # Gris dim
|
|
|
|
|
|
def color(c: str, text: str) -> str:
|
|
"""Applique une couleur si le terminal le supporte."""
|
|
return f"{c}{text}{Colors.RESET}"
|
|
|
|
|
|
def hexdump(data: bytes, offset: int = 0, length: int | None = None,
|
|
show_header: bool = True) -> str:
|
|
"""Affiche un dump hexadécimal formaté (style xxd)."""
|
|
if length is None:
|
|
length = len(data)
|
|
end = min(offset + length, len(data))
|
|
|
|
lines = []
|
|
if show_header:
|
|
lines.append(color(Colors.DIM,
|
|
" Offset │ 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F │ ASCII"))
|
|
|
|
for pos in range(offset, end, 16):
|
|
chunk = data[pos:min(pos + 16, end)]
|
|
hex_parts = []
|
|
ascii_parts = []
|
|
|
|
for i in range(16):
|
|
if i < len(chunk):
|
|
b = chunk[i]
|
|
hex_parts.append(f"{b:02X}")
|
|
ascii_parts.append(chr(b) if 32 <= b <= 126 else '.')
|
|
else:
|
|
hex_parts.append(' ')
|
|
ascii_parts.append(' ')
|
|
|
|
hex_str = ' '.join(hex_parts[:8]) + ' ' + ' '.join(hex_parts[8:])
|
|
ascii_str = ''.join(ascii_parts)
|
|
addr = color(Colors.HEX_ADDR, f"{pos:08X}")
|
|
text = color(Colors.DIM, ascii_str)
|
|
|
|
lines.append(f" {addr} │ {hex_str} │ {text}")
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def analyze_header(data: bytes) -> str:
|
|
"""Analyse et affiche l'en-tête TP."""
|
|
lines = []
|
|
lines.append(color(Colors.SECTION, "\n═══ TP HEADER ═══"))
|
|
|
|
try:
|
|
header = TPHeader.unpack(data)
|
|
except Exception as e:
|
|
lines.append(color(Colors.WARN, f" Erreur parsing header: {e}"))
|
|
# Fallback : dump brut des 64 premiers octets
|
|
lines.append(hexdump(data, 0, 64))
|
|
return '\n'.join(lines)
|
|
|
|
lines.append(f" Magic: {header.magic.hex(' ').upper()}")
|
|
lines.append(f" Version: {header.version_str()} (raw: 0x{header.version:04X})")
|
|
lines.append(f" Programme: '{header.name}'")
|
|
|
|
ts_dt = header.timestamp_dt()
|
|
ts_str = ts_dt.strftime('%Y-%m-%d %H:%M:%S') if ts_dt else 'N/A'
|
|
lines.append(f" Timestamp: {header.timestamp} ({ts_str})")
|
|
|
|
lines.append(f" Instructions: {header.instr_count}")
|
|
lines.append(f" Positions: {header.pos_count}")
|
|
lines.append(f" Instr offset: 0x{header.instr_offset:04X}")
|
|
lines.append(f" Pos offset: 0x{header.pos_offset:04X}")
|
|
lines.append(f" Taille tot.: {header.total_size} bytes")
|
|
|
|
# Version détectée
|
|
detected = identify_tp_version(data)
|
|
if detected:
|
|
lines.append(color(Colors.DIM, f" Détecté: {detected}"))
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def analyze_instructions(data: bytes, header: TPHeader) -> str:
|
|
"""Tente d'analyser la section instructions."""
|
|
lines = []
|
|
lines.append(color(Colors.SECTION, "\n═══ INSTRUCTIONS ═══"))
|
|
|
|
if header.instr_offset == 0 or header.instr_offset >= len(data):
|
|
# Essayer de trouver les instructions après le header
|
|
start = header.HEADER_SIZE
|
|
lines.append(color(Colors.DIM,
|
|
f" Offset table non trouvé. Scan depuis offset 0x{start:04X}..."))
|
|
|
|
# Afficher les 256 premiers bytes après le header comme instructions potentielles
|
|
scan_len = min(512, len(data) - start)
|
|
lines.append(hexdump(data, start, scan_len))
|
|
return '\n'.join(lines)
|
|
|
|
# Lire la table d'offsets
|
|
lines.append(f" Offset table: 0x{header.instr_offset:04X}")
|
|
lines.append(f" Nombre d'instructions: {header.instr_count}")
|
|
|
|
# Chaque entrée de la table fait probablement 4 bytes (offset)
|
|
table_start = header.instr_offset
|
|
for i in range(min(header.instr_count, 50)): # Max 50 pour lisibilité
|
|
entry_off = table_start + i * 4
|
|
if entry_off + 4 > len(data):
|
|
break
|
|
instr_off = struct.unpack('<I', data[entry_off:entry_off + 4])[0]
|
|
lines.append(f" [{i:4d}] offset=0x{instr_off:06X}")
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def analyze_positions(data: bytes, header: TPHeader) -> str:
|
|
"""Tente d'analyser la section positions."""
|
|
lines = []
|
|
lines.append(color(Colors.SECTION, "\n═══ POSITIONS ═══"))
|
|
|
|
if header.pos_count == 0:
|
|
lines.append(" Aucune position dans ce fichier.")
|
|
return '\n'.join(lines)
|
|
|
|
lines.append(f" Nombre de positions: {header.pos_count}")
|
|
|
|
if header.pos_offset > 0 and header.pos_offset < len(data):
|
|
lines.append(f" Offset données: 0x{header.pos_offset:06X}")
|
|
|
|
# Essayer d'interpréter les positions
|
|
pos_data = data[header.pos_offset:]
|
|
lines.append(f" Taille données positions: {len(pos_data)} bytes")
|
|
|
|
# Afficher un échantillon
|
|
sample_len = min(256, len(pos_data))
|
|
lines.append(hexdump(data, header.pos_offset, sample_len))
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def compare_files(data1: bytes, data2: bytes, name1: str, name2: str) -> str:
|
|
"""Compare deux fichiers TP octet par octet."""
|
|
lines = []
|
|
lines.append(color(Colors.SECTION, "\n═══ COMPARAISON ═══"))
|
|
lines.append(f" Fichier 1: {name1} ({len(data1)} bytes)")
|
|
lines.append(f" Fichier 2: {name2} ({len(data2)} bytes)")
|
|
|
|
if len(data1) != len(data2):
|
|
lines.append(color(Colors.WARN,
|
|
f" ⚠ Tailles différentes: Δ = {abs(len(data1) - len(data2))} bytes"))
|
|
|
|
# Comparer les headers
|
|
try:
|
|
h1 = TPHeader.unpack(data1)
|
|
h2 = TPHeader.unpack(data2)
|
|
lines.append("\n Différences header:")
|
|
if h1.version != h2.version:
|
|
lines.append(f" Version: {h1.version_str()} vs {h2.version_str()}")
|
|
if h1.instr_count != h2.instr_count:
|
|
lines.append(f" Instructions: {h1.instr_count} vs {h2.instr_count}")
|
|
if h1.pos_count != h2.pos_count:
|
|
lines.append(f" Positions: {h1.pos_count} vs {h2.pos_count}")
|
|
except Exception:
|
|
pass
|
|
|
|
# Comparer octet par octet
|
|
diff_count = 0
|
|
min_len = min(len(data1), len(data2))
|
|
for i in range(min_len):
|
|
if data1[i] != data2[i]:
|
|
diff_count += 1
|
|
|
|
lines.append(f"\n Octets différents: {diff_count} sur {min_len} "
|
|
f"({diff_count / min_len * 100:.1f}%)")
|
|
|
|
# Afficher les 20 premières différences
|
|
shown = 0
|
|
for i in range(min_len):
|
|
if data1[i] != data2[i] and shown < 20:
|
|
if shown == 0:
|
|
lines.append("\n Premières différences:")
|
|
lines.append(f" 0x{i:06X}: {data1[i]:02X} → {data2[i]:02X}")
|
|
shown += 1
|
|
if diff_count > 20:
|
|
lines.append(f" ... et {diff_count - 20} autres")
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def full_analysis(filepath: Path) -> str:
|
|
"""Analyse complète d'un fichier TP."""
|
|
data = filepath.read_bytes()
|
|
size = len(data)
|
|
|
|
lines = []
|
|
lines.append(color(Colors.HEADER, f"\n{'═' * 60}"))
|
|
lines.append(color(Colors.HEADER, f" TP DUMP — {filepath.name}"))
|
|
lines.append(color(Colors.HEADER, f"{'═' * 60}"))
|
|
lines.append(f" Taille: {size} bytes ({size / 1024:.1f} KB)")
|
|
|
|
is_valid = is_valid_tp(data)
|
|
status = color(Colors.VALUE, "✓ Valide") if is_valid else color(Colors.WARN, "⚠ Format non reconnu")
|
|
lines.append(f" Status: {status}")
|
|
|
|
# Header
|
|
lines.append(analyze_header(data))
|
|
|
|
# Essayer de parser le header pour la suite
|
|
try:
|
|
header = TPHeader.unpack(data)
|
|
except Exception:
|
|
header = TPHeader()
|
|
|
|
# Instructions
|
|
lines.append(analyze_instructions(data, header))
|
|
|
|
# Positions
|
|
lines.append(analyze_positions(data, header))
|
|
|
|
# Dump hexadécimal des 256 derniers bytes (footer potentiel)
|
|
if size > 256:
|
|
lines.append(color(Colors.SECTION, "\n═══ FOOTER (derniers 128 bytes) ═══"))
|
|
lines.append(hexdump(data, max(0, size - 128), 128))
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# CLI
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Analyseur de fichiers TP Fanuc — rétro-ingénierie du format binaire",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Exemples:
|
|
tp_dump MONPROG.TP Analyse complète
|
|
tp_dump MONPROG.TP --header-only Header uniquement
|
|
tp_dump MONPROG.TP --hex Dump hexadécimal complet
|
|
tp_dump PROG1.TP --compare PROG2.TP Comparer deux TP
|
|
"""
|
|
)
|
|
parser.add_argument('file', type=Path, help='Fichier TP à analyser')
|
|
parser.add_argument('--header-only', action='store_true',
|
|
help='Afficher uniquement le header')
|
|
parser.add_argument('--instructions', action='store_true',
|
|
help='Analyser les instructions')
|
|
parser.add_argument('--positions', action='store_true',
|
|
help='Analyser les positions')
|
|
parser.add_argument('--hex', action='store_true',
|
|
help='Dump hexadécimal complet')
|
|
parser.add_argument('--hex-offset', type=lambda x: int(x, 0), default=0,
|
|
help='Offset de départ pour le dump hex')
|
|
parser.add_argument('--hex-length', type=lambda x: int(x, 0), default=512,
|
|
help='Longueur du dump hex (défaut: 512)')
|
|
parser.add_argument('--compare', type=Path,
|
|
help='Comparer avec un autre fichier TP')
|
|
parser.add_argument('--raw-offsets', action='store_true',
|
|
help='Afficher les offsets bruts des sections')
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not args.file.exists():
|
|
print(f"Erreur: fichier '{args.file}' introuvable.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
data = args.file.read_bytes()
|
|
|
|
if args.compare:
|
|
if not args.compare.exists():
|
|
print(f"Erreur: fichier de comparaison '{args.compare}' introuvable.",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
data2 = args.compare.read_bytes()
|
|
print(compare_files(data, data2, args.file.name, args.compare.name))
|
|
elif args.header_only:
|
|
print(analyze_header(data))
|
|
elif args.hex:
|
|
print(hexdump(data, args.hex_offset, args.hex_length))
|
|
else:
|
|
print(full_analysis(args.file))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|