Skip to content

fix(libc): convert scanf integers as FreeBSD does on Windows - #9479

Merged
adriadam10 merged 6 commits into
boykopovar:mainfrom
EagleBearerYS:fix/libc-scanf-integer-widths-windows
Oct 25, 2026
Merged

adriadam10 merged 6 commits into
boykopovar:mainfrom
EagleBearerYS:fix/libc-scanf-integer-widths-windows

Conversation

@EagleBearerYS

@EagleBearerYS EagleBearerYS commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What

On Windows the scanf family (sscanf, vsscanf, fscanf and their callers) translates the guest's length modifiers and hands the format to the host's scanner. Two integer cases differ from FreeBSD 11.4's vfscanf.

  1. A %d, %i, %o, %u, %x or %X without a length modifier, or with h or hh, goes to the host unchanged. The host converts it with its 32-bit long, so an out-of-range value is clamped and errno set to ERANGE. FreeBSD converts every integer with strtoimax or strtoumax and stores the result truncated to the destination type (vfscanf.c L741-L771).
  2. q, FreeBSD's quad length modifier, is not recognized, so %qd, %qu and %qx throw NotImplemented_nid_no_patch ("vsscanf format conversion"). FreeBSD's source treats q like ll (vfscanf.c L529-L531), but the console's own libc doesn't: run natively from three titles, its sscanf returns 0 for %qd and stores nothing (@JJenkx's comment). The console's behaviour overrides FreeBSD 11.4's source here.

Measured with main's code:

Input Format Windows Linux, and FreeBSD
"2147483648" %d 2147483647, errno 34 -2147483648
"3000000000" %i 2147483647, errno 34 -1294967296
"4294967296" %u 4294967295, errno 34 0
"100000000" %x 4294967295, errno 34 0
"3000000000" %qd throws 3000000000 (the console's libc: 0, nothing stored)

A colour or mask written as 0xFFFFFFFF and read with %i into an int therefore comes back as 0x7FFFFFFF.

ScanWindowsArguments_nid_no_patch (WindowsScanning.hpp) now does two things:

  • A q directive ends the scan there with nothing stored, as the console's libc does for %qd, and the count of conversions before it is returned. What the console does with a q after other conversions isn't measured.
  • It scans such a 32-bit-or-narrower conversion as ll into a temporary of the conversion's signedness: long long for %d and %i, unsigned long long for %o, %u, %x and %X. A %n after it shows whether the conversion took place. The value is then stored truncated to the destination's int, short or char, signed or unsigned to match, as FreeBSD's assignment does.

A value beyond the 64-bit range still sets ERANGE, as strtoimax does there. Linux hands the format to glibc, which already truncates this way but reads q as ll; the libc debt file records that difference. No title is claimed.

Overlap, merge order agreed: #9087 also adds a check function after CheckWidePrecision in core/libs/tests/Formatting.cpp and a call at the end of RunChecks, so the two conflict in that test file. Their library changes are in different files: #9087's in Formatting.cpp and WindowsFormatting.hpp, this PR's in WindowsScanning.hpp. This PR merges first, and #9087 then rebases its test file over it, keeping both check functions and both calls; orezraey agreed to that order (comment).

Tested

On GitHub's runners from my fork, merged with main at a6dcea9 (run):

  • New CheckIntegerScanWidths in guest_formatting checks:
    • truncation for %d, %i ("0xFFFFFFFF" gives -1), %u, %x, %o, %hd ("5000000000" gives -3584) and %hhd ("300" gives 44), with errno left alone;
    • ERANGE for "99999999999999999999" with %d (-1);
    • unsigned destinations above the signed range: %u of 4294967295, %hu of 40000 and of 70000 (4464), %hx of ffff, %hhu of 200 and of 300 (44), and %hho of 377 (255);
    • %qd and %qx;
    • that %n still counts after the new conversions, and that a failed conversion leaves its destination unchanged.
  • Windows: with main's WindowsScanning.hpp guest_formatting fails. With the change it passes 10 runs in a row.
  • Linux: guest_formatting passes with main's code and, 10 runs in a row, with the change; the code change does not apply there, and glibc already gives these results.
  • On both, every test registered under core/libs/prx/libc and core/libs/prx/libkernel passes: 115 and 197 on Linux, 142 and 207 on Windows.
  • The signedness commit, b39956b, after MarcZX10's review (run): guest_formatting passes 10 runs in a row on both. The new unsigned checks also pass with the previous head, since both hosts store the same bits there. In that run's Windows libkernel suite, guest_raise_in_system_call timed out after 30 s with and without the commit.
  • After the merge with main at da54c65 (8fab129, no conflicts, the PR's own diff unchanged; run): guest_formatting fails on Windows with main's WindowsScanning.hpp; with the change, guest_formatting and guest_wide_formatting pass 10 runs in a row on both runners. The libc and libkernel suites pass on both (116 and 197 on Linux, 143 and 207 on Windows).
  • tools/check_conventions.py --base origin/main passes.
  • Against titles' own libc.prx, by @JJenkx (comment): head 8fab129 merged onto main f1db096, on Windows 11 with GCC 15.2.0 UCRT, Release. A loader runs a title's decrypted libc.prx natively and compares its sscanf with this build's on 60,000 random inputs per format, checking the result, the value, the %n count and errno. The copies bundled with three titles give the same counts.
    • %d, %u, %hd and %hhd match the console on all 60,000 inputs each. On main 16,892, 7,541, 7,455 and 7,499 of them differ.
    • %i and %x go from 20,669 and 14,643 differences on main to 3,521 and 3,597. %o, %lld and %3d match on both trees, and %lli and %llx differ as much as on main.
    • The %i and %x differences left, here and on main, are one case: a 0x prefix with no hexadecimal digit after it, which the console treats as a matching failure. That matching rule is outside this PR.
    • ctest -R 'scan|format|stream|libc|wide|string' selects 44 tests, and all pass in three runs of three.
  • The console's sscanf on fixed inputs, by @JJenkx (comment): the libc.prx bundled with PPSA07923, PPSA13934 and PPSA35169, loaded natively on Windows, into a 64-bit destination filled with 0x55…; all three give the same results.
    • Narrow conversions wrap without errno: 2147483648 and -2147483649 with %d give 0x80000000 and 0x7fffffff, 4294967296 and -1 with %u give 0 and 0xffffffff, 99999999999 with %x gives 0x99999999, 300 with %hhd gives 0x2c and 70000 with %hd gives 0x1170. Only the 64-bit ones saturate with ERANGE: %lld and %llu past their range.
    • %qd of 5 returns 0 and stores nothing, which this PR now does too (a809ad7).
  • a809ad7, the q change (run): CheckIntegerScanWidths takes those rows, and %qd of 5 must return 0 with the destination unchanged (Windows only, since Linux goes to glibc).
    • Red on Windows with the previous WindowsScanning.hpp: guest_formatting fails. On Linux it passes before and after, as the change doesn't apply there.
    • Green: guest_formatting and guest_wide_formatting pass 10 runs in a row on both runners, 2 of 2 each, none skipped. The libc and libkernel suites pass on both, run with APS5_SKIP_TIMING_TESTS=1, which relaxes precise_wait's median bounds on GitHub's hosted runners.
  • On a PS5, by @rooklz (comment): a payload calling the console's own fscanf on a file in /data. %d on 99999999999 returns 1 and stores 0x4876e7ff with errno 0, %hhd on 300 stores 0x2c, %u on -1 stores 0xffffffff, and %lld on -9223372036854775809 stores INT64_MIN with errno 34: the FreeBSD conversion this PR gives Windows, as they say. They also report two differences from glibc outside this PR: %i on 0x returns -1, and %x on 0xZ returns 0 with Z next, where glibc returns 1 with 0 stored for both.
  • Not tested: macOS, any title.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none (merges before fix(libc): follow snprintf_s and sscanf_s constraints as measured on a PS5 #9087, agreed in its thread; see Overlap)
  • Fixes: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes

boykopovar#5070 adds its own check before main() and rewrites the one-line main(), so defining CheckIntegerScanWidths there and calling it from main() made the two pull requests conflict. It now sits before RunChecks and runs after the other scanf checks.

@MarcZX10 MarcZX10 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unsigned narrow-integer path has undefined behavior: the rewritten %llu/%llx/%llo formats are given a long long*, and the results are stored through signed char*, short* or int*, including for unsigned destinations. I left an inline comment at the temporary declaration with the required signedness fix and tests. The current hosted Linux/Windows build/test jobs are skipped, and the relinker matrix is still running; I have not run a local test.

A %u, %o or %x scanned through the 64-bit temporary now writes an unsigned long long and is stored through an unsigned destination, as the rewritten %llu, %llo and %llx require.
@MarcZX10

Copy link
Copy Markdown
Contributor

Follow-up to my review: the relinker matrix, register-context, write-watch, eligibility, conventions, labels, and report jobs have now completed successfully. The full Linux and Windows build/test jobs were skipped by the PR workflow. The signed/unsigned temporary and destination-pointer issue remains, so the requested code change is unchanged.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
PR 9087 core/libs/tests/Formatting.cpp L155, L390

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 5021 core/libs/tests/Formatting.cpp
docs/dev/debt/libc.md
PR 5303 docs/dev/debt/libc.md
PR 6713 docs/dev/debt/libc.md
PR 7081 docs/dev/debt/libc.md
PR 7201 core/libs/tests/Formatting.cpp
PR 7386 core/libs/prx/libc/include/WindowsScanning.hpp
core/libs/tests/Formatting.cpp
docs/dev/debt/libc.md
PR 7447 docs/dev/debt/libc.md
PR 7544 docs/dev/debt/libc.md
PR 7648 docs/dev/debt/libc.md
PR 7715 docs/dev/debt/libc.md
PR 8150 docs/dev/debt/libc.md
PR 8417 docs/dev/debt/libc.md
PR 9269 docs/dev/debt/libc.md
PR 9371 docs/dev/debt/libc.md
PR 9447 docs/dev/debt/libc.md
PR 9526 docs/dev/debt/libc.md
PR 9669 docs/dev/debt/libc.md
PR 9784 docs/dev/debt/libc.md
PR 9863 docs/dev/debt/libc.md
PR 9873 docs/dev/debt/libc.md
PR 9929 docs/dev/debt/libc.md
PR 9973 docs/dev/debt/libc.md
PR 10113 docs/dev/debt/libc.md
PR 10222 docs/dev/debt/libc.md
PR 10226 docs/dev/debt/libc.md
PR 10229 docs/dev/debt/libc.md
PR 10283 docs/dev/debt/libc.md
PR 10405 docs/dev/debt/libc.md
PR 10460 docs/dev/debt/libc.md
PR 10898 docs/dev/debt/libc.md

@MarcZX10 MarcZX10 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest commit fixes the signedness issue from my earlier review: %u/%o/%x now scan through an unsigned long long temporary and store through unsigned destination types, while signed conversions retain signed temporaries/destinations. The added unsigned narrow cases cover truncation, and the author reports the focused Windows/Linux formatting tests and hosted relinker/platform matrices pass; full Linux/Windows product jobs were skipped.

One repository coordination blocker remains. #9087 is open and edits the same Formatting.cpp test function/call site. The body says whichever PR merges second needs a rebase, but the required merge order/dependency is not recorded and I do not see agreement from #9087's author about this overlap. Please coordinate and state the agreed order in the Depends on checklist (or confirm the changes are order-independent and record the agreed rebase owner), then preserve both test sets. No local tests were run here.

@MarcZX10

Copy link
Copy Markdown
Contributor

The signedness correction in the latest head resolves my prior code blocker. The remaining request is merge coordination with #9087 because both change Formatting.cpp test registration/body; please get agreement on sequencing/rebase responsibility and reflect the order in Depends on before requesting another review. Current hosted relinker and platform check matrix is green, while full product builds were skipped.

@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

Thanks, @MarcZX10. orezraey agreed on #9087 that this PR merges first and #9087 then rebases its test file over it, keeping both check functions and both calls in RunChecks (comment). The Overlap section and the Depends on line now say so.

@MarcZX10 MarcZX10 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest head fixes the earlier signedness/undefined-behavior issue: unsigned conversions use an unsigned long long temporary and unsigned destination stores, while signed conversions use signed types. The extensive %d/%i/%u/%x/%o, narrow-width, %q, %n, overflow and failed-conversion cases are covered in the reported Windows/Linux focused runs. The #9087 ordering blocker is now resolved: both authors agree #9479 lands first and #9087 rebases its test changes afterward; the PR records that order. I am requesting changes only because the tested branch remains based on a6dcea9 while current upstream main is ab347d1. Please rebase, preserve both Formatting test sets, and rerun guest_formatting, conventions and the overlap scan on current main. Hosted full Linux/Windows product jobs are skipped; I did not run local tests.

@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

Thanks, @MarcZX10. The branch now has main at da54c65 merged in (8fab129), with the PR's own diff unchanged. On GitHub's runners (run) guest_formatting and guest_wide_formatting pass 10 runs in a row on both, and guest_formatting fails on Windows with main's code; the libc and libkernel suites pass on both, and conventions pass. Tested now says so.

@github-actions github-actions Bot added the libc label Oct 22, 2026
@JJenkx

JJenkx commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

I checked head 8fab129 against titles' bundled libc.prx.

Setup. I merged it locally onto main f1db096 (no conflicts) and built it on Windows 11 with GCC 15.2.0 UCRT r7, Release. ctest -R 'scan|format|stream|libc|wide|string' selects 44 tests, and all pass in three runs out of three.

Method. A scratch loader runs a title's decrypted libc.prx natively and supplies __error. For each format below it compares that copy's sscanf with this build's sscanf_nid_postfix on 60,000 random inputs, checking the result, the value, the %n count and errno. The inputs are one to three pieces drawn from blanks, signs, 0, 1, 7, 9, 0x/0X, a, f, g, x, 08, 0b1, 2147483647, 2147483648, 4294967296, 9223372036854775808 and 99999999999999999999. The copies bundled with PPSA07923, PPSA05916 and PPSA13934 give the same counts.

format main this PR
%d 16,892 differ 0
%u 7,541 differ 0
%hd 7,455 differ 0
%hhd 7,499 differ 0
%i 20,669 differ 3,521 differ
%x 14,643 differ 3,597 differ
%lli 3,589 differ 3,589 differ
%llx 3,580 differ 3,580 differ
%o, %lld, %3d 0 0

So the overflow and truncation behaviour now matches the console. On main, for example, %d of 21474836481 gives 2147483647 with ERANGE; the console and this PR give 1 without errno.

The %i/%x differences left, here and on main, are all one case: a 0x prefix without a hexadecimal digit after it. The console treats that as a matching failure, not as the value 0. This build converts 0 and consumes the x:

input format console this PR
0x, 0X, 0x %x%n, %i%n -1, nothing stored 1, value 0, %n 2 or 3
0xg, 0x 5, 0x-1, -0x %x%n, %i%n 0, nothing stored 1, value 0, %n 2 or 3
0x 5 %x%d%n 0 2
0x1 %2x%n (the width ends after 0x) -1 1, value 0

That may be out of scope here, which is about integer width. I mention it because the FreeBSD vfscanf this PR follows accepts the bare prefix. #8849 already made strtol stop before such a prefix; scanf's matching rule is separate.

@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

Thanks, @JJenkx. That's the check this PR needed. %d, %u, %hd and %hhd now match the console's own libc.prx on all 60,000 of your inputs each, and your %d of 21474836481 (1 without errno, where main gives 2147483647 with ERANGE) is exactly the behaviour it's after.

The bare 0x prefix is a separate matching rule, as you say, and it's the same on main, so it stays out of this PR. I've passed your table of those cases on, since it's what a separate fix would start from. The Tested section now lists your run.

@JJenkx

JJenkx commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Console data for this PR. I loaded the decrypted libc.prx bundled with PPSA07923, PPSA13934 and PPSA35169 natively (Windows host, __error supplied by the harness) and called their sscanf on a 64-bit destination prefilled with 0x5555…. All three give identical output:

Input Format Returns Stored errno
2147483648 %d 1 0x80000000 0
-2147483649 %d 1 0x7fffffff 0
4294967296 %u 1 0x00000000 0
-1 %u 1 0xffffffff 0
99999999999 %x 1 0x99999999 0
300 %hhd 1 0x2c 0
70000 %hd 1 0x1170 0
9223372036854775808 %lld 1 0x7fffffffffffffff ERANGE
18446744073709551616 %llu 1 0xffffffffffffffff ERANGE
5 %qd 0 untouched 0

So the first part matches the titles' copies: narrow conversions wrap (truncate the 64-bit result) without errno, and only the 64-bit ones saturate with ERANGE.

The q part doesn't. The titles' sscanf does not accept q: %qd returns 0 and stores nothing, so it is not treated like ll there. I haven't checked what the rest of the format does after the q. If the goal is the console's behaviour rather than FreeBSD 11.4's source, %q… should fail the conversion the same way.

AI-assisted: yes (Claude Code)

The console's own sscanf, run natively from three titles' libc.prx, returns 0 for %qd and stores nothing, where FreeBSD 11.4's source reads q as ll. A q directive now ends the scan there, and the count of conversions before it is returned. The test adds the console's rows for wrapping narrow conversions, saturating 64-bit ones with ERANGE, and %qd.
@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

Thanks, @JJenkx, the console's own sscanf on fixed inputs settles both parts. The wrapping and the 64-bit ERANGE match, and %qd returning 0 with nothing stored now holds here too: a809ad7 makes a q directive end the scan on Windows, with your rows as the test. The console's behaviour wins over FreeBSD 11.4's source for q. On Linux the scanner is glibc's, which reads q as ll, so that stays a recorded difference for now.

@github-actions github-actions Bot added the docs label Oct 24, 2026
@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

@MarcZX10 8fab129 merged main da54c65 with guest_formatting 10 of 10 on both runners, and a809ad7 since makes %q fail as the console's libc does. Could you re-review this head?

@rooklz

rooklz commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Measured on a PS5 (a payload calling the console's fscanf on a file in /data), for the integer cases here: %d on 99999999999 returns 1 and stores 0x4876e7ff with errno 0 (truncated, not clamped), %hhd on 300 stores 0x2c, %u on -1 stores 0xffffffff, and %lld on -9223372036854775809 stores INT64_MIN with errno 34. That matches the FreeBSD behaviour this PR gives Windows. Two things outside this PR's scope also differ from glibc, for reference: %i on 0x returns -1 and %x on 0xZ returns 0 with Z next (glibc returns 1 with 0 stored for both). Separately, macOS's scanf pushes back a partly matched prefix (+, -, 1e, .), where the console and glibc consume it.

@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

@rooklz thank you for measuring the console's own fscanf. As you say, the four integer cases match the FreeBSD conversion this PR gives Windows. They're in Tested now, with the %i on 0x and %x on 0xZ cases noted there as outside this PR.

@adriadam10 adriadam10 self-assigned this Oct 25, 2026
@adriadam10
adriadam10 dismissed MarcZX10’s stale review October 25, 2026 14:49

Dismissing a stale review: the requested changes were addressed after it (new head) and the merge queue blocks on it. Please request changes again if anything is still open.

@adriadam10
adriadam10 merged commit 91a5573 into boykopovar:main Oct 25, 2026
21 checks passed
@adriadam10

Copy link
Copy Markdown
Collaborator

Checked the ScanWindowsArguments_nid_no_patch change (ll temporary with matching signedness, truncating store, %n conversion check, q ending the scan), the expected values in CheckIntegerScanWidths by hand, and the console measurements from @JJenkx and @rooklz; merged.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants