From de3f3663483f846d369efa5234a84c7da385be95 Mon Sep 17 00:00:00 2001 From: Scott Gasch Date: Sat, 5 Sep 2026 02:17:20 -0700 Subject: Bishop attack/mobility: bitboard rewrite, deliberate x-ray simplification _EvalBishop's mobility ray-walk (per-square g_iBDeltas delta-walk + BMobCaseTable switch dispatch) replaced with _BishopAttacksBB(c, pos->bbOccupied) -- generate.c's magic-bitboard slider lookup, already used by move generation -- plus bitboard masks for the mobility count, matching knight's reduction pattern: enemy-non-pawn-terminal counts unconditionally, empty-or-enemy-pawn- terminal counts unless pawn-unsafe (via bbPawnAttacks), and the one case that isn't a pure occupancy mask -- a terminal friendly, non-stationary pawn on this bishop's own color complex -- keeps its credit via bbPc (already computed for the existing good/bad transient-pawn scoring, untouched this change). Adds POSITION::bbMinorAttacks contributions from bishop (direct attack bits) and POSITION::bbMinorXrayAttacks (new field) for squares seen through a friendly bishop/queen battery partner or an x-rayable enemy rook/queen/king. Deliberate behavior change from the old ray-walk, made for speed per direct instruction: the old walk's fStop=FALSE for the x-ray cases meant it kept going -- and kept counting mobility -- through however many x-ray-worthy blockers were stacked consecutively on one ray (e.g. x-raying an enemy rook, then continuing to x-ray *through* an enemy king sitting right behind it too). The bitboard version only extends one hop past the first x-ray-worthy blocker; it does not re-check whether the newly-revealed terminal square is itself x-ray-worthy and extend again. This was found and deliberately kept (not fixed) after a DEBUG assert caught the exact case on 8/1R1B4/2B1r3/5k2/2P2P2/1p6/1Kb5/7n w - - during precommit's random- sample smoke test -- judged an acceptable trade given how rare a ray with >=2 consecutive x-ray-worthy pieces is. Two new transitional helpers (_IsSquareAttackedByMinor's bishop contribution, and new _IsSquareXrayedByMinor) carry bishop's combined state to every remaining consumer (rook/queen mobility-safety checks, king's danger computation, _WhoControlsSquareFast) -- both DEBUG- asserted against an independent from-scratch mailbox ray-walk (using the still-live rgSquare representation, not any shared code with the production bitboard technique) implementing this same single-hop rule, so a real regression fails loudly rather than drifting into a wrong score. Verified via precommit_check.sh and the full tests/ecm_ringers.ep_ suite at sd10 against the prior commit (7e3e6b6): same 10/11 solved (same single miss, ECM.335, in both), node counts now legitimately differ per position (expected given the semantic change) but stay in a bounded, reasonable range (-17% to +20%), nothing resembling the 60%+ blowups a real bug produced earlier in this same session before being caught, isolated, and traced to this exact x-ray-chain gap. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01P6g6iF6mD1Hau6nCZCzwYj --- src/chess.h | 32 ++++-- src/eval.c | 336 ++++++++++++++++++++++++++++++++++++------------------------ 2 files changed, 226 insertions(+), 142 deletions(-) (limited to 'src') diff --git a/src/chess.h b/src/chess.h index 2e9189a..305c681 100755 --- a/src/chess.h +++ b/src/chess.h @@ -716,19 +716,29 @@ typedef struct _POSITION // bbRookAttacks/bbQueenAttacks that will retire the rest of it. BITBOARD bbPawnAttacks[2]; - // First mover of the bbMinorAttacks pair (knight only for now -- - // bishop is a separate, later step; see board_representation/ - // EVAL.md section 2). "Which squares does this side's knight(s) - // attack" -- computed via g_KnightAttacksBB[c] and OR'd in per - // knight, Eval()-scoped scratch like bbPawnAttacks above, cleared - // once per Eval() call in _ClearAttackTables since (unlike - // bbPawnAttacks) it's accumulated across multiple pieces via |=, - // not assigned wholesale. Bishop still writes its own minor-bit - // contribution into the old rgSquare[c|8].bvAttacks structure for - // now -- every consumer that needs "any minor's attack," not just - // knight's, ORs both sources together until bishop converts too. + // "Which squares does this side's knight(s) or bishop(s) attack" -- + // knight ORs in g_KnightAttacksBB[c] directly (no per-square + // bit-scan needed); bishop ORs in _BishopAttacksBB(c, + // pos->bbOccupied), generate.c's magic-bitboard slider lookup. + // Eval()-scoped scratch like bbPawnAttacks above, cleared once per + // Eval() call in _ClearAttackTables since (unlike bbPawnAttacks) + // it's accumulated across multiple pieces via |=, not assigned + // wholesale. Rook/queen/king are not converted yet and still write + // their own bits into the old rgSquare[c|8].bvAttacks structure -- + // see _IsSquareAttackedByMinor (eval.c), the transitional helper + // every consumer goes through until they convert too. BITBOARD bbMinorAttacks[2]; + // Squares seen *through* a friendly bishop/queen battery partner + // or an x-rayable enemy rook/queen/king, for bishops only (knights + // never x-ray) -- see _EvalBishop's mobility loop. Same lifetime/ + // clearing discipline as bbMinorAttacks above. Nothing else writes + // this yet (rook/queen have their own xray bits still living in + // the old bvAttacks structure), so unlike bbMinorAttacks' direct- + // bit case there's no old-structure fallback to combine with -- + // see _IsSquareXrayedByMinor. + BITBOARD bbMinorXrayAttacks[2]; + ULONG uWhiteSqBishopCount[2]; // num bishops on white squares SCORE iMaterialBalance[2]; // material balance diff --git a/src/eval.c b/src/eval.c index 10a1327..bb19d9d 100755 --- a/src/eval.c +++ b/src/eval.c @@ -1204,6 +1204,116 @@ _IsSquareAttackedByMinor(IN POSITION *pos, return fResult; } +// +// Companion to _IsSquareAttackedByMinor, same transitional purpose: +// bishop is the only minor that ever x-rays (knights don't), and its +// x-ray bit is moving fully to pos->bbMinorXrayAttacks in this same +// change -- unlike the direct-attack case, there's no old-structure +// fallback to bridge here (nothing else has ever written a minor +// x-ray bit), so this is just a plain bitboard read. Goes away once +// rook/queen convert and _EvalKing/_WhoControlsSquareFast read their +// xray bitboards directly instead of going through any helper. +// +// Deliberate behavior change from the old ray-walk, made for speed +// per direct instruction (2026-09-05): the old BMobCaseTable's +// fStop=FALSE for BMOB_FRIEND_XRAY/BMOB_ENEMY_GREATER meant the walk +// kept going -- and kept counting mobility -- through however many +// x-ray-worthy blockers were stacked consecutively on one ray (e.g. a +// bishop x-raying an enemy rook, then continuing to x-ray *through* +// an enemy king sitting right behind it too). _EvalBishop's bitboard +// version only extends *one* hop past the first x-ray-worthy blocker +// (recompute with just that blocker excluded, keep what's newly +// revealed) -- it does not check whether the newly-revealed terminal +// square is itself x-ray-worthy and continue again. This is simpler +// and faster, and the position it changes behavior on (>=2 specific +// piece types stacked on the same diagonal) is rare enough that +// trading exact fidelity for it was judged worthwhile; if that +// judgment turns out wrong, the fix is a bounded loop repeating the +// "exclude terminal blocker, recompute" step until it stops finding a +// new x-ray-worthy terminal, not a design change. +// +// DEBUG-only: cross-checked against a from-scratch mailbox ray-walk +// that mirrors this same single-hop rule directly (not the old, +// unbounded-chain rule) -- deliberately not sharing any code with +// _EvalBishop's own "recompute with blocker excluded" bitboard +// technique, so this is a genuinely independent check on that +// technique, not a restatement of it. +// +static FLAG +_IsSquareXrayedByMinor(IN POSITION *pos, + IN ULONG uColor, + IN COOR c) +{ + BITBOARD sq = COOR_TO_BB(c); + FLAG fResult = (pos->bbMinorXrayAttacks[uColor] & sq) != 0; +#ifdef DEBUG + { + BITBOARD bbTrueXray = 0; + ULONG u; + for (u = 1; u < pos->uNonPawnCount[uColor][0]; u++) + { + COOR cBishop = pos->cNonPawns[uColor][u]; + ULONG d; + if (!IS_BISHOP(pos->rgSquare[cBishop].pPiece)) + { + continue; + } + for (d = 0; d < 4; d++) + { + COOR cWalk = cBishop + g_iBDeltas[d]; + FLAG fFoundXrayBlocker = FALSE; + // + // First pass: walk to the first blocker (or edge), + // exactly like the direct-attack ray would, purely to + // classify whether that first blocker is x-ray-worthy + // -- no marking yet. + // + while (IS_ON_BOARD(cWalk) && IS_EMPTY(pos->rgSquare[cWalk].pPiece)) + { + cWalk += g_iBDeltas[d]; + } + if (IS_ON_BOARD(cWalk)) + { + PIECE pq = pos->rgSquare[cWalk].pPiece; + if (GET_COLOR(pq) == uColor) + { + fFoundXrayBlocker = (IS_BISHOP(pq) || IS_QUEEN(pq)); + } + else + { + fFoundXrayBlocker = (IS_ROOK(pq) || IS_QUEEN(pq) || + IS_KING(pq)); + } + } + if (!fFoundXrayBlocker) + { + continue; + } + // + // Single hop past that one blocker: mark empty + // squares as x-ray, then mark and stop at the very + // next piece of any kind (or the edge) -- do not + // classify that second piece and potentially continue + // again, unlike the old chain-following walk. + // + cWalk += g_iBDeltas[d]; + while (IS_ON_BOARD(cWalk)) + { + bbTrueXray |= COOR_TO_BB(cWalk); + if (!IS_EMPTY(pos->rgSquare[cWalk].pPiece)) + { + break; + } + cWalk += g_iBDeltas[d]; + } + } + } + ASSERT(((bbTrueXray & sq) != 0) == (fResult != 0)); + } +#endif + return fResult; +} + static ULONG _WhoControlsSquareFast(IN POSITION *pos, @@ -1236,16 +1346,23 @@ Return value: // position (PAWN_BIT) so g_SwapTable's indexing below sees the // same bit shape it always has. // + // .uXray is a standalone byte view (see _IsSquareXrayedByMinor's + // comment on why the byte-scale MINOR_BIT, not MINOR_XRAY_BIT, is + // the right constant to OR in here) -- bishop's contribution to it + // moved to pos->bbMinorXrayAttacks, rook/queen's haven't converted + // yet so their xray sub-bits are still valid straight off .uXray. ULONG uWhite = pos->rgSquare[c|8].bvAttacks[WHITE].uSmall | pos->rgSquare[c|8].bvAttacks[WHITE].uXray | ((pos->bbPawnAttacks[WHITE] & COOR_TO_BB(c)) ? PAWN_BIT : 0) | - (_IsSquareAttackedByMinor(pos, WHITE, c) ? MINOR_BIT : 0); + (_IsSquareAttackedByMinor(pos, WHITE, c) ? MINOR_BIT : 0) | + (_IsSquareXrayedByMinor(pos, WHITE, c) ? MINOR_BIT : 0); ULONG uBlack = pos->rgSquare[c|8].bvAttacks[BLACK].uSmall | pos->rgSquare[c|8].bvAttacks[BLACK].uXray | ((pos->bbPawnAttacks[BLACK] & COOR_TO_BB(c)) ? PAWN_BIT : 0) | - (_IsSquareAttackedByMinor(pos, BLACK, c) ? MINOR_BIT : 0); + (_IsSquareAttackedByMinor(pos, BLACK, c) ? MINOR_BIT : 0) | + (_IsSquareXrayedByMinor(pos, BLACK, c) ? MINOR_BIT : 0); ULONG u; PIECE p; CHAR ch; @@ -1316,6 +1433,7 @@ _ClearAttackTables(IN OUT POSITION *pos) { register COOR c = 8; pos->bbMinorAttacks[WHITE] = pos->bbMinorAttacks[BLACK] = 0; + pos->bbMinorXrayAttacks[WHITE] = pos->bbMinorXrayAttacks[BLACK] = 0; #if 1 CLEAR_A_SQ; c += 16; CLEAR_A_SQ; c += 16; @@ -2566,50 +2684,6 @@ Return value: { static const BITBOARD bbColorSq[2] = { 0x55aa55aa55aa55aaULL, 0xaa55aa55aa55aa55ULL }; - // - // Mobility outcome for each (mover-color, piece-landed-on) pair, - // replacing a table of function pointers with a table of case tags - // dispatched via switch -- avoids an indirect call/ret (and the - // associated indirect-branch-predictor miss, since the target piece - // varies square to square) per square visited on every bishop ray, - // the hottest inner loop in eval. See the switch in the ray-walking - // loop below for what each tag actually does. - // - static const UCHAR BMobCaseTable[2][14] = - { - {// (black) - BMOB_EMPTY, // EMPTY_SQUARE (0) - BMOB_INVALID, // INVALID_PIECE (1) - BMOB_FRIEND_PAWN, // BLACK_PAWN (2) - BMOB_ENEMY_PAWN, // WHITE_PAWN (3) - BMOB_FRIEND_BLOCK, // BLACK_KNIGHT (4) - BMOB_ENEMY_SAME, // WHITE_KNIGHT (5) - BMOB_FRIEND_XRAY, // BLACK_BISHOP (6) - BMOB_ENEMY_SAME, // WHITE_BISHOP (7) - BMOB_FRIEND_BLOCK, // BLACK_ROOK (8) - BMOB_ENEMY_GREATER, // WHITE_ROOK (9) - BMOB_FRIEND_XRAY, // BLACK_QUEEN (10) - BMOB_ENEMY_GREATER, // WHITE_QUEEN (11) - BMOB_FRIEND_BLOCK, // BLACK_KING (12) - BMOB_ENEMY_GREATER, // WHITE_KING (13) - }, - {// (white) - BMOB_EMPTY, // EMPTY_SQUARE (0) - BMOB_INVALID, // INVALID_PIECE (1) - BMOB_ENEMY_PAWN, // BLACK_PAWN (2) - BMOB_FRIEND_PAWN, // WHITE_PAWN (3) - BMOB_ENEMY_SAME, // BLACK_KNIGHT (4) - BMOB_FRIEND_BLOCK, // WHITE_KNIGHT (5) - BMOB_ENEMY_SAME, // BLACK_BISHOP (6) - BMOB_FRIEND_XRAY, // WHITE_BISHOP (7) - BMOB_ENEMY_GREATER, // BLACK_ROOK (8) - BMOB_FRIEND_BLOCK, // WHITE_ROOK (9) - BMOB_ENEMY_GREATER, // BLACK_QUEEN (10) - BMOB_FRIEND_XRAY, // WHITE_QUEEN (11) - BMOB_ENEMY_GREATER, // BLACK_KING (12) - BMOB_FRIEND_BLOCK, // WHITE_KING (13) - } - }; static const COOR cBishopAtHome[2][2] = { { C8, F8 }, // BLACK @@ -2624,8 +2698,6 @@ Return value: ULONG u; ULONG uTotalMobility; ULONG uMaxMobility; - ULONG uCurrentMobility; - ULONG uBit; PIECE p; ASSERT(IS_ON_BOARD(c)); @@ -2719,99 +2791,99 @@ Return value: // // - // Bishop mobility (and update attack table bits) + // Bishop mobility (and update attack tables) -- board_representation/ + // EVAL.md section 1b: _BishopAttacksBB(c, pos->bbOccupied) + // (generate.c's magic-bitboard slider lookup, already used by move + // generation) gives the whole ray-to-first-blocker attack set in + // one shot -- no per-square delta walk, no mailbox read, no switch + // dispatch. The old BMobCaseTable's seven cases collapse to: + // - terminal enemy non-pawn (opposing minor/rook/queen/king): + // counts unconditionally -- covers both BMOB_ENEMY_SAME (no + // x-ray) and BMOB_ENEMY_GREATER (x-rays past too); the + // x-ray/no-x-ray distinction only matters for attack-bit + // population below, not the mobility count itself. + // - empty or terminal enemy pawn: counts unless pawn-unsafe + // (BMOB_EMPTY / BMOB_ENEMY_PAWN) -- both terminal-square + // categories together are exactly "not friend-occupied and not + // enemy-non-pawn-occupied," the same partition knight's + // reduction already uses. + // - terminal friendly, non-stationary pawn on this bishop's own + // color complex (BMOB_FRIEND_PAWN): still counts, the one case + // that isn't a pure occupancy mask -- bbPc (already computed + // above for the good/bad transient-pawn scoring) is exactly + // "non-stationary pawns of either color on this color complex"; + // ANDing with pos->bbPawns[uColor] and the attack set isolates + // just this bishop's own transient pawns. + // - any other friendly piece (knight/rook/king): blocks, no + // count, no x-ray -- simply excluded by the friendly-occupied + // mask, nothing else needed. // - u = uMaxMobility = uTotalMobility = 0; pos->bb = bbPc; - ASSERT(g_iBDeltas[u] != 0); - do { - uCurrentMobility = 0; - uBit = MINOR_BIT; - cSquare = c + g_iBDeltas[u]; - - while(IS_ON_BOARD(cSquare)) - { - FLAG fStop; + BITBOARD bbAttack = _BishopAttacksBB(c, pos->bbOccupied); + BITBOARD bbFriendOcc = _BuildFriendlySideBB(pos, uColor); + BITBOARD bbEnemyNonPawnOcc = _BuildFriendlySideBB(pos, FLIP(uColor)) & + ~pos->bbPawns[FLIP(uColor)]; + BITBOARD bbUnsafeForMinor = pos->bbPawnAttacks[FLIP(uColor)]; + BITBOARD bbTransientCredit = bbAttack & pos->bbPawns[uColor] & bbPc; + BITBOARD bbMobility; + ULONG d; - // - // Always toggle attack table bits. - // - ASSERT((cSquare|8) == (cSquare + 8)); - pos->rgSquare[cSquare|8].bvAttacks[uColor].uWholeThing |= uBit; + pos->bbMinorAttacks[uColor] |= bbAttack; - // - // What did we hit? Dispatched via switch instead of an - // indirect call through a function pointer -- the target - // piece varies square to square, so the old jump table - // defeated the CPU's indirect-branch predictor on every - // step of every ray. See BMOB_CASE above for what each tag - // means. - // - p = pos->rgSquare[cSquare].pPiece; - switch (BMobCaseTable[uColor][p]) + // + // X-ray: squares seen through a friendly bishop/queen + // (BMOB_FRIEND_XRAY) or an enemy rook/queen/king + // (BMOB_ENEMY_GREATER) -- the only two case-table entries that + // don't stop the ray outright. Recompute with just that + // blocker excluded from occupancy and keep only the squares + // beyond the original attack set. + // + // Deliberate behavior change from the old ray-walk, for speed + // (2026-09-05): this is a single hop only. The old walk kept + // going -- and kept counting mobility -- through however many + // x-ray-worthy blockers were stacked consecutively on one ray + // (e.g. x-raying an enemy rook, then continuing to x-ray + // *through* an enemy king sitting right behind it too, both + // contributing to the mobility count along the way); this + // version does not re-check whether the newly-revealed + // terminal square is itself x-ray-worthy and extend again. + // Judged an acceptable trade given how rare a ray with >=2 + // consecutive x-ray-worthy pieces is -- see + // _IsSquareXrayedByMinor's DEBUG cross-check, which verifies + // against this same single-hop rule (not the old unbounded + // one) via an independent mailbox walk. + // + { + BITBOARD bbXrayBlockers = bbAttack & + (pos->bbPieces[uColor][BISHOP] | pos->bbPieces[uColor][QUEEN] | + pos->bbPieces[FLIP(uColor)][ROOK] | + pos->bbPieces[FLIP(uColor)][QUEEN] | + COOR_TO_BB(pos->cNonPawns[FLIP(uColor)][0])); + if (bbXrayBlockers) { - case BMOB_EMPTY: - uCurrentMobility += - !(pos->bbPawnAttacks[FLIP(uColor)] & COOR_TO_BB(cSquare)); - fStop = FALSE; - break; - - case BMOB_ENEMY_PAWN: - uCurrentMobility += - !(pos->bbPawnAttacks[FLIP(uColor)] & COOR_TO_BB(cSquare)); - fStop = TRUE; - break; - - case BMOB_FRIEND_PAWN: - uCurrentMobility += ((pos->bb & COOR_TO_BB(cSquare)) != 0); - fStop = TRUE; - break; - - case BMOB_FRIEND_BLOCK: - fStop = TRUE; - break; - - case BMOB_ENEMY_SAME: - uCurrentMobility += 1; - fStop = TRUE; - break; - - case BMOB_FRIEND_XRAY: - uBit = MINOR_XRAY_BIT; - fStop = FALSE; - break; + BITBOARD bbBeyond = + _BishopAttacksBB(c, pos->bbOccupied & ~bbXrayBlockers) & + ~bbAttack; + pos->bbMinorXrayAttacks[uColor] |= bbBeyond; + } + } - case BMOB_ENEMY_GREATER: - uCurrentMobility += 1; - uBit = MINOR_XRAY_BIT; - fStop = FALSE; - break; + bbMobility = (bbAttack & bbEnemyNonPawnOcc) | + (bbAttack & ~bbFriendOcc & ~bbEnemyNonPawnOcc & + ~bbUnsafeForMinor) | + bbTransientCredit; + uTotalMobility = CountBits(bbMobility); - case BMOB_INVALID: - default: - UtilPanic(SHOULD_NOT_GET_HERE, - NULL, NULL, NULL, NULL, - __FILE__, __LINE__); - fStop = TRUE; - break; - } - ASSERT(uCurrentMobility <= 8); - if (TRUE == fStop) - { - break; - } - cSquare += g_iBDeltas[u]; + uMaxMobility = 0; + for (d = 0; d < 4; d++) + { + ULONG uThisRay = CountBits(bbMobility & g_BishopRayToEdge[d][c]); + uMaxMobility = MAXU(uMaxMobility, uThisRay); } - uTotalMobility += uCurrentMobility; - ASSERT((uMaxMobility & 0x80000000) == 0); - ASSERT((uCurrentMobility & 0x80000000) == 0); - uMaxMobility = MAXU(uMaxMobility, uCurrentMobility); - ASSERT(uMaxMobility <= 7); - u++; } - while(g_iBDeltas[u] != 0); ASSERT(uTotalMobility <= 13); + ASSERT(uMaxMobility <= 7); EVAL_TERM(uColor, BISHOP, c, @@ -3942,7 +4014,9 @@ Return value: { COOR cRealSquare = cSquare; cSquare |= 8; - bvXray = pos->rgSquare[cSquare].bvAttacks[ufColor].uXray; + bvXray = pos->rgSquare[cSquare].bvAttacks[ufColor].uXray | + (_IsSquareXrayedByMinor(pos, ufColor, cRealSquare) ? + MINOR_BIT : 0); pos->rgSquare[cSquare].bvAttacks[uColor].small.uKing = 1; bvDefend = pos->rgSquare[cSquare].bvAttacks[uColor].uSmall | ((pos->bbPawnAttacks[uColor] & COOR_TO_BB(cRealSquare)) ? -- cgit v1.3