Merge pull request #11004 from verusferro/fix/libc-strftime-iso-week

fix(libc): compute strftime week numbers as the console's libc does
This commit is contained in:
Dean authored and GitHub committed 2026-10-27 10:58:48 +13:00
commit c980e6ef33
3 files changed
+53 -26

No files matched your search

+32 -24
View File
@@ -244,10 +244,6 @@ constexpr const char* MONTH_FULL_NAMES[] = {"January", "February", "March", "Apr
"October", "November", "December"};
constexpr int YEAR_BASE = 1900;
bool isLeapYearSum(int year, int base) {
return isLeapYear(year % 400 + base % 400);
}
struct FormatBuffer {
char* cursor;
char* limit;
@@ -287,32 +283,44 @@ int twoDigitYear(std::int64_t year) {
return static_cast<int>((year % 100 + 100) % 100);
}
std::int64_t weekOfYear(const GuestTm& time, int shift) {
return (static_cast<std::int64_t>(time.tm_yday) + 7 - (static_cast<std::int64_t>(time.tm_wday) + shift) % 7) / 7;
int wrapped(std::int64_t value) {
return static_cast<int>(static_cast<std::uint32_t>(value));
}
int weekOfYear(const GuestTm& time, int shift) {
return wrapped(static_cast<std::int64_t>(time.tm_yday) + 7 - wrapped(static_cast<std::int64_t>(time.tm_wday) + shift) % 7) / 7;
}
struct IsoWeek {
std::int64_t year;
int year;
int week;
};
bool isoLeapYear(int year) {
return year % 4 == 0 && (year % 100 != 0 || year / 400 == 100 || year / 400 == -300);
}
bool midweek(int weekday) {
return weekday >= 2 && weekday <= 4;
}
IsoWeek isoWeek(const GuestTm& time) {
if (time.tm_yday < 0) NotImplemented_nid_no_patch("strftime: ISO week of a negative tm_yday");
if (time.tm_yday >= (isLeapYearSum(time.tm_year, YEAR_BASE) ? 366 : 365)) NotImplemented_nid_no_patch("strftime: ISO week of a tm_yday past the year");
if (time.tm_wday < -6) NotImplemented_nid_no_patch("strftime: ISO week of a tm_wday below -6");
int base = YEAR_BASE;
std::int64_t yday = time.tm_yday;
for (;;) {
const int length = isLeapYearSum(time.tm_year, base) ? 366 : 365;
const std::int64_t bottom = (yday + 11 - time.tm_wday) % 7 - 3;
std::int64_t top = bottom - length % 7;
if (top < -3) top += 7;
top += length;
if (yday >= top) return {static_cast<std::int64_t>(time.tm_year) + base + 1, 1};
if (yday >= bottom) return {static_cast<std::int64_t>(time.tm_year) + base, static_cast<int>(1 + (yday - bottom) / 7)};
--base;
yday += isLeapYearSum(time.tm_year, base) ? 366 : 365;
}
const int leap = isoLeapYear(time.tm_year) ? 1 : 0;
const int span = wrapped(static_cast<std::int64_t>(time.tm_wday) - time.tm_yday);
const int firstWeekday = wrapped(static_cast<std::int64_t>(span) + 371) % 7;
const int nextFirstWeekday = (leap + firstWeekday + 365) % 7;
const int nextYear = wrapped(static_cast<std::int64_t>(time.tm_year) + 1);
if (midweek(nextFirstWeekday) && wrapped(static_cast<std::int64_t>(time.tm_yday) - leap) >= 366 - nextFirstWeekday) return {nextYear, 1};
const int mondayWeekday = wrapped(static_cast<std::int64_t>(time.tm_wday) + 6) % 7;
const int week = wrapped(static_cast<std::int64_t>(time.tm_yday) - mondayWeekday + 7) / 7 + (midweek(firstWeekday) ? 1 : 0);
if (week < 0) return {nextYear, 1};
if (week > 0) return {time.tm_year, week};
const int previousYear = wrapped(static_cast<std::int64_t>(time.tm_year) - 1);
const int previousLeap = isoLeapYear(previousYear) ? 1 : 0;
const int previousFirstWeekday = wrapped(static_cast<std::int64_t>(span) + 13 - previousLeap) % 7;
if (midweek((previousLeap + previousFirstWeekday + 365) % 7)) return {previousYear, 0};
const int lastWeekday = wrapped(static_cast<std::int64_t>(span) + 13) % 7;
return {previousYear, (372 + previousLeap - lastWeekday) / 7 + (midweek(previousFirstWeekday) ? 1 : 0)};
}
void formatTime(FormatBuffer& buffer, const char* format, const GuestTm& time);
@@ -342,7 +350,7 @@ bool addConversion(FormatBuffer& buffer, char conversion, const GuestTm& time) {
case 'e': addNumber(buffer, time.tm_mday, 100, "%2d"); return true;
case 'F': formatTime(buffer, "%Y-%m-%d", time); return true;
case 'g': addNumber(buffer, twoDigitYear(isoWeek(time).year), 100, "%02d"); return true;
case 'G': addNumber(buffer, isoWeek(time).year, 10000, "%04d"); return true;
case 'G': addNumber(buffer, static_cast<std::int64_t>(isoWeek(time).year) + YEAR_BASE, 10000, "%04d"); return true;
case 'H': addNumber(buffer, time.tm_hour, 100, "%02d"); return true;
case 'I': addNumber(buffer, (static_cast<std::int64_t>(time.tm_hour) + 11) % 12 + 1, 100, "%02d"); return true;
case 'j': addNumber(buffer, static_cast<std::int64_t>(time.tm_yday) + 1, 1000, "%03d"); return true;
+20 -1
View File
@@ -2,6 +2,7 @@
#include "prx/libc/include/general/VabiMacros.hpp"
#include "prx/libc/include/GuestTime.hpp"
#include <cstddef>
#include <climits>
#include <cstdio>
#include <cstring>
#include <stdexcept>
@@ -165,7 +166,7 @@ int main() {
constexpr Iso outOfRangeWeeks[] = {
{126, 0, 11, -2, 10, "01|01"}, {126, 1, 5, 4, 400, "57|57"}, {126, 3, 11, -19, 100, "16|16"}, {126, 9, 5, -16, 277, "40|41"},
{126, 0, 19, -12, 18, "04|04"}, {126, 0, 1, 6, 1365, "95|95"}, {97, 0, 1, 6, 415, "59|59"}, {113, 11, 31, 1, 365, "53|53"},
{7, 11, 31, 1, 365, "53|53"}, {126, 9, 24, 6, 296, "42|42"},
{7, 11, 31, 1, 365, "53|53"}, {126, 9, 24, 6, 296, "42|42"}, {126, 0, 1, 5, INT_MAX, "00|00"}, {126, 0, 1, INT_MAX, 10, "02|03"},
};
for (const auto& week : outOfRangeWeeks) {
GuestTm time = base;
@@ -179,6 +180,24 @@ int main() {
Check(name, time, "%U|%W", week.expected);
}
constexpr Iso outOfRangeIsoWeeks[] = {
{126, 0, 1, 5, -25, "2027|27|01"}, {126, 0, 1, 5, -13, "2025|25|00"}, {126, 0, 1, 5, -1, "2025|25|52"},
{126, 1, 5, 4, 400, "2027|27|01"}, {97, 0, 1, 6, 415, "1997|97|59"}, {113, 11, 31, 1, 365, "2013|13|53"},
{126, 0, 19, -12, 18, "2026|26|04"}, {100, 11, 28, 1, 362, "2001|01|01"}, {INT_MAX, 0, 1, 4, 400, "0000|52|01"},
{INT_MIN, 0, 1, 5, -13, "5547|47|00"},
};
for (const auto& week : outOfRangeIsoWeeks) {
GuestTm time = base;
time.tm_year = week.year;
time.tm_mon = week.mon;
time.tm_mday = week.mday;
time.tm_wday = week.wday;
time.tm_yday = week.yday;
char name[64];
std::snprintf(name, sizeof(name), "ISO week %d wday %d yday %d", week.year, week.wday, week.yday);
Check(name, time, "%G|%g|%V", week.expected);
}
char text[8];
std::memset(text, '#', sizeof(text));
if (strftime_nid_postfix(text, 5, "%Y", &base) != 4 || std::strcmp(text, "2026") != 0) {
+1 -1
View File
@@ -168,7 +168,7 @@
- [Colons in guest names](../../../core/libs/prx/libc/src/General.cpp) (libc) - on Windows (NTFS) a guest name containing `:` is not a plain file name: `name:stream` opens or creates the alternate data stream `stream` of the file `name`. Creating `/savedata0/C:file` makes an empty file `C` whose stream `file` holds the data, and `stat` of `/savedata0/C` then reports a regular file. `mkdir` of a name containing `:`, such as `C:` or `dir:x`, fails with EINVAL. `name:stream` already behaved this way before drive-letter components were kept under their mount
- [tzname](../../../core/libs/prx/libc/src/Time.cpp) (libc) points at the host's zone names, read at load and again by `tzset`: the abbreviations a `TZ` string gives (`EST5EDT` is `EST` and `EDT` on both hosts), but on Windows without `TZ` the CRT's full names such as `GMT Standard Time`, which ICU's [uprv_tzname](https://github.com/unicode-org/icu/blob/457157a92aa053e632cc7fcfd0e12f8a943b2d11/icu4c/source/common/putil.cpp#L1244-L1264) (ES-DE in PPSA99169) cannot map to a zone ID. A payload process on a PS5 (firmware 13.60) read both names empty with `TZ` unset, and its `%Z` printed nothing whatever `tm_zone` held; the names a title process sees are not known
- [localtime, mktime](../../../core/libs/prx/libc/src/Time.cpp) (libc) on Windows convert through the CRT's `localtime_s` and `mktime`, which accept about 1970 to January 3001 UTC and apply the CRT's daylight-saving rule. A time outside that range is converted a whole number of 400-year Gregorian cycles away, where the calendar repeats, so it gets the rule the CRT applies to that year and not the zone's historical one. On macOS `mktime` converts through the host's `mktime`, which refuses every year before 1900. A local time before 1900 is instead solved for directly: the offset `localtime_r` gives for a candidate time, which follows the zone's history (local mean time before its first transition), is subtracted from the local fields, and the step is repeated until the offset holds. `tm_isdst` is not consulted there, so a time with `tm_isdst` set where the zone had no daylight offset is taken as standard time. FreeBSD's [`time1`](https://github.com/freebsd/freebsd-src/blob/stable/11/contrib/tzcode/stdtime/localtime.c#L2036) would instead shift it by the difference between one of the zone's daylight types and a standard one. `gmtime` converts every time whose year fits an `int` on both hosts.
- [strftime and asctime](../../../core/libs/prx/libc/src/Time.cpp) (libc) follow a payload process on a PS5 (firmware 13.60) in the C locale. `%G`, `%g` and `%V` throw for a negative `tm_yday`, a `tm_yday` past the year or a `tm_wday` below -6, where no rule fits the console: `%V` is 01, 00, 00 and 52 for `tm_yday` -25, -13, -12 and -1 (`tm_wday` 5), `tm_yday` 400 of 2026 is week 01 of 2027 but 415 of 1997 is week 59 and 365 of 2013 week 53, and `tm_wday` -12 with `tm_yday` 18 is week 04. `%k`, `%l`, `%P`, `%s`, `%+`, `%v`, flags and widths are copied as written, `E` or `O` before an unknown conversion is dropped, and a format ending in either throws, where the console crashed. Negative fields print 0 and fields wrap at 100 (`%j` at 1000, `%u` and `%w` at 10); `%Y` and `%G` are the year mod 10000, `%C` is `tm_year / 100 + 19`. A negative weekday or month gives `Sun` or `Jan`. `%z` and `%Z` ignore `tm_gmtoff` and `tm_zone` and use the guest zone and `tzname`
- [strftime and asctime](../../../core/libs/prx/libc/src/Time.cpp) (libc) follow a payload process on a PS5 (firmware 13.60) in the C locale. Out-of-range fields in `%G`, `%g`, `%V`, `%U` and `%W` follow the SDK 7.00 `libc.prx` that PPSA06416 ships, read from its disassembly and run, and are measured on a console only for the cases in the test; it counts a year divisible by 100 as leap only when `tm_year / 400` is 100 or -300. `%k`, `%l`, `%P`, `%s`, `%+`, `%v`, flags and widths are copied as written, `E` or `O` before an unknown conversion is dropped, and a format ending in either throws, where the console crashed. Negative fields print 0 and fields wrap at 100 (`%j` at 1000, `%u` and `%w` at 10); `%Y` and `%G` are the year mod 10000, `%C` is `tm_year / 100 + 19`. A negative weekday or month gives `Sun` or `Jan`. `%z` and `%Z` ignore `tm_gmtoff` and `tm_zone` and use the guest zone and `tzname`
- [wcsftime](../../../core/libs/prx/libc/src/Time.cpp) (libc) converts the format to UTF-8, runs this library's `strftime` and converts the result back, where the console's `libSceLibcInternal.sprx` (0x854e0) copies literal wide characters and expands each conversion straight into the buffer; the results match for the formats tried, and it has the `strftime` gaps above. A result that does not fit returns 0 with the buffer untouched, where the console leaves the characters written so far. A null buffer returns 0, where the console writes through it, and a size too large for the narrow buffer returns 0 with `EINVAL`, where the console formats as usual. A null format or `tm` throws
- [syslog](../../../core/libs/prx/libc/src/Syslog.cpp) (libc) writes each message to `stderr` as `[syslog:<priority>] <message>`, with a newline added when missing, instead of sending it to the console's system log. Priority and facility handling follow FreeBSD's `vsyslog`: `%m` expands to the guest errno's message, unknown bits are reported (as priority 0x23, since FreeBSD passes `LOG_ERR|LOG_CONS|LOG_PERROR|LOG_PID` as the priority) and masked, a missing facility becomes `LOG_USER` or the facility set by `openlog`, and errno is preserved. `openlog` applies its identity and `LOG_PID`; a null identity retains the previous one, and a valid nonzero facility replaces the default. `closelog` clears the identity but retains the options and facility, as in FreeBSD 11; when no identity is set, FreeBSD falls back to the program name, which this backend does not emit. `LOG_PERROR` has no extra effect because stderr is the emulated log sink; `LOG_NDELAY`/`LOG_ODELAY`, `LOG_NOWAIT` and `LOG_CONS` have no daemon, child or console backend to control. `setlogmask` follows [FreeBSD 11](https://github.com/freebsd/freebsd-src/blob/8d2708de5d6112a492744d367cfdfb8fd219227a/lib/libc/gen/syslog.c): the process-wide mask defaults to 0xff, zero queries it, and nonzero values replace it; masked messages are skipped before formatting, including the unknown-priority diagnostic. The console's mask behaviour is unverified
- [fwscanf, vfwscanf, wscanf, vwscanf and their `_s` forms](../../../core/libs/prx/libc/src/ScanningWide.cpp) (libc) run the `swscanf` scanner on characters read with `fgetwc`, so everything listed for `swscanf` in this file applies to them. As in the console's `libc.prx`, a field takes characters while they can still begin a valid item, so a failed field such as `0x` for `%x` stays consumed, and only the character that ended the scan goes back to the stream. That character is pushed back as its UTF-8 bytes with `ungetc`, because `ungetwc` takes single bytes only; the Windows C runtime guarantees one byte of push-back, so a non-ASCII lookahead can be lost there. A null stream or format returns `EOF`; the console checks them only in the `_s` forms.