mirror of
https://github.com/StewBC/cc65-Chess.git
synced 2026-10-25 01:02:25 +00:00
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>
95 lines
3.9 KiB
Makefile
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
|