oaknut-basic 12.15.0__tar.gz → 12.16.0__tar.gz

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.
Files changed (42) hide show
  1. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/PKG-INFO +1 -1
  2. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/__init__.py +3 -2
  3. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/cli.py +22 -2
  4. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/tokeniser.py +103 -7
  5. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut_basic.egg-info/PKG-INFO +1 -1
  6. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut_basic.egg-info/SOURCES.txt +1 -0
  7. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_cli.py +20 -0
  8. oaknut_basic-12.16.0/tests/test_greedy_crunch.py +150 -0
  9. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/LICENSE +0 -0
  10. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/README.md +0 -0
  11. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/pyproject.toml +0 -0
  12. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/setup.cfg +0 -0
  13. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/datafile.py +0 -0
  14. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/detect.py +0 -0
  15. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/detokeniser.py +0 -0
  16. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/dialect.py +0 -0
  17. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/exceptions.py +0 -0
  18. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/float5.py +0 -0
  19. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/linenumber.py +0 -0
  20. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/numbering.py +0 -0
  21. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/scanner.py +0 -0
  22. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut/basic/tokens.py +0 -0
  23. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut_basic.egg-info/dependency_links.txt +0 -0
  24. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut_basic.egg-info/entry_points.txt +0 -0
  25. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut_basic.egg-info/requires.txt +0 -0
  26. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/src/oaknut_basic.egg-info/top_level.txt +0 -0
  27. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_basic.py +0 -0
  28. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_basic_v.py +0 -0
  29. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_crunch_rules.py +0 -0
  30. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_data_cli.py +0 -0
  31. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_datafile.py +0 -0
  32. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_detect.py +0 -0
  33. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_detokeniser.py +0 -0
  34. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_float5.py +0 -0
  35. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_keyword_coverage.py +0 -0
  36. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_linenumber.py +0 -0
  37. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_numbering.py +0 -0
  38. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_rom_golden.py +0 -0
  39. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_rom_golden_detokenise.py +0 -0
  40. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_scanner.py +0 -0
  41. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_tokeniser.py +0 -0
  42. {oaknut_basic-12.15.0 → oaknut_basic-12.16.0}/tests/test_tokens.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oaknut-basic
3
- Version: 12.15.0
3
+ Version: 12.16.0
4
4
  Summary: BBC BASIC tools: program tokeniser/de-tokeniser and PRINT#/INPUT# data-file reader/writer
5
5
  Author-email: Robert Smallshire <robert@smallshire.org.uk>
6
6
  License-Expression: MIT
@@ -67,7 +67,7 @@ from oaknut.basic.scanner import (
67
67
  scan,
68
68
  scan_program,
69
69
  )
