diff options
| author | Scott Gasch <[email protected]> | 2026-09-16 00:08:27 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-16 00:08:27 -0700 |
| commit | b91c8453f3fe21c8ccd3568923d8e39141c9c945 (patch) | |
| tree | b908fea282ecd4075744590d3dfc88ffa903d943 /src/fen.c | |
| parent | c0ac3d7845f60c7f6f2c3bfc306ceb0e4ac045ba (diff) | |
Add search self-tests (hash consistency, QSearch sanity) and a native EPD parser (`epd` command + ParseEpdRecord), fixing a silent-corruption bug in convert_epd.pl that broke every Black-to-move EPD conversion.
testsearch.c: TestSearchHashConsistency exercises the hash table through real search (repeat-with-warm-table and shallow-then-deep agreement) rather than synthetic store/probe calls. TestQSearchSanity checks termination and a node-count ceiling; its own header documents that the TEST=1 self-test build doesn't define PERF_COUNTERS, so it checks whole-search node count rather than the qsearch-only counter. A planned null-move zugzwang test was dropped in favor of tests/zugzwang.epd (best-move outcome, not an internal counter) after the three classic literature positions turned out not to structurally reach this engine's verification-search code path on this build.
fen.c/script.c/command.c: new `epd <file>` command reads raw EPD directly via ParseEpdRecord, reusing ScriptCommand's existing suite-reporting machinery (factored into _ProcessSuiteLine/_PrintSuiteReport). Replaces the need for tests/convert_epd.pl, which had a real bug: its bm/am regex's [^b]+ filler breaks on any Black-to-move record (side-to-move field is literally "b"), and unscoped $fen/$id/$solution globals meant a failed match silently duplicated the previous record instead of erroring.
tests/ep_to_epd.pl converts the existing setboard/solution/avoid/id/go suites back to real EPD; cross-checked ecm_ringers.ep_ via `script` against ecm_ringers.epd via `epd` -- byte-identical node counts and solve stats. Added .epd siblings for every genuine tactical suite in tests/.
Added src/TESTING_PLAN.md: full audit of test*.c coverage gaps (dead SEE oracle, unwired eval/recognizer tests, zero hash-table correctness coverage, thin search self-tests) with a prioritized plan for closing them.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/fen.c')
| -rw-r--r-- | src/fen.c | 216 |
1 files changed, 216 insertions, 0 deletions
@@ -781,3 +781,219 @@ Return value: SystemFreeMemory(szCapturedFen); return(TRUE); } + + +FLAG +ParseEpdRecord(IN char *szLineIn, + OUT char *szFen, IN ULONG cbFen, + OUT char *szId, IN ULONG cbId, + OUT char szMoves[][SMALL_STRING_LEN_CHAR], + IN ULONG uMaxMoves, + OUT ULONG *puNumMoves, + OUT FLAG *pfIsAvoid) +/** + +Routine description: + + Real per-record EPD parsing: pull the board/side-to-move/castling/ + en-passant fields plus the "bm"/"am" and "id" opcodes out of one + raw EPD line, e.g.: + + 8/8/1p1r1k2/p1pPN1p1/P3KnP1/1P6/8/3R4 b - - 0 1 bm Nxd5; id "zugzwang.005"; + + Written to replace tests/convert_epd.pl's single-regex approach + ("everything between the board and bm is [^b]+"), which silently + breaks on exactly this kind of line: side-to-move "b" contains the + character the regex's filler class explicitly excludes, so the + match fails outright, and because that script's $fen/$id/$solution + were plain globals rather than per-line locals, a failed match + silently carried the *previous* line's values forward instead of + erroring. This function fails closed instead: a line it can't + parse returns FALSE and touches none of the OUT parameters, so a + caller looping over a file can never mistake stale data left over + from the previous line for a real result. + + Tolerates the two EPD-ish flavors seen in real suites: strict EPD + (exactly 4 position fields, then opcodes) and the more common + FEN-plus-opcodes hybrid (4 position fields, then halfmove/fullmove + counters, then opcodes) -- an opcode name is never purely numeric, + so the two are unambiguous to tell apart by just skipping leading + all-digit tokens before looking for "bm"/"am"/"id". + +Parameters: + + IN char *szLineIn : one raw EPD line (not modified) + OUT char *szFen : receives "<board> <stm> <castle> <ep>" + IN ULONG cbFen : size of szFen + OUT char *szId : receives the id string (unquoted), or "" if none + IN ULONG cbId : size of szId + OUT char szMoves[][] : receives each bm/am move token, in order + IN ULONG uMaxMoves : capacity of szMoves (row count) + OUT ULONG *puNumMoves : number of moves written to szMoves + OUT FLAG *pfIsAvoid : TRUE if the opcode was "am", FALSE if "bm" + +Return value: + + FLAG : TRUE if a fen and at least one bm/am move were found and + fit in the caller's buffers; FALSE otherwise (all OUT + parameters left untouched) + +**/ +{ + char szWork[SMALL_STRING_LEN_CHAR]; + char *szTokens[4]; + char *p; + char *szOpcodeSection; + char *szChunk; + char *szSavePtr1, *szSavePtr2; + ULONG u; + ULONG uNumMoves = 0; + FLAG fIsAvoid = FALSE; + char szLocalId[SMALL_STRING_LEN_CHAR]; + char szLocalFen[SMALL_STRING_LEN_CHAR]; + + szLocalId[0] = '\0'; + + if ((NULL == szLineIn) || ('\0' == szLineIn[0])) + { + return(FALSE); + } + strncpy(szWork, szLineIn, ARRAY_LENGTH(szWork) - 1); + szWork[ARRAY_LENGTH(szWork) - 1] = '\0'; + szWork[strcspn(szWork, "\r\n")] = '\0'; + + p = szWork; + while (isspace((unsigned char)*p)) p++; + if (('\0' == *p) || ('#' == *p)) + { + return(FALSE); + } + + // First 4 whitespace-delimited tokens are always the position. + for (u = 0; u < 4; u++) + { + while (isspace((unsigned char)*p)) p++; + if ('\0' == *p) + { + return(FALSE); + } + szTokens[u] = p; + while (*p && !isspace((unsigned char)*p)) p++; + if (*p) + { + *p = '\0'; + p++; + } + } + if (snprintf(szLocalFen, ARRAY_LENGTH(szLocalFen), "%s %s %s %s", + szTokens[0], szTokens[1], szTokens[2], szTokens[3]) < 0) + { + return(FALSE); + } + + // Skip FEN-style halfmove/fullmove counters ahead of the first + // real opcode -- an opcode name is never purely numeric. + for (;;) + { + char *q; + while (isspace((unsigned char)*p)) p++; + q = p; + if ('\0' == *q) + { + break; + } + while (isdigit((unsigned char)*q)) q++; + if ((q == p) || (*q && !isspace((unsigned char)*q) && (';' != *q))) + { + // Not a bare numeric token -- this is the opcode section. + break; + } + if ('\0' == *q) + { + p = q; + break; + } + p = q; + } + szOpcodeSection = p; + + // Now split what's left on ';' into individual opcode chunks. + for (szChunk = strtok_r(szOpcodeSection, ";", &szSavePtr1); + NULL != szChunk; + szChunk = strtok_r(NULL, ";", &szSavePtr1)) + { + char *szTrimmed = szChunk; + char *szTail; + + while (isspace((unsigned char)*szTrimmed)) szTrimmed++; + szTail = szTrimmed + strlen(szTrimmed); + while ((szTail > szTrimmed) && isspace((unsigned char)szTail[-1])) + { + szTail--; + *szTail = '\0'; + } + if ('\0' == *szTrimmed) + { + continue; + } + + if (((0 == STRNCMPI(szTrimmed, "bm", 2)) && + isspace((unsigned char)szTrimmed[2])) || + ((0 == STRNCMPI(szTrimmed, "am", 2)) && + isspace((unsigned char)szTrimmed[2]))) + { + char *szMoveTok; + + fIsAvoid = (0 == STRNCMPI(szTrimmed, "am", 2)); + szMoveTok = strtok_r(szTrimmed + 2, " \t", &szSavePtr2); + while (NULL != szMoveTok) + { + if (uNumMoves < uMaxMoves) + { + strncpy(szMoves[uNumMoves], szMoveTok, + SMALL_STRING_LEN_CHAR - 1); + szMoves[uNumMoves][SMALL_STRING_LEN_CHAR - 1] = '\0'; + uNumMoves++; + } + szMoveTok = strtok_r(NULL, " \t", &szSavePtr2); + } + } + else if ((0 == STRNCMPI(szTrimmed, "id", 2)) && + isspace((unsigned char)szTrimmed[2])) + { + char *szIdVal = szTrimmed + 2; + while (isspace((unsigned char)*szIdVal)) szIdVal++; + if ('"' == *szIdVal) + { + char *szEndQuote; + szIdVal++; + szEndQuote = strchr(szIdVal, '"'); + if (NULL != szEndQuote) + { + *szEndQuote = '\0'; + } + } + strncpy(szLocalId, szIdVal, ARRAY_LENGTH(szLocalId) - 1); + szLocalId[ARRAY_LENGTH(szLocalId) - 1] = '\0'; + } + // Other opcodes (acd, ce, dm, c0, ...) intentionally ignored. + } + + if (0 == uNumMoves) + { + return(FALSE); + } + if (strlen(szLocalFen) >= cbFen) + { + return(FALSE); + } + strcpy(szFen, szLocalFen); + if (strlen(szLocalId) >= cbId) + { + szLocalId[cbId - 1] = '\0'; + } + strcpy(szId, szLocalId); + *puNumMoves = uNumMoves; + *pfIsAvoid = fIsAvoid; + return(TRUE); +} |
