Files
cc65-Chess/tests/Makefile
T
Stefan WesselsandClaude Opus 5 fafd4daf25 Give the engine a different opening to play
It played a knight to c3 from the start position.  Every game, on every
machine, since 2014.  The cause was never mysterious: the evaluation
rates several opening moves exactly equal and the root loop keeps a move
only when it beats the incumbent, so ties fell to whatever the generator
emitted first - the order of two nested loops over the board, and not
anything to do with chess.

The fix leans on the one thing alpha-beta promises about move ordering:
it does not change the answer.  Order the moves any way you like and the
search returns a move with the same score.  The single exception is
exactly the case above, where equal scores are decided by which move was
tried first - so perturbing the ordering score at the root varies the
opening and cannot make the engine prefer a worse move.  That is a
structural property rather than a statistical one, which is why this
landed without a 512-game match behind it: there was nothing to lose and
so nothing to measure.

+0..3 rather than a shuffle, because ordering is what makes alpha-beta
cut and randomising it wholesale would cost nodes for nothing.  It stops
after five of the engine's own moves - its moves, not plies, so the
promise is the same whichever colour it has - which keeps it out of the
endgames the piece-square tables were built for.

plat_GetSeed is the first addition to plat.h since it was frozen, and it
clears the bar that freeze exists to set: three lines a port, a read of a
counter that is already running, and a failure mode that looks like the
behaviour being fixed rather than like a crash.  Every machine had one
and cc65's own asminc names all of them.  Three of the six recipes
needed correcting - the SID readback is $D41B and not $D400, the Plus/4's
jiffy clock is at $A3 and not the C64's $A0, and the Apple II has no
clock at all but does have a counter cc65's cgetc increments while it
waits for a key, which holds how long the human took and is better
entropy than a clock.  Whether those IRQs are running needed no
emulator: cgetc spins waiting for one to fill the key buffer, so there
is no way to be asking the question if it is not.

The seed is read once a game and after the menu rather than at
board_Init, because on a cold boot the menu is the only thing that has
happened that the machine could not have predicted.  A zero seed is
honoured rather than replaced - it is the LFSR's dead state, so it costs
no special case, and it means "play this game the way the old engine
would".  That is what the terminal build asks for.

Determinism for the harness needed no flag in the end.  search_SetSeed is
called by main.c, which is not in the test build, so every figure in
strength.md was measured by an engine sitting in that dead state.  The
ladder rung that anchors the claim reproduces to the digit across the
change: 248-129-135 before and after.

188 bytes of code and 2 of BSS.  The Atari's free space fell by two
rather than 188, because DLIST is page-aligned and the padding in front
of it swallowed the rest - 50 bytes of that padding are left, and the
byte that exhausts them costs 256 at once.  Recorded in AGENTS.md,
because it makes "there is room" a misleading thing to say about that
target.

tests/opening.c checks the two things that could go wrong quietly: that
different seeds really do produce different openings, and that a seeded
search and an unseeded one always agree on the score even when they
disagree on the move.  Breaking the feature on purpose to confirm the
test noticed is how the next one turned up - tests/Makefile did not list
the engine headers as prerequisites, so make test reported green from a
binary that never saw the change.  Headers are prerequisites now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:17:59 -07:00

95 lines
3.9 KiB
Makefile

###############################################################################
### cc65 Chess - native test build ###
### ###
### Nothing here is ever compiled into a cc65 target. The engine sources ###
### are pulled in from ../src and linked against a stub platform layer, so ###
### the game logic can be driven and checked on the host. ###
### ###
### make build ./chesstest ###
### make test build and run the pass/fail suite ###
### make clean ###
###############################################################################
SRCDIR := ../src
# -funsigned-char matches cc65, where char is unsigned by default. A lot of
# this code leans on that, so building without it will produce nonsense
CFLAGS := -I$(SRCDIR) -funsigned-char -O2 -g -Wall -DEVAL_TUNING -Wno-char-subscripts
# main.c is deliberately absent - the tests supply their own
ENGINE := \
$(SRCDIR)/globals.c \
$(SRCDIR)/engine.c \
$(SRCDIR)/eval.c \
$(SRCDIR)/search.c \
$(SRCDIR)/board.c \
$(SRCDIR)/cpu.c \
$(SRCDIR)/human.c \
$(SRCDIR)/undo.c \
$(SRCDIR)/frontend.c
HARNESS := \
main.c \
testutil.c \
platStub.c \
engineperft.c \
quiescence.c \
budget.c \
search.c \
match.c \
gamefuzz.c \
castle.c \
repetition.c \
opening.c \
selfplay.c
# The engine headers are prerequisites too. Without them an edit to search.h
# leaves a stale binary and the suite reports green for code that is no longer
# there - which cost an afternoon once and is invisible when it happens
HEADERS := $(wildcard $(SRCDIR)/*.h) testutil.h
chesstest: $(ENGINE) $(HARNESS) $(HEADERS)
$(CC) $(CFLAGS) -o $@ $(ENGINE) $(HARNESS)
test: chesstest
./chesstest all
# The UCI adapter, for playing other engines under a match runner. Built
# WITHOUT -DEVAL_TUNING on purpose: that switch exists for the tuning harness
# and makes every node dearer, and the thing being measured has to be the thing
# that ships. engineperft.c comes along only for its FEN parser
UCIFLAGS := -I$(SRCDIR) -funsigned-char -O2 -Wall -Wno-char-subscripts
uci: $(ENGINE) uci.c testutil.c engineperft.c platStub.c $(HEADERS)
$(CC) $(UCIFLAGS) -o $@ $(ENGINE) uci.c testutil.c engineperft.c platStub.c
# The same adapter WITH the tuning switches, for A/B matches against an outside
# opponent - the ladder can then be re-run with one term off. It is not the
# binary any published figure is measured with: tuning costs nodes, so the two
# builds must be shown to play the same games before an A/B means anything
uci-tuning: $(ENGINE) uci.c testutil.c engineperft.c platStub.c $(HEADERS)
$(CC) $(CFLAGS) -o $@ $(ENGINE) uci.c testutil.c engineperft.c platStub.c
genbook: $(ENGINE) genbook.c testutil.c platStub.c $(HEADERS)
$(CC) $(UCIFLAGS) -o $@ $(ENGINE) genbook.c testutil.c platStub.c
# book.epd - 256 openings, four plies deep - is checked in rather than built,
# and is deliberately NOT a dependency of anything. It is the set every
# published figure in doc/strength.md was measured against, so it has to stay
# put: the same seeds give the same file only while genbook is unchanged, and
# a silently regenerated book would make a new result quietly incomparable with
# an old one. Rebuild it only when you mean to, and expect to re-measure.
book: genbook
./genbook 256 4 > book.epd
# the whole external-match setup, ready to run. Needs a match runner and a
# reference engine - see gauntlet.py for what they are and why
gauntlet: uci genbook
# note book.epd is not removed here: make clean must not delete a tracked file
clean:
rm -f chesstest uci uci-tuning genbook
rm -rf chesstest.dSYM uci.dSYM uci-tuning.dSYM genbook.dSYM
.PHONY: test clean gauntlet book