Skip to content

Bent riders in XBetza bracket notation - #1035

Open
fhoudebert wants to merge 2 commits into
fairy-stockfish:masterfrom
fhoudebert:master
Open

fhoudebert wants to merge 2 commits into
fairy-stockfish:masterfrom
fhoudebert:master

Conversation

@fhoudebert

@fhoudebert fhoudebert commented Aug 31, 2026

Copy link
Copy Markdown

Bent riders in XBetza bracket notation

Adds bent riders to the Betza parser: a piece that takes one leg, may stop
there, and otherwise rides on without limit in the outward direction. Seven
pieces fall out of it, none of them expressible today, all declarable through
customPieceN:

notation piece move
[F?R] griffon diagonal step, then an orthogonal ride out
[W?B] rhinoceros orthogonal step, then a diagonal ride out
[D?B] osprey two-square orthogonal leap, then a diagonal ride out
v[F?R] ship the griffon, riding only along files
v[W?B] snake the rhinoceros, stepping only along a file first
[F-B] picket a bishop that must move at least two squares
[W-R] ski-rook a rook that must move at least two squares

420 insertions across 7 files, none of them position.cpp.

Related: #962, where @HGMuller implemented a superset of this on the pieces
branch of his repository, and #966 / #967 — the crashes reported there turned
out to be the #966 bug fixed by #967, so there is no known blocker left on top
of master.

PieceInfo currently has only steps, slider and hopper, none of which
can encode a change of direction mid-move, so this is a data-model addition
rather than a parser tweak.

The notation

This follows the bracket notation @HGMuller has been implementing in the
Interactive Diagram, rather than the older a-modifier spelling: [A?B] is a
leg with atom A, which the piece may stop on, followed by a ride with atom
B in the outward direction; [A-B] is the same with the leg square only
passed through — still blocking the ride when occupied, just not a
destination.

Three things follow, and all three earn their keep:

  • The second atom decides how far the move turns. Riding with the other
    family is a 45 degree turn and forks both ways; riding with the same family
    carries straight on. So [F-B] and [W-R] — the picket and the ski-rook —
    come out of the same code as the griffon, with no special case.
  • A leg longer than one square is a leap: what it passes over is
    irrelevant, which is what makes [D?B] an osprey rather than a lame one.
  • A modifier written outside the brackets sorts moves by the shape of the
    whole displacement
    , the brackets standing in for an oblique atom. v
    keeps the moves travelling further along a file than across one, s the
    other way round, and a move going equally far both ways is kept by either.

That last rule is what makes v[F?R] the ship and v[W?B] the snake, by the
same reading rather than by two conventions. It has to apply to the leg square
as well as to the ride — the leg square is part of the compound — which is why
the snake keeps neither the sideways rides nor the sideways steps that would
start them.

It is also cheap. The comparison never changes as the ride lengthens: for a
ride along a file or a rank one component is fixed while the other grows, and
for a diagonal ride off an orthogonal leg both grow at the same rate, so the
leg's own offset decides once and for all. One step of the ride is enough to
settle it, at parse time.

Modifiers written inside the brackets are ordinary directional modifiers on
the leg atom, so [vW?B] reaches the snake by the other route and gives the
identical move set. v[W?B] reads better and is what the documentation uses.

Not supported: a second leg carrying its own directional modifiers,
[W?sR]. That would be a turn of something other than 45 degrees, which
breaks the single-lookup construction below, since the continuations would no
longer share a family. The parser leaves such a group alone rather than
reading it as something it is not, and variants.ini says so.

How the attacks are computed

No new magic table. A griffon's relevant-occupancy mask spans two whole
files and two whole ranks — around 24 squares on 8x8 before edge trimming, and
worse on 12x10 — so a dedicated magic would be memory-hungry for a piece most
variants never use. The attacks are composed from the rook and bishop magics
instead.

The construction rests on one observation about QueenDirections, which this
patch adds in rotational order, so that stepping ±1 through the table is a
45 degree turn and the diagonals sit at the odd indices:

Every continuation of a given leg belongs to the same family. A 45 degree
turn flips the index parity, so both forks of [F?R] are orthogonal and
both forks of [W?B] are diagonal — never a mix. A ride carrying straight
on stays in the leg's own family.

