pyvacs 0.1.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.
pyvacs/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """pyvacs: a Python 2650 (Signetics/Philips) assembler and disassembler.
2
+
3
+ A clean-room Python reimplementation of the VACS 1.24 assembler for the
4
+ Signetics 2650 microprocessor family, plus a round-trip-safe disassembler.
5
+ """
6
+
7
+ from .assembler import AssembledProgram, AssemblyError, Assembler, assemble
8
+ from .disassembler import Disassembler, Line, disassemble, to_source
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = [
13
+ "AssembledProgram",
14
+ "AssemblyError",
15
+ "Assembler",
16
+ "assemble",
17
+ "Disassembler",
18
+ "Line",
19
+ "disassemble",
20
+ "to_source",
21
+ "__version__",
22
+ ]
pyvacs/assembler.py ADDED
@@ -0,0 +1,542 @@
1
+ """Two-pass assembler for the Signetics 2650.
2
+
3
+ The assembler accepts VACS-style source: an optional label, a mnemonic or
4
+ directive, operands, and an optional ``;`` comment. Because every 2650
5
+ instruction has a fixed length, the first pass only needs to track the location
6
+ counter and record label addresses; the second pass evaluates expressions and
7
+ emits bytes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Dict, List, Optional, Tuple
14
+
15
+ from . import isa
16
+ from .lexer import Token, evaluate, tokenize
17
+
18
+ DIRECTIVES = {
19
+ "ORG",
20
+ "EQU",
21
+ "SET",
22
+ "DB",
23
+ "BYTE",
24
+ "ASCII",
25
+ "DW",
26
+ "WORD",
27
+ "DD",
28
+ "DS",
29
+ "DBX",
30
+ "DBFILL",
31
+ "END",
32
+ "NAME",
33
+ "TITLE",
34
+ "PAGE",
35
+ "PAGELEN",
36
+ "PGLEN",
37
+ "WIDTH",
38
+ "LMARG",
39
+ "LEFTMARG",
40
+ "TABS",
41
+ "OFFS",
42
+ "LIST",
43
+ "NOLIST",
44
+ "NOLST",
45
+ "EJECT",
46
+ "SBTTL",
47
+ "STITLE",
48
+ }
49
+
50
+
51
+ class AssemblyError(Exception):
52
+ """Raised on a source error, with the offending line number attached."""
53
+
54
+ def __init__(self, message: str, lineno: int) -> None:
55
+ super().__init__(f"line {lineno}: {message}")
56
+ self.lineno = lineno
57
+ self.raw_message = message
58
+
59
+
60
+ @dataclass
61
+ class Segment:
62
+ """A contiguous run of emitted bytes starting at ``start``."""
63
+
64
+ start: int
65
+ data: bytearray = field(default_factory=bytearray)
66
+
67
+
68
+ @dataclass
69
+ class AssembledProgram:
70
+ """Result of assembling a source file."""
71
+
72
+ segments: List[Segment]
73
+ symbols: Dict[str, int]
74
+ start_address: int
75
+
76
+ def to_bytes(self, fill: int = 0x00) -> bytes:
77
+ """Flatten all segments into one binary, padding gaps with ``fill``."""
78
+ if not self.segments:
79
+ return b""
80
+ lowest = min(seg.start for seg in self.segments)
81
+ highest = max(seg.start + len(seg.data) for seg in self.segments)
82
+ out = bytearray([fill & 0xFF]) * (highest - lowest)
83
+ for seg in self.segments:
84
+ offset = seg.start - lowest
85
+ out[offset : offset + len(seg.data)] = seg.data
86
+ return bytes(out)
87
+
88
+ @property
89
+ def origin(self) -> int:
90
+ """Lowest address emitted (0 when nothing was emitted)."""
91
+ if not self.segments:
92
+ return 0
93
+ return min(seg.start for seg in self.segments)
94
+
95
+
96
+ class Assembler:
97
+ """Assemble 2650 source text into bytes."""
98
+
99
+ def __init__(self) -> None:
100
+ self.symbols: Dict[str, int] = {}
101
+ self.location = 0
102
+ self.start_address = 0
103
+ self._pass = 1
104
+ self._segments: List[Segment] = []
105
+ self._current: Optional[Segment] = None
106
+ self._lineno = 0
107
+ for name, value in isa.CONDITION_VALUES.items():
108
+ self.symbols[name.upper()] = value
109
+
110
+ # -- public API -------------------------------------------------------
111
+
112
+ def assemble(self, source: str) -> AssembledProgram:
113
+ """Assemble ``source`` and return the resulting program."""
114
+ lines = source.splitlines()
115
+ self._run_pass(lines, first=True)
116
+ self._run_pass(lines, first=False)
117
+ return AssembledProgram(
118
+ segments=self._segments,
119
+ symbols=dict(self.symbols),
120
+ start_address=self.start_address,
121
+ )
122
+
123
+ # -- pass driver ------------------------------------------------------
124
+
125
+ def _run_pass(self, lines: List[str], first: bool) -> None:
126
+ self._pass = 1 if first else 2
127
+ self.location = 0
128
+ self.start_address = 0
129
+ self._segments = []
130
+ self._current = None
131
+ for index, line in enumerate(lines, start=1):
132
+ self._lineno = index
133
+ tokens = self._safe_tokenize(line)
134
+ if tokens is None:
135
+ continue
136
+ column0 = bool(line) and not line[0].isspace()
137
+ if self._handle_line(tokens, column0):
138
+ break
139
+
140
+ def _safe_tokenize(self, line: str) -> Optional[List[Token]]:
141
+ try:
142
+ return tokenize(line)
143
+ except ValueError as exc:
144
+ raise AssemblyError(str(exc), self._lineno) from exc
145
+
146
+ # -- line handling ----------------------------------------------------
147
+
148
+ def _handle_line(self, tokens: List[Token], column0: bool) -> bool:
149
+ """Process one tokenised line. Returns True at END."""
150
+ pos = self._consume_label(tokens, column0)
151
+ tok = tokens[pos]
152
+ if tok.kind == "EOL":
153
+ return False
154
+ if tok.kind != "IDENT":
155
+ raise AssemblyError(f"unexpected token {tok.text!r}", self._lineno)
156
+ name = str(tok.value)
157
+ if name == "END":
158
+ self._directive_end(tokens, pos + 1)
159
+ return True
160
+ if name in DIRECTIVES:
161
+ self._handle_directive(name, tokens, pos + 1)
162
+ return False
163
+ if name in isa.INSTRUCTIONS:
164
+ self._assemble_instruction(name, tokens, pos + 1)
165
+ return False
166
+ raise AssemblyError(f"unknown opcode {tok.text!r}", self._lineno)
167
+
168
+ def _consume_label(self, tokens: List[Token], column0: bool) -> int:
169
+ """Define a leading label if present; return the next token index.
170
+
171
+ ``column0`` is True when the line had no leading whitespace, which (per
172
+ VACS convention) is what lets a bare identifier with no opcode be a
173
+ label rather than a mistyped instruction.
174
+ """
175
+ first = tokens[0]
176
+ if first.kind != "IDENT":
177
+ return 0
178
+ nxt = tokens[1]
179
+ if nxt.kind == "OP" and nxt.text == ":":
180
+ self._define_label(str(first.value))
181
+ return 2
182
+ if str(first.value) in isa.INSTRUCTIONS or str(first.value) in DIRECTIVES:
183
+ return 0 # the leading word is itself an opcode/directive
184
+ if nxt.kind == "IDENT" and nxt.value in ("EQU", "SET"):
185
+ self._define_label(str(first.value))
186
+ return 1 # the EQU/SET directive sits at index 1
187
+ if nxt.kind == "EOL" and not column0:
188
+ return 0 # an indented lone word is an (unknown) opcode, not a label
189
+ self._define_label(str(first.value))
190
+ return 1
191
+
192
+ def _define_label(self, name: str) -> None:
193
+ self._set_symbol(name, self.location)
194
+
195
+ def _set_symbol(self, name: str, value: int) -> None:
196
+ key = name.upper()
197
+ if self._pass == 1 and key in self.symbols and key not in _PREDEFINED:
198
+ raise AssemblyError(f"duplicate symbol {name!r}", self._lineno)
199
+ self.symbols[key] = value
200
+
201
+ # -- expression helpers ----------------------------------------------
202
+
203
+ def _resolve(self, name: str) -> Optional[int]:
204
+ return self.symbols.get(name.upper())
205
+
206
+ def _eval(self, tokens: List[Token], pos: int) -> Tuple[int, int]:
207
+ try:
208
+ value, defined, new_pos = evaluate(
209
+ tokens, pos, self._resolve, self.location
210
+ )
211
+ except ValueError as exc:
212
+ raise AssemblyError(str(exc), self._lineno) from exc
213
+ if self._pass == 2 and not defined:
214
+ raise AssemblyError("undefined symbol in expression", self._lineno)
215
+ return value, new_pos
216
+
217
+ # -- byte emission ----------------------------------------------------
218
+
219
+ def _emit(self, data: bytes) -> None:
220
+ if self._pass == 2:
221
+ if self._current is None or self.location != (
222
+ self._current.start + len(self._current.data)
223
+ ):
224
+ self._current = Segment(self.location)
225
+ self._segments.append(self._current)
226
+ self._current.data.extend(data)
227
+ self.location += len(data)
228
+
229
+ # -- directives -------------------------------------------------------
230
+
231
+ def _handle_directive(self, name: str, tokens: List[Token], pos: int) -> None:
232
+ handler = {
233
+ "ORG": self._directive_org,
234
+ "EQU": self._directive_equ,
235
+ "SET": self._directive_equ,
236
+ "DB": self._directive_db,
237
+ "BYTE": self._directive_db,
238
+ "ASCII": self._directive_db,
239
+ "DW": self._directive_dw,
240
+ "WORD": self._directive_dw,
241
+ "DD": self._directive_dd,
242
+ "DS": self._directive_ds,
243
+ "DBX": self._directive_dbx,
244
+ "DBFILL": self._directive_dbfill,
245
+ }.get(name)
246
+ if handler is None:
247
+ return # listing-only directive: ignore the rest of the line
248
+ handler(tokens, pos)
249
+
250
+ def _directive_org(self, tokens: List[Token], pos: int) -> None:
251
+ value, _ = self._eval(tokens, pos)
252
+ self.location = value
253
+ self._current = None
254
+
255
+ def _directive_equ(self, tokens: List[Token], pos: int) -> None:
256
+ # The label was not consumed yet; it sits at tokens[0].
257
+ if tokens[0].kind != "IDENT":
258
+ raise AssemblyError("EQU needs a label", self._lineno)
259
+ name = str(tokens[0].value)
260
+ value, _ = self._eval(tokens, pos)
261
+ self.symbols[name.upper()] = value
262
+
263
+ def _directive_db(self, tokens: List[Token], pos: int) -> None:
264
+ for item, next_pos in self._iter_items(tokens, pos):
265
+ if item.kind == "STRING":
266
+ self._emit(bytes(ord(ch) & 0xFF for ch in str(item.value)))
267
+ pos = next_pos
268
+ else:
269
+ value, pos = self._eval(tokens, pos)
270
+ self._check_range(value, -128, 255, "byte")
271
+ self._emit(bytes([value & 0xFF]))
272
+ pos = self._skip_comma(tokens, pos)
273
+
274
+ def _directive_dw(self, tokens: List[Token], pos: int) -> None:
275
+ pos = self._emit_words(tokens, pos, width=2, low=-32768, high=65535)
276
+
277
+ def _directive_dd(self, tokens: List[Token], pos: int) -> None:
278
+ pos = self._emit_words(tokens, pos, width=4, low=None, high=None)
279
+
280
+ def _emit_words(
281
+ self,
282
+ tokens: List[Token],
283
+ pos: int,
284
+ width: int,
285
+ low: Optional[int],
286
+ high: Optional[int],
287
+ ) -> int:
288
+ while True:
289
+ value, pos = self._eval(tokens, pos)
290
+ if low is not None and high is not None:
291
+ self._check_range(value, low, high, "word")
292
+ self._emit((value & ((1 << (8 * width)) - 1)).to_bytes(width, "big"))
293
+ pos = self._skip_comma(tokens, pos)
294
+ if tokens[pos].kind == "EOL":
295
+ return pos
296
+
297
+ def _directive_ds(self, tokens: List[Token], pos: int) -> None:
298
+ count, pos = self._eval(tokens, pos)
299
+ self._check_range(count, 0, 32767, "storage size")
300
+ fill = 0
301
+ if tokens[pos].kind == "OP" and tokens[pos].text == ",":
302
+ fill, pos = self._eval(tokens, pos + 1)
303
+ self._emit(bytes([fill & 0xFF]) * count)
304
+
305
+ def _directive_dbfill(self, tokens: List[Token], pos: int) -> None:
306
+ count, pos = self._eval(tokens, pos)
307
+ self._check_range(count, 0, 32767, "fill count")
308
+ fill = 0
309
+ if tokens[pos].kind == "OP" and tokens[pos].text == ",":
310
+ fill, pos = self._eval(tokens, pos + 1)
311
+ self._emit(bytes([fill & 0xFF]) * count)
312
+
313
+ def _directive_dbx(self, tokens: List[Token], pos: int) -> None:
314
+ tok = tokens[pos]
315
+ if tok.kind != "STRING":
316
+ raise AssemblyError("DBX needs a quoted pattern", self._lineno)
317
+ pattern = str(tok.value)
318
+ if len(pattern) != 8:
319
+ raise AssemblyError("DBX needs 8 pattern characters", self._lineno)
320
+ value = 0
321
+ for index, ch in enumerate(pattern):
322
+ if ch not in (" ", "."):
323
+ value |= 1 << (7 - index)
324
+ self._emit(bytes([value]))
325
+
326
+ def _directive_end(self, tokens: List[Token], pos: int) -> None:
327
+ if tokens[pos].kind != "EOL":
328
+ value, _ = self._eval(tokens, pos)
329
+ self.start_address = value
330
+
331
+ # -- item iteration helpers ------------------------------------------
332
+
333
+ def _iter_items(self, tokens: List[Token], pos: int):
334
+ """Yield ``(token, next_pos)`` for each comma-separated DB item."""
335
+ while tokens[pos].kind != "EOL":
336
+ yield tokens[pos], pos + 1
337
+ if tokens[pos].kind == "STRING":
338
+ pos += 1
339
+ else:
340
+ _, pos = self._eval(tokens, pos)
341
+ pos = self._skip_comma(tokens, pos)
342
+
343
+ @staticmethod
344
+ def _skip_comma(tokens: List[Token], pos: int) -> int:
345
+ if tokens[pos].kind == "OP" and tokens[pos].text == ",":
346
+ return pos + 1
347
+ return pos
348
+
349
+ def _check_range(self, value: int, low: int, high: int, what: str) -> None:
350
+ if not low <= value <= high:
351
+ raise AssemblyError(
352
+ f"{what} value {value} out of range {low}..{high}", self._lineno
353
+ )
354
+
355
+ # -- instruction assembly --------------------------------------------
356
+
357
+ def _assemble_instruction(
358
+ self, mnemonic: str, tokens: List[Token], pos: int
359
+ ) -> None:
360
+ mode = isa.INSTRUCTIONS[mnemonic][1]
361
+ address = self.location
362
+ ops, pos = self._parse_operands(mode, tokens, pos)
363
+ self._expect_eol(tokens, pos)
364
+ if self._pass == 1:
365
+ # Pass one only needs the (fixed) instruction length; operand values
366
+ # may still reference undefined forward symbols, so we must not run
367
+ # the encoder's range checks against placeholder zeros here.
368
+ self.location += isa.MODE_LENGTHS[mode]
369
+ return
370
+ try:
371
+ data = isa.encode(mnemonic, ops, address)
372
+ except isa.EncodingError as exc:
373
+ raise AssemblyError(str(exc), self._lineno) from exc
374
+ self._emit(data)
375
+
376
+ def _parse_operands(
377
+ self, mode: str, tokens: List[Token], pos: int
378
+ ) -> Tuple[Dict[str, int], int]:
379
+ parser = {
380
+ "inh": self._parse_inh,
381
+ "reg": self._parse_reg,
382
+ "reg13": self._parse_reg,
383
+ "regz": self._parse_reg,
384
+ "creg": self._parse_creg,
385
+ "ccond": self._parse_ccond,
386
+ "imm": self._parse_imm,
387
+ "regimm": self._parse_regimm,
388
+ "zbr": self._parse_zbr,
389
+ "regrel": self._parse_regaddr,
390
+ "crel": self._parse_caddr,
391
+ "crel3": self._parse_caddr,
392
+ "regabs": self._parse_regaddr,
393
+ "cabs": self._parse_caddr,
394
+ "cabs3": self._parse_caddr,
395
+ "idxabs": self._parse_idxabs,
396
+ "absx": self._parse_absx,
397
+ }[mode]
398
+ return parser(tokens, pos)
399
+
400
+ def _parse_inh(self, _tokens: List[Token], pos: int) -> Tuple[Dict[str, int], int]:
401
+ return {}, pos
402
+
403
+ def _parse_reg(self, tokens: List[Token], pos: int) -> Tuple[Dict[str, int], int]:
404
+ reg, pos = self._read_register(tokens, pos)
405
+ return {"reg": reg}, pos
406
+
407
+ def _parse_creg(self, tokens: List[Token], pos: int) -> Tuple[Dict[str, int], int]:
408
+ pos = self._expect_comma(tokens, pos)
409
+ reg, pos = self._read_register(tokens, pos)
410
+ return {"reg": reg}, pos
411
+
412
+ def _parse_ccond(self, tokens: List[Token], pos: int) -> Tuple[Dict[str, int], int]:
413
+ pos = self._expect_comma(tokens, pos)
414
+ cond, pos = self._read_condition(tokens, pos)
415
+ return {"cond": cond}, pos
416
+
417
+ def _parse_imm(self, tokens: List[Token], pos: int) -> Tuple[Dict[str, int], int]:
418
+ value, pos = self._eval(tokens, pos)
419
+ return {"value": value}, pos
420
+
421
+ def _parse_regimm(
422
+ self, tokens: List[Token], pos: int
423
+ ) -> Tuple[Dict[str, int], int]:
424
+ pos = self._expect_comma(tokens, pos)
425
+ reg, pos = self._read_register(tokens, pos)
426
+ value, pos = self._eval(tokens, pos)
427
+ return {"reg": reg, "value": value}, pos
428
+
429
+ def _parse_zbr(self, tokens: List[Token], pos: int) -> Tuple[Dict[str, int], int]:
430
+ indirect, pos = self._read_indirect(tokens, pos)
431
+ target, pos = self._eval(tokens, pos)
432
+ return {"target": target, "indirect": indirect}, pos
433
+
434
+ def _parse_regaddr(
435
+ self, tokens: List[Token], pos: int
436
+ ) -> Tuple[Dict[str, int], int]:
437
+ pos = self._expect_comma(tokens, pos)
438
+ reg, pos = self._read_register(tokens, pos)
439
+ indirect, pos = self._read_indirect(tokens, pos)
440
+ target, pos = self._eval(tokens, pos)
441
+ return {"reg": reg, "indirect": indirect, "target": target}, pos
442
+
443
+ def _parse_caddr(self, tokens: List[Token], pos: int) -> Tuple[Dict[str, int], int]:
444
+ pos = self._expect_comma(tokens, pos)
445
+ cond, pos = self._read_condition(tokens, pos)
446
+ indirect, pos = self._read_indirect(tokens, pos)
447
+ target, pos = self._eval(tokens, pos)
448
+ return {"cond": cond, "indirect": indirect, "target": target}, pos
449
+
450
+ def _parse_idxabs(
451
+ self, tokens: List[Token], pos: int
452
+ ) -> Tuple[Dict[str, int], int]:
453
+ indirect, pos = self._read_indirect(tokens, pos)
454
+ target, pos = self._eval(tokens, pos)
455
+ # Optional ",3" index designator (implied anyway).
456
+ if tokens[pos].kind == "OP" and tokens[pos].text == ",":
457
+ _, pos = self._read_register_or_value(tokens, pos + 1)
458
+ return {"target": target, "indirect": indirect}, pos
459
+
460
+ def _parse_absx(self, tokens: List[Token], pos: int) -> Tuple[Dict[str, int], int]:
461
+ pos = self._expect_comma(tokens, pos)
462
+ reg, pos = self._read_register(tokens, pos)
463
+ indirect, pos = self._read_indirect(tokens, pos)
464
+ target, pos = self._eval(tokens, pos)
465
+ index_ctl = isa.INDEX_NONE
466
+ if tokens[pos].kind == "OP" and tokens[pos].text == ",":
467
+ pos += 1
468
+ if tokens[pos].kind == "REG":
469
+ reg = int(tokens[pos].value)
470
+ index_ctl = isa.INDEX_PLAIN
471
+ pos += 1
472
+ if tokens[pos].kind == "OP" and tokens[pos].text == ",":
473
+ pos += 1
474
+ if tokens[pos].kind == "OP" and tokens[pos].text in ("+", "-"):
475
+ index_ctl = (
476
+ isa.INDEX_AUTO_INC
477
+ if tokens[pos].text == "+"
478
+ else isa.INDEX_AUTO_DEC
479
+ )
480
+ pos += 1
481
+ return {
482
+ "reg": reg,
483
+ "indirect": indirect,
484
+ "target": target,
485
+ "index_ctl": index_ctl,
486
+ }, pos
487
+
488
+ # -- low-level operand readers ---------------------------------------
489
+
490
+ def _read_register(self, tokens: List[Token], pos: int) -> Tuple[int, int]:
491
+ tok = tokens[pos]
492
+ if tok.kind != "REG":
493
+ raise AssemblyError("register expected", self._lineno)
494
+ return int(tok.value), pos + 1
495
+
496
+ def _read_register_or_value(self, tokens: List[Token], pos: int) -> Tuple[int, int]:
497
+ if tokens[pos].kind == "REG":
498
+ return int(tokens[pos].value), pos + 1
499
+ return self._eval(tokens, pos)
500
+
501
+ def _read_condition(self, tokens: List[Token], pos: int) -> Tuple[int, int]:
502
+ tok = tokens[pos]
503
+ if tok.kind == "NUMBER":
504
+ value = int(tok.value)
505
+ elif tok.kind == "IDENT":
506
+ resolved = self._resolve(str(tok.value))
507
+ if resolved is None:
508
+ if self._pass == 2:
509
+ raise AssemblyError("undefined condition", self._lineno)
510
+ value = 0
511
+ else:
512
+ value = resolved
513
+ else:
514
+ raise AssemblyError("condition expected", self._lineno)
515
+ if self._pass == 2 and not 0 <= value <= 3:
516
+ raise AssemblyError("condition must be 0..3", self._lineno)
517
+ return value, pos + 1
518
+
519
+ def _read_indirect(self, tokens: List[Token], pos: int) -> Tuple[int, int]:
520
+ if tokens[pos].kind == "OP" and tokens[pos].text == "*":
521
+ return 1, pos + 1
522
+ return 0, pos
523
+
524
+ def _expect_comma(self, tokens: List[Token], pos: int) -> int:
525
+ if tokens[pos].kind == "OP" and tokens[pos].text == ",":
526
+ return pos + 1
527
+ raise AssemblyError("comma expected", self._lineno)
528
+
529
+ def _expect_eol(self, tokens: List[Token], pos: int) -> None:
530
+ if tokens[pos].kind != "EOL":
531
+ raise AssemblyError(
532
+ f"unexpected operand {tokens[pos].text!r}", self._lineno
533
+ )
534
+
535
+
536
+ # Symbols predefined by the assembler that may be shadowed by user EQUs.
537
+ _PREDEFINED = {name.upper() for name in isa.CONDITION_VALUES}
538
+
539
+
540
+ def assemble(source: str) -> AssembledProgram:
541
+ """Convenience wrapper: assemble ``source`` and return the program."""
542
+ return Assembler().assemble(source)
pyvacs/cli.py ADDED
@@ -0,0 +1,108 @@
1
+ """Command-line interface for pyvacs.
2
+
3
+ Provides two console entry points:
4
+
5
+ * ``pyvacs-asm`` - assemble 2650 source into a binary.
6
+ * ``pyvacs-dasm`` - disassemble a binary into 2650 source.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+ from typing import List, Optional
14
+
15
+ from . import __version__
16
+ from .assembler import Assembler, AssemblyError
17
+ from .disassembler import Disassembler
18
+
19
+
20
+ def _read_text(path: str) -> str:
21
+ with open(path, "r", encoding="utf-8") as handle:
22
+ return handle.read()
23
+
24
+
25
+ def _read_bytes(path: str) -> bytes:
26
+ with open(path, "rb") as handle:
27
+ return handle.read()
28
+
29
+
30
+ def assemble_main(argv: Optional[List[str]] = None) -> int:
31
+ """Entry point for ``pyvacs-asm``."""
32
+ parser = argparse.ArgumentParser(
33
+ prog="pyvacs-asm", description="Assemble Signetics 2650 source into a binary."
34
+ )
35
+ parser.add_argument("source", help="assembly source file")
36
+ parser.add_argument("-o", "--output", help="output binary (default: stdout)")
37
+ parser.add_argument(
38
+ "-f",
39
+ "--fill",
40
+ default="0",
41
+ help="pad byte for gaps between ORG blocks (default: 0)",
42
+ )
43
+ parser.add_argument("--version", action="version", version=f"pyvacs {__version__}")
44
+ args = parser.parse_args(argv)
45
+
46
+ try:
47
+ program = Assembler().assemble(_read_text(args.source))
48
+ except AssemblyError as exc:
49
+ print(f"pyvacs-asm: {exc}", file=sys.stderr)
50
+ return 1
51
+ except OSError as exc:
52
+ print(f"pyvacs-asm: {exc}", file=sys.stderr)
53
+ return 1
54
+
55
+ data = program.to_bytes(fill=int(args.fill, 0))
56
+ if args.output:
57
+ with open(args.output, "wb") as handle:
58
+ handle.write(data)
59
+ else:
60
+ sys.stdout.buffer.write(data)
61
+ return 0
62
+
63
+
64
+ def disassemble_main(argv: Optional[List[str]] = None) -> int:
65
+ """Entry point for ``pyvacs-dasm``."""
66
+ parser = argparse.ArgumentParser(
67
+ prog="pyvacs-dasm",
68
+ description="Disassemble a Signetics 2650 binary into source.",
69
+ )
70
+ parser.add_argument("binary", help="input binary file")
71
+ parser.add_argument("-o", "--output", help="output source (default: stdout)")
72
+ parser.add_argument(
73
+ "-r",
74
+ "--origin",
75
+ default="0",
76
+ help="load address of the binary (default: 0)",
77
+ )
78
+ parser.add_argument(
79
+ "-l",
80
+ "--listing",
81
+ action="store_true",
82
+ help="emit an address/hex listing instead of reassemblable source",
83
+ )
84
+ parser.add_argument("--version", action="version", version=f"pyvacs {__version__}")
85
+ args = parser.parse_args(argv)
86
+
87
+ try:
88
+ data = _read_bytes(args.binary)
89
+ except OSError as exc:
90
+ print(f"pyvacs-dasm: {exc}", file=sys.stderr)
91
+ return 1
92
+
93
+ origin = int(args.origin, 0)
94
+ disassembler = Disassembler()
95
+ if args.listing:
96
+ text = (
97
+ "\n".join(line.listing() for line in disassembler.disassemble(data, origin))
98
+ + "\n"
99
+ )
100
+ else:
101
+ text = disassembler.to_source(data, origin)
102
+
103
+ if args.output:
104
+ with open(args.output, "w", encoding="utf-8") as handle:
105
+ handle.write(text)
106
+ else:
107
+ sys.stdout.write(text)
108
+ return 0