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.
This commit is contained in:
AyeZeeBB committed 2026-10-13 01:14:01 -05:00
1 parent 180cdc9c85
commit 30a320ff62
4 files changed
+103

No files matched your search

@@ -149,6 +149,10 @@ inline constexpr std::uintptr_t bool_and = 0x16e7130, bool_or = 0x16eb6e0, bool_
inline constexpr std::array<unsigned char, 9> bool_and_prefix{0x80,0x39,0x00,0x74,0x0a,0x80,0x3a,0x00,0x74};
inline constexpr std::array<unsigned char, 9> bool_or_prefix{0x80,0x39,0x00,0x75,0x0a,0x80,0x3a,0x00,0x75};
inline constexpr std::array<unsigned char, 9> 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<unsigned char, 10> 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<unsigned char, 19> player_id_prefix{
0x40,0x53,0x48,0x83,0xec,0x20,0x48,0x8b,0x11,0x48,0x8b,0xd9,0x48,0x85,0xd2,0x0f,0x84,0xb9,0x00};
@@ -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 <Windows.h>
@@ -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();
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <climits>
#include <cstddef>
// 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<const unsigned char *>(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<long long>(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<int>(value);
else result.value = value >= limit ? INT_MAX : static_cast<int>(value);
return result;
}
} // namespace dingosdk::throwdowns_text
@@ -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<unsigned> 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<unsigned char>(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<std::array<unsigned char, 10>>(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<void*>(base + throwdowns::parse_int);
if (hook_prepare(target, reinterpret_cast<void*>(&parse_int_hook), reinterpret_cast<void**>(&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<unsigned char, 19> prefix; void* replacement; void** original; };
const std::array hooks{
Hook{throwdowns::mode_available, throwdowns::mode_available_prefix, reinterpret_cast<void*>(&mode_available_hook), reinterpret_cast<void**>(&s.mode_available)},