That is what makes a single lookup enough. For each leg:

  1. Step or leap to the corner square t. If it is off the board, wrapped
    (distance(s, t) != len), or occupied, the leg contributes nothing
    further — an occupied corner blocks the ride, which is the rule.
  2. Take one attack lookup from t, attacks_bb<ROOK> or
    attacks_bb<BISHOP> according to the parity of the continuation
    direction. One probe covers every fork at once, and it already applies the
    blocking rules along them, which are exactly a bent rider's.
  3. Mask with the RayBB entries of the continuations actually taken, which
    discards the rays pointing back toward the origin.
Square t = Square(s + br.len[k] * QueenDirections[i]);
if (!is_ok(t) || distance(s, t) != br.len[k] || (occupied & t))
    continue;
Bitboard rays = 0;
if (br.cont[k] & 1) rays |= RayBB[(i + 1) & 7][t];
if (br.cont[k] & 2) rays |= RayBB[(i + 7) & 7][t];
if (br.cont[k] & 4) rays |= RayBB[i][t];
int j = br.cont[k] & 4 ? i : br.cont[k] & 1 ? (i + 1) & 7 : (i + 7) & 7;
b |= ((j & 1) ? attacks_bb<BISHOP>(t, occupied) : attacks_bb<ROOK>(t, occupied)) & rays;

The only new table is RayBB[8][SQUARE_NB], the rays to the board edge in
each queen direction: 8 KB, or 16 KB with LARGEBOARDS.

Cost per attack query: four legs, one rook or bishop lookup each. Since the
rook is split into H and V magics, a griffon costs 8 magic probes against 2
for a queen; a rhinoceros or osprey rides diagonally and costs 4.
Per-quadrant magics — mask being one partial file plus one partial rank from
the corner, hence small — are the obvious follow-up if this ever shows up in a
profile.

The rider bit carries no shape. One RIDER_BENT bit covers all seven
pieces; the shape lives in BentAttacks[COLOR_NB][PIECE_TYPE_NB], holding per
leg a direction index, a leg length and a three-bit continuation mask. Black
is the same table with each leg turned through 180 degrees, a half-turn in the
direction table, which leaves the mask unchanged. The bit is placed after
every magic-backed rider
and stripped by a new MAGIC_RIDERS mask before
the loop in rider_attacks_bb, which indexes magics[] by bit position and
would otherwise run off the end.

Why bent riders cannot use the existing fast paths

Reachability is symmetric, but the path is not. A griffon on a1 reaches b5
via b2-b3-b4; a griffon on b5 reaches a1 via a4-a3-a2. The blocking sets are
disjoint. Three consequences:

  • checkSquares[pt], computed from the king square, assumes reversibility.
  • BetweenBB, built as the intersection of the two reciprocal attack sets,
    gives {c2} for a1/b5 instead of {a2, a3, a4}.
  • slider_blockers() would compute pins along the wrong squares.

Putting RIDER_BENT in NON_SLIDING_RIDERS and ASYMMETRICAL_RIDERS is
enough, and nothing else was needed — which matches what @HGMuller reported in
#962. NON_SLIDING_RIDERS makes generate_all<EVASIONS> fall back to
target = ~pos.pieces(Us) and makes legal() skip its between_bb shortcut,
leaving the full attackers_to() at the end of legal() to cover pins;
ASYMMETRICAL_RIDERS makes slider_blockers(), attackers_to() and
gives_check() recompute from each candidate square rather than reversing.

bent_path_bb() supplies a directed between_bb(s1, s2, pt), traced from
the piece towards the target. checkSquares stays the approximate forward
set, exactly as it already is for HORSE, which is also in
ASYMMETRICAL_RIDERS; it only feeds QUIET_CHECKS, so the cost is a little
search quality, not correctness.

One incidental fix: psqt.cpp reads only slider and hopper when deciding
isSlider, so a piece whose movement lives in bent was silently classified
as a non-slider. Each continuation now counts as three quarters of a slider
direction, which puts the griffon slightly below the queen — matching its
empty-board mobility of 21.4 squares against 22.75 — and values the
half-versions at about half the full piece.

Testing

No regression on standard chess. bench gives 6180480 with and without
the patch. Perft from the start position to depth 6 = 119060324 and Kiwipete
to depth 5 = 193690690.

Move generation cross-checked against an independent path-walking
reference over 600 random 12x10 positions with friendly and enemy blockers,
spread across four of the shapes: 0 disagreements. On an empty 8x8 board from
d4 the seven pieces give 24, 24, 16, 12, 14, 8 and 10 moves, each also
verified by hand. v[W?B] and [vW?B] give not just the same count but the
same twelve squares.

Check detection. Two positions, same pieces, the blocker moved from b3 to
a3, in a variant with customPiece1 = g:[F?R]:

FEN expected result
7k/8/8/1g6/8/1P6/8/K7 w - - 0 1 check, 2 legal moves Checkers: b5, perft 1 = 2
7k/8/8/1g6/8/P7/8/K7 w - - 0 1 no check, 4 legal moves no checkers, perft 1 = 4

An implementation treating the griffon as symmetric gets both backwards.
Worth keeping as a regression test.

The osprey leaps. With a knight on e4 all 16 moves from d4 remain,
including d4f4 and the ride g5/h6; with the landing square f4 occupied, d4f4
stays as a capture but the ride beyond disappears.

Stress. 18,000 plies of random games on a debug=yes build in a 12x10
variant holding all seven shapes at once — both spellings of the snake
included — plus go depth 22 on K+G vs K endgames, which is where #962
crashed. No assertion failures.

Two example variants are added to variants.ini: griffonchess (perft 4 =
187940) and ospreychess (perft 4 = 593048).

Cost

Move generation only, which is where the whole cost sits and the only figure I
could measure repeatably: go perft 5 from the start position runs at 19.3
Mn/s for a chess variant with a queen against 8.9 Mn/s for the same variant
with a griffon, so roughly half the speed. In search the gap is smaller,
since evaluation dilutes it, but my machine was too noisy to pin a number down
and I would rather quote the reproducible one. It only affects variants that
actually use a bent rider.

Questions

  1. Would you rather upstream @HGMuller's pieces branch as a whole now that
    Set piece value for black custom pieces #967 has landed? It is broader — ski sliders, long-stride riders — and this
    PR should be dropped in its favour if so.

NNUE does not know these pieces, so variants using them run on classical eval.

@HGMuller

HGMuller commented Sep 1, 2026

Copy link
Copy Markdown

Note that the preferred notation for the Snake should (in analogy with the Ship) be v[W?B] not [vW?B]. Although it could work in the case of an orthogonal atom, directional modifiers in the first leg of a bracket notation are in general troublesome, as the symmetry class of the entire path can be lower than that of the first leg, and interpretation of the directional modifiers depends on symmetry class. E.g. what would [fsW?B] mean? frW in itself would mean fW+rW. In that interpretation each of these would splint into two B slides, leaving 4 slides, But the distant moves of the slide are oblique, and in the oblique system fr would select only a single path. fr[W?B] unambiguously selects the single path.

Nothing implements this yet, but I believe it is the proper future of this notation. In my 'piece' branch I already interpret prefixes of the bent-slider notation (such as fryafsF) as if they act on obliques, rather than on F (what the a notation, and thus XBoard currently assume). So in fact my FSF Griffon is now incompatible with that of XBoard for making asymmetric subsets. Which would be corrected if both switch to bracket notation.

@fhoudebert

Copy link
Copy Markdown
Author

Following HGMuller's comment, the Snake notation has been modified to v[W?B] in commit 0dbf730 and the PR description updated.

@HGMuller

HGMuller commented Sep 2, 2026

Copy link
Copy Markdown

Note that [W-R] denotes a LAME Ski-Rook; a true Ski-Rook would jump over the first square, i.e. [D?R].

I am a bit on the fence of whether this development is good or bad. It is certainly nice to see developers interested in expanding FSF's piece repertoire. But there are a lot of fundamental bottlenecks in the current implementation that severely hinder that. And I am afraid that work-arounds to those for implementing specific pieces would make it harder to address these later, so that we run the risk of painting ourselves into a corner. So even though it is very nice to have bracket notation and some bent riders it might be better to introduce some generally useful changes that help implementing these first.

The way to transfer move information from the parser to the (initialization of the) bitboard move generator is one such bottleneck. In this commit the problem of conveying bent riders was is solved by introducing a pi->bent next to the leaper, slider and hopper, but to keep that up would lead to proliferation of such direction sets. Bent riders are sliders, and there also exist bent hoppers. It would be more generally useful if the way move paths are encoded in the existing (lame) leaper / slider / hopper sets was not limited to straight paths, but could also indicate single bent paths of various corner locations and bend angles, and perhaps even doubly-bent paths of various types. In other words, generalize the direction encoding used in these sets. Which at the same time could increade the maximal size the leaps can havem which now is quite inconveniently limited to 3 squares.

@HGMuller

HGMuller commented Sep 3, 2026

Copy link
Copy Markdown

Please note my proposal #1038 , which would be yet an alternative way for implementing bent riders.

@fhoudebert

Copy link
Copy Markdown
Author

The proposal seems interesting.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants