gsasm 0.2.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.
gsasm/__init__.py ADDED
File without changes
gsasm/__main__.py ADDED
@@ -0,0 +1,307 @@
1
+ """CLI entry points for gsasm (assembler) and gslink (linker)."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+
8
+ def asm_main():
9
+ """gsasm — assemble an MPW IIgs source file to an OMF object file."""
10
+ p = argparse.ArgumentParser(
11
+ prog="gsasm",
12
+ description="MPW IIgs-compatible assembler (65816, OMF v2).",
13
+ )
14
+ p.add_argument("source", help="source file (.asm or .aii)")
15
+ p.add_argument(
16
+ "-I", dest="incdirs", metavar="DIR", action="append", default=[],
17
+ help="include search directory (may be repeated)",
18
+ )
19
+ p.add_argument(
20
+ "-d", dest="defines", metavar="KEY=VAL", action="append", default=[],
21
+ help="pre-define a symbol, e.g. -d Big=1 (may be repeated)",
22
+ )
23
+ p.add_argument(
24
+ "-o", dest="output", metavar="FILE",
25
+ help="output file (default: <source>.obj)",
26
+ )
27
+ args = p.parse_args()
28
+
29
+ defines = {}
30
+ for kv in args.defines:
31
+ if "=" in kv:
32
+ k, v = kv.split("=", 1)
33
+ try:
34
+ v = int(v, 0)
35
+ except ValueError:
36
+ pass
37
+ defines[k] = v
38
+ else:
39
+ defines[kv] = 1
40
+
41
+ src = args.source
42
+ incdirs = args.incdirs or [os.path.dirname(os.path.abspath(src))]
43
+ outfile = args.output or (src + ".obj")
44
+
45
+ from gsasm import asm, omf
46
+
47
+ try:
48
+ a = asm.assemble(src, incdirs, defines=defines)
49
+ except Exception as exc:
50
+ print(f"gsasm: error: {exc}", file=sys.stderr)
51
+ sys.exit(1)
52
+
53
+ for e in a.errors:
54
+ print(e, file=sys.stderr)
55
+ if a.errors:
56
+ sys.exit(1)
57
+
58
+ obj = omf.emit(a)
59
+ with open(outfile, "wb") as fh:
60
+ fh.write(obj)
61
+
62
+ # print segment summary
63
+ for seg in a.segs:
64
+ nm = seg.name or "(unnamed)"
65
+ n = seg.length()
66
+ print(f" {nm:24s} {n:#8x} ({n} bytes)")
67
+ print(f"→ {outfile} ({len(obj)} bytes)")
68
+
69
+
70
+ def link_main():
71
+ """gslink — link an OMF object file to a load file."""
72
+ p = argparse.ArgumentParser(
73
+ prog="gslink",
74
+ description="OMF v2 linker: evaluates relocation records and produces a load file.",
75
+ )
76
+ p.add_argument("obj", help="OMF object file (.obj)")
77
+ p.add_argument(
78
+ "-o", dest="output", metavar="FILE",
79
+ help="output file (default: <obj without .obj>.out, or <obj>.out)",
80
+ )
81
+ args = p.parse_args()
82
+
83
+ objfile = args.obj
84
+ if args.output:
85
+ outfile = args.output
86
+ elif objfile.endswith(".obj"):
87
+ outfile = objfile[:-4] + ".out"
88
+ else:
89
+ outfile = objfile + ".out"
90
+
91
+ from gsasm import link, omf
92
+
93
+ with open(objfile, "rb") as fh:
94
+ obj_bytes = fh.read()
95
+
96
+ try:
97
+ load_bytes = link.link(obj_bytes)
98
+ except Exception as exc:
99
+ print(f"gslink: error: {exc}", file=sys.stderr)
100
+ sys.exit(1)
101
+
102
+ # print segment summary from the output
103
+ off = 0
104
+ while off < len(load_bytes):
105
+ h = omf.parse_header(load_bytes[off:])
106
+ if h["BYTECNT"] == 0:
107
+ break
108
+ nm = h["SEGNAME"].decode("mac_roman", "replace").strip()
109
+ print(f" {nm:24s} {h['LENGTH']:#8x} ({h['LENGTH']} bytes)")
110
+ off += h["BYTECNT"]
111
+
112
+ with open(outfile, "wb") as fh:
113
+ fh.write(load_bytes)
114
+ print(f"→ {outfile} ({len(load_bytes)} bytes)")
115
+
116
+
117
+ def _resolve_read_file(name, search_dirs):
118
+ """Case-insensitive filename search across `search_dirs`, in order
119
+ (mirrors gsasm.rez.lexer's `#include` search-path convention: the
120
+ including file's own directory, then each configured search directory,
121
+ matched case-insensitively). Returns the resolved path, or None."""
122
+ lname = name.lower()
123
+ for d in search_dirs:
124
+ if not d or not os.path.isdir(d):
125
+ continue
126
+ for entry in os.listdir(d):
127
+ if entry.lower() == lname:
128
+ return os.path.join(d, entry)
129
+ return None
130
+
131
+
132
+ def _parse_meta_override(kv, defaults):
133
+ """Parse one `--meta KEY=VAL` argument against `emit.DEFAULT_META`'s
134
+ field types (bool / int / bytes / str). Raises SystemExit with a
135
+ `gsrez:`-prefixed message on an unknown key or malformed value."""
136
+ if "=" not in kv:
137
+ raise SystemExit(f"gsrez: --meta expects KEY=VAL, got {kv!r}")
138
+ key, val = kv.split("=", 1)
139
+ if key not in defaults:
140
+ raise SystemExit(f"gsrez: unknown --meta key {key!r} "
141
+ f"(known: {', '.join(sorted(defaults))})")
142
+ default = defaults[key]
143
+ if isinstance(default, bool):
144
+ return key, val.lower() not in ("0", "false", "no", "")
145
+ if isinstance(default, int):
146
+ try:
147
+ return key, int(val, 0)
148
+ except ValueError:
149
+ raise SystemExit(f"gsrez: --meta {key}: invalid integer value "
150
+ f"{val!r}")
151
+ if isinstance(default, bytes):
152
+ # An explicit `0x` prefix means hex bytes; anything else is taken
153
+ # literally (encoded latin-1). Guessing "looks like hex" from
154
+ # content alone would be ambiguous -- e.g. a 4-character creator
155
+ # like "ABCD" is simultaneously valid hex (2 bytes) and a valid
156
+ # literal (4 bytes) -- so an explicit marker is required instead.
157
+ if val[:2].lower() == "0x":
158
+ try:
159
+ return key, bytes.fromhex(val[2:])
160
+ except ValueError:
161
+ raise SystemExit(f"gsrez: --meta {key}: invalid hex value "
162
+ f"{val!r}")
163
+ return key, val.encode("latin-1")
164
+ return key, val
165
+
166
+
167
+ def rez_main():
168
+ """gsrez — compile an Apple IIgs Rez `.r` source into a raw resource-fork
169
+ image (docs/design/rez.md, milestone M7; replaces MPW `RezIIgs`).
170
+
171
+ Mirrors the Sys.Resources makefile's `reziigs -rd sys.resources.r -o
172
+ SYS.RESOURCES -t "F9 "` invocation as:
173
+
174
+ gsrez sys.resources.r -o SYS.RESOURCES -t F9
175
+
176
+ Pipeline: `gsasm.rez.parser.parse()` (always predefining `RezIIGS=1`,
177
+ exactly as the real RezIIgs tool predefines it — see
178
+ `gsasm/rez/lexer.py`'s `_Preprocessor.__init__` docstring: this gates
179
+ the null-longint array terminator some type templates need) ->
180
+ `gsasm.rez.gen.generate()` -> resolve every `read` statement's file
181
+ (searched case-insensitively across each `--read-dir`, in order, then
182
+ the source file's own directory) through `gsasm.rez.convert.
183
+ convert_load()` -> `gsasm.rez.gen.to_emit_tuples()` ->
184
+ `gsasm.rez.emit.emit_fork()` -> write the raw fork bytes to `-o`.
185
+
186
+ Output is the RAW RESOURCE-FORK IMAGE ONLY: packaging it together with
187
+ a (typically empty) data fork into one dual-fork disk file is out of
188
+ scope here (see e.g. a2til's `Volume.write_file(..., resource=...)` for
189
+ that step).
190
+
191
+ Fork metadata (docs/design/rez.md "Golden fork format", `gsasm/rez/
192
+ emit.py`'s `DEFAULT_META`): this CLI keeps HONEST, non-golden defaults
193
+ (creator `'pdos'`; a zero memo timestamp; no file type set) rather than
194
+ guessing at a specific captured file's undocumented bytes -- reproducing
195
+ one archival fork byte-exact (its name, creation timestamp, ...) is a
196
+ HARNESS's job (`work/rezbuildcheck.py`), done through `-t`/`-c`/`--meta`
197
+ or by calling `gsasm.rez.emit.emit_fork()` directly as a library.
198
+ """
199
+ p = argparse.ArgumentParser(
200
+ prog="gsrez",
201
+ description="Rez resource compiler (Apple IIgs resource-fork image).",
202
+ )
203
+ p.add_argument("source", help="Rez source file (.r/.rez/.rii)")
204
+ p.add_argument(
205
+ "-I", dest="incdirs", metavar="DIR", action="append", default=[],
206
+ help="#include search directory (may be repeated)",
207
+ )
208
+ p.add_argument(
209
+ "-o", dest="output", metavar="FILE",
210
+ help="output file (default: <source>.rsrc)",
211
+ )
212
+ p.add_argument(
213
+ "-t", "--filetype", dest="filetype", metavar="TT",
214
+ help="2-hex-digit ProDOS file type, e.g. -t F9 (default: unset)",
215
+ )
216
+ p.add_argument(
217
+ "-c", "--creator", dest="creator", default="pdos", metavar="CCCC",
218
+ help='4-character creator (default: "pdos", matching every '
219
+ 'observed golden fork)',
220
+ )
221
+ p.add_argument(
222
+ "--read-dir", dest="read_dirs", metavar="DIR", action="append",
223
+ default=[],
224
+ help="directory to search for `read` statement files (may be "
225
+ "repeated; searched before the source file's own directory)",
226
+ )
227
+ p.add_argument(
228
+ "--meta", dest="meta_overrides", metavar="KEY=VAL", action="append",
229
+ default=[],
230
+ help="override a gsasm.rez.emit fork-metadata field (may be "
231
+ "repeated), e.g. --meta creation_mac_ts=2819554517",
232
+ )
233
+ args = p.parse_args()
234
+
235
+ from gsasm.rez import lexer, parser as rez_parser, gen, emit, convert
236
+
237
+ try:
238
+ stmts = rez_parser.parse(args.source, include_dirs=args.incdirs,
239
+ predefined={"RezIIGS": 1})
240
+ except (lexer.LexError, rez_parser.ParseError) as exc:
241
+ print(f"gsrez: error: {exc}", file=sys.stderr)
242
+ sys.exit(1)
243
+
244
+ try:
245
+ entries = gen.generate(stmts)
246
+ except gen.GenError as exc:
247
+ print(f"gsrez: error: {exc}", file=sys.stderr)
248
+ sys.exit(1)
249
+
250
+ src_dir = os.path.dirname(os.path.abspath(args.source))
251
+ search_dirs = args.read_dirs + [src_dir]
252
+
253
+ read_stmts = [s for s in stmts if isinstance(s, rez_parser.ReadStmt)]
254
+ read_entries = [e for e in entries if e.kind == "read"]
255
+ # generate() appends exactly one 'read' GenEntry per ReadStmt, in the
256
+ # same source order (see gen.py's "Public API" docstring) -- zipping
257
+ # them pairs each statement with its resolved (rtype, rid) without
258
+ # reimplementing gen.py's own (private) id-expression evaluator here.
259
+ assert len(read_stmts) == len(read_entries)
260
+
261
+ read_data = {}
262
+ for stmt, entry in zip(read_stmts, read_entries):
263
+ filename = stmt.filename.decode("latin-1")
264
+ path = _resolve_read_file(filename, search_dirs)
265
+ if path is None:
266
+ print(f"gsrez: error: {stmt.file}:{stmt.line}: read file not "
267
+ f"found (case-insensitively) in search path: "
268
+ f"{filename!r}", file=sys.stderr)
269
+ sys.exit(1)
270
+ with open(path, "rb") as fh:
271
+ raw = fh.read()
272
+ read_data[(entry.rtype, entry.rid)] = convert.convert_load(raw)
273
+
274
+ try:
275
+ tuples = gen.to_emit_tuples(entries, read_data)
276
+ except gen.GenError as exc:
277
+ print(f"gsrez: error: {exc}", file=sys.stderr)
278
+ sys.exit(1)
279
+
280
+ meta = {"creator": args.creator}
281
+ if args.filetype:
282
+ try:
283
+ filetype_val = int(args.filetype, 16)
284
+ except ValueError:
285
+ raise SystemExit(f"gsrez: -t/--filetype: invalid hex value "
286
+ f"{args.filetype!r}")
287
+ meta["filetype"] = emit.format_filetype(filetype_val)
288
+ for kv in args.meta_overrides:
289
+ key, val = _parse_meta_override(kv, emit.DEFAULT_META)
290
+ meta[key] = val
291
+
292
+ try:
293
+ fork = emit.emit_fork(tuples, meta)
294
+ except ValueError as exc:
295
+ print(f"gsrez: error: {exc}", file=sys.stderr)
296
+ sys.exit(1)
297
+
298
+ outfile = args.output or (args.source + ".rsrc")
299
+ with open(outfile, "wb") as fh:
300
+ fh.write(fork)
301
+ print(f" {len(tuples)} resource(s)")
302
+ print(f"→ {outfile} ({len(fork)} bytes)")
303
+
304
+
305
+ if __name__ == "__main__":
306
+ # allow `python -m gsasm` to run the assembler
307
+ asm_main()