From 30a320ff627a67025fc2b13d8e87bb12612e4f16 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Tue, 13 Oct 2026 01:14:01 -0500 Subject: [PATCH] Throwdowns: a grouped line score is no longer cut at its first separator A Jam Session takes the score of the line just landed from the score HUD's text, with the script native ParseInt, which is the C library's atoi. atoi stops at the first character that is not a digit, so a line shown as 5,432 was read as 5 and added 5 to the player's total. Reported for Jam Sessions and Spot Battles: lines of 5,000 and more adding one or two points. ParseInt now reads groups of three digits whole, set apart by a comma, a full stop, an apostrophe or a space: "5,432" is 5432. Text that is not a grouped number is read exactly as before. The first few grouped scores are logged, to confirm the cause in play. --- .../Game/Build/20260929/native_throwdowns.h | 4 ++ .../Throwdowns/Test/throwdown_relay_tests.cpp | 22 ++++++++ Extension/Throwdowns/grouped_number.h | 50 +++++++++++++++++++ Extension/Throwdowns/native_throwdowns.cpp | 27 ++++++++++ 4 files changed, 103 insertions(+) create mode 100644 Extension/Throwdowns/grouped_number.h diff --git a/Engine/Game/Build/20260929/native_throwdowns.h b/Engine/Game/Build/20260929/native_throwdowns.h index 1745851..5151b2f 100644 --- a/Engine/Game/Build/20260929/native_throwdowns.h +++ b/Engine/Game/Build/20260929/native_throwdowns.h @@ -149,6 +149,10 @@ inline constexpr std::uintptr_t bool_and = 0x16e7130, bool_or = 0x16eb6e0, bool_ inline constexpr std::array bool_and_prefix{0x80,0x39,0x00,0x74,0x0a,0x80,0x3a,0x00,0x74}; inline constexpr std::array bool_or_prefix{0x80,0x39,0x00,0x75,0x0a,0x80,0x3a,0x00,0x75}; inline constexpr std::array bool_not_prefix{0x80,0x39,0x00,0x0f,0x94,0xc0,0x88,0x02,0xc3}; +// ParseInt expression native (09586973): int (const char* const* text), which is +// "mov rcx, [rcx]; jmp [atoi]". Its record's generic and fast entries both call this. +inline constexpr std::uintptr_t parse_int = 0x864dc0; +inline constexpr std::array parse_int_prefix{0x48,0x8b,0x09,0x48,0xff,0x25,0x7e,0x66,0x78,0x05}; inline constexpr std::uintptr_t player_id = 0x854b90; inline constexpr std::array player_id_prefix{ 0x40,0x53,0x48,0x83,0xec,0x20,0x48,0x8b,0x11,0x48,0x8b,0xd9,0x48,0x85,0xd2,0x0f,0x84,0xb9,0x00}; diff --git a/Extension/Throwdowns/Test/throwdown_relay_tests.cpp b/Extension/Throwdowns/Test/throwdown_relay_tests.cpp index da28c45..95fe1ac 100644 --- a/Extension/Throwdowns/Test/throwdown_relay_tests.cpp +++ b/Extension/Throwdowns/Test/throwdown_relay_tests.cpp @@ -4,6 +4,7 @@ #include "Extension/Throwdowns/throwdown_lab.h" #include "Extension/Throwdowns/throwdown_wire.h" #include "Extension/Throwdowns/native_throwdowns.h" +#include "Extension/Throwdowns/grouped_number.h" #include "Extension/Multiplayer/Hud/native_party.h" #include "Extension/Settings/named_settings.h" #include @@ -780,12 +781,33 @@ void level_change_flow() { } } // namespace +// A score read from text: what atoi reads, and digit groups read whole. +void grouped_number_checks() { + using dingosdk::throwdowns_text::parse_grouped_int; + const auto same = [](const char *text, int value, bool grouped) { + const auto parsed = parse_grouped_int(text); + return parsed.value == value && parsed.grouped == grouped; + }; + if (!(same("5432", 5432, false) && same("0", 0, false) && same(" -17", -17, false) && same("+9", 9, false) && + same("", 0, false) && same("abc", 0, false) && same("12abc", 12, false) && same(nullptr, 0, false))) + throw std::runtime_error("A plain number was not read as atoi reads it"); + if (!(same("5,432", 5432, true) && same("1,250", 1250, true) && same("12,345,678", 12345678, true) && + same("5.432", 5432, true) && same("5 432", 5432, true) && same("5'432", 5432, true) && + same("5\xC2\xA0" "432", 5432, true) && same("5\xE2\x80\xAF" "432", 5432, true) && same("-1,000 pts", -1000, true))) + throw std::runtime_error("A grouped score was cut short"); + if (!(same("12.5", 12, false) && same("5,43", 5, false) && same("5,4321", 5, false) && same("1234,567", 1234, false) && + same("5, 432", 5, false) && same("5,,432", 5, false) && same("1,234.5", 1234, true))) + throw std::runtime_error("Text that is not a grouped number was read as one"); + if (!(same("99999999999", INT_MAX, false) && same("-99999999999", INT_MIN, false) && same("999,999,999,999", INT_MAX, true))) + throw std::runtime_error("A number out of range did not saturate"); +} int main() { try { guest_flow(); leader_flow(); removal_flow(); spot_battle_flow(); + grouped_number_checks(); skate_flow(); alone_flow(); challenge_flow(); diff --git a/Extension/Throwdowns/grouped_number.h b/Extension/Throwdowns/grouped_number.h new file mode 100644 index 0000000..0c43b44 --- /dev/null +++ b/Extension/Throwdowns/grouped_number.h @@ -0,0 +1,50 @@ +#pragma once +#include +#include + +// The game's scripts read a number out of text with ParseInt, which is the C library's atoi: +// it stops at the first character that is not a digit. A Jam Session takes the score of the +// line just landed from the score HUD's own label (analysis/throwdowns-re/jam.md), so where +// that label groups its digits a 5,432 line is read as 5 and adds 5 to the player's total +// (a 1,250 one adds 1). This reads the same text and takes the groups as one number. +namespace dingosdk::throwdowns_text { +struct GroupedInt { + int value{}; + bool grouped{}; // the text had digit groups, which atoi would have cut short +}; + +// atoi's reading of `text` (leading space, a sign, digits), going on through groups of +// exactly three digits set apart by one of , . ' a space, or a no-break, narrow no-break or +// thin space (UTF-8). "5,432" is 5432 and "1 234 567" is 1234567; "12.5", "5,43" and +// "1234,567" end where atoi ends them. Out of range saturates. +inline GroupedInt parse_grouped_int(const char *text) noexcept { + if (!text) return {}; + const auto *p = reinterpret_cast(text); + const auto digit = [](unsigned char c) { return c >= '0' && c <= '9'; }; + while (*p == ' ' || (*p >= '\t' && *p <= '\r')) ++p; + bool negative{}; + if (*p == '+' || *p == '-') negative = *p++ == '-'; + constexpr long long limit = static_cast(INT_MAX) + 1; + long long value{}; + int first{}; + for (; digit(*p); ++p, ++first) value = value * 10 + (*p - '0') > limit ? limit : value * 10 + (*p - '0'); + GroupedInt result; + // Only a number that starts with one to three digits is grouped. + while (first >= 1 && first <= 3) { + std::size_t separator{}; + if (*p == ',' || *p == '.' || *p == ' ' || *p == '\'') separator = 1; + else if (p[0] == 0xC2 && p[1] == 0xA0) separator = 2; + else if (p[0] == 0xE2 && p[1] == 0x80 && (p[2] == 0xAF || p[2] == 0x89)) separator = 3; + if (!separator) break; + const auto *group = p + separator; + if (!digit(group[0]) || !digit(group[1]) || !digit(group[2]) || digit(group[3])) break; + const auto thousands = (group[0] - '0') * 100 + (group[1] - '0') * 10 + (group[2] - '0'); + value = value * 1000 + thousands > limit ? limit : value * 1000 + thousands; + p = group + 3; + result.grouped = true; + } + if (negative) result.value = value >= limit ? INT_MIN : -static_cast(value); + else result.value = value >= limit ? INT_MAX : static_cast(value); + return result; +} +} // namespace dingosdk::throwdowns_text diff --git a/Extension/Throwdowns/native_throwdowns.cpp b/Extension/Throwdowns/native_throwdowns.cpp index 68a8335..fd87c43 100644 --- a/Extension/Throwdowns/native_throwdowns.cpp +++ b/Extension/Throwdowns/native_throwdowns.cpp @@ -1,4 +1,5 @@ #include "native_throwdowns.h" +#include "grouped_number.h" #include "throwdown_lab.h" #include "throwdown_relay.h" #include "virtual_player_names.h" @@ -566,10 +567,36 @@ void apply_throwdown_strings(Address base) noexcept { } } catch (...) {} } +// ParseInt, reading a grouped number whole (grouped_number.h). Any script may call it. +int (*parse_int_original)(const char* const*){}; +int parse_int_hook(const char* const* text) { + const auto parsed = throwdowns_text::parse_grouped_int(text ? *text : nullptr); + if (parsed.grouped) { + static std::atomic logged{}; + if (logged.fetch_add(1, std::memory_order_relaxed) < 12) { + std::string shown(*text, strnlen(*text, 32)); + for (auto& ch : shown) if (static_cast(ch) < 0x20) ch = ' '; + logging::log(logging::Level::info, logging::Channel::progression, + "Scripts: \"{}\" read as {} (the game's own ParseInt stops at the first separator).", shown, parsed.value); + } + } + return parsed.value; +} +void install_parse_int(Address base) noexcept { + try { + if (read>(base + throwdowns::parse_int) != throwdowns::parse_int_prefix) + throw std::runtime_error("ParseInt fingerprint differs; grouped scores are read as the game reads them"); + auto* const target = reinterpret_cast(base + throwdowns::parse_int); + if (hook_prepare(target, reinterpret_cast(&parse_int_hook), reinterpret_cast(&parse_int_original)) != HookOk || + hook_enable(target) != HookOk) + throw std::runtime_error("Cannot replace ParseInt; grouped scores are read as the game reads them"); + } catch (const std::exception& e) { logging::write(logging::Level::warning, logging::Channel::progression, e.what()); } +} void initialize_native_throwdowns(Address base) noexcept { auto& s = state(); if (s.attempted || !base) return; s.attempted = true; s.base = base; + install_parse_int(base); struct Hook { Address rva; std::array prefix; void* replacement; void** original; }; const std::array hooks{ Hook{throwdowns::mode_available, throwdowns::mode_available_prefix, reinterpret_cast(&mode_available_hook), reinterpret_cast(&s.mode_available)},