70
- from oaknut.basic.tokeniser import tokenise
70
+ from oaknut.basic.tokeniser import Crunch, tokenise
71
71
  from oaknut.basic.tokens import (
72
72
  FLAG_CONDITIONAL,
73
73
  FLAG_FN_PROC,
@@ -81,7 +81,7 @@ from oaknut.basic.tokens import (
81
81
  TOKEN_TO_KEYWORD,
82
82
  )
83
83
 
84
- __version__ = "12.15.0"
84
+ __version__ = "12.16.0"
85
85
 
86
86
  # Canonical load addresses for BBC BASIC programs on each host.
87
87
  # Programs saved by *SAVE on a real machine use these by default.
@@ -111,6 +111,7 @@ __all__ = [
111
111
  "BbcBasicDataFileBase",
112
112
  "BbcBasicDataReader",
113
113
  "BbcBasicDataWriter",
114
+ "Crunch",
114
115
  "DataFileError",
115
116
  "DataFileTypeMismatchError",
116
117
  "Detection",
@@ -212,8 +212,23 @@ def _listing_to_bytes(listing: str, encoding: str) -> bytes:
212
212
  default=None,
213
213
  help="Auto-numbering increment. Defaults to 10 when only --start is given.",
214
214
  )
215
+ @click.option(
216
+ "--crunch",
217
+ type=click.Choice(["rom", "greedy"]),
218
+ default="rom",
219
+ show_default=True,
220
+ help="Which tokeniser to emulate. \"rom\" is byte-exact to the BBC BASIC "
221
+ "ROM. Use \"greedy\" to reproduce the greedier third-party tokeniser "
222
+ "behind some early-1980s commercial programs, so their de-tokenised "
223
+ "source re-tokenises byte-identically.",
224
+ )
215
225
  def tokenise(
216
- input_stream, output_stream, encoding: str, start: int | None, step: int | None
226
+ input_stream,
227
+ output_stream,
228
+ encoding: str,
229
+ start: int | None,
230
+ step: int | None,
231
+ crunch: str,
217
232
  ) -> None:
218
233
  """Tokenise BBC BASIC source text into a stored program.
219
234
 
@@ -232,6 +247,11 @@ def tokenise(
232
247
  \b
233
248
  oaknut-basic tokenise --start 10 unnumbered.bas MENU
234
249
 
250
+ Pass ``--crunch greedy`` to reproduce a greedier third-party tokeniser
251
+ used by a class of early-1980s commercial programs; the default
252
+ ``rom`` is byte-exact to the BBC BASIC ROM. Use it only to regenerate
253
+ such a program byte-for-byte from its de-tokenised source.
254
+
235
255
  INPUT is read in --encoding (``utf-8`` by default, for source authored
236
256
  in a modern editor; pass ``acorn`` for the BBC character set, e.g.
237
257
  source taken straight off a disc image).
@@ -239,7 +259,7 @@ def tokenise(
239
259
  from oaknut.basic import tokenise as tokenise_source
240
260
 
241
261
  source = _source_to_code_points(input_stream.read(), encoding)
242
- output_stream.write(tokenise_source(source, start=start, step=step))
262
+ output_stream.write(tokenise_source(source, start=start, step=step, crunch=crunch))
243
263
 
244
264
 
245
265
  @cli.command()
@@ -27,6 +27,8 @@ the CLI does it at its I/O boundary.
27
27
 
28
28
  from __future__ import annotations
29
29
 
30
+ from typing import Literal
31
+
30
32
  from oaknut.basic.exceptions import (
31
33
  AlreadyNumberedError,
32
34
  LineNumberOrderError,
@@ -59,6 +61,13 @@ from oaknut.basic.tokens import (
59
61
  _CR = 0x0D
60
62
  _END_MARKER = 0xFF
61
63
 
64
+ # The crunch dialect. ``"rom"`` is byte-exact to the BBC BASIC ROM's
65
+ # line-input crunch (the default). ``"greedy"`` reproduces a greedier
66
+ # third-party tokeniser found in early-1980s commercial programs, whose
67
+ # keyword recognition differs from the ROM in three localised ways (see
68
+ # :func:`tokenise`); everything else is identical to the ROM crunch.
69
+ Crunch = Literal["rom", "greedy"]
70
+
62
71
 
63
72
  def _split_source_lines(source: str) -> list[str]:
64
73
  return LINE_SEPARATOR_RE.split(source)
@@ -70,7 +79,13 @@ for _keyword, _token, _flags in KEYWORDS:
70
79
  _KEYWORDS_BY_FIRST.setdefault(_keyword[0], []).append((_keyword, _token, _flags))
71
80
 
72
81
 
73
- def tokenise(source: str, *, start: int | None = None, step: int | None = None) -> bytes:
82
+ def tokenise(
83
+ source: str,
84
+ *,
85
+ start: int | None = None,
86
+ step: int | None = None,
87
+ crunch: Crunch = "rom",
88
+ ) -> bytes:
74
89
  """Tokenise BBC BASIC II source text into a stored program.
75
90
 
76
91
  Args:
@@ -82,6 +97,24 @@ def tokenise(source: str, *, start: int | None = None, step: int | None = None)
82
97
  numbers. Defaults to 10 when only *step* is given.
83
98
  step: Increment for auto-numbering. Defaults to 10 when only
84
99
  *start* is given.
100
+ crunch: Which tokeniser to emulate. ``"rom"`` (the default) is
101
+ byte-exact to the BBC BASIC ROM's line-input crunch.
102
+ ``"greedy"`` reproduces a greedier third-party tokeniser used
103
+ by a class of early-1980s commercial programs, so their
104
+ de-tokenised source re-tokenises byte-identically. It differs
105
+ from the ROM in three ways only:
106
+
107
+ 1. A keyword interrupts a ``&`` hex constant, ending the hex
108
+ run where the keyword begins (``&FE60AND`` becomes ``&FE60``
109
+ then ``AND``, not the literal run ``&FE60A`` then ``NDROW``).
110
+ 2. An ``FN``/``PROC`` name terminates at a following ``THEN``
111
+ or ``ELSE`` (the ``FLAG_START`` keywords), keeping the first
112
+ name character but ending the name there. Other embedded
113
+ keywords (``READ`` in ``PROCREADKP``) are left intact.
114
+ 3. A conditional keyword before a name character is suppressed
115
+ only when that character does not itself begin a keyword, so
116
+ ``STOP`` before ``ELSE`` tokenises while ``NEW`` in
117
+ ``NEWKEY%`` stays literal.
85
118
 
86
119
  Returns:
87
120
  The tokenised program bytes, terminated by ``&0D &FF``.
@@ -118,7 +151,7 @@ def tokenise(source: str, *, start: int | None = None, step: int | None = None)
118
151
  raise LineNumberOrderError(line_index, line_number, previous_number, line)
119
152
  previous_number = line_number
120
153
 
121
- body = _tokenise_body(body_text, line_index=line_index, line_text=line)
154
+ body = _tokenise_body(body_text, line_index=line_index, line_text=line, crunch=crunch)
122
155
  if len(body) > MAX_BODY_LENGTH:
123
156
  raise LineTooLongError(line_index, line_number, len(body), line)
124
157
 
@@ -174,13 +207,18 @@ def _is_hex_digit(c: str) -> bool:
174
207
  return ("0" <= c <= "9") or ("A" <= c <= "F")
175
208
 
176
209
 
177
- def _try_keyword(text: str, i: int) -> tuple[int, int, int] | None:
210
+ def _try_keyword(text: str, i: int, *, crunch: Crunch) -> tuple[int, int, int] | None:
178
211
  """Match a keyword at ``text[i]``.
179
212
 
180
213
  Returns ``(token, consumed, flags)`` for the first full or
181
214
  ``.``-abbreviated match in ROM order, or ``None`` if the cursor does
182
215
  not begin a keyword (including a conditional keyword suppressed by a
183
216
  following name character).
217
+
218
+ Conditional suppression depends on *crunch*: the ``"rom"`` crunch
219
+ suppresses whenever a name character follows; ``"greedy"`` suppresses
220
+ only when that character does not itself begin a keyword, so a
221
+ conditional keyword is still recognised before ``ELSE`` and the like.
184
222
  """
185
223
  group = _KEYWORDS_BY_FIRST.get(text[i])
186
224
  if group is None:
@@ -209,14 +247,59 @@ def _try_keyword(text: str, i: int) -> tuple[int, int, int] | None:
209
247
  if abbreviated:
210
248
  return token, p + 1, flags
211
249
  # Full match. A conditional keyword is suppressed when a name
212
- # character follows, so it stays part of an identifier.
250
+ # character follows, so it stays part of an identifier — unless
251
+ # the greedy crunch sees that character begin its own keyword.
213
252
  if flags & FLAG_CONDITIONAL and i + klen < n and _is_name_char(text[i + klen]):
214
- return None
253
+ if crunch == "rom" or not _starts_keyword(text, i + klen):
254
+ return None
215
255
  return token, klen, flags
216
256
  return None
217
257
 
218
258
 
219
- def _tokenise_body(text: str, *, line_index: int, line_text: str) -> bytearray:
259
+ def _starts_keyword(text: str, i: int) -> bool:
260
+ """True if a keyword (full or ``.``-abbreviated) begins at ``text[i]``.
261
+
262
+ A plain prefix test with no conditional suppression, used by the
263
+ greedy crunch to decide where a hex run or a suppressed conditional
264
+ keyword should yield to a fresh keyword match.
265
+ """
266
+ group = _KEYWORDS_BY_FIRST.get(text[i])
267
+ if group is None:
268
+ return False
269
+ n = len(text)
270
+ for keyword, _token, _flags in group:
271
+ for p, keyword_char in enumerate(keyword):
272
+ if i + p >= n:
273
+ break
274
+ source_char = text[i + p]
275
+ if source_char == keyword_char:
276
+ continue
277
+ if source_char == ".":
278
+ return True
279
+ break
280
+ else:
281
+ return True # ran the whole keyword: a full match
282
+ return False
283
+
284
+
285
+ def _starts_flag_start_keyword(text: str, i: int) -> bool:
286
+ """True if a ``FLAG_START`` keyword (``THEN``/``ELSE``/...) begins here.
287
+
288
+ A full-match-only test (no abbreviation): the greedy crunch breaks an
289
+ ``FN``/``PROC`` name at such a keyword.
290
+ """
291
+ group = _KEYWORDS_BY_FIRST.get(text[i])
292
+ if group is None:
293
+ return False
294
+ for keyword, _token, flags in group:
295
+ if flags & FLAG_START and text[i : i + len(keyword)] == keyword:
296
+ return True
297
+ return False
298
+
299
+
300
+ def _tokenise_body(
301
+ text: str, *, line_index: int, line_text: str, crunch: Crunch = "rom"
302
+ ) -> bytearray:
220
303
  """Crunch one statement-text body into tokenised bytes."""
221
304
  out = bytearray()
222
305
  i = 0
@@ -250,6 +333,10 @@ def _tokenise_body(text: str, *, line_index: int, line_text: str) -> bytearray:
250
333
  out.append(ord("&"))
251
334
  i += 1
252
335
  while i < n and _is_hex_digit(text[i]):
336
+ # Greedy crunch (rule 1): a keyword beginning inside the run
337
+ # ends the hex constant, so `&FE60AND` -> `&FE60`, `AND`.
338
+ if crunch == "greedy" and _is_letter(text[i]) and _starts_keyword(text, i):
339
+ break
253
340
  out.append(ord(text[i]))
254
341
  i += 1
255
342
  mid = True # a value, but does not disarm (`&FF 1` encodes the 1)
@@ -304,7 +391,7 @@ def _tokenise_body(text: str, *, line_index: int, line_text: str) -> bytearray:
304
391
  continue
305
392
 
306
393
  if "A" <= c <= "W": # potential keyword
307
- match = _try_keyword(text, i)
394
+ match = _try_keyword(text, i, crunch=crunch)
308
395
  if match is not None:
309
396
  token, consumed, flags = match
310
397
  emit = token
@@ -328,6 +415,15 @@ def _tokenise_body(text: str, *, line_index: int, line_text: str) -> bytearray:
328
415
  if flags & FLAG_FN_PROC:
329
416
  name_start = i
330
417
  while i < n and _is_name_char(text[i]):
418
+ # Greedy crunch (rule 2): the name breaks at a
419
+ # following THEN/ELSE, but the first char is always
420
+ # kept (`PROCWTKEYELSE...` -> name `WTKEY`, `ELSE`).
421
+ if (
422
+ crunch == "greedy"
423
+ and i > name_start
424
+ and _starts_flag_start_keyword(text, i)
425
+ ):
426
+ break
331
427
  out.append(ord(text[i]))
332
428
  i += 1
333
429
  if i > name_start: # consumed an identifier -> read a name
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oaknut-basic
3
- Version: 12.15.0
3
+ Version: 12.16.0
4
4
  Summary: BBC BASIC tools: program tokeniser/de-tokeniser and PRINT#/INPUT# data-file reader/writer
5
5
  Author-email: Robert Smallshire <robert@smallshire.org.uk>
6
6
  License-Expression: MIT
@@ -29,6 +29,7 @@ tests/test_datafile.py
29
29
  tests/test_detect.py
30
30
  tests/test_detokeniser.py
31
31
  tests/test_float5.py
32
+ tests/test_greedy_crunch.py
32
33
  tests/test_keyword_coverage.py
33
34
  tests/test_linenumber.py
34
35
  tests/test_numbering.py
@@ -252,6 +252,26 @@ class TestTokeniseCommand:
252
252
  assert result.exit_code != 0
253
253
  assert "no line number" in result.output
254
254
 
255
+ def test_default_crunch_is_rom(self):
256
+ runner = CliRunner()
257
+ result = runner.invoke(cli, ["tokenise"], input=b"10 A=?&FE60ANDROW%\n")
258
+ assert result.exit_code == 0
259
+ assert result.stdout_bytes == basic.tokenise("10 A=?&FE60ANDROW%\n", crunch="rom")
260
+
261
+ def test_greedy_crunch_selects_the_greedier_tokeniser(self):
262
+ runner = CliRunner()
263
+ source = b"10 A=?&FE60ANDROW%\n"
264
+ result = runner.invoke(cli, ["tokenise", "--crunch", "greedy"], input=source)
265
+ assert result.exit_code == 0
266
+ assert result.stdout_bytes == basic.tokenise(source.decode(), crunch="greedy")
267
+ # The greedy result genuinely differs from the ROM default.
268
+ assert result.stdout_bytes != basic.tokenise(source.decode(), crunch="rom")
269
+
270
+ def test_unknown_crunch_is_rejected(self):
271
+ runner = CliRunner()
272
+ result = runner.invoke(cli, ["tokenise", "--crunch", "nope"], input=b"10 END\n")
273
+ assert result.exit_code != 0
274
+
255
275
 
256
276
  class TestTokeniseEncoding:
257
277
  def test_default_decodes_host_utf8_pound_sign(self):
@@ -0,0 +1,150 @@
1
+ """The greedy crunch mode (``tokenise(..., crunch="greedy")``).
2
+
3
+ The BBC BASIC ROM's crunch is only one of the tokenisers that produced
4
+ tokenised BBC BASIC in the wild. A class of early-1980s commercial
5
+ programs was crunched by a *greedier* tool whose keyword recognition
6
+ differs from the ROM in three specific ways, so their de-tokenised source
7
+ does not re-tokenise byte-identically under the ROM-accurate default.
8
+
9
+ ``crunch="greedy"`` reproduces that tool. The default (``crunch="rom"``)
10
+ stays byte-exact to the ROM, so the two modes are pinned side by side on
11
+ the same source below.
12
+
13
+ The three rule differences (see :func:`oaknut.basic.tokenise`):
14
+
15
+ - **Rule 1** — a keyword interrupts a hex constant. The ROM copies every
16
+ ``0``-``9`` / ``A``-``F`` in a ``&`` run unconditionally; greedy stops
17
+ the run where a keyword begins (``&FE60ANDROW%`` -> ``&FE60``, ``AND``,
18
+ ``ROW%``).
19
+ - **Rule 2** — an ``FN``/``PROC`` name terminates at a ``FLAG_START``
20
+ keyword (``THEN``/``ELSE``). The ROM's name-skip swallows every
21
+ alphanumeric; greedy keeps the first name character but breaks the name
22
+ at a following ``THEN``/``ELSE`` — and *only* those, so a function
23
+ keyword embedded in a name (``READ`` in ``PROCREADKP``) is left intact.
24
+ - **Rule 3** — refined conditional suppression. The ROM keeps a
25
+ conditional keyword literal whenever a name character follows; greedy
26
+ does so only when that character does not itself begin a keyword, so
27
+ ``STOP`` before ``ELSE`` tokenises while ``NEW`` in ``NEWKEY%`` stays
28
+ literal.
29
+
30
+ The two commercial programs under ``data/greedy`` (Voltmace / Custom
31
+ Video Productions, 1983) validate the whole model: their own
32
+ ``detokenise`` output re-tokenises byte-for-byte under greedy.
33
+ """
34
+
35
+ from pathlib import Path
36
+
37
+ import pytest
38
+ from oaknut.basic import detokenise, tokenise
39
+
40
+ _DATA_DIRPATH = Path(__file__).parent / "data"
41
+ _GREEDY_DIRPATH = _DATA_DIRPATH / "greedy"
42
+
43
+
44
+ def _body(statement: str, *, crunch: str = "rom") -> bytes:
45
+ """Tokenise a single body (no leading space) and return its bytes."""
46
+ program = tokenise("10" + statement, crunch=crunch)
47
+ length = program[3]
48
+ return program[4:length]
49
+
50
+
51
+ # (source after the line number, ROM body, greedy body). The ROM column is
52
+ # the current default and must not change; the greedy column reproduces the
53
+ # commercial files. Values transcribed from issue #48.
54
+ _VECTORS = [
55
+ # Rule 1 — a keyword interrupts a hex constant.
56
+ (
57
+ "A=?&FE60ANDROW%",
58
+ bytes.fromhex("41 3d 3f 26 46 45 36 30 41 4e 44 52 4f 57 25"),
59
+ bytes.fromhex("41 3d 3f 26 46 45 36 30 80 52 4f 57 25"),
60
+ ),
61
+ # Rule 2 — an FN/PROC name breaks at a FLAG_START keyword (THEN/ELSE).
62
+ (
63
+ "IFA THENPROCWTKEYELSEPROCBKKEY",
64
+ bytes.fromhex(
65
+ "e7 41 20 8c f2 57 54 4b 45 59 45 4c 53 45 50 52 4f 43 42 4b 4b 45 59"
66
+ ),
67
+ bytes.fromhex("e7 41 20 8c f2 57 54 4b 45 59 8b f2 42 4b 4b 45 59"),
68
+ ),
69
+ # Rule 2 — a function keyword embedded in a name is not a break point.
70
+ (
71
+ "PROCREADKP",
72
+ bytes.fromhex("f2 52 45 41 44 4b 50"),
73
+ bytes.fromhex("f2 52 45 41 44 4b 50"),
74
+ ),
75
+ # Rule 3 — a conditional keyword before another keyword tokenises.
76
+ (
77
+ "IFA=1THENSTOPELSEGOTO90",
78
+ bytes.fromhex("e7 41 3d 31 8c 53 54 4f 50 45 4c 53 45 47 4f 54 4f 39 30"),
79
+ bytes.fromhex("e7 41 3d 31 8c fa 8b e5 8d 44 5a 40"),
80
+ ),
81
+ # Rule 3 — a conditional keyword before a plain name char stays literal.
82
+ (
83
+ "NEWKEY%=0",
84
+ bytes.fromhex("4e 45 57 4b 45 59 25 3d 30"),
85
+ bytes.fromhex("4e 45 57 4b 45 59 25 3d 30"),
86
+ ),
87
+ ]
88
+
89
+
90
+ @pytest.mark.parametrize(
91
+ ("source", "rom_body", "greedy_body"),
92
+ _VECTORS,
93
+ ids=[v[0] for v in _VECTORS],
94
+ )
95
+ def test_rom_default_is_unchanged(source, rom_body, greedy_body):
96
+ assert _body(source, crunch="rom") == rom_body
97
+
98
+
99
+ @pytest.mark.parametrize(
100
+ ("source", "rom_body", "greedy_body"),
101
+ _VECTORS,
102
+ ids=[v[0] for v in _VECTORS],
103
+ )
104
+ def test_greedy_reproduces_the_greedier_tool(source, rom_body, greedy_body):
105
+ assert _body(source, crunch="greedy") == greedy_body
106
+
107
+
108
+ def test_default_crunch_is_rom():
109
+ # The default must remain ROM-accurate: an un-flagged call matches "rom".
110
+ source = "10 A=?&FE60ANDROW%"
111
+ assert tokenise(source) == tokenise(source, crunch="rom")
112
+
113
+
114
+ def _program_lines(program: bytes) -> dict[int, bytes]:
115
+ """Split a stored program into ``{line_number: body_bytes}``."""
116
+ lines: dict[int, bytes] = {}
117
+ offset = 0
118
+ while offset + 3 < len(program):
119
+ if program[offset] != 0x0D or program[offset + 1] == 0xFF:
120
+ break
121
+ record_length = program[offset + 3]
122
+ number = (program[offset + 1] << 8) | program[offset + 2]
123
+ lines[number] = program[offset + 4 : offset + record_length]
124
+ offset += record_length
125
+ return lines
126
+
127
+
128
+ class TestCommercialPrograms:
129
+ """The KEYPAD / JOYSTIK Voltmace programs round-trip under greedy."""
130
+
131
+ def test_keypad_round_trips_byte_identically(self):
132
+ program = (_GREEDY_DIRPATH / "keypad.tokens").read_bytes()
133
+ assert tokenise(detokenise(program), crunch="greedy") == program
134
+
135
+ def test_joystik_round_trips_except_the_encryption_boundary_line(self):
136
+ # JOYSTIK's line 1890 is the file's ROL-encryption boundary line, a
137
+ # decode artefact of that binary rather than a tokeniser difference;
138
+ # every other line re-tokenises byte-for-byte under greedy.
139
+ program = (_GREEDY_DIRPATH / "joystik.tokens").read_bytes()
140
+ retokenised = tokenise(detokenise(program), crunch="greedy")
141
+ original = _program_lines(program)
142
+ rebuilt = _program_lines(retokenised)
143
+ differing = [n for n in original if original[n] != rebuilt.get(n)]
144
+ assert differing == [1890]
145
+
146
+ def test_rom_crunch_does_not_round_trip_the_greedy_files(self):
147
+ # Guard the premise: the ROM-accurate default cannot regenerate a
148
+ # program the greedier tool produced.
149
+ program = (_GREEDY_DIRPATH / "keypad.tokens").read_bytes()
150
+ assert tokenise(detokenise(program), crunch="rom") != program
File without changes
File without changes
File without changes