cc65-Chess versus Sargon II
This directory contains the match harness and the operational notes for playing
the native cc65-Chess engine against Apple II Sargon II under a2m. The
emulator stays visible and runs at maximum turbo so a person can follow the
games.
Nothing in this workflow modifies the source Sargon disk. Match artifacts go
under scratch/, and the harness is resumable after an interruption or bridge
failure.
The current HEAD result is in doc/strength.md §4.5. This file is how to run
the harness, not a results archive.
Files
match.pydrives Sargon through the a2m control port, drives the exact cc65-Chess engine throughtests/uci, referees games with python-chess, and writes CSV, PGN, JSON summaries, and failure logs.levelcost.pyanswers "how slow is each Sargon level" in six minutes, which is the question that decides whether a match at some level is an afternoon or a fortnight. It shares the boot choreography, the control port and the one-harness-at-a-time rule withmatch.py.analyse.pyruns Stockfish over a finished PGN and attributes every large evaluation swing to the side that played it.piececost.pyanswers "what does moving each kind of piece cost, per move, by phase". Drop mate scores and print a median beside the mean — a plain mean over evaluation deltas is not a robust statistic.- This file is the runbook. Update it whenever a new Sargon display or emulator behavior is discovered.
Required local layout
The harness derives these paths from the cc65-Chess repository root:
cc65-Chess/
sargon/match.py
sargon/Sargon-trimmed.dsk
tests/uci
../a2m/
build/a2m
tools/a2m_control_client.py
a2m is at https://github.com/StewBC/a2m; build it Release with
cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j.
The disk image is not in this repository
Sargon II is a commercial program from 1978 and its disk image is nobody's here
to redistribute, so .gitignore excludes sargon/*.dsk while tracking the rest
of this directory. Supply your own copy at:
sargon/Sargon-trimmed.dsk
It must be exactly 143,360 bytes — a standard DOS 3.3 image — with SHA-256:
f0ad0bd2c4b162f5a410f4b2ddb4e6a0e70e4a0bbadd247c604f80e0cd47e1b8
shasum -a 256 sargon/Sargon-trimmed.dsk
The copy these results were produced with came from:
https://www.myabandonware.com/game/sargon-ii-54m#Apple%20II
Recorded as provenance, not as a recommendation or a guarantee — check the hash above rather than trusting any particular download, since what is served under one name changes over time.
Two things that image is not. The commonly circulated Sargon.dsk is 153,394
bytes: the same 143,360-byte image followed by a 10,034-byte trailer, and a2m
will not boot the oversized raw file — trimming it to the first 143,360 bytes
produces the hash above. And do not substitute the protected
Sargon II.nib; that image was observed announcing a false mate from the
initial position.
match.py validates the size, copies the file to
/private/tmp/cc65-sargon-match.dsk, and mounts that disposable working copy,
so nothing in this workflow can write to your original.
Python needs the chess package. Build the native UCI adapter before a run:
make -C tests -B uci
The repository test suite can be rebuilt and run with:
make -C tests test
Exact Sargon boot choreography
The compilation disk is not directly bootable into the game. The reliable sequence is:
- Launch a2m with the trimmed disk and a control port. Do not use
--headless. - Wait for the
SARGON ][catalog/menu screen. - Send uppercase
R, then uppercaseH, as individual keys. Do not send Return with either key. - Ignore the transient
INSERT COPY DISK & HIT RETURNscreen. It clears by itself. Pressing Return there drops into the monitor. - Wait for the
NEW GAMEprompt. - Send
Gfollowed by Return. - At
YOUR COLOR, sendWorBfollowed by Return. - At
LEVEL OF PLAY, send0through6followed by Return.
R and H must be uppercase. The distinction between the no-Return loader
keys and the later choice-plus-Return prompts is important.
After boot, send the control command set-turbo max. a2m does run at max,
but its window title currently does not update when speed is changed through
the control port.
Reset behavior
The a2m reset control command returns this Sargon disk to the reusable
NEW GAME state. This was verified both textually and graphically:
- initial board frame SHA-256:
c8a566b8c37bddd5bdb5d23add6aefa2113cecc86753d58f86ae8d8c1a688f20 - after
E2-E4 C7-C5:2275ee882c95532bcab053fbb194490ef8e9dd13a286cde2fe9d09a87e0e1d04 - after reset and starting another game, the frame returned byte-for-byte to the initial hash.
Apple II snapshots are exposed by a2m but currently time out and are not usable. Reset is therefore the per-game restoration mechanism.
cc65-Chess side
tests/uci uses the same engine and four shipped skill budgets as the Apple II
game:
| UCI skill | Menu name | Nodes |
|---|---|---|
| 1 | Very Easy | 400 |
| 2 | Easy | 1,200 |
| 3 | Harder | 18,000 |
| 4 | Very Hard | 65,000 |
The shipping 8-bit front end bypasses normal search for the first move of a game — four entries as White, and five entries of two replies each as Black.
tests/uci has OwnBook and BookSeed. UCIEngine sets both, and the
harness asks the engine what it plays. --no-own-book turns the tables off,
which is the control for anything measured with them on.
Sargon opening randomization
A scripted reset otherwise repeats Sargon's opening choice. Sargon's keyboard
wait loop increments zero-page $4E, which supplies its opening entropy. At
the level prompt the harness:
- pauses the emulator;
- writes a deterministic byte to
$004E; - queues the level and Return while paused;
- resumes execution.
Color-swapped game pairs receive the same seed, and consecutive pairs advance
the low bits because Sargon's picker consumes only a few of them. The exact
seed is recorded implicitly by the batch name, game index, and retry number in
seed_for().
Sargon is not reproducible. The $4E seed is written before the level is
typed, but the keyboard-wait counter keeps advancing afterwards, so book
choices depend on host timing. The same seed has opened 1.d4 Nf6 in one run
and 1.d4 d5 in the next with cc65's own moves identical. Paired replay of a
game index against an earlier run is therefore impossible; all comparisons
are unpaired.
cc65's own opening variety is small and bounded: four first moves as White, and two replies to each of five White first moves as Black. Do not treat a handful of repeated cc65 openers as a bridge failure — the rest of the diversity comes from Sargon's book, Sargon's colour, and later play. Summaries count distinct six-ply prefixes; that count often matters more than the score.
Text screen layout and move parsing
The Apple II text page is read from $0400-$07ff and decoded using the normal
24-row interleave. Sargon's move table has fixed columns:
- move number in the first ten columns;
- Player/White cell at columns 10-17;
- Sargon/Black cell at columns 23-30.
Which column belongs to Sargon depends on the selected color. Legal move forms observed so far are:
E2-E4 ordinary move
F5XD3 capture
0-0 king-side castle
0-0-0 queen-side castle
PXPEP en passant
Coordinates are translated against python-chess's current legal move list,
which also resolves promotion to a queen. CHECK is a nonterminal annotation
after a coordinate move. CHECKMATE and STALEMATE are terminal displays;
on those screens Sargon may not put the normal player cursor in its usual
column.
MATE IN n is status, not a move
Sargon can display MATE IN 1 (and similar forecasts) for a long time while
searching. It can also put the forecast in the next move row after its real
coordinate move, for example:
52 ... C1-D2
53 ` MATE IN 1
The forecast must never be fed to the referee as a move. When the input
cursor has returned, the bridge compares the new table with the pre-move
snapshot and selects the newest newly-added valid coordinate token, ignoring
later status cells. Do not press a key merely because MATE IN n persists;
some Level 1 searches have taken more than a minute at emulator max.
Move 100 is printed as :0
Sargon has only a two-character move-number field and does not implement a hundreds digit. It advances the tens character through ASCII instead:
99, :0 (100), :1, ... ;0 (110), ... ?0 (150)
The parser decodes 0 through ? as digit values 0 through 15. ?0 is full
move 150, matching the harness's 300-ply safety cap. If this rollover is not
recognized, the bridge cannot see the player cursor at move 100 and appears to
hang.
Referee and game policy
python-chess is an independent referee. It validates every coordinate move, maintains the authoritative position, and determines game outcomes. Draw claims are enabled, including threefold repetition and the fifty-move rule. Games also have a 300-ply safety cap. Colors alternate every game.
Every completed game is appended immediately, so Ctrl-C is safe. Rerunning the same command resumes at the next CSV index. A failed game is retried up to three times; the retry changes its seed so one broken position cannot wedge an entire batch. Failures are retained rather than silently discarded.
Each batch writes:
<batch>.csv
<batch>.pgn
<batch>-summary.json
<batch>-failures.log # only when a failure occurred
The CSV contains the full UCI move list as well as result, cc65 color, draw or mate reason, ply count, duration, and six-ply opening prefix. The PGN is the best starting point for reproducing suspected cc65 late-endgame defects.
Running screens and matches
Use a scratch output directory so generated games remain outside the shipped source tree:
python3 sargon/match.py \
--only calibration \
--calibration-games 64 \
--cc-skill 2 \
--output scratch/sargon-vs-cc65-YYYYMMDD
The emulator window remains visible. Sargon defaults to L1 for calibration. To run a measured match explicitly:
python3 sargon/match.py \
--only match \
--match-games 64 \
--cc-skill 3 \
--sargon-level 4 \
--output scratch/sargon-l4-YYYYMMDD
A four-game bridge smoke test is:
python3 sargon/match.py \
--only smoke \
--smoke-games 4 \
--cc-skill 1 \
--sargon-level 1 \
--output scratch/sargon-smoke-YYYYMMDD
--only calibration plays Sargon level 1 whatever --sargon-level says.
The calibration phase is a fixed screen — level 1, raise cc65 — and it
hard-codes the level and the batch name to match. Any other level is
--only match --sargon-level N --match-games N, which is also what names
the output match-ccX-sargonY instead of calibration-....
A colour-split screen is two runs, and they want separate output
directories: --cc-color does not reach the batch name, so both halves would
otherwise resume into one CSV and the second half would start at the first
half's game index and inherit its seeds.
python3 sargon/match.py --only match --match-games 32 \
--cc-skill 3 --sargon-level 4 --cc-color white --move-timeout 300 \
--output scratch/sargon-l4-cc3-white-YYYYMMDD
--cc-color pins the colour for a whole run. The per-game seed advances every
game rather than every pair, because there is no colour-swapped partner to
hold it constant for.
--no-own-book turns the tables off.
And the cost of any of this before starting it:
python3 sargon/levelcost.py --output scratch/sargon-levelcost-YYYYMMDD
Do not run more than one harness on the default control port 6511. A second read-only control client may also block because the active harness polls the same emulator continuously.
How long a run takes
levelcost.py plays the opening of one throwaway game per level and times
Sargon's replies; all seven levels take six minutes of emulator time.
level opening replies (s) median per Sargon move 64 games 512 games
0 0.1 0.1 0.1 0.1 0.07 ~0.3 s 20 min 2.5 h
1 0.3 0.3 0.4 0.4 0.34 0.6 s * 40 min 5 h
2 0.3 0.4 0.5 0.8 0.42 ~0.7 s 45 min 6.0 h
3 1.0 1.3 1.8 2.6 1.54 1.50 s * 1.6 h 13 h
4 4.2 4.8 5.4 4.79 3.79 s * 4.3 h 34 h
5 7.3 8.9 16.5 8.95 7 to 17 s 8 to 18 h 65-145 h
6 44.7 48.6 191.0 48.59 90.7 s * 93 h 31 days
* measured over whole games. The opening probe is representative through
level 4 (slightly pessimistic, if anything) and only half the story at
level 6, where a whole game costs about 1.9× its opening per move. Use the
probe as-is up to level 4, expect up to double it at 5 and 6, and check the
first few games of any long run against the estimate.
The wall between an afternoon and a fortnight sits between level 4 and level 6. Sargon's levels are not a ladder that can be interpolated, and cost per level is not strength per level.
Size MOVE_TIMEOUT at about 20× the projected per-move cost: the 120s default
is ample through level 3, 300 at level 4, 600 at level 5, and 1200 was used
for level 6. A search that overruns the timeout is scored as a bridge failure
and the game is retried.
At the cheap levels this harness is a large part of its own timing:
player_move types six keys at KEY_DELAY (0.15s) before Sargon has thought
at all, plus about 25ms of screen poll. That is nearly a third of the level-1
figure and 0.2% of the level-6 one. The host matters too: a timing figure
taken under load is about 11% pessimistic. Both programs are node limited, so
the match result does not care.
Reading the games afterwards
python3 sargon/analyse.py scratch/<run>/smoke.pgn --depth 18 --engine cc65
Three cautions:
- Stockfish judges by a standard neither program can play to. A position it calls equal can be lost for a 60,000-node engine.
- Mate scores clamp at ±10000, so walking into a forced mate and failing to deliver one both print as ~9000cp swings. They are opposite errors.
- A mean over evaluation deltas is not a robust statistic. One clamped
move outweighs two hundred ordinary ones. Drop the mate scores and print a
median beside the mean, which is what
piececost.pydoes. - A per-game count is not a per-move cost. Counting queen moves per game correlates with the result because a losing position invites queen moves.
Known a2m limitations and cautions
- Do not use
--headless; the visible window is part of this study. set-turbo maxworks, but the window title does not reflect the control-port speed change.- Apple II snapshots currently time out. Use verified reset instead.
- Do not send Return during the
R,Hboot-loader sequence. - Do not type into the emulator window while automation owns focus; host keystrokes can be consumed by the Apple II.
- Do not assume
MATE IN nmeans the program is finished. - Do not assume decimal-only move numbers after move 99.