summaryrefslogtreecommitdiff
path: root/src/fen.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-16 00:08:27 -0700
committerScott Gasch <[email protected]>2026-09-16 00:08:27 -0700
commitb91c8453f3fe21c8ccd3568923d8e39141c9c945 (patch)
treeb908fea282ecd4075744590d3dfc88ffa903d943 /src/fen.c
parentc0ac3d7845f60c7f6f2c3bfc306ceb0e4ac045ba (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.c216
1 files changed, 216 insertions, 0 deletions
diff --git a/src/fen.c b/src/fen.c
index 33810e6..60ff4dc 100644
--- a/src/fen.c
+++ b/src/fen.c
@@ -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);
+}