From 28c204204d9cfebc66eda01c6e4204f4b54c249a Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Mon, 24 Aug 2026 22:07:46 +0200 Subject: [PATCH 1/4] Expect the substitute, not the lookalike, when the codepage is short WideCharToMultiByte approximates a character the target codepage lacks and leaves lpUsedDefaultChar clear while doing it, so the two --selftest blocks that build an ANSI fixture read an approximation as held. On a codepage that best-fits, the catalog check then compared the engine's output against a lookalike: harmless while the engine approximated too, a false FATAL now that it blocks best-fit and writes the codepage default character instead. Both fixtures go through CopyTextWideToACP(), which blocks best-fit as the engine does, so the MBCS gate skips where the codepage genuinely cannot hold the accent and the catalog expectation matches the converter on every codepage. That removes the catalog block's own skip, and adds the case the change is about: U+0100, which cp1252 best-fits to 'A'. The reported line carries a suffix when the codepage has a lookalike to offer, so CI can pin that the case is able to fail on this runner. Needs the engine's best-fit fix (xroche/httrack#1410). Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .github/actions/smoke-test/action.yml | 4 +- WinHTTrack/Shell.cpp | 17 ++++++ WinHTTrack/Shell.h | 4 ++ WinHTTrack/WinHTTrack.cpp | 8 +-- WinHTTrack/newlang.cpp | 81 +++++++++++++++------------ 5 files changed, 72 insertions(+), 42 deletions(-) diff --git a/.github/actions/smoke-test/action.yml b/.github/actions/smoke-test/action.yml index 4cee297..f9df707 100644 --- a/.github/actions/smoke-test/action.yml +++ b/.github/actions/smoke-test/action.yml @@ -210,7 +210,9 @@ runs: if ("$so" -notmatch 'MBCS->UTF-8 ok on 2 checks') { throw "selftest did not exercise the MBCS->UTF-8 conversion" } - if ("$so" -notmatch 'catalog decoding ok on 3 checks') { + # The suffix is the control for the best-fit case: without a lookalike to offer, + # which this runner's cp1252 has, that case cannot fail. + if ("$so" -notmatch 'catalog decoding ok on 4 checks \(best-fit acp\)') { throw 'selftest did not report catalog decoding: a legacy catalog may be silently mangled' } # Swapping decode and unescape is invisible on this cp1252 runner, so this drives CP932. diff --git a/WinHTTrack/Shell.cpp b/WinHTTrack/Shell.cpp index 6e7f671..e2cf20c 100755 --- a/WinHTTrack/Shell.cpp +++ b/WinHTTrack/Shell.cpp @@ -1715,6 +1715,23 @@ void CopyTextUTF8ToCP(LPSTR dest, int destSize, LPCSTR lpString) { freet(cp); } +// Contract in Shell.h. +BOOL CopyTextWideToACP(LPSTR dest, int destSize, LPCWSTR wide) { + // A best-fit substitute leaves lpUsedDefaultChar clear, so without the flag an + // approximation comes back and reads as held. A UTF-8 codepage rejects both. + const BOOL utf8 = GetACP() == CP_UTF8; + BOOL lost = FALSE; + int n; + + if (destSize <= 0) + return FALSE; + n = WideCharToMultiByte(CP_ACP, utf8 ? 0 : WC_NO_BEST_FIT_CHARS, wide, -1, dest, destSize, + NULL, utf8 ? NULL : &lost); + if (n <= 0) + dest[0] = '\0'; + return n > 0 && !lost; +} + bool ShellOpen(LPCSTR file, int nShowCmd) { return (INT_PTR) ShellExecute(NULL, "open", file, NULL, NULL, nShowCmd) > 32; } diff --git a/WinHTTrack/Shell.h b/WinHTTrack/Shell.h index 54be02c..17fd524 100755 --- a/WinHTTrack/Shell.h +++ b/WinHTTrack/Shell.h @@ -159,6 +159,10 @@ char *strdupt_utf8(const char *const s); // TTN_NEEDTEXTA tooltip text. Falls back to the raw bytes if conversion fails. void CopyTextUTF8ToCP(LPSTR dest, int destSize, LPCSTR lpString); +// The ANSI-codepage form of a wide string, always NUL-terminated. Best-fit substitutes are +// blocked, as the engine's converter blocks them, so FALSE means the codepage substituted. +BOOL CopyTextWideToACP(LPSTR dest, int destSize, LPCWSTR wide); + // Drop a trailing '/' or '\\', reporting whether there was one. Empty-string safe. inline bool StripTrailingSlash(char* s) { const size_t len = (s != NULL) ? strlen(s) : 0; diff --git a/WinHTTrack/WinHTTrack.cpp b/WinHTTrack/WinHTTrack.cpp index 43f62d2..005047c 100755 --- a/WinHTTrack/WinHTTrack.cpp +++ b/WinHTTrack/WinHTTrack.cpp @@ -345,13 +345,9 @@ BOOL CWinHTTrackApp::InitInstance() // Only reachable by typing into a dialog, so test the MBCS->UTF-8 conversion here instead. { static const WCHAR wide[] = { 'c', 'a', 'f', 0x00E9, 0 }; /* cafe-acute */ - BOOL lost = FALSE; - // WideCharToMultiByte rejects a non-NULL lpUsedDefaultChar when the code page is CP_UTF8. - BOOL *const plost = (GetACP() == CP_UTF8) ? NULL : &lost; char ansi[16]; - const int n = WideCharToMultiByte(CP_ACP, 0, wide, -1, ansi, sizeof(ansi), - NULL, plost); - if (n > 0 && !lost) { /* skip where the ANSI codepage cannot hold it at all */ + /* skip where the ANSI codepage cannot hold it at all */ + if (CopyTextWideToACP(ansi, sizeof(ansi), wide)) { int nchecks = 0; char *got = strdupt_utf8(ansi); /* freet() nulls it, so not const */ if (got == NULL || strcmp(got, "caf\xc3\xa9") != 0) { diff --git a/WinHTTrack/newlang.cpp b/WinHTTrack/newlang.cpp index 64767c6..427abe0 100755 --- a/WinHTTrack/newlang.cpp +++ b/WinHTTrack/newlang.cpp @@ -363,41 +363,52 @@ void LANG_SELFTEST_ESCAPE_ORDER(void) { /* --selftest: the catalogs are the only non-ASCII data the GUI loads, and the English-only smoke walk cannot see a decoding regression. */ void LANG_SELFTEST_DECODE(void) { - static const WCHAR wide[] = { 'c', 'a', 'f', 0x00E9, 0 }; /* cafe-acute */ - BOOL lost = FALSE; - // WideCharToMultiByte rejects a non-NULL lpUsedDefaultChar when the code page is CP_UTF8. - BOOL *const plost = (GetACP() == CP_UTF8) ? NULL : &lost; - char want[16]; - const int n = WideCharToMultiByte(CP_ACP, 0, wide, -1, want, sizeof(want), NULL, plost); - - if (n > 0 && !lost) { /* skip where the ANSI codepage cannot hold it at all */ - static const struct { const char* in; const char* want; const char* what; } cases[] = { - /* A converted catalog reaches the ANSI codepage. */ - { "caf\xc3\xa9", NULL, "utf-8" }, - /* A catalog still in its legacy charset must survive byte for byte: drop the - UTF-8 gate and the engine's converter substitutes U+FFFD, giving "caf?". */ - { "caf\xe9", "caf\xe9", "legacy" }, - { "Options", "Options", "ascii" }, - { NULL, NULL, NULL } - }; - int nchecks = 0; - for(int k=0 ; cases[k].in != NULL ; k++) { - const char* const expect = (cases[k].want != NULL) ? cases[k].want : want; - const int len = (int) strlen(cases[k].in); - char got[64]; - if (IsValidUTF8(cases[k].in, len)) - CopyTextUTF8ToCP(got, sizeof(got), cases[k].in); - else - lstrcpynA(got, cases[k].in, sizeof(got)); - if (strcmp(got, expect) != 0) { - fprintf(stderr, "FATAL: %s catalog text gave '%s', expected '%s'\n", - cases[k].what, got, expect); - fflush(stderr); - ExitProcess(3); - } else - nchecks++; - } - printf("catalog decoding ok on %d checks\n", nchecks); + static const WCHAR cafe[] = { 'c', 'a', 'f', 0x00E9, 0 }; /* cafe-acute */ + static const WCHAR macron[] = { 0x0100, 0 }; /* A-macron, 'A' by best-fit on cp1252 */ + static const struct { const char* in; const WCHAR* wide; const char* want; const char* what; } cases[] = { + /* A converted catalog reaches the ANSI codepage. */ + { "caf\xc3\xa9", cafe, NULL, "utf-8" }, + /* The engine blocks best-fit, so a character the codepage lacks must arrive as its + default character, not as a lookalike. */ + { "\xc4\x80", macron, NULL, "best-fit" }, + /* A catalog still in its legacy charset must survive byte for byte: drop the + UTF-8 gate and the engine's converter substitutes U+FFFD, giving "caf?". */ + { "caf\xe9", NULL, "caf\xe9", "legacy" }, + { "Options", NULL, "Options", "ascii" }, + { NULL, NULL, NULL, NULL } + }; + int nchecks = 0; + + for(int k=0 ; cases[k].in != NULL ; k++) { + const int len = (int) strlen(cases[k].in); + char expect[64]; + char got[64]; + /* Expecting what this codepage holds, so none has to be skipped. */ + if (cases[k].wide != NULL) + CopyTextWideToACP(expect, sizeof(expect), cases[k].wide); + else + lstrcpynA(expect, cases[k].want, sizeof(expect)); + if (IsValidUTF8(cases[k].in, len)) + CopyTextUTF8ToCP(got, sizeof(got), cases[k].in); + else + lstrcpynA(got, cases[k].in, sizeof(got)); + if (strcmp(got, expect) != 0) { + fprintf(stderr, "FATAL: %s catalog text gave '%s', expected '%s'\n", + cases[k].what, got, expect); + fflush(stderr); + ExitProcess(3); + } else + nchecks++; + } + + { + /* The best-fit case only discriminates where the codepage has a lookalike to offer. */ + char expect[64], lookalike[64]; + const int n = WideCharToMultiByte(CP_ACP, 0, macron, -1, lookalike, sizeof(lookalike), + NULL, NULL); + CopyTextWideToACP(expect, sizeof(expect), macron); + printf("catalog decoding ok on %d checks%s\n", nchecks, + (n > 0 && strcmp(lookalike, expect) != 0) ? " (best-fit acp)" : ""); } } From 9dce838c75a36c8948fa06a13133d427c7de926a Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Mon, 24 Aug 2026 22:16:40 +0200 Subject: [PATCH 2/4] Pin the substitute to the codepage's own default character Comparing the engine's output against another WideCharToMultiByte call only proves the two agree; where the codepage has a lookalike to offer, the substitute is now checked against CPINFO.DefaultChar, and the helper must report the text as not held. That check runs only on such a codepage, so its count moves with the suffix already pinned. Also from review: the helper guards destSize first, as its neighbour does, and its name says which way the boolean goes, since it copies on both paths. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .github/actions/smoke-test/action.yml | 7 +++--- WinHTTrack/Shell.cpp | 6 +++--- WinHTTrack/Shell.h | 7 +++--- WinHTTrack/WinHTTrack.cpp | 4 ++-- WinHTTrack/newlang.cpp | 31 ++++++++++++++++++++++----- 5 files changed, 39 insertions(+), 16 deletions(-) diff --git a/.github/actions/smoke-test/action.yml b/.github/actions/smoke-test/action.yml index f9df707..cb363ac 100644 --- a/.github/actions/smoke-test/action.yml +++ b/.github/actions/smoke-test/action.yml @@ -210,9 +210,10 @@ runs: if ("$so" -notmatch 'MBCS->UTF-8 ok on 2 checks') { throw "selftest did not exercise the MBCS->UTF-8 conversion" } - # The suffix is the control for the best-fit case: without a lookalike to offer, - # which this runner's cp1252 has, that case cannot fail. - if ("$so" -notmatch 'catalog decoding ok on 4 checks \(best-fit acp\)') { + # The suffix proves the best-fit case can fail: cp1252 offers a lookalike for + # U+0100, and where a codepage offers none that case cannot catch anything. The + # fifth check runs only there, so the count and the suffix move together. + if ("$so" -notmatch 'catalog decoding ok on 5 checks \(best-fit acp\)') { throw 'selftest did not report catalog decoding: a legacy catalog may be silently mangled' } # Swapping decode and unescape is invisible on this cp1252 runner, so this drives CP932. diff --git a/WinHTTrack/Shell.cpp b/WinHTTrack/Shell.cpp index e2cf20c..f1c3a0f 100755 --- a/WinHTTrack/Shell.cpp +++ b/WinHTTrack/Shell.cpp @@ -1716,9 +1716,9 @@ void CopyTextUTF8ToCP(LPSTR dest, int destSize, LPCSTR lpString) { } // Contract in Shell.h. -BOOL CopyTextWideToACP(LPSTR dest, int destSize, LPCWSTR wide) { - // A best-fit substitute leaves lpUsedDefaultChar clear, so without the flag an - // approximation comes back and reads as held. A UTF-8 codepage rejects both. +BOOL CopyTextWideToCPExact(LPSTR dest, int destSize, LPCWSTR wide) { + // A best-fit substitute leaves lpUsedDefaultChar clear, so without WC_NO_BEST_FIT_CHARS an + // approximation comes back and reads as held. CP_UTF8 rejects that flag and the pointer both. const BOOL utf8 = GetACP() == CP_UTF8; BOOL lost = FALSE; int n; diff --git a/WinHTTrack/Shell.h b/WinHTTrack/Shell.h index 17fd524..5b0fcdf 100755 --- a/WinHTTrack/Shell.h +++ b/WinHTTrack/Shell.h @@ -159,9 +159,10 @@ char *strdupt_utf8(const char *const s); // TTN_NEEDTEXTA tooltip text. Falls back to the raw bytes if conversion fails. void CopyTextUTF8ToCP(LPSTR dest, int destSize, LPCSTR lpString); -// The ANSI-codepage form of a wide string, always NUL-terminated. Best-fit substitutes are -// blocked, as the engine's converter blocks them, so FALSE means the codepage substituted. -BOOL CopyTextWideToACP(LPSTR dest, int destSize, LPCWSTR wide); +// The ANSI-codepage form of a wide string, always NUL-terminated, with best-fit substitutes +// blocked as the engine's converter blocks them; --selftest needs its own oracle for that. +// FALSE: the codepage held it only by substituting, or, with dest left empty, not at all. +BOOL CopyTextWideToCPExact(LPSTR dest, int destSize, LPCWSTR wide); // Drop a trailing '/' or '\\', reporting whether there was one. Empty-string safe. inline bool StripTrailingSlash(char* s) { diff --git a/WinHTTrack/WinHTTrack.cpp b/WinHTTrack/WinHTTrack.cpp index 005047c..acca14f 100755 --- a/WinHTTrack/WinHTTrack.cpp +++ b/WinHTTrack/WinHTTrack.cpp @@ -346,8 +346,8 @@ BOOL CWinHTTrackApp::InitInstance() { static const WCHAR wide[] = { 'c', 'a', 'f', 0x00E9, 0 }; /* cafe-acute */ char ansi[16]; - /* skip where the ANSI codepage cannot hold it at all */ - if (CopyTextWideToACP(ansi, sizeof(ansi), wide)) { + /* skip where the codepage cannot hold it exactly */ + if (CopyTextWideToCPExact(ansi, sizeof(ansi), wide)) { int nchecks = 0; char *got = strdupt_utf8(ansi); /* freet() nulls it, so not const */ if (got == NULL || strcmp(got, "caf\xc3\xa9") != 0) { diff --git a/WinHTTrack/newlang.cpp b/WinHTTrack/newlang.cpp index 427abe0..3884b3b 100755 --- a/WinHTTrack/newlang.cpp +++ b/WinHTTrack/newlang.cpp @@ -385,7 +385,7 @@ void LANG_SELFTEST_DECODE(void) { char got[64]; /* Expecting what this codepage holds, so none has to be skipped. */ if (cases[k].wide != NULL) - CopyTextWideToACP(expect, sizeof(expect), cases[k].wide); + CopyTextWideToCPExact(expect, sizeof(expect), cases[k].wide); else lstrcpynA(expect, cases[k].want, sizeof(expect)); if (IsValidUTF8(cases[k].in, len)) @@ -402,13 +402,34 @@ void LANG_SELFTEST_DECODE(void) { } { - /* The best-fit case only discriminates where the codepage has a lookalike to offer. */ + /* The case above only discriminates where the codepage has a lookalike to offer. Where it + does, pin the substitute to the codepage's own default character: comparing the engine + against another WideCharToMultiByte call only proves the two agree. */ + CPINFO cpinfo; + char def[MAX_DEFAULTCHAR + 1]; char expect[64], lookalike[64]; const int n = WideCharToMultiByte(CP_ACP, 0, macron, -1, lookalike, sizeof(lookalike), NULL, NULL); - CopyTextWideToACP(expect, sizeof(expect), macron); - printf("catalog decoding ok on %d checks%s\n", nchecks, - (n > 0 && strcmp(lookalike, expect) != 0) ? " (best-fit acp)" : ""); + const BOOL held = CopyTextWideToCPExact(expect, sizeof(expect), macron); + const BOOL bestfit = n > 0 && strcmp(lookalike, expect) != 0; + + if (bestfit) { + if (!GetCPInfo(CP_ACP, &cpinfo)) { + fprintf(stderr, "FATAL: no CPINFO for the ANSI codepage\n"); + fflush(stderr); + ExitProcess(3); + } + memcpy(def, cpinfo.DefaultChar, MAX_DEFAULTCHAR); + def[MAX_DEFAULTCHAR] = '\0'; + if (held || strcmp(expect, def) != 0) { + fprintf(stderr, "FATAL: U+0100 became '%s', expected the default character '%s'%s\n", + expect, def, held ? " (reported as held)" : ""); + fflush(stderr); + ExitProcess(3); + } + nchecks++; + } + printf("catalog decoding ok on %d checks%s\n", nchecks, bestfit ? " (best-fit acp)" : ""); } } From bc3f75c640cb6adb64247310aa4548cb7d2ca183 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Mon, 24 Aug 2026 22:29:26 +0200 Subject: [PATCH 3/4] Say which selftest check failed, not just that one did The staged-tree run redirects stdout and stderr to files nobody reads, so a FATAL line naming the failing check is thrown away and the log carries only "--selftest exited 3". Print both when the exit code is non-zero. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .github/workflows/windows-build.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 5ed0ac7..599eec1 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -724,7 +724,12 @@ jobs: # --selftest throws one exception on purpose. $p = Start-Process (Join-Path $bin 'WinHTTrack.exe') -ArgumentList '--selftest' -Wait -PassThru ` -RedirectStandardOutput "$PWD\crash-st.txt" -RedirectStandardError "$PWD\crash-st.err" - if ($p.ExitCode -ne 0) { throw "--selftest exited $($p.ExitCode) from the staged tree" } + if ($p.ExitCode -ne 0) { + # The selftest names the failing check on stderr, and the exit code alone does not. + Write-Host "--- selftest stdout ---`n$(Get-Content "$PWD\crash-st.txt" -Raw -ErrorAction SilentlyContinue)" + Write-Host "--- selftest stderr ---`n$(Get-Content "$PWD\crash-st.err" -Raw -ErrorAction SilentlyContinue)" + throw "--selftest exited $($p.ExitCode) from the staged tree" + } if (-not (Test-Path $report)) { throw 'no crash report written: the first-chance hook never ran' } $trace = Get-Content $report -Raw Write-Host "--- crash report ---`n$trace" From 49f63330b895be6d810da7eea6135721db417eb9 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Tue, 25 Aug 2026 08:26:38 +0200 Subject: [PATCH 4/4] Count the substitute and the report as the two checks they are A helper that dropped its lpUsedDefaultChar check would still substitute, so the two halves cannot share one increment: deleting either would leave the pin reading the same number. Splitting them moves the count to six. Also the sentence splits the prose pass asked for. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .github/actions/smoke-test/action.yml | 8 ++++---- WinHTTrack/Shell.cpp | 2 +- WinHTTrack/Shell.h | 4 ++-- WinHTTrack/newlang.cpp | 22 +++++++++++++++------- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/.github/actions/smoke-test/action.yml b/.github/actions/smoke-test/action.yml index cb363ac..0bea9be 100644 --- a/.github/actions/smoke-test/action.yml +++ b/.github/actions/smoke-test/action.yml @@ -210,10 +210,10 @@ runs: if ("$so" -notmatch 'MBCS->UTF-8 ok on 2 checks') { throw "selftest did not exercise the MBCS->UTF-8 conversion" } - # The suffix proves the best-fit case can fail: cp1252 offers a lookalike for - # U+0100, and where a codepage offers none that case cannot catch anything. The - # fifth check runs only there, so the count and the suffix move together. - if ("$so" -notmatch 'catalog decoding ok on 5 checks \(best-fit acp\)') { + # The suffix proves the best-fit case can fail: cp1252 offers a lookalike for U+0100. + # Where a codepage offers none, that case cannot catch anything. The last two checks + # run only there, so the count and the suffix move together. + if ("$so" -notmatch 'catalog decoding ok on 6 checks \(best-fit acp\)') { throw 'selftest did not report catalog decoding: a legacy catalog may be silently mangled' } # Swapping decode and unescape is invisible on this cp1252 runner, so this drives CP932. diff --git a/WinHTTrack/Shell.cpp b/WinHTTrack/Shell.cpp index f1c3a0f..043a717 100755 --- a/WinHTTrack/Shell.cpp +++ b/WinHTTrack/Shell.cpp @@ -1718,7 +1718,7 @@ void CopyTextUTF8ToCP(LPSTR dest, int destSize, LPCSTR lpString) { // Contract in Shell.h. BOOL CopyTextWideToCPExact(LPSTR dest, int destSize, LPCWSTR wide) { // A best-fit substitute leaves lpUsedDefaultChar clear, so without WC_NO_BEST_FIT_CHARS an - // approximation comes back and reads as held. CP_UTF8 rejects that flag and the pointer both. + // approximation comes back and reads as held. CP_UTF8 rejects both that flag and the pointer. const BOOL utf8 = GetACP() == CP_UTF8; BOOL lost = FALSE; int n; diff --git a/WinHTTrack/Shell.h b/WinHTTrack/Shell.h index 5b0fcdf..77c28a5 100755 --- a/WinHTTrack/Shell.h +++ b/WinHTTrack/Shell.h @@ -160,8 +160,8 @@ char *strdupt_utf8(const char *const s); void CopyTextUTF8ToCP(LPSTR dest, int destSize, LPCSTR lpString); // The ANSI-codepage form of a wide string, always NUL-terminated, with best-fit substitutes -// blocked as the engine's converter blocks them; --selftest needs its own oracle for that. -// FALSE: the codepage held it only by substituting, or, with dest left empty, not at all. +// blocked as the engine's converter blocks them. --selftest needs its own oracle for that. +// FALSE: the codepage substituted; dest is empty instead if it could not convert at all. BOOL CopyTextWideToCPExact(LPSTR dest, int destSize, LPCWSTR wide); // Drop a trailing '/' or '\\', reporting whether there was one. Empty-string safe. diff --git a/WinHTTrack/newlang.cpp b/WinHTTrack/newlang.cpp index 3884b3b..43a96b1 100755 --- a/WinHTTrack/newlang.cpp +++ b/WinHTTrack/newlang.cpp @@ -403,8 +403,8 @@ void LANG_SELFTEST_DECODE(void) { { /* The case above only discriminates where the codepage has a lookalike to offer. Where it - does, pin the substitute to the codepage's own default character: comparing the engine - against another WideCharToMultiByte call only proves the two agree. */ + does, pin the substitute to the codepage's own default character. A second + WideCharToMultiByte call would only prove the two agree. */ CPINFO cpinfo; char def[MAX_DEFAULTCHAR + 1]; char expect[64], lookalike[64]; @@ -421,13 +421,21 @@ void LANG_SELFTEST_DECODE(void) { } memcpy(def, cpinfo.DefaultChar, MAX_DEFAULTCHAR); def[MAX_DEFAULTCHAR] = '\0'; - if (held || strcmp(expect, def) != 0) { - fprintf(stderr, "FATAL: U+0100 became '%s', expected the default character '%s'%s\n", - expect, def, held ? " (reported as held)" : ""); + /* Counted apart: a helper that forgot its lpUsedDefaultChar check still substitutes, + so sharing one increment would leave that half unpinned. */ + if (held) { + fprintf(stderr, "FATAL: U+0100 reported as held by a codepage that substitutes it\n"); fflush(stderr); ExitProcess(3); - } - nchecks++; + } else + nchecks++; + if (strcmp(expect, def) != 0) { + fprintf(stderr, "FATAL: U+0100 became '%s', expected the default character '%s'\n", + expect, def); + fflush(stderr); + ExitProcess(3); + } else + nchecks++; } printf("catalog decoding ok on %d checks%s\n", nchecks, bestfit ? " (best-fit acp)" : ""); }