termigma 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- termigma/__init__.py +6 -0
- termigma/__main__.py +4 -0
- termigma/engine.py +381 -0
- termigma/tui.py +416 -0
- termigma-1.0.0.dist-info/METADATA +130 -0
- termigma-1.0.0.dist-info/RECORD +10 -0
- termigma-1.0.0.dist-info/WHEEL +5 -0
- termigma-1.0.0.dist-info/entry_points.txt +2 -0
- termigma-1.0.0.dist-info/licenses/LICENSE +21 -0
- termigma-1.0.0.dist-info/top_level.txt +1 -0
termigma/__init__.py
ADDED
termigma/__main__.py
ADDED
termigma/engine.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core Enigma engine: wheels, reflectors, plugboard, and the Enigma machine
|
|
3
|
+
itself, plus the plain-data helpers used by the settings screen. No curses
|
|
4
|
+
here on purpose, so this module is importable and testable on its own.
|
|
5
|
+
|
|
6
|
+
Historical scope: Wehrmacht/Kriegsmarine Enigma I, M3 (rotors I-VIII) and
|
|
7
|
+
M4 "Shark" (adds a non-stepping Beta/Gamma wheel and a thin reflector).
|
|
8
|
+
See ../../data/wiring_tables.json for sourcing notes, including which
|
|
9
|
+
commercial/national variants are deliberately NOT modelled here.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import string
|
|
13
|
+
|
|
14
|
+
ALPHA = string.ascii_uppercase
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Historical wiring tables
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
ROTOR_DATA = {
|
|
20
|
+
"I": {"wiring": "EKMFLGDQVZNTOWYHXUSPAIBRCJ", "notches": {"Q"}},
|
|
21
|
+
"II": {"wiring": "AJDKSIRUXBLHWTMCQGZNPYFVOE", "notches": {"E"}},
|
|
22
|
+
"III": {"wiring": "BDFHJLCPRTXVZNYEIWGAKMUSQO", "notches": {"V"}},
|
|
23
|
+
"IV": {"wiring": "ESOVPZJAYQUIRHXLNFTGKDCMWB", "notches": {"J"}},
|
|
24
|
+
"V": {"wiring": "VZBRGITYUPSDNHLXAWMJQOFECK", "notches": {"Z"}},
|
|
25
|
+
"VI": {"wiring": "JPGVOUMFYQBENHZRDKASXLICTW", "notches": {"Z", "M"}},
|
|
26
|
+
"VII": {"wiring": "NZJHGRCXMYSWBOUFAIVLPEKQDT", "notches": {"Z", "M"}},
|
|
27
|
+
"VIII": {"wiring": "FKQHTLXOCBJSPDZRAMEWNIUYGV", "notches": {"Z", "M"}},
|
|
28
|
+
}
|
|
29
|
+
ROTOR_CHOICES = list(ROTOR_DATA.keys())
|
|
30
|
+
|
|
31
|
+
FOURTH_WHEEL_DATA = {
|
|
32
|
+
"Beta": {"wiring": "LEYJVCNIXWPBQMDRTAKZGFUHOS"},
|
|
33
|
+
"Gamma": {"wiring": "FSOKANUERHMBTIYCWLQPZXVGJD"},
|
|
34
|
+
}
|
|
35
|
+
FOURTH_WHEEL_CHOICES = list(FOURTH_WHEEL_DATA.keys())
|
|
36
|
+
|
|
37
|
+
REFLECTOR_DATA = {
|
|
38
|
+
"B": "YRUHQSLDPXNGOKMIEBFZCWVJAT",
|
|
39
|
+
"C": "FVPJIAOYEDRZXWGCTKUQSBNMHL",
|
|
40
|
+
"B-thin": "ENKQAUYWJICOPBLMDXZVFTHRGS",
|
|
41
|
+
"C-thin": "RDOBJNTKVEHMLFCWZAXGYIPSUQ",
|
|
42
|
+
}
|
|
43
|
+
REFLECTOR_CHOICES = ["B", "C", "B-thin", "C-thin", "Custom"]
|
|
44
|
+
THIN_REFLECTORS = {"B-thin", "C-thin"}
|
|
45
|
+
|
|
46
|
+
# Historical Enigma keyboard / lampboard layout (QWERTZ, staggered)
|
|
47
|
+
KB_ROWS = ["QWERTZUIO", "ASDFGHJK", "PYXCVBNML"]
|
|
48
|
+
KB_INDENT = [0, 1, 2]
|
|
49
|
+
COMMERCIAL_ETW_ORDER = "".join(KB_ROWS) # commercial ETW follows keyboard order
|
|
50
|
+
|
|
51
|
+
MAX_PLUGS = 13 # full theoretical stecker capacity (wartime issue was 10 cables)
|
|
52
|
+
|
|
53
|
+
MODEL_NAMES = [
|
|
54
|
+
"Custom",
|
|
55
|
+
"Enigma I (Army/GAF)",
|
|
56
|
+
"M3 (Army/Navy)",
|
|
57
|
+
"M4 'Shark' (U-boats)",
|
|
58
|
+
"Commercial-style (K/D ETW demo)",
|
|
59
|
+
]
|
|
60
|
+
PRESETS = {
|
|
61
|
+
"Enigma I (Army/GAF)": dict(etw="military", plugboard_enabled=True, reflector="B",
|
|
62
|
+
L_type="I", M_type="II", R_type="III"),
|
|
63
|
+
"M3 (Army/Navy)": dict(etw="military", plugboard_enabled=True, reflector="B",
|
|
64
|
+
L_type="I", M_type="V", R_type="VIII"),
|
|
65
|
+
"M4 'Shark' (U-boats)": dict(etw="military", plugboard_enabled=True, reflector="B-thin",
|
|
66
|
+
L_type="II", M_type="IV", R_type="I", G_type="Beta"),
|
|
67
|
+
"Commercial-style (K/D ETW demo)": dict(etw="commercial", plugboard_enabled=False,
|
|
68
|
+
reflector="B", L_type="I", M_type="II", R_type="III"),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def default_custom_pairs():
|
|
73
|
+
pairs = {}
|
|
74
|
+
for i in range(0, 26, 2):
|
|
75
|
+
a, b = ALPHA[i], ALPHA[i + 1]
|
|
76
|
+
pairs[a] = b
|
|
77
|
+
pairs[b] = a
|
|
78
|
+
return pairs
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Engine
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
class Wheel:
|
|
85
|
+
"""Shared forward/backward substitution math for rotors and the 4th wheel."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, wiring, ring_setting=1, start_pos="A"):
|
|
88
|
+
self.wiring = wiring
|
|
89
|
+
self.ring_setting = ring_setting
|
|
90
|
+
self.position = ALPHA.index(start_pos)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def position_letter(self):
|
|
94
|
+
return ALPHA[self.position]
|
|
95
|
+
|
|
96
|
+
def forward(self, c):
|
|
97
|
+
shift = self.position - (self.ring_setting - 1)
|
|
98
|
+
entry = (c + shift) % 26
|
|
99
|
+
out = ord(self.wiring[entry]) - ord("A")
|
|
100
|
+
return (out - shift) % 26
|
|
101
|
+
|
|
102
|
+
def backward(self, c):
|
|
103
|
+
shift = self.position - (self.ring_setting - 1)
|
|
104
|
+
entry = (c + shift) % 26
|
|
105
|
+
out = self.wiring.index(ALPHA[entry])
|
|
106
|
+
return (out - shift) % 26
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Rotor(Wheel):
|
|
110
|
+
def __init__(self, name, ring_setting=1, start_pos="A", notch_override=None):
|
|
111
|
+
super().__init__(ROTOR_DATA[name]["wiring"], ring_setting, start_pos)
|
|
112
|
+
self.name = name
|
|
113
|
+
self.notches = {notch_override} if notch_override else set(ROTOR_DATA[name]["notches"])
|
|
114
|
+
|
|
115
|
+
def at_notch(self):
|
|
116
|
+
return self.position_letter in self.notches
|
|
117
|
+
|
|
118
|
+
def step(self):
|
|
119
|
+
self.position = (self.position + 1) % 26
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class FourthWheel(Wheel):
|
|
123
|
+
"""Non-stepping M4 'Greek' wheel (Beta/Gamma): set by hand, never turns."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, name, ring_setting=1, start_pos="A"):
|
|
126
|
+
super().__init__(FOURTH_WHEEL_DATA[name]["wiring"], ring_setting, start_pos)
|
|
127
|
+
self.name = name
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class EntryWheel:
|
|
131
|
+
"""Fixed (non-rotating) entry wiring. Military = straight-through.
|
|
132
|
+
Commercial = wired in QWERTZU keyboard order."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, mode="military"):
|
|
135
|
+
self.mode = mode
|
|
136
|
+
order = COMMERCIAL_ETW_ORDER if mode == "commercial" else ALPHA
|
|
137
|
+
self.fwd = {order[i]: i for i in range(26)}
|
|
138
|
+
self.inv = [None] * 26
|
|
139
|
+
for letter, idx in self.fwd.items():
|
|
140
|
+
self.inv[idx] = ALPHA.index(letter)
|
|
141
|
+
|
|
142
|
+
def forward(self, c):
|
|
143
|
+
return self.fwd[ALPHA[c]]
|
|
144
|
+
|
|
145
|
+
def backward(self, c):
|
|
146
|
+
return self.inv[c]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Reflector:
|
|
150
|
+
def __init__(self, kind="B", custom_pairs=None):
|
|
151
|
+
self.kind = kind
|
|
152
|
+
self.pairs = dict(custom_pairs) if kind == "Custom" else None
|
|
153
|
+
|
|
154
|
+
def apply(self, c):
|
|
155
|
+
if self.kind == "Custom":
|
|
156
|
+
return ALPHA.index(self.pairs[ALPHA[c]])
|
|
157
|
+
return ord(REFLECTOR_DATA[self.kind][c]) - ord("A")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Plugboard:
|
|
161
|
+
def __init__(self, pairs=None):
|
|
162
|
+
self.pairs = dict(pairs or {})
|
|
163
|
+
|
|
164
|
+
def apply(self, c):
|
|
165
|
+
letter = ALPHA[c]
|
|
166
|
+
return ALPHA.index(self.pairs.get(letter, letter))
|
|
167
|
+
|
|
168
|
+
def pairs_list(self):
|
|
169
|
+
seen, out = set(), []
|
|
170
|
+
for a, b in self.pairs.items():
|
|
171
|
+
if a not in seen:
|
|
172
|
+
out.append((a, b))
|
|
173
|
+
seen.add(a)
|
|
174
|
+
seen.add(b)
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Enigma:
|
|
179
|
+
def __init__(self, rotor_names=("I", "II", "III"), ring_settings=(1, 1, 1),
|
|
180
|
+
positions=("A", "A", "A"), reflector_kind="B", plug_pairs=None,
|
|
181
|
+
etw_mode="military", fourth_wheel=None, fourth_ring=1, fourth_pos="A",
|
|
182
|
+
rotor_notches=(None, None, None), custom_reflector_pairs=None,
|
|
183
|
+
plugboard_enabled=True):
|
|
184
|
+
self.left = Rotor(rotor_names[0], ring_settings[0], positions[0], rotor_notches[0])
|
|
185
|
+
self.middle = Rotor(rotor_names[1], ring_settings[1], positions[1], rotor_notches[1])
|
|
186
|
+
self.right = Rotor(rotor_names[2], ring_settings[2], positions[2], rotor_notches[2])
|
|
187
|
+
self.reflector_kind = reflector_kind
|
|
188
|
+
self.custom_reflector_pairs = custom_reflector_pairs or default_custom_pairs()
|
|
189
|
+
self.reflector = Reflector(reflector_kind, self.custom_reflector_pairs)
|
|
190
|
+
self.plugboard = Plugboard(plug_pairs)
|
|
191
|
+
self.plugboard_enabled = plugboard_enabled
|
|
192
|
+
self.etw = EntryWheel(etw_mode)
|
|
193
|
+
self.fourth = FourthWheel(fourth_wheel, fourth_ring, fourth_pos) if fourth_wheel else None
|
|
194
|
+
self.movable_notches = any(n is not None for n in rotor_notches)
|
|
195
|
+
|
|
196
|
+
def step_rotors(self):
|
|
197
|
+
mid_notch = self.middle.at_notch()
|
|
198
|
+
right_notch = self.right.at_notch()
|
|
199
|
+
if mid_notch:
|
|
200
|
+
self.left.step()
|
|
201
|
+
self.middle.step()
|
|
202
|
+
elif right_notch:
|
|
203
|
+
self.middle.step()
|
|
204
|
+
self.right.step()
|
|
205
|
+
|
|
206
|
+
def encode_letter(self, letter):
|
|
207
|
+
self.step_rotors()
|
|
208
|
+
path = [("Keyboard", letter)]
|
|
209
|
+
c = ALPHA.index(letter)
|
|
210
|
+
if self.plugboard_enabled:
|
|
211
|
+
c = self.plugboard.apply(c)
|
|
212
|
+
path.append(("Plugboard", ALPHA[c]))
|
|
213
|
+
c = self.etw.forward(c)
|
|
214
|
+
path.append(("Entry Wheel (ETW)", ALPHA[c]))
|
|
215
|
+
c = self.right.forward(c)
|
|
216
|
+
path.append((f"Rotor R ({self.right.name}) ->", ALPHA[c]))
|
|
217
|
+
c = self.middle.forward(c)
|
|
218
|
+
path.append((f"Rotor M ({self.middle.name}) ->", ALPHA[c]))
|
|
219
|
+
c = self.left.forward(c)
|
|
220
|
+
path.append((f"Rotor L ({self.left.name}) ->", ALPHA[c]))
|
|
221
|
+
if self.fourth:
|
|
222
|
+
c = self.fourth.forward(c)
|
|
223
|
+
path.append((f"4th Wheel ({self.fourth.name}) ->", ALPHA[c]))
|
|
224
|
+
c = self.reflector.apply(c)
|
|
225
|
+
path.append((f"Reflector {self.reflector_kind}", ALPHA[c]))
|
|
226
|
+
if self.fourth:
|
|
227
|
+
c = self.fourth.backward(c)
|
|
228
|
+
path.append((f"<- 4th Wheel ({self.fourth.name})", ALPHA[c]))
|
|
229
|
+
c = self.left.backward(c)
|
|
230
|
+
path.append((f"<- Rotor L ({self.left.name})", ALPHA[c]))
|
|
231
|
+
c = self.middle.backward(c)
|
|
232
|
+
path.append((f"<- Rotor M ({self.middle.name})", ALPHA[c]))
|
|
233
|
+
c = self.right.backward(c)
|
|
234
|
+
path.append((f"<- Rotor R ({self.right.name})", ALPHA[c]))
|
|
235
|
+
c = self.etw.backward(c)
|
|
236
|
+
path.append(("<- Entry Wheel (ETW)", ALPHA[c]))
|
|
237
|
+
if self.plugboard_enabled:
|
|
238
|
+
c = self.plugboard.apply(c)
|
|
239
|
+
path.append(("Plugboard", ALPHA[c]))
|
|
240
|
+
path.append(("Lamp", ALPHA[c]))
|
|
241
|
+
return ALPHA[c], path
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Settings-screen logic (pure data; the curses UI in tui.py just renders it)
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
FIELD_LABEL = {
|
|
248
|
+
"model": "Model preset",
|
|
249
|
+
"etw": "Entry wheel (ETW)",
|
|
250
|
+
"plugboard_enabled": "Plugboard",
|
|
251
|
+
"movable_notches": "Movable notches",
|
|
252
|
+
"reflector": "Reflector (UKW)",
|
|
253
|
+
"G_type": "4th wheel type", "G_ring": "4th wheel ring", "G_pos": "4th wheel start",
|
|
254
|
+
"L_type": "Left rotor type", "L_ring": "Left ring", "L_pos": "Left start pos", "L_notch": "Left notch",
|
|
255
|
+
"M_type": "Mid rotor type", "M_ring": "Mid ring", "M_pos": "Mid start pos", "M_notch": "Mid notch",
|
|
256
|
+
"R_type": "Right rotor type", "R_ring": "Right ring", "R_pos": "Right start pos", "R_notch": "Right notch",
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def build_field_order(settings):
|
|
261
|
+
order = ["model", "etw", "plugboard_enabled", "movable_notches", "reflector"]
|
|
262
|
+
if settings["reflector"] in THIN_REFLECTORS:
|
|
263
|
+
order += ["G_type", "G_ring", "G_pos"]
|
|
264
|
+
for p in ("L", "M", "R"):
|
|
265
|
+
order += [f"{p}_type", f"{p}_ring", f"{p}_pos"]
|
|
266
|
+
if settings["movable_notches"]:
|
|
267
|
+
order.append(f"{p}_notch")
|
|
268
|
+
return order
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def apply_model_preset(settings, name):
|
|
272
|
+
preset = PRESETS.get(name)
|
|
273
|
+
if not preset:
|
|
274
|
+
return
|
|
275
|
+
settings.update(preset)
|
|
276
|
+
for p in ("L", "M", "R"):
|
|
277
|
+
settings[f"{p}_ring"] = 1
|
|
278
|
+
settings[f"{p}_pos"] = "A"
|
|
279
|
+
settings["movable_notches"] = False
|
|
280
|
+
if settings["reflector"] in THIN_REFLECTORS:
|
|
281
|
+
settings["G_type"] = preset.get("G_type", "Beta")
|
|
282
|
+
settings["G_ring"] = 1
|
|
283
|
+
settings["G_pos"] = "A"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def adjust_field(settings, field, d):
|
|
287
|
+
if field == "model":
|
|
288
|
+
names = MODEL_NAMES
|
|
289
|
+
settings[field] = names[(names.index(settings[field]) + d) % len(names)]
|
|
290
|
+
if settings[field] != "Custom":
|
|
291
|
+
apply_model_preset(settings, settings[field])
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
if field == "etw":
|
|
295
|
+
opts = ["military", "commercial"]
|
|
296
|
+
settings[field] = opts[(opts.index(settings[field]) + d) % 2]
|
|
297
|
+
elif field == "plugboard_enabled":
|
|
298
|
+
settings[field] = not settings[field]
|
|
299
|
+
elif field == "movable_notches":
|
|
300
|
+
settings[field] = not settings[field]
|
|
301
|
+
if settings[field]:
|
|
302
|
+
for p in ("L", "M", "R"):
|
|
303
|
+
settings[f"{p}_notch"] = sorted(ROTOR_DATA[settings[f"{p}_type"]]["notches"])[0]
|
|
304
|
+
elif field == "reflector":
|
|
305
|
+
opts = REFLECTOR_CHOICES
|
|
306
|
+
settings[field] = opts[(opts.index(settings[field]) + d) % len(opts)]
|
|
307
|
+
if settings[field] in THIN_REFLECTORS:
|
|
308
|
+
settings.setdefault("G_type", "Beta")
|
|
309
|
+
settings.setdefault("G_ring", 1)
|
|
310
|
+
settings.setdefault("G_pos", "A")
|
|
311
|
+
elif field == "G_type":
|
|
312
|
+
opts = FOURTH_WHEEL_CHOICES
|
|
313
|
+
settings[field] = opts[(opts.index(settings[field]) + d) % len(opts)]
|
|
314
|
+
elif field == "G_ring":
|
|
315
|
+
settings[field] = ((settings[field] - 1 + d) % 26) + 1
|
|
316
|
+
elif field == "G_pos":
|
|
317
|
+
i = ALPHA.index(settings[field])
|
|
318
|
+
settings[field] = ALPHA[(i + d) % 26]
|
|
319
|
+
elif field.endswith("_type"):
|
|
320
|
+
opts = ROTOR_CHOICES
|
|
321
|
+
settings[field] = opts[(opts.index(settings[field]) + d) % len(opts)]
|
|
322
|
+
elif field.endswith("_ring"):
|
|
323
|
+
settings[field] = ((settings[field] - 1 + d) % 26) + 1
|
|
324
|
+
elif field.endswith("_notch") or field.endswith("_pos"):
|
|
325
|
+
i = ALPHA.index(settings[field])
|
|
326
|
+
settings[field] = ALPHA[(i + d) % 26]
|
|
327
|
+
|
|
328
|
+
settings["model"] = "Custom"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def fmt_val(v):
|
|
332
|
+
if isinstance(v, bool):
|
|
333
|
+
return "Yes" if v else "No"
|
|
334
|
+
if isinstance(v, int):
|
|
335
|
+
return f"{v:02d}"
|
|
336
|
+
return str(v)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
# Plugboard / custom-reflector text parsing (pure logic, no curses)
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
def parse_plug_pairs(buf):
|
|
343
|
+
tokens = buf.split()
|
|
344
|
+
if len(tokens) > MAX_PLUGS:
|
|
345
|
+
return False, f"Max {MAX_PLUGS} plug pairs (historical limit)"
|
|
346
|
+
used, pairs = set(), {}
|
|
347
|
+
for t in tokens:
|
|
348
|
+
if len(t) != 2 or not t.isalpha():
|
|
349
|
+
return False, f"Invalid pair '{t}' (need two letters)"
|
|
350
|
+
a, b = t[0], t[1]
|
|
351
|
+
if a == b:
|
|
352
|
+
return False, f"Cannot plug {a} to itself"
|
|
353
|
+
if a in used or b in used:
|
|
354
|
+
return False, f"Letter reused in '{t}'"
|
|
355
|
+
used.add(a)
|
|
356
|
+
used.add(b)
|
|
357
|
+
pairs[a] = b
|
|
358
|
+
pairs[b] = a
|
|
359
|
+
return True, pairs
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def parse_reflector_pairs(buf):
|
|
363
|
+
tokens = buf.split()
|
|
364
|
+
if len(tokens) != 13:
|
|
365
|
+
return False, f"Need exactly 13 pairs covering all 26 letters (got {len(tokens)})"
|
|
366
|
+
used, pairs = set(), {}
|
|
367
|
+
for t in tokens:
|
|
368
|
+
if len(t) != 2 or not t.isalpha():
|
|
369
|
+
return False, f"Invalid pair '{t}'"
|
|
370
|
+
a, b = t[0], t[1]
|
|
371
|
+
if a == b:
|
|
372
|
+
return False, f"Cannot reflect {a} to itself"
|
|
373
|
+
if a in used or b in used:
|
|
374
|
+
return False, f"Letter reused in '{t}'"
|
|
375
|
+
used.add(a)
|
|
376
|
+
used.add(b)
|
|
377
|
+
pairs[a] = b
|
|
378
|
+
pairs[b] = a
|
|
379
|
+
if len(used) != 26:
|
|
380
|
+
return False, "All 26 letters must be used exactly once"
|
|
381
|
+
return True, pairs
|
termigma/tui.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Curses front-end for the termigma Enigma engine. All model logic lives
|
|
2
|
+
in engine.py; this module only draws things and reads the keyboard."""
|
|
3
|
+
|
|
4
|
+
import curses
|
|
5
|
+
|
|
6
|
+
from .engine import (
|
|
7
|
+
ALPHA, KB_ROWS, KB_INDENT, MAX_PLUGS, THIN_REFLECTORS,
|
|
8
|
+
Rotor, FourthWheel, Reflector, Plugboard, Enigma,
|
|
9
|
+
FIELD_LABEL, build_field_order, adjust_field, fmt_val,
|
|
10
|
+
parse_plug_pairs, parse_reflector_pairs,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def safe_addstr(win, y, x, text, attr=0):
|
|
15
|
+
h, w = win.getmaxyx()
|
|
16
|
+
if y < 0 or y >= h or x >= w or x < 0:
|
|
17
|
+
return
|
|
18
|
+
try:
|
|
19
|
+
win.addstr(y, x, text[: max(0, w - x - 1)], attr)
|
|
20
|
+
except curses.error:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def draw_box(win, y, x, h, w, title="", attr=0):
|
|
25
|
+
safe_addstr(win, y, x, "+" + "-" * (w - 2) + "+", attr)
|
|
26
|
+
for i in range(1, h - 1):
|
|
27
|
+
safe_addstr(win, y + i, x, "|", attr)
|
|
28
|
+
safe_addstr(win, y + i, x + w - 1, "|", attr)
|
|
29
|
+
safe_addstr(win, y + h - 1, x, "+" + "-" * (w - 2) + "+", attr)
|
|
30
|
+
if title:
|
|
31
|
+
safe_addstr(win, y, x + 2, f" {title} ", attr | curses.A_BOLD)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def init_colors():
|
|
35
|
+
curses.start_color()
|
|
36
|
+
try:
|
|
37
|
+
curses.use_default_colors()
|
|
38
|
+
bg = -1
|
|
39
|
+
except curses.error:
|
|
40
|
+
bg = curses.COLOR_BLACK
|
|
41
|
+
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE) # pressed key
|
|
42
|
+
curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_YELLOW) # lit lamp
|
|
43
|
+
curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_BLUE) # title bar
|
|
44
|
+
curses.init_pair(4, curses.COLOR_CYAN, bg) # box borders
|
|
45
|
+
curses.init_pair(5, curses.COLOR_GREEN, bg) # signal path
|
|
46
|
+
curses.init_pair(6, curses.COLOR_WHITE, curses.COLOR_RED) # errors
|
|
47
|
+
curses.init_pair(7, curses.COLOR_MAGENTA, bg) # plugboard
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def draw_keyrow(win, y, x0, row, indent, highlight_letter, attr_on, attr_off):
|
|
51
|
+
x = x0 + indent * 2
|
|
52
|
+
for ch in row:
|
|
53
|
+
attr = attr_on if ch == highlight_letter else attr_off
|
|
54
|
+
safe_addstr(win, y, x, f"[{ch}]", attr)
|
|
55
|
+
x += 4
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def draw_wheel_panel(win, y, x, label, wheel, attr_border, attr_val):
|
|
59
|
+
draw_box(win, y, x, 7, 14, label, attr_border)
|
|
60
|
+
safe_addstr(win, y + 1, x + 2, f"Type:{wheel.name:<5}")
|
|
61
|
+
safe_addstr(win, y + 2, x + 2, f"Ring:{wheel.ring_setting:02d}")
|
|
62
|
+
eff = ALPHA[(wheel.position - (wheel.ring_setting - 1)) % 26]
|
|
63
|
+
safe_addstr(win, y + 3, x + 2, f"Eff :{eff}")
|
|
64
|
+
safe_addstr(win, y + 5, x + 4, f" {wheel.position_letter} ", attr_val | curses.A_BOLD)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def group5(s):
|
|
68
|
+
return " ".join(s[i:i + 5] for i in range(0, len(s), 5))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def draw_all(stdscr, machine, state):
|
|
72
|
+
stdscr.erase()
|
|
73
|
+
h, w = stdscr.getmaxyx()
|
|
74
|
+
if h < 34 or w < 108:
|
|
75
|
+
safe_addstr(stdscr, 0, 0,
|
|
76
|
+
f"Terminal too small ({w}x{h}). Please resize to at least 108x34.")
|
|
77
|
+
stdscr.refresh()
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
c_title = curses.color_pair(3)
|
|
81
|
+
c_press = curses.color_pair(1) | curses.A_BOLD
|
|
82
|
+
c_lamp = curses.color_pair(2) | curses.A_BOLD
|
|
83
|
+
c_dim = curses.A_DIM
|
|
84
|
+
c_border = curses.color_pair(4)
|
|
85
|
+
c_sig = curses.color_pair(5)
|
|
86
|
+
c_plug = curses.color_pair(7)
|
|
87
|
+
|
|
88
|
+
safe_addstr(stdscr, 0, 0, " " * w, c_title)
|
|
89
|
+
safe_addstr(stdscr, 0, 2, "T E R M I G M A - Interactive Enigma TUI", c_title | curses.A_BOLD)
|
|
90
|
+
safe_addstr(stdscr, 0, w - 22, "F1 Help ESC Quit", c_title)
|
|
91
|
+
|
|
92
|
+
ry = 2
|
|
93
|
+
draw_box(stdscr, ry, 2, 10, 60, "ROTORS", c_border)
|
|
94
|
+
safe_addstr(stdscr, ry + 1, 4,
|
|
95
|
+
f"Reflector: {machine.reflector_kind:<8} ETW: {machine.etw.mode:<10}"
|
|
96
|
+
f"Plugboard: {'ON' if machine.plugboard_enabled else 'OFF'}")
|
|
97
|
+
row = ry + 2
|
|
98
|
+
cols = []
|
|
99
|
+
if machine.fourth:
|
|
100
|
+
cols.append(("4TH", machine.fourth))
|
|
101
|
+
cols += [("LEFT", machine.left), ("MID", machine.middle), ("RIGHT", machine.right)]
|
|
102
|
+
for i, (label, wheel) in enumerate(cols):
|
|
103
|
+
draw_wheel_panel(stdscr, row, 4 + i * 14, label, wheel, c_border, c_lamp)
|
|
104
|
+
|
|
105
|
+
ky = 13
|
|
106
|
+
draw_box(stdscr, ky, 2, 6, 46, "KEYBOARD (type A-Z)", c_border)
|
|
107
|
+
pressed = state.get("last_key")
|
|
108
|
+
for i, krow in enumerate(KB_ROWS):
|
|
109
|
+
draw_keyrow(stdscr, ky + 1 + i, 4, krow, KB_INDENT[i], pressed, c_press, 0)
|
|
110
|
+
|
|
111
|
+
ly = 20
|
|
112
|
+
draw_box(stdscr, ly, 2, 6, 46, "LAMPBOARD (lit letter)", c_border)
|
|
113
|
+
lit = state.get("last_out")
|
|
114
|
+
for i, krow in enumerate(KB_ROWS):
|
|
115
|
+
draw_keyrow(stdscr, ly + 1 + i, 4, krow, KB_INDENT[i], lit, c_lamp, c_dim)
|
|
116
|
+
|
|
117
|
+
pby = 27
|
|
118
|
+
draw_box(stdscr, pby, 2, 4, 46, "PLUGBOARD (F3 edit, F2 on/off)", c_border)
|
|
119
|
+
if not machine.plugboard_enabled:
|
|
120
|
+
safe_addstr(stdscr, pby + 1, 4, "DISABLED for this model", c_dim)
|
|
121
|
+
else:
|
|
122
|
+
pairs = machine.plugboard.pairs_list()
|
|
123
|
+
ptxt = " ".join(f"{a}{b}" for a, b in pairs) if pairs else "(none)"
|
|
124
|
+
safe_addstr(stdscr, pby + 1, 4, ptxt[:42], c_plug)
|
|
125
|
+
safe_addstr(stdscr, pby + 2, 4, f"{len(pairs)}/{MAX_PLUGS} pairs used", c_dim)
|
|
126
|
+
|
|
127
|
+
sx = 64
|
|
128
|
+
sig_h = min(21, h - 3)
|
|
129
|
+
draw_box(stdscr, 2, sx, sig_h, max(30, w - sx - 2), "SIGNAL PATH (this keypress)", c_border)
|
|
130
|
+
path = state.get("last_path")
|
|
131
|
+
if not path:
|
|
132
|
+
safe_addstr(stdscr, 4, sx + 3, "Press a letter key to see the current flow", c_dim)
|
|
133
|
+
safe_addstr(stdscr, 5, sx + 3, "through plugboard -> ETW -> rotors ->", c_dim)
|
|
134
|
+
safe_addstr(stdscr, 6, sx + 3, "reflector -> rotors -> ETW -> plugboard -> lamp.", c_dim)
|
|
135
|
+
else:
|
|
136
|
+
for i, (label, val) in enumerate(path):
|
|
137
|
+
r = 4 + i
|
|
138
|
+
if r >= 2 + sig_h - 1:
|
|
139
|
+
break
|
|
140
|
+
safe_addstr(stdscr, r, sx + 3, f"{label:<24}", c_sig)
|
|
141
|
+
safe_addstr(stdscr, r, sx + 29, val, c_lamp)
|
|
142
|
+
|
|
143
|
+
logy = 2 + sig_h + 1
|
|
144
|
+
draw_box(stdscr, logy, sx, 9, max(30, w - sx - 2), "MESSAGE LOG", c_border)
|
|
145
|
+
plain = "".join(p for p, _ in state["log"])
|
|
146
|
+
cipher = "".join(cp for _, cp in state["log"])
|
|
147
|
+
inner_w = max(10, w - sx - 6)
|
|
148
|
+
safe_addstr(stdscr, logy + 1, sx + 3, "Plain :")
|
|
149
|
+
safe_addstr(stdscr, logy + 2, sx + 3, group5(plain)[-inner_w:])
|
|
150
|
+
safe_addstr(stdscr, logy + 4, sx + 3, "Cipher:")
|
|
151
|
+
safe_addstr(stdscr, logy + 5, sx + 3, group5(cipher)[-inner_w:], curses.A_BOLD)
|
|
152
|
+
letters_only = sum(1 for p, _ in state["log"] if p != "/")
|
|
153
|
+
safe_addstr(stdscr, logy + 7, sx + 3,
|
|
154
|
+
f"Count: {letters_only} Last input: {pressed or '-'}", c_dim)
|
|
155
|
+
|
|
156
|
+
fy = h - 2
|
|
157
|
+
safe_addstr(stdscr, fy, 0, "-" * w, c_dim)
|
|
158
|
+
safe_addstr(stdscr, fy + 1, 2,
|
|
159
|
+
"F1 Help F2 Settings F3 Plugboard F4 Reset F5 Custom UKW ESC/Q Quit")
|
|
160
|
+
stdscr.refresh()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def flash_error(stdscr, msg):
|
|
164
|
+
h, w = stdscr.getmaxyx()
|
|
165
|
+
y = min(h - 4, 32)
|
|
166
|
+
safe_addstr(stdscr, y, 2, " " * min(len(msg) + 4, max(1, w - 4)))
|
|
167
|
+
safe_addstr(stdscr, y, 2, f" {msg} ", curses.color_pair(6) | curses.A_BOLD)
|
|
168
|
+
stdscr.refresh()
|
|
169
|
+
curses.napms(1200)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def draw_help(stdscr):
|
|
173
|
+
stdscr.erase()
|
|
174
|
+
lines = [
|
|
175
|
+
"TERMIGMA - HELP",
|
|
176
|
+
"",
|
|
177
|
+
"Simulates the Wehrmacht/Kriegsmarine Enigma family: Enigma I (3 rotors",
|
|
178
|
+
"from I-V), M3 (3 rotors from I-VIII), and M4 'Shark' (3 rotors from",
|
|
179
|
+
"I-VIII plus a non-stepping Beta/Gamma 4th wheel and a thin reflector).",
|
|
180
|
+
"",
|
|
181
|
+
"Type a letter A-Z to encipher it; the SIGNAL PATH panel shows every",
|
|
182
|
+
"stage: plugboard -> entry wheel (ETW) -> 3 (or 4) rotors -> reflector",
|
|
183
|
+
"-> back through the rotors -> ETW -> plugboard -> lamp.",
|
|
184
|
+
"",
|
|
185
|
+
"Rotors step automatically every keypress (including the middle-rotor",
|
|
186
|
+
"double-step) and this cannot be undone, exactly like the real machine.",
|
|
187
|
+
"",
|
|
188
|
+
"Controls:",
|
|
189
|
+
" A-Z encipher a letter SPACE visual separator only",
|
|
190
|
+
" F2 settings: model preset, ETW (military/commercial), plugboard",
|
|
191
|
+
" on/off, reflector (B/C/B-thin/C-thin/Custom), movable notches,",
|
|
192
|
+
" 4th wheel (when a thin reflector is chosen), and all 3 rotors",
|
|
193
|
+
" (type / ring / start position). UP/DOWN move, LEFT/RIGHT",
|
|
194
|
+
" change, ENTER apply, ESC cancel.",
|
|
195
|
+
" F3 plugboard editor - pairs like 'AB CD EF', up to 13 pairs",
|
|
196
|
+
" F5 custom (rewirable) reflector editor - needs exactly 13 pairs",
|
|
197
|
+
" covering all 26 letters (only used when Reflector = Custom)",
|
|
198
|
+
" F4 reset to default settings ESC/Q quit",
|
|
199
|
+
"",
|
|
200
|
+
"Note: exact wirings for the more exotic commercial/national variants",
|
|
201
|
+
"(K, D, Swiss-K, Railway, Tirpitz, Norenigma, Sonder, Abwehr G-machines)",
|
|
202
|
+
"are not included - see data/wiring_tables.json for why. The",
|
|
203
|
+
"'commercial-style' preset demonstrates the real QWERTZU entry wiring",
|
|
204
|
+
"and no-plugboard quirk of commercial machines using the Enigma I",
|
|
205
|
+
"rotor set as a stand-in.",
|
|
206
|
+
"",
|
|
207
|
+
"Press any key to return...",
|
|
208
|
+
]
|
|
209
|
+
for i, line in enumerate(lines):
|
|
210
|
+
safe_addstr(stdscr, 1 + i, 4, line)
|
|
211
|
+
stdscr.refresh()
|
|
212
|
+
stdscr.getch()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def draw_rotor_settings(stdscr, settings, active_field, field_order):
|
|
216
|
+
stdscr.erase()
|
|
217
|
+
safe_addstr(stdscr, 1, 2, "SETTINGS", curses.A_BOLD)
|
|
218
|
+
safe_addstr(stdscr, 2, 2, "UP/DOWN move LEFT/RIGHT change ENTER apply ESC cancel")
|
|
219
|
+
for i, field in enumerate(field_order):
|
|
220
|
+
y = 4 + i
|
|
221
|
+
attr = curses.A_REVERSE if field == active_field else 0
|
|
222
|
+
label = FIELD_LABEL[field]
|
|
223
|
+
val = fmt_val(settings[field])
|
|
224
|
+
safe_addstr(stdscr, y, 4, f"{label:<20}: < {val:<30} >", attr)
|
|
225
|
+
safe_addstr(stdscr, 5 + len(field_order), 4,
|
|
226
|
+
"Note: the three main rotors must all be different types.")
|
|
227
|
+
stdscr.refresh()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def rotor_settings_screen(stdscr, machine):
|
|
231
|
+
settings = {
|
|
232
|
+
"model": "Custom",
|
|
233
|
+
"etw": machine.etw.mode,
|
|
234
|
+
"plugboard_enabled": machine.plugboard_enabled,
|
|
235
|
+
"movable_notches": machine.movable_notches,
|
|
236
|
+
"reflector": machine.reflector_kind,
|
|
237
|
+
"L_type": machine.left.name, "L_ring": machine.left.ring_setting, "L_pos": machine.left.position_letter,
|
|
238
|
+
"M_type": machine.middle.name, "M_ring": machine.middle.ring_setting, "M_pos": machine.middle.position_letter,
|
|
239
|
+
"R_type": machine.right.name, "R_ring": machine.right.ring_setting, "R_pos": machine.right.position_letter,
|
|
240
|
+
"G_type": machine.fourth.name if machine.fourth else "Beta",
|
|
241
|
+
"G_ring": machine.fourth.ring_setting if machine.fourth else 1,
|
|
242
|
+
"G_pos": machine.fourth.position_letter if machine.fourth else "A",
|
|
243
|
+
}
|
|
244
|
+
if machine.movable_notches:
|
|
245
|
+
settings["L_notch"] = sorted(machine.left.notches)[0]
|
|
246
|
+
settings["M_notch"] = sorted(machine.middle.notches)[0]
|
|
247
|
+
settings["R_notch"] = sorted(machine.right.notches)[0]
|
|
248
|
+
else:
|
|
249
|
+
from .engine import ROTOR_DATA
|
|
250
|
+
settings["L_notch"] = sorted(ROTOR_DATA[settings["L_type"]]["notches"])[0]
|
|
251
|
+
settings["M_notch"] = sorted(ROTOR_DATA[settings["M_type"]]["notches"])[0]
|
|
252
|
+
settings["R_notch"] = sorted(ROTOR_DATA[settings["R_type"]]["notches"])[0]
|
|
253
|
+
|
|
254
|
+
idx = 0
|
|
255
|
+
while True:
|
|
256
|
+
field_order = build_field_order(settings)
|
|
257
|
+
idx = idx % len(field_order)
|
|
258
|
+
draw_rotor_settings(stdscr, settings, field_order[idx], field_order)
|
|
259
|
+
ch = stdscr.getch()
|
|
260
|
+
if ch == 27:
|
|
261
|
+
return
|
|
262
|
+
elif ch in (10, 13, curses.KEY_ENTER):
|
|
263
|
+
types = [settings["L_type"], settings["M_type"], settings["R_type"]]
|
|
264
|
+
if len(set(types)) < 3:
|
|
265
|
+
flash_error(stdscr, "The three main rotors must be different!")
|
|
266
|
+
continue
|
|
267
|
+
notch_over = (None, None, None)
|
|
268
|
+
if settings["movable_notches"]:
|
|
269
|
+
notch_over = (settings["L_notch"], settings["M_notch"], settings["R_notch"])
|
|
270
|
+
machine.left = Rotor(settings["L_type"], settings["L_ring"], settings["L_pos"], notch_over[0])
|
|
271
|
+
machine.middle = Rotor(settings["M_type"], settings["M_ring"], settings["M_pos"], notch_over[1])
|
|
272
|
+
machine.right = Rotor(settings["R_type"], settings["R_ring"], settings["R_pos"], notch_over[2])
|
|
273
|
+
machine.reflector_kind = settings["reflector"]
|
|
274
|
+
machine.reflector = Reflector(settings["reflector"], machine.custom_reflector_pairs)
|
|
275
|
+
from .engine import EntryWheel
|
|
276
|
+
machine.etw = EntryWheel(settings["etw"])
|
|
277
|
+
machine.plugboard_enabled = settings["plugboard_enabled"]
|
|
278
|
+
machine.movable_notches = settings["movable_notches"]
|
|
279
|
+
if settings["reflector"] in THIN_REFLECTORS:
|
|
280
|
+
machine.fourth = FourthWheel(settings["G_type"], settings["G_ring"], settings["G_pos"])
|
|
281
|
+
else:
|
|
282
|
+
machine.fourth = None
|
|
283
|
+
return
|
|
284
|
+
elif ch == curses.KEY_UP:
|
|
285
|
+
idx = (idx - 1) % len(field_order)
|
|
286
|
+
elif ch == curses.KEY_DOWN:
|
|
287
|
+
idx = (idx + 1) % len(field_order)
|
|
288
|
+
elif ch == curses.KEY_LEFT:
|
|
289
|
+
adjust_field(settings, field_order[idx], -1)
|
|
290
|
+
elif ch == curses.KEY_RIGHT:
|
|
291
|
+
adjust_field(settings, field_order[idx], 1)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def draw_plugboard_editor(stdscr, buf):
|
|
295
|
+
stdscr.erase()
|
|
296
|
+
safe_addstr(stdscr, 1, 2, "PLUGBOARD EDITOR", curses.A_BOLD)
|
|
297
|
+
safe_addstr(stdscr, 3, 2, "Type letter pairs separated by spaces, e.g.: AB CD EF")
|
|
298
|
+
safe_addstr(stdscr, 4, 2, f"(max {MAX_PLUGS} pairs, each letter used at most once)")
|
|
299
|
+
safe_addstr(stdscr, 6, 2, "> " + buf, curses.A_REVERSE)
|
|
300
|
+
safe_addstr(stdscr, 8, 2, "ENTER: apply ESC: cancel BACKSPACE: delete")
|
|
301
|
+
stdscr.refresh()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def plugboard_screen(stdscr, machine):
|
|
305
|
+
if not machine.plugboard_enabled:
|
|
306
|
+
flash_error(stdscr, "This model's plugboard is off - enable it in F2 first.")
|
|
307
|
+
return
|
|
308
|
+
buf = " ".join(f"{a}{b}" for a, b in machine.plugboard.pairs_list())
|
|
309
|
+
curses.curs_set(1)
|
|
310
|
+
while True:
|
|
311
|
+
draw_plugboard_editor(stdscr, buf)
|
|
312
|
+
ch = stdscr.getch()
|
|
313
|
+
if ch == 27:
|
|
314
|
+
break
|
|
315
|
+
elif ch in (10, 13, curses.KEY_ENTER):
|
|
316
|
+
ok, result = parse_plug_pairs(buf.upper())
|
|
317
|
+
if ok:
|
|
318
|
+
machine.plugboard = Plugboard(result)
|
|
319
|
+
break
|
|
320
|
+
else:
|
|
321
|
+
flash_error(stdscr, result)
|
|
322
|
+
elif ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
323
|
+
buf = buf[:-1]
|
|
324
|
+
elif 32 <= ch < 127:
|
|
325
|
+
c = chr(ch).upper()
|
|
326
|
+
if c.isalpha() or c == " ":
|
|
327
|
+
if len(buf) < 45:
|
|
328
|
+
buf += c
|
|
329
|
+
curses.curs_set(0)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def draw_reflector_editor(stdscr, buf):
|
|
333
|
+
stdscr.erase()
|
|
334
|
+
safe_addstr(stdscr, 1, 2, "CUSTOM REFLECTOR EDITOR (rewirable, like UKW-D)", curses.A_BOLD)
|
|
335
|
+
safe_addstr(stdscr, 3, 2, "Type exactly 13 pairs covering all 26 letters, e.g.:")
|
|
336
|
+
safe_addstr(stdscr, 4, 2, "AB CD EF GH IJ KL MN OP QR ST UV WX YZ")
|
|
337
|
+
safe_addstr(stdscr, 6, 2, "> " + buf, curses.A_REVERSE)
|
|
338
|
+
safe_addstr(stdscr, 8, 2, "ENTER: apply ESC: cancel BACKSPACE: delete")
|
|
339
|
+
stdscr.refresh()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def custom_reflector_screen(stdscr, machine):
|
|
343
|
+
if machine.reflector_kind != "Custom":
|
|
344
|
+
flash_error(stdscr, "Set Reflector to 'Custom' in F2 settings first.")
|
|
345
|
+
return
|
|
346
|
+
buf = " ".join(f"{a}{b}" for a, b in sorted(machine.custom_reflector_pairs.items()) if a < b)
|
|
347
|
+
curses.curs_set(1)
|
|
348
|
+
while True:
|
|
349
|
+
draw_reflector_editor(stdscr, buf)
|
|
350
|
+
ch = stdscr.getch()
|
|
351
|
+
if ch == 27:
|
|
352
|
+
break
|
|
353
|
+
elif ch in (10, 13, curses.KEY_ENTER):
|
|
354
|
+
ok, result = parse_reflector_pairs(buf.upper())
|
|
355
|
+
if ok:
|
|
356
|
+
machine.custom_reflector_pairs = result
|
|
357
|
+
machine.reflector = Reflector("Custom", result)
|
|
358
|
+
break
|
|
359
|
+
else:
|
|
360
|
+
flash_error(stdscr, result)
|
|
361
|
+
elif ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
362
|
+
buf = buf[:-1]
|
|
363
|
+
elif 32 <= ch < 127:
|
|
364
|
+
c = chr(ch).upper()
|
|
365
|
+
if c.isalpha() or c == " ":
|
|
366
|
+
if len(buf) < 60:
|
|
367
|
+
buf += c
|
|
368
|
+
curses.curs_set(0)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def run(stdscr):
|
|
372
|
+
curses.curs_set(0)
|
|
373
|
+
stdscr.keypad(True)
|
|
374
|
+
init_colors()
|
|
375
|
+
|
|
376
|
+
machine = Enigma()
|
|
377
|
+
state = {"log": [], "last_path": [], "last_key": None, "last_out": None}
|
|
378
|
+
|
|
379
|
+
while True:
|
|
380
|
+
draw_all(stdscr, machine, state)
|
|
381
|
+
ch = stdscr.getch()
|
|
382
|
+
|
|
383
|
+
if ch in (27, ord("q"), ord("Q")):
|
|
384
|
+
break
|
|
385
|
+
elif ch == curses.KEY_F1:
|
|
386
|
+
draw_help(stdscr)
|
|
387
|
+
elif ch == curses.KEY_F2:
|
|
388
|
+
rotor_settings_screen(stdscr, machine)
|
|
389
|
+
elif ch == curses.KEY_F3:
|
|
390
|
+
plugboard_screen(stdscr, machine)
|
|
391
|
+
elif ch == curses.KEY_F4:
|
|
392
|
+
machine = Enigma()
|
|
393
|
+
state = {"log": [], "last_path": [], "last_key": None, "last_out": None}
|
|
394
|
+
elif ch == curses.KEY_F5:
|
|
395
|
+
custom_reflector_screen(stdscr, machine)
|
|
396
|
+
elif ch == 32:
|
|
397
|
+
state["log"].append(("/", "/"))
|
|
398
|
+
state["last_key"] = None
|
|
399
|
+
state["last_out"] = None
|
|
400
|
+
elif 65 <= ch <= 90 or 97 <= ch <= 122:
|
|
401
|
+
letter = chr(ch).upper()
|
|
402
|
+
out, path = machine.encode_letter(letter)
|
|
403
|
+
state["last_key"] = letter
|
|
404
|
+
state["last_out"] = out
|
|
405
|
+
state["last_path"] = path
|
|
406
|
+
state["log"].append((letter, out))
|
|
407
|
+
if len(state["log"]) > 500:
|
|
408
|
+
state["log"] = state["log"][-500:]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def main():
|
|
412
|
+
curses.wrapper(run)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
if __name__ == "__main__":
|
|
416
|
+
main()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: termigma
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: An interactive terminal (TUI) Enigma machine simulator
|
|
5
|
+
Author: Rex Ackermann
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rexackermann/termigma
|
|
8
|
+
Project-URL: Repository, https://github.com/rexackermann/termigma
|
|
9
|
+
Project-URL: Issues, https://github.com/rexackermann/termigma/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/rexackermann/termigma/blob/main/CHANGELOG.md
|
|
11
|
+
Project-URL: Reference simulator, https://people.physik.hu-berlin.de/~palloks/js/enigma/enigma-u_v262_en.html
|
|
12
|
+
Keywords: enigma,cipher,cryptography,tui,curses,terminal,wwii,history
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Environment :: Console :: Curses
|
|
15
|
+
Classifier: Intended Audience :: Education
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Security :: Cryptography
|
|
19
|
+
Classifier: Topic :: Games/Entertainment :: Simulation
|
|
20
|
+
Classifier: Topic :: Education
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: windows-curses; platform_system == "Windows"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# termigma
|
|
30
|
+
|
|
31
|
+
A terminal simulator of the Wehrmacht/Kriegsmarine Enigma machine — rotors,
|
|
32
|
+
plugboard, lampboard, and a live signal-path monitor, all in your terminal.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install termigma
|
|
36
|
+
termigma
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
No dependencies on Linux/macOS (just the standard library's `curses`). On
|
|
40
|
+
Windows, `pip install termigma` pulls in `windows-curses` automatically.
|
|
41
|
+
|
|
42
|
+
## What it does
|
|
43
|
+
|
|
44
|
+
You type a letter, it lights up on the lampboard, and the **signal path**
|
|
45
|
+
panel shows exactly how it got there: plugboard → entry wheel → through each
|
|
46
|
+
rotor → reflector → back through the rotors → entry wheel → plugboard → lamp.
|
|
47
|
+
The rotors step on every keypress, including the historically correct
|
|
48
|
+
middle-rotor "double step," and there's no undo — same as the real machine.
|
|
49
|
+
|
|
50
|
+
It covers three points in the machine's history:
|
|
51
|
+
|
|
52
|
+
- **Enigma I** (Army/Air Force) — 3 rotors chosen from I–V
|
|
53
|
+
- **M3** (Army/Navy) — 3 rotors chosen from I–VIII
|
|
54
|
+
- **M4 "Shark"** (U-boats) — adds a fourth, non-stepping Beta/Gamma wheel and
|
|
55
|
+
a thin reflector
|
|
56
|
+
|
|
57
|
+
Plus a **commercial-style** mode that demonstrates the real quirk of
|
|
58
|
+
commercial Enigmas: no plugboard, and an entry wheel wired in keyboard
|
|
59
|
+
(QWERTZU) order instead of straight A–Z.
|
|
60
|
+
|
|
61
|
+
## Controls
|
|
62
|
+
|
|
63
|
+
| Key | Does |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `A`–`Z` | Encipher a letter |
|
|
66
|
+
| `Space` | Visual separator (doesn't step the rotors) |
|
|
67
|
+
| `F1` | Help |
|
|
68
|
+
| `F2` | Settings — model preset, ETW mode, plugboard on/off, reflector, movable notches, 4th wheel, all three rotors |
|
|
69
|
+
| `F3` | Plugboard editor |
|
|
70
|
+
| `F5` | Custom rewirable reflector editor (needs Reflector = Custom) |
|
|
71
|
+
| `F4` | Reset to defaults |
|
|
72
|
+
| `Esc` / `Q` | Quit |
|
|
73
|
+
|
|
74
|
+
Terminal needs to be at least 108×34 or it'll ask you to resize.
|
|
75
|
+
|
|
76
|
+
## Why it looks the way it does
|
|
77
|
+
|
|
78
|
+
Real Enigmas didn't have an undo button, a Ctrl-Z, or a way to peek inside
|
|
79
|
+
while it ran. This tries to keep that feel — the rotors always turn, the
|
|
80
|
+
plugboard is genuinely optional depending on which model you pick — while
|
|
81
|
+
also showing you the one thing the real machine couldn't: what's happening
|
|
82
|
+
electrically at every stage, for every keystroke.
|
|
83
|
+
|
|
84
|
+
## Project layout
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
src/termigma/engine.py the machine itself — no curses, fully unit-testable
|
|
88
|
+
src/termigma/tui.py curses rendering and the input loop
|
|
89
|
+
tests/test_engine.py pytest suite (reciprocity checks, stepping, etc.)
|
|
90
|
+
data/wiring_tables.json the historical wiring data, with sourcing notes
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Running from source
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
git clone https://github.com/rexackermann/termigma
|
|
97
|
+
cd termigma
|
|
98
|
+
pip install -e ".[dev]"
|
|
99
|
+
pytest
|
|
100
|
+
termigma
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Accuracy
|
|
104
|
+
|
|
105
|
+
The rotor and reflector wiring (rotors I–VIII, the M4 Beta/Gamma wheels, and
|
|
106
|
+
reflectors B/C plus their thin M4 counterparts) is the widely published
|
|
107
|
+
historical data, checked here against a known reference test vector and a
|
|
108
|
+
set of round-trip (reciprocity) tests — see `tests/test_engine.py`.
|
|
109
|
+
|
|
110
|
+
What's **not** included: exact wiring for the more exotic commercial and
|
|
111
|
+
national variants (Enigma K, Enigma D, Swiss-K, Railway, Tirpitz, Norenigma,
|
|
112
|
+
Sonder-Enigma, the Abwehr G-machines). This project doesn't have confidently
|
|
113
|
+
sourced wiring for those, and a guess dressed up as fact is worse than an
|
|
114
|
+
honest gap. The "commercial-style" preset demonstrates the real no-plugboard
|
|
115
|
+
/ QWERTZU-entry-wheel behavior of commercial machines but reuses the Enigma I
|
|
116
|
+
rotor set rather than true K/D rotors. See `data/wiring_tables.json` for the
|
|
117
|
+
full sourcing note, and cross-check the Crypto Museum's Enigma pages if you
|
|
118
|
+
need one of those specific machines to be byte-exact.
|
|
119
|
+
|
|
120
|
+
## Credits
|
|
121
|
+
|
|
122
|
+
- Inspired by Daniel Palloks' [Universal Enigma](https://people.physik.hu-berlin.de/~palloks/js/enigma/enigma-u_v262_en.html),
|
|
123
|
+
a browser-based simulator covering a much wider range of historical
|
|
124
|
+
Enigma variants than this project attempts.
|
|
125
|
+
- Enigma explainer video: https://www.youtube.com/watch?v=JsBZOcqZerk — for inspiration.
|
|
126
|
+
- Built with help from [Claude](https://claude.ai) (Anthropic).
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
termigma/__init__.py,sha256=7qibzk03V2ookkUXN_dSpZMILxY0bA4mTo5ZvoETfB0,154
|
|
2
|
+
termigma/__main__.py,sha256=RHyE9mNpvuVsj3mMvjuuDh7TjxHzLE0DppyxR_-Td0o,61
|
|
3
|
+
termigma/engine.py,sha256=x6EhdLeWFHHQaOfsYdp3KymqK9xszjkieB05UbecyPQ,14499
|
|
4
|
+
termigma/tui.py,sha256=us91FmYnx1iKIdkvQ2UFjujMV-PV1vtfxhQouExueRo,17219
|
|
5
|
+
termigma-1.0.0.dist-info/licenses/LICENSE,sha256=WxWOtff7tYX_f8A_q-uOJj_OAFUnqh_AW1zdKjG9u4U,1070
|
|
6
|
+
termigma-1.0.0.dist-info/METADATA,sha256=LpE-GQmK2_hpYD6waDMRBuTFhI_Jhbs9K8ElploqG9I,5205
|
|
7
|
+
termigma-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
termigma-1.0.0.dist-info/entry_points.txt,sha256=eVfdT6PtORuzEoKwbM656dEe-cJXl69wE_2A5c0i3XE,47
|
|
9
|
+
termigma-1.0.0.dist-info/top_level.txt,sha256=F652UW-vzCk1u3I6_i_z1F37isZ1XQ4B_ZbV2DwK2I4,9
|
|
10
|
+
termigma-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rex Ackermann
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
termigma
|