From 71260906543a0c5e3b98b32069c2f3caf993ac02 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 13 Oct 2026 00:37:51 +0400 Subject: [PATCH] fix(libc): convert local times before 1970 and after 3000 on Windows --- core/libs/prx/libc/src/Time.cpp | 51 +++++++++++++++++++++++-- core/libs/tests/GuestMktime.cpp | 18 --------- core/libs/tests/GuestTimeConversion.cpp | 45 ++++++++++++++++++++++ docs/dev/debt/libc.md | 2 +- 4 files changed, 94 insertions(+), 22 deletions(-) diff --git a/core/libs/prx/libc/src/Time.cpp b/core/libs/prx/libc/src/Time.cpp index 7bc8c87c1..1de62e3ee 100644 --- a/core/libs/prx/libc/src/Time.cpp +++ b/core/libs/prx/libc/src/Time.cpp @@ -22,6 +22,27 @@ constexpr const char* MONTH_NAMES[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", constexpr std::size_t ASCTIME_SIZE = sizeof("Sun Jan-2147483648 -2147483648:-2147483648:-2147483648 -2147483648\n"); constexpr std::int64_t SECONDS_PER_DAY = 86400; constexpr std::int64_t DAYS_FROM_MARCH_TO_YEAR_END = 306; +#ifdef _WIN32 +constexpr std::int64_t CALENDAR_CYCLE_YEARS = 400; +constexpr std::int64_t CALENDAR_CYCLE_SECONDS = 146097 * SECONDS_PER_DAY; +constexpr std::int64_t HOST_LAST_SECOND = 32535215999; + +std::int64_t calendarCycles(std::int64_t value, std::int64_t cycle) { + return value >= 0 ? value / cycle : -((-(value + 1)) / cycle + 1); +} + +std::int64_t utcSeconds(const std::tm& time) { + const std::int64_t years = calendarCycles(time.tm_mon, 12); + const std::int64_t month = time.tm_mon - years * 12 + 1; + const std::int64_t year = std::int64_t{time.tm_year} + 1900 + years - (month <= 2 ? 1 : 0); + const std::int64_t era = calendarCycles(year, CALENDAR_CYCLE_YEARS); + const std::int64_t yearOfEra = year - era * CALENDAR_CYCLE_YEARS; + const std::int64_t dayOfYear = (153 * (month > 2 ? month - 3 : month + 9) + 2) / 5; + const std::int64_t dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear; + const std::int64_t days = era * 146097 + dayOfEra - 719468 + time.tm_mday - 1; + return days * SECONDS_PER_DAY + std::int64_t{time.tm_hour} * 3600 + std::int64_t{time.tm_min} * 60 + time.tm_sec; +} +#endif void copyCalendarFields(const std::tm& host, GuestTm& guest) { guest.tm_sec = host.tm_sec; @@ -274,10 +295,17 @@ GuestTm* APS5_VABI localtime_nid_postfix(const int64_t* timer) { GuestTm* APS5_VABI localtime_s_nid_postfix(const int64_t* timer, GuestTm* result) { if (timer == nullptr || result == nullptr) return nullptr; - const std::time_t t = static_cast(*timer); std::tm host{}; #ifdef _WIN32 + const std::int64_t cycles = *timer >= 0 && *timer <= HOST_LAST_SECOND ? 0 : calendarCycles(*timer, CALENDAR_CYCLE_SECONDS); + const std::time_t t = static_cast(*timer - cycles * CALENDAR_CYCLE_SECONDS); if (localtime_s(&host, &t) != 0) return nullptr; + const std::int64_t year = host.tm_year + cycles * CALENDAR_CYCLE_YEARS; + if (year < std::numeric_limits::min() || year > std::numeric_limits::max()) { + errno = EINVAL; + return nullptr; + } + host.tm_year = static_cast(year); copyCalendarFields(host, *result); long standardBias = 0; long daylightBias = 0; @@ -287,6 +315,7 @@ GuestTm* APS5_VABI localtime_s_nid_postfix(const int64_t* timer, GuestTm* result result->tm_gmtoff = -static_cast(standardBias + (daylight ? daylightBias : 0)); result->tm_zone = _tzname[daylight ? 1 : 0]; #else + const std::time_t t = static_cast(*timer); if (localtime_r(&t, &host) == nullptr) return nullptr; copyCalendarFields(host, *result); result->tm_gmtoff = host.tm_gmtoff; @@ -310,10 +339,26 @@ GuestTm* APS5_VABI gmtime_nid_postfix(const int64_t* timer) { int64_t APS5_VABI libc_mktime_nid_postfix(GuestTm* timeptr) { std::tm host = toHostTm(*timeptr); host.tm_wday = -1; + int64_t moved = 0; +#ifdef _WIN32 + const bool ordinary = host.tm_year > 70 && host.tm_year < 1100 && host.tm_mon >= 0 && host.tm_mon < 12 && host.tm_mday >= 1 && host.tm_mday <= 31 + && host.tm_hour >= 0 && host.tm_hour < 24 && host.tm_min >= 0 && host.tm_min < 60 && host.tm_sec >= 0 && host.tm_sec <= 60; + const std::int64_t approximate = ordinary ? SECONDS_PER_DAY : utcSeconds(host); + if (approximate < SECONDS_PER_DAY || approximate > HOST_LAST_SECOND - SECONDS_PER_DAY) { + moved = calendarCycles(approximate - SECONDS_PER_DAY, CALENDAR_CYCLE_SECONDS) * CALENDAR_CYCLE_SECONDS; + GuestTm window{}; + if (!utcCalendarFields(approximate - moved, window)) return -1; + window.tm_isdst = host.tm_isdst; + host = toHostTm(window); + host.tm_wday = -1; + } +#endif const std::time_t t = std::mktime(&host); if (t == static_cast(-1) && host.tm_wday == -1) return -1; - const int64_t time = static_cast(t); - localtime_s_nid_postfix(&time, timeptr); + const int64_t time = static_cast(t) + moved; + GuestTm normalized{}; + if (localtime_s_nid_postfix(&time, &normalized) == nullptr) return -1; + *timeptr = normalized; return time; } diff --git a/core/libs/tests/GuestMktime.cpp b/core/libs/tests/GuestMktime.cpp index 0a81573f9..beaf27337 100644 --- a/core/libs/tests/GuestMktime.cpp +++ b/core/libs/tests/GuestMktime.cpp @@ -61,19 +61,6 @@ static void CheckFailure(Converter convert) { const GuestTm original = input; Require(convert(&input) == -1, "mktime accepted an unrepresentable calendar"); Require(Equal(input, original), "Failed mktime changed the guest calendar"); -#ifdef _WIN32 - input = {}; - input.tm_year = 69; - input.tm_mon = 11; - input.tm_mday = 31; - input.tm_hour = 23; - input.tm_min = 59; - input.tm_sec = 59; - input.tm_zone = "unchanged"; - const GuestTm beforeEpoch = input; - Require(convert(&input) == -1, "Windows mktime accepted a time before the epoch"); - Require(Equal(input, beforeEpoch), "Failed Windows mktime changed the guest calendar"); -#endif } static void CheckZone(const char* zone) { @@ -87,9 +74,6 @@ static void CheckZone(const char* zone) { constexpr std::array timers{-86401, -2, -1, 0, 1, 59, 60, 86400}; for (auto convert : converters) { for (const auto timer : timers) { -#ifdef _WIN32 - if (timer < 0) continue; -#endif CheckNormalization(convert, timer); } CheckFailure(convert); @@ -99,7 +83,5 @@ static void CheckZone(const char* zone) { int main() { CheckZone("UTC0"); CheckZone("UTC-2"); -#ifndef _WIN32 CheckZone("UTC5"); -#endif } diff --git a/core/libs/tests/GuestTimeConversion.cpp b/core/libs/tests/GuestTimeConversion.cpp index bdf1486d4..624508e4d 100644 --- a/core/libs/tests/GuestTimeConversion.cpp +++ b/core/libs/tests/GuestTimeConversion.cpp @@ -252,6 +252,50 @@ static void CheckLiteralConversions() { Require(buffer[0] == 'x' && buffer[1] == 'y', "strftime wrote to a zero-size buffer"); } +static void CheckLocalRange() { + constexpr std::array timers{-1, -7200, -7201, -86400, -2208988800, -12622780801, -62135596800, 32535216000, 40000000000, 253402300799}; + for (const std::int64_t timer : timers) { + GuestTm local{}; + *__error_nid_postfix() = 0; + Require(localtime_s_nid_postfix(&timer, &local) == &local && local.tm_gmtoff == 7200, "Local conversion rejected a time outside 1970 to 3000"); + Require(Equal(local, UtcTime(timer + 7200)), "Local conversion outside 1970 to 3000 has the wrong calendar fields"); + Require(local.tm_zone != nullptr && std::strlen(local.tm_zone) > 0, "Local conversion outside 1970 to 3000 did not set tm_zone"); + GuestTm normalized = local; + normalized.tm_wday = -1; + normalized.tm_yday = -1; + Require(mktime_nid_postfix(&normalized) == timer && Equal(normalized, local), "mktime did not invert localtime outside 1970 to 3000"); + Require(*__error_nid_postfix() == 0, "A local conversion outside 1970 to 3000 set errno"); + const GuestTm* shared = localtime_nid_postfix(&timer); + Require(shared != nullptr && Equal(*shared, local), "localtime rejected a time outside 1970 to 3000"); + Require(ctime_nid_postfix(&timer)[0] != '?', "ctime rejected a time outside 1970 to 3000"); + } + GuestTm beforeEpoch{}; + beforeEpoch.tm_year = 70; + beforeEpoch.tm_mon = -1; + beforeEpoch.tm_mday = 1; + beforeEpoch.tm_isdst = -1; + Require(mktime_nid_postfix(&beforeEpoch) == -2685600 && beforeEpoch.tm_year == 69 && beforeEpoch.tm_mon == 11 && beforeEpoch.tm_mday == 1 + && beforeEpoch.tm_wday == 1 && beforeEpoch.tm_yday == 334, "mktime did not normalize a month before 1970"); + GuestTm afterLimit{}; + afterLimit.tm_year = 1200; + afterLimit.tm_mon = 17; + afterLimit.tm_mday = 15; + afterLimit.tm_hour = 12; + afterLimit.tm_isdst = -1; + const std::int64_t late = mktime_nid_postfix(&afterLimit); + Require(late == 35705181600 && afterLimit.tm_year == 1201 && afterLimit.tm_mon == 5 && afterLimit.tm_mday == 15 && afterLimit.tm_hour == 12 + && Equal(afterLimit, UtcTime(late + 7200)), "mktime did not normalize a month after 3000"); + GuestTm farDay{}; + farDay.tm_year = 70; + farDay.tm_mday = -36500; + farDay.tm_isdst = -1; + const std::int64_t early = mktime_nid_postfix(&farDay); + Require(early == -3153693600 && farDay.tm_mday >= 1 && Equal(farDay, UtcTime(early + 7200)), "mktime did not normalize a day before 1970"); + const std::int64_t overflow = std::numeric_limits::min(); + GuestTm scratch{}; + Require(localtime_s_nid_postfix(&overflow, &scratch) == nullptr, "Unrepresentable negative local time was accepted"); +} + int main() { #ifdef _WIN32 _putenv_s("TZ", "UTC-2"); @@ -328,6 +372,7 @@ int main() { && std::strcmp(formatted, "1970-01-01 00:00:00") == 0, "strftime did not read the guest tm"); CheckFreeBsdConversions(); + CheckLocalRange(); CheckLiteralConversions(); CheckConcurrent(libc_gmtime_nid_postfix, gmtime_s_nid_postfix, "Concurrent libc_gmtime returned another thread's date"); diff --git a/docs/dev/debt/libc.md b/docs/dev/debt/libc.md index 598078c1f..c1e0ebe34 100644 --- a/docs/dev/debt/libc.md +++ b/docs/dev/debt/libc.md @@ -51,7 +51,7 @@ - [The scanf family](../../../core/libs/prx/libc/src/Formatting.cpp) (libc), the `_s` forms included, on Linux throws for the 16-bit wide conversions (`%ls`, `%lc`, `%l[`, `%S`, `%C`): glibc would store a 32-bit `wchar_t` per character into the guest's 16-bit buffer. Windows hands `%ls`, `%lc` and `%l[` to the host, whose `wchar_t` is 16 bits - [The scanf family](../../../core/libs/prx/libc/include/WindowsScanning.hpp) (libc) on Windows returns 0, as C17 and FreeBSD do, when input fails after only suppressed conversions have completed (`sscanf("12", "%*d%d", &value)`). Linux hands the format to glibc, which returns `EOF` there. On Windows the host stops a `%x`, `%X`, `%i` or `%p` after the `x` of a `0x` that no hex digit follows (or that the width cuts off), and a float conversion (`%e`, `%f`, `%g`, `%a`) after the `e` of an exponent with no digits (`1e`), where C17 and FreeBSD stop before the `x` or the `e` - [Guest path resolution](../../../core/libs/prx/libc/src/General.cpp) falls back to ASCII case-insensitive component lookup, following [shadPS4's host filesystem](https://github.com/shadps4-emu/shadPS4/blob/945dbc3cc3eee80ac3e053b438502ed936fa6bb2/src/core/file_sys/backends/host_fs.cpp). PPSA03747 requests `Skyrim.INI`, `Data/Video` and lowercase archive/plugin names that differ from the dump's filenames. Exact host names take precedence; multiple fallback matches throw instead of selecting an arbitrary file. Existing parent names are resolved when creating a new file, and guest mount aliases use the same ASCII comparison. Results are not cached, so renames and newly created files remain visible. Unicode case folding and PS5 behaviour for case-colliding host entries are unverified; host roots are used as supplied. Explicit native Windows drive and UNC paths bypass guest parent-component validation. -- [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; outside that range `localtime` returns null and `mktime` returns -1, where the console converts. `gmtime` converts every time whose year fits an `int` on both hosts. +- [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. `gmtime` converts every time whose year fits an `int` on both hosts. - [strftime](../../../core/libs/prx/libc/src/Time.cpp) (libc) expands FreeBSD's `%k`, `%l`, `%s`, `%v` and `%+` itself, ignoring a leading `E` or `O` as FreeBSD does. Unknown conversions, repeated modifiers and incomplete conversions emit literals as in FreeBSD 11; recognized conversions otherwise go to the host's `strftime`. On Windows the `%-`, `%_` and `%0` padding flags still produce nothing, including on the five expanded conversions (`%-k`, `%_k` and `%0k` go to the host), and a standalone `%z` or `%Z` uses the host's zone instead of the broken-down time's `tm_zone`. `%s` follows FreeBSD 9 and 11 and is the local-time `mktime` of the broken-down time (current FreeBSD uses `tm_gmtoff`), which is -1 on Windows outside the range above. `%+` is the C locale's `%a %b %e %H:%M:%S %Z %Y` with `%Z` taken from `tm_zone`, or from `tzname[tm_isdst != 0]` when it is null. - [syslog](../../../core/libs/prx/libc/src/Syslog.cpp) (libc) writes each message to `stderr` as `[syslog:] `, 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`, and errno is preserved. `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. `openlog` and `closelog` are not implemented, so their identity and options are not applied - [daemon](../../../core/libs/prx/libc/src/Process.cpp) (libc) throws: forking into the background is not supported. PPSA85300 imports it and fails to load without it. VLC 3.0 calls it only when its `daemon` option is set ([libvlc.c](https://github.com/videolan/vlc/blob/6de05adcbaf2e8b85fe86aad4169393098628119/src/libvlc.c#L190-L197)), and the title starts libVLC with `--ignore-config` and without `--daemon` ([player.cc](https://github.com/ZiZc3/VLC/blob/f3253020a64368153233ede29183014c0b6c8979/src/player.cc#L960-L981))