mirror of
https://github.com/open-goal/jak-project
synced 2026-05-23 15:02:01 -04:00
006d24b29a
Resolves #3075 TODO before merge: - [x] Properly draw non-korean strings while in korean mode (language selection) - [x] Check jak 3 - [x] Translation scaffolding (allow korean characters, add to Crowdin, fix japanese locale, etc) - [x] Check translation of text lines - [x] Check translation of subtitle lines - [x] Cleanup PR / some performance optimization (it's take a bit too long to build the text and it shouldn't since the information is in a giant lookup table) - [x] Wait until release is cut I confirmed the font textures are identical between Jak 2 and Jak 3, so thank god for that. Some examples of converting the korean encoding to utf-8. These show off all scenarios, pure korean / korean with ascii and japanese / korean with replacements (flags): <img width="316" height="611" alt="Screenshot 2025-07-26 191511" src="https://github.com/user-attachments/assets/614383ba-8049-4bf4-937e-24ad3e605d41" /> <img width="254" height="220" alt="Screenshot 2025-07-26 191529" src="https://github.com/user-attachments/assets/1f6e5a6c-8527-4f98-a988-925ec66e437d" /> And it working in game. `Input Options` is a custom not-yet-translated string. It now shows up properly instead of a disgusting block of glyphs, and all the original strings are hopefully the same semantically!: <img width="550" height="493" alt="Screenshot 2025-07-26 202838" src="https://github.com/user-attachments/assets/9ebdf6c0-f5a3-4a30-84a1-e5840809a1a2" /> Quite the challenge. The crux of the problem is -- Naughty Dog came up with their own encoding for representing korean syllable blocks, and that source information is lost so it has to be reverse engineered. Instead of trying to figure out their encoding from the text -- I went at it from the angle of just "how do i draw every single korean character using their glyph set". One might think this is way too time consuming but it's important to remember: - Korean letters are designed to be composable from a relatively small number of glyphs (more on this later) - Someone at naughty dog did basically this exact process - There is no other way! While there are loose patterns, there isn't an overarching rhyme or reason, they just picked the right glyph for the writing context (more on this later). And there are even situations where there IS NO good looking glyph, or the one ND chose looks awful and unreadable (we could technically fix this by adjusting the positioning of the glyphs but....no more)! Information on their encoding that gets passed to `convert-korean-text`: - It's a raw stream of bytes - It can contain normal font letters - Every syllable block begins with: `0x04 <num_glyphs> <...the glyph bytes...>` - DO NOT confuse `num_glyphs` with num jamo, because some glyphs can have multiple jamo! - Every section of normal text starts with `0x03`. For example a space would be `0x03 0x20` - There are a very select few number of jamo glyphs on a secondary texture page, these glyph bytes are preceeded with a `0x05`. These jamo are a variant of some of the final vowels, moving them as low down as possible. Crash course on korean writing: - Nice resource as this is basically what we are doing - https://glyphsapp.com/learn/creating-a-hangeul-font - Korean syllable blocks have either 2 or 3 jamo. Jamo are basically letters and are the individual pieces that make up the syllable blocks. - The jamo are split up into "initial", "medial" and "final" categories. Within the "medial" category there are obvious visual variants: - Horizontal - Vertical - Combination (horizontal + a vertical) - These jamo are laid out in 6 main pre-defined "orientations": - initial + vertical medial - initial + horizontal medial - initial + combination - initial + vertical medial + final - initial + horizontal medial + final - initial + combination + final - Sometimes, for stylistic reasons, jamo will be written in different ways (ie. if there is nothing below a vertical vowel will be extended). - Annoying, and ND's glyph set supports this stylistic choice! - There are some combination of jamo that are never used, and some that are only used for a single word in the entire language! With all that in mind, my basic process was: - Scan the game's entire corpus of korean text, that includes subtitles. It's very easy to look at the font texture's glyphs and assign them to their respective jamo - This let me construct a mapping and see which glyphs were used under which context - I then shoved this information into a 2-D matrix in excel, and created an in-game tool to check every single jamo permutation to fill in the gaps / change them if naughty dogs was bad. Most of the time, ND's encoding was fine. - https://docs.google.com/spreadsheets/d/e/2PACX-1vTtyMeb5-mL5rXseS9YllVj32BGCISOGZFic6nkRV5Er5aLZ9CLq1Hj_rTY7pRCn-wrQDH1rvTqUHwB/pubhtml?gid=886895534&single=true anything in red is an addition / modification on my part. - This was the most lengthy part but not as long as you may think, you can do a lot of pruning. For example if you are checking a 3-jamo variant (the ones with the most permutations) and you've verified that the medial jamo is as far up vertically as it can be, and you are using the lowest final jamo that are available -- there is nothing to check or improve -- for better or worse! So those end up being the permutations between the initial and medial instead of a three-way permutation nightmare. - Also, while it is a 2d matrix, there's a lot of pruning even within that. For example, for the first 3 orientations, you dont have to care about final vowels at all. - At the end, I'm left with a lookup table that I can use the encode the best looking korean syllable blocks possible given the context of the jamo combination.
924 lines
37 KiB
C++
924 lines
37 KiB
C++
#include "kmachine_extras.h"
|
|
|
|
#include <bitset>
|
|
#include <regex>
|
|
|
|
#include "kscheme.h"
|
|
|
|
#include "common/symbols.h"
|
|
#include "common/util/font/font_utils.h"
|
|
|
|
#include "game/external/discord.h"
|
|
#include "game/external/discord_jak2.h"
|
|
#include "game/kernel/common/Symbol4.h"
|
|
#include "game/kernel/common/kmachine.h"
|
|
#include "game/kernel/common/kscheme.h"
|
|
#include "game/overlord/jak2/iso.h"
|
|
|
|
namespace kmachine_extras {
|
|
using namespace jak2;
|
|
|
|
AutoSplitterBlock g_auto_splitter_block_jak2;
|
|
|
|
void update_discord_rpc(u32 discord_info) {
|
|
if (gDiscordRpcEnabled) {
|
|
DiscordRichPresence rpc;
|
|
char state[128];
|
|
char large_image_key[128];
|
|
char large_image_text[128];
|
|
char small_image_key[128];
|
|
char small_image_text[128];
|
|
auto info = discord_info ? Ptr<DiscordInfo>(discord_info).c() : NULL;
|
|
if (info) {
|
|
// Get the data from GOAL
|
|
int orbs = (int)info->orb_count;
|
|
int gems = (int)info->gem_count;
|
|
// convert encodings
|
|
std::string status = get_font_bank(GameTextVersion::JAK2)
|
|
->convert_game_to_utf8(Ptr<String>(info->status).c()->data());
|
|
|
|
// get rid of special encodings like <COLOR_WHITE>
|
|
std::regex r("<.*?>");
|
|
while (std::regex_search(status, r)) {
|
|
status = std::regex_replace(status, r, "");
|
|
}
|
|
|
|
char* level = Ptr<String>(info->level).c()->data();
|
|
auto cutscene = Ptr<Symbol4<u32>>(info->cutscene)->value();
|
|
float time = info->time_of_day;
|
|
float percent_completed = info->percent_completed;
|
|
std::bitset<32> focus_status;
|
|
focus_status = info->focus_status;
|
|
char* task = Ptr<String>(info->task).c()->data();
|
|
|
|
// Construct the DiscordRPC Object
|
|
const char* full_level_name =
|
|
get_full_level_name(level_names, level_name_remap, Ptr<String>(info->level).c()->data());
|
|
memset(&rpc, 0, sizeof(rpc));
|
|
// if we have an active task, set the mission specific image for it
|
|
// also small hack to prevent oracle image from showing up while inside levels
|
|
// like hideout, onintent, etc.
|
|
if (strcmp(task, "unknown") != 0 && strcmp(task, "city-oracle") != 0) {
|
|
strcpy(large_image_key, task);
|
|
} else {
|
|
// if we are in an outdoors level, use the picture for the corresponding time of day
|
|
if (!indoors(indoor_levels, level)) {
|
|
char level_with_tod[128];
|
|
strcpy(level_with_tod, level);
|
|
strcat(level_with_tod, "-");
|
|
strcat(level_with_tod, time_of_day_str(time));
|
|
strcpy(large_image_key, level_with_tod);
|
|
} else {
|
|
strcpy(large_image_key, level);
|
|
}
|
|
}
|
|
strcpy(large_image_text, full_level_name);
|
|
if (!strcmp(full_level_name, "unknown")) {
|
|
strcpy(large_image_key, full_level_name);
|
|
strcpy(large_image_text, level);
|
|
}
|
|
rpc.largeImageKey = large_image_key;
|
|
if (cutscene != offset_of_s7()) {
|
|
strcpy(state, "Watching a cutscene");
|
|
// temporarily move these counters to the large image tooltip during a cutscene
|
|
strcat(large_image_text,
|
|
fmt::format(" | {:.0f}% | Orbs: {} | Gems: {} | {}", percent_completed,
|
|
std::to_string(orbs), std::to_string(gems), get_time_of_day(time))
|
|
.c_str());
|
|
} else {
|
|
strcpy(state, fmt::format("{:.0f}% | Orbs: {} | Gems: {} | {}", percent_completed,
|
|
std::to_string(orbs), std::to_string(gems), get_time_of_day(time))
|
|
.c_str());
|
|
}
|
|
rpc.largeImageText = large_image_text;
|
|
rpc.state = state;
|
|
// check for any special conditions to display for the small image
|
|
if (FOCUS_TEST(focus_status, FocusStatus::Board)) {
|
|
strcpy(small_image_key, "focus-status-board");
|
|
strcpy(small_image_text, "On the JET-Board");
|
|
} else if (FOCUS_TEST(focus_status, FocusStatus::Mech)) {
|
|
strcpy(small_image_key, "focus-status-mech");
|
|
strcpy(small_image_text, "In the Titan Suit");
|
|
} else if (FOCUS_TEST(focus_status, FocusStatus::Pilot)) {
|
|
strcpy(small_image_key, "focus-status-pilot");
|
|
strcpy(small_image_text, "Driving a Zoomer");
|
|
} else if (FOCUS_TEST(focus_status, FocusStatus::Indax)) {
|
|
strcpy(small_image_key, "focus-status-indax");
|
|
strcpy(small_image_text, "Playing as Daxter");
|
|
} else if (FOCUS_TEST(focus_status, FocusStatus::Dark)) {
|
|
strcpy(small_image_key, "focus-status-dark");
|
|
strcpy(small_image_text, "Dark Jak");
|
|
} else if (FOCUS_TEST(focus_status, FocusStatus::Disable) &&
|
|
FOCUS_TEST(focus_status, FocusStatus::Grabbed)) {
|
|
// being in a turret sets disable and grabbed flags
|
|
strcpy(small_image_key, "focus-status-turret");
|
|
strcpy(small_image_text, "In a Gunpod");
|
|
} else if (FOCUS_TEST(focus_status, FocusStatus::Gun)) {
|
|
strcpy(small_image_key, "focus-status-gun");
|
|
strcpy(small_image_text, "Using a Gun");
|
|
} else {
|
|
strcpy(small_image_key, "");
|
|
strcpy(small_image_text, "");
|
|
}
|
|
rpc.smallImageKey = small_image_key;
|
|
rpc.smallImageText = small_image_text;
|
|
rpc.startTimestamp = gStartTime;
|
|
rpc.details = status.c_str();
|
|
rpc.partySize = 0;
|
|
rpc.partyMax = 0;
|
|
Discord_UpdatePresence(&rpc);
|
|
}
|
|
} else {
|
|
Discord_ClearPresence();
|
|
}
|
|
}
|
|
|
|
void pc_set_levels(u32 lev_list) {
|
|
if (!Gfx::GetCurrentRenderer()) {
|
|
return;
|
|
}
|
|
std::vector<std::string> levels;
|
|
for (int i = 0; i < LEVEL_MAX; i++) {
|
|
u32 lev = *Ptr<u32>(lev_list + i * 4);
|
|
std::string ls = Ptr<String>(lev).c()->data();
|
|
if (ls != "none" && ls != "#f" && ls != "") {
|
|
levels.push_back(ls);
|
|
}
|
|
}
|
|
|
|
Gfx::GetCurrentRenderer()->set_levels(levels);
|
|
}
|
|
|
|
void pc_set_active_levels(u32 lev_list) {
|
|
if (!Gfx::GetCurrentRenderer()) {
|
|
return;
|
|
}
|
|
std::vector<std::string> levels;
|
|
for (int i = 0; i < LEVEL_MAX; i++) {
|
|
u32 lev = *Ptr<u32>(lev_list + i * 4);
|
|
std::string ls = Ptr<String>(lev).c()->data();
|
|
if (ls != "none" && ls != "#f" && ls != "") {
|
|
levels.push_back(ls);
|
|
}
|
|
}
|
|
|
|
Gfx::GetCurrentRenderer()->set_active_levels(levels);
|
|
}
|
|
|
|
u32 alloc_vagdir_names(u32 heap_sym) {
|
|
auto alloced_heap = (Ptr<u64>)alloc_heap_memory(heap_sym, gVagDir.count * 8 + 8);
|
|
if (alloced_heap.offset) {
|
|
*alloced_heap = gVagDir.count;
|
|
// use entry -1 to get the amount
|
|
alloced_heap = alloced_heap + 8;
|
|
for (size_t i = 0; i < gVagDir.count; ++i) {
|
|
char vagname_temp[9];
|
|
memcpy(vagname_temp, gVagDir.vag[i].name, 8);
|
|
for (int j = 0; j < 8; ++j) {
|
|
vagname_temp[j] = tolower(vagname_temp[j]);
|
|
}
|
|
vagname_temp[8] = 0;
|
|
u64 vagname_val;
|
|
memcpy(&vagname_val, vagname_temp, 8);
|
|
*(alloced_heap + i * 8) = vagname_val;
|
|
}
|
|
return alloced_heap.offset;
|
|
}
|
|
return s7.offset;
|
|
}
|
|
|
|
inline u64 bool_to_symbol(const bool val) {
|
|
return val ? static_cast<u64>(s7.offset) + true_symbol_offset(g_game_version) : s7.offset;
|
|
}
|
|
|
|
inline bool symbol_to_bool(const u32 symptr) {
|
|
return symptr != s7.offset;
|
|
}
|
|
|
|
void init_autosplit_struct() {
|
|
g_auto_splitter_block_jak2.pointer_to_symbol =
|
|
(u64)g_ee_main_mem + (u64)intern_from_c("*autosplit-info-jak2*")->value();
|
|
}
|
|
|
|
// TODO - currently using a single mutex for all background task synchronization
|
|
std::mutex background_task_lock;
|
|
|
|
std::string last_rpc_error = "";
|
|
|
|
// TODO - add a TTL to this
|
|
std::unordered_map<std::string, std::vector<std::pair<std::string, float>>>
|
|
external_speedrun_time_cache = {};
|
|
std::unordered_map<std::string, std::vector<std::pair<std::string, float>>>
|
|
external_race_time_cache = {};
|
|
std::unordered_map<std::string, std::vector<std::pair<std::string, float>>>
|
|
external_highscores_cache = {};
|
|
|
|
// clang-format off
|
|
// TODO - eventually don't depend on SRC
|
|
const std::unordered_map<std::string, std::string> external_speedrun_lookup_urls = {
|
|
{"any", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/category/n2y6y4ed?embed=players&max=200"},
|
|
{"anyhoverless", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/category/7kjyn5gk?embed=players&max=200"},
|
|
{"allmissions", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/category/xk96myxk?embed=players&max=200"},
|
|
{"100", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/category/z27exp5k?embed=players&max=200"},
|
|
{"anyorbs", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/category/zdn3vm72?embed=players&max=200"},
|
|
{"anyhero", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/category/q25pv0wd?embed=players&max=200"}};
|
|
const std::unordered_map<std::string, std::string> external_race_lookup_urls = {
|
|
{"class3", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/level/y9m7qmx9/jdr0mg0d?embed=players&max=200"},
|
|
{"class2", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/level/5wk5zmpw/jdr0mg0d?embed=players&max=200"},
|
|
{"class1", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/level/5922g639/jdr0mg0d?embed=players&max=200"},
|
|
{"class3rev", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/level/29v4e8l9/jdr0mg0d?embed=players&max=200"},
|
|
{"class2rev", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/level/xd4475rd/jdr0mg0d?embed=players&max=200"},
|
|
{"class1rev", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/level/xd0mre4w/jdr0mg0d?embed=players&max=200"},
|
|
{"erol", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/level/rw68p7gd/jdr0mg0d?embed=players&max=200"},
|
|
{"port", "https://www.speedrun.com/api/v1/leaderboards/3dxk47y1/level/n93v5xzd/jdr0mg0d?embed=players&max=200"}};
|
|
const std::unordered_map<std::string, std::string> external_highscores_lookup_urls = {
|
|
{"scatter", "https://api.jakspeedruns.workers.dev/v1/highscores/2"},
|
|
{"blaster", "https://api.jakspeedruns.workers.dev/v1/highscores/3"},
|
|
{"vulcan", "https://api.jakspeedruns.workers.dev/v1/highscores/4"},
|
|
{"peacemaker", "https://api.jakspeedruns.workers.dev/v1/highscores/5"},
|
|
{"jetboard", "https://api.jakspeedruns.workers.dev/v1/highscores/6"},
|
|
{"onin", "https://api.jakspeedruns.workers.dev/v1/highscores/7"},
|
|
{"mash", "https://api.jakspeedruns.workers.dev/v1/highscores/8"}};
|
|
// clang-format on
|
|
|
|
void callback_fetch_external_speedrun_times(bool success,
|
|
const std::string& cache_id,
|
|
std::optional<std::string> result) {
|
|
std::scoped_lock lock{background_task_lock};
|
|
|
|
if (!success) {
|
|
intern_from_c("*pc-rpc-error?*")->value() = bool_to_symbol(true);
|
|
if (result) {
|
|
last_rpc_error = result.value();
|
|
} else {
|
|
last_rpc_error = "Unexpected Error Occurred";
|
|
}
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(false);
|
|
return;
|
|
}
|
|
|
|
// TODO - might be nice to have an error if we get an unexpected payload
|
|
if (!result) {
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(false);
|
|
return;
|
|
}
|
|
|
|
// Parse the response
|
|
const auto data = safe_parse_json(result.value());
|
|
if (!data || !data->contains("data") || !data->at("data").contains("players") ||
|
|
!data->at("data").at("players").contains("data") || !data->at("data").contains("runs")) {
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(false);
|
|
return;
|
|
}
|
|
|
|
auto& players = data->at("data").at("players").at("data");
|
|
auto& runs = data->at("data").at("runs");
|
|
std::vector<std::pair<std::string, float>> times = {};
|
|
for (const auto& run_info : runs) {
|
|
std::pair<std::string, float> time_info;
|
|
if (players.size() > times.size() && players.at(times.size()).contains("names") &&
|
|
players.at(times.size()).at("names").contains("international")) {
|
|
time_info.first = players.at(times.size()).at("names").at("international");
|
|
} else if (players.size() > times.size() && players.at(times.size()).contains("name")) {
|
|
time_info.first = players.at(times.size()).at("name");
|
|
} else {
|
|
time_info.first = "Unknown";
|
|
}
|
|
if (run_info.contains("run") && run_info.at("run").contains("times") &&
|
|
run_info.at("run").at("times").contains("primary_t")) {
|
|
time_info.second = run_info.at("run").at("times").at("primary_t");
|
|
times.push_back(time_info);
|
|
}
|
|
}
|
|
external_speedrun_time_cache[cache_id] = times;
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(false);
|
|
}
|
|
|
|
// TODO - duplicate code, put it in a function
|
|
void callback_fetch_external_race_times(bool success,
|
|
const std::string& cache_id,
|
|
std::optional<std::string> result) {
|
|
std::scoped_lock lock{background_task_lock};
|
|
|
|
if (!success) {
|
|
intern_from_c("*pc-rpc-error?*")->value() = bool_to_symbol(true);
|
|
if (result) {
|
|
last_rpc_error = result.value();
|
|
} else {
|
|
last_rpc_error = "Unexpected Error Occurred";
|
|
}
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(false);
|
|
return;
|
|
}
|
|
|
|
// TODO - might be nice to have an error if we get an unexpected payload
|
|
if (!result) {
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(false);
|
|
return;
|
|
}
|
|
|
|
// Parse the response
|
|
const auto data = safe_parse_json(result.value());
|
|
if (!data || !data->contains("data") || !data->at("data").contains("players") ||
|
|
!data->at("data").at("players").contains("data") || !data->at("data").contains("runs")) {
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(false);
|
|
return;
|
|
}
|
|
|
|
auto& players = data->at("data").at("players").at("data");
|
|
auto& runs = data->at("data").at("runs");
|
|
std::vector<std::pair<std::string, float>> times = {};
|
|
for (const auto& run_info : runs) {
|
|
std::pair<std::string, float> time_info;
|
|
if (players.size() > times.size() && players.at(times.size()).contains("names") &&
|
|
players.at(times.size()).at("names").contains("international")) {
|
|
time_info.first = players.at(times.size()).at("names").at("international");
|
|
} else if (players.size() > times.size() && players.at(times.size()).contains("name")) {
|
|
time_info.first = players.at(times.size()).at("name");
|
|
} else {
|
|
time_info.first = "Unknown";
|
|
}
|
|
if (run_info.contains("run") && run_info.at("run").contains("times") &&
|
|
run_info.at("run").at("times").contains("primary_t")) {
|
|
time_info.second = run_info.at("run").at("times").at("primary_t");
|
|
times.push_back(time_info);
|
|
}
|
|
}
|
|
external_race_time_cache[cache_id] = times;
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(false);
|
|
}
|
|
|
|
// TODO - duplicate code, put it in a function
|
|
void callback_fetch_external_highscores(bool success,
|
|
const std::string& cache_id,
|
|
std::optional<std::string> result) {
|
|
std::scoped_lock lock{background_task_lock};
|
|
|
|
if (!success) {
|
|
intern_from_c("*pc-rpc-error?*")->value() = bool_to_symbol(true);
|
|
if (result) {
|
|
last_rpc_error = result.value();
|
|
} else {
|
|
last_rpc_error = "Unexpected Error Occurred";
|
|
}
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(false);
|
|
return;
|
|
}
|
|
|
|
// TODO - might be nice to have an error if we get an unexpected payload
|
|
if (!result) {
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(false);
|
|
return;
|
|
}
|
|
|
|
// Parse the response
|
|
const auto data = safe_parse_json(result.value());
|
|
std::vector<std::pair<std::string, float>> times = {};
|
|
for (const auto& highscore_info : data.value()) {
|
|
if (highscore_info.contains("playerName") && highscore_info.contains("score")) {
|
|
std::pair<std::string, float> time_info;
|
|
time_info.first = highscore_info.at("playerName");
|
|
time_info.second = highscore_info.at("score");
|
|
times.push_back(time_info);
|
|
}
|
|
}
|
|
external_highscores_cache[cache_id] = times;
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(false);
|
|
}
|
|
|
|
void pc_fetch_external_speedrun_times(u32 speedrun_id_ptr) {
|
|
std::scoped_lock lock{background_task_lock};
|
|
auto speedrun_id = std::string(Ptr<String>(speedrun_id_ptr).c()->data());
|
|
if (external_speedrun_lookup_urls.find(speedrun_id) == external_speedrun_lookup_urls.end()) {
|
|
lg::error("No URL for speedrun_id: '{}'", speedrun_id);
|
|
return;
|
|
}
|
|
|
|
// First check to see if we've already retrieved this info
|
|
if (external_speedrun_time_cache.find(speedrun_id) == external_speedrun_time_cache.end()) {
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(true);
|
|
intern_from_c("*pc-rpc-error?*")->value() = bool_to_symbol(false);
|
|
// otherwise, hit the URL
|
|
WebRequestJobPayload req;
|
|
req.callback = callback_fetch_external_speedrun_times;
|
|
req.url = external_speedrun_lookup_urls.at(speedrun_id);
|
|
req.cache_id = speedrun_id;
|
|
g_background_worker.enqueue_webrequest(req);
|
|
}
|
|
}
|
|
|
|
void pc_fetch_external_race_times(u32 race_id_ptr) {
|
|
std::scoped_lock lock{background_task_lock};
|
|
auto race_id = std::string(Ptr<String>(race_id_ptr).c()->data());
|
|
if (external_race_lookup_urls.find(race_id) == external_race_lookup_urls.end()) {
|
|
lg::error("No URL for race_id: '{}'", race_id);
|
|
return;
|
|
}
|
|
|
|
// First check to see if we've already retrieved this info
|
|
if (external_race_time_cache.find(race_id) == external_race_time_cache.end()) {
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(true);
|
|
intern_from_c("*pc-rpc-error?*")->value() = bool_to_symbol(false);
|
|
// otherwise, hit the URL
|
|
WebRequestJobPayload req;
|
|
req.callback = callback_fetch_external_race_times;
|
|
req.url = external_race_lookup_urls.at(race_id);
|
|
req.cache_id = race_id;
|
|
g_background_worker.enqueue_webrequest(req);
|
|
}
|
|
}
|
|
|
|
void pc_fetch_external_highscores(u32 highscore_id_ptr) {
|
|
std::scoped_lock lock{background_task_lock};
|
|
auto highscore_id = std::string(Ptr<String>(highscore_id_ptr).c()->data());
|
|
if (external_highscores_lookup_urls.find(highscore_id) == external_highscores_lookup_urls.end()) {
|
|
lg::error("No URL for highscore_id: '{}'", highscore_id);
|
|
return;
|
|
}
|
|
|
|
// First check to see if we've already retrieved this info
|
|
if (external_highscores_cache.find(highscore_id) == external_highscores_cache.end()) {
|
|
intern_from_c("*pc-waiting-on-rpc?*")->value() = bool_to_symbol(true);
|
|
intern_from_c("*pc-rpc-error?*")->value() = bool_to_symbol(false);
|
|
// otherwise, hit the URL
|
|
WebRequestJobPayload req;
|
|
req.callback = callback_fetch_external_highscores;
|
|
req.url = external_highscores_lookup_urls.at(highscore_id);
|
|
req.cache_id = highscore_id;
|
|
g_background_worker.enqueue_webrequest(req);
|
|
}
|
|
}
|
|
|
|
void pc_get_external_speedrun_time(u32 speedrun_id_ptr,
|
|
s32 index,
|
|
u32 name_dest_ptr,
|
|
u32 time_dest_ptr) {
|
|
std::scoped_lock lock{background_task_lock};
|
|
auto speedrun_id = std::string(Ptr<String>(speedrun_id_ptr).c()->data());
|
|
if (external_speedrun_time_cache.find(speedrun_id) != external_speedrun_time_cache.end()) {
|
|
const auto& runs = external_speedrun_time_cache.at(speedrun_id);
|
|
if (index < (int)runs.size()) {
|
|
const auto& run_info = external_speedrun_time_cache.at(speedrun_id).at(index);
|
|
std::string converted =
|
|
get_font_bank(GameTextVersion::JAK2)->convert_utf8_to_game(run_info.first);
|
|
strcpy(Ptr<String>(name_dest_ptr).c()->data(), converted.c_str());
|
|
*(Ptr<float>(time_dest_ptr).c()) = run_info.second;
|
|
} else {
|
|
std::string converted = get_font_bank(GameTextVersion::JAK2)->convert_utf8_to_game("");
|
|
strcpy(Ptr<String>(name_dest_ptr).c()->data(), converted.c_str());
|
|
*(Ptr<float>(time_dest_ptr).c()) = -1.0;
|
|
}
|
|
}
|
|
}
|
|
|
|
void pc_get_external_race_time(u32 race_id_ptr, s32 index, u32 name_dest_ptr, u32 time_dest_ptr) {
|
|
std::scoped_lock lock{background_task_lock};
|
|
auto race_id = std::string(Ptr<String>(race_id_ptr).c()->data());
|
|
if (external_race_time_cache.find(race_id) != external_race_time_cache.end()) {
|
|
const auto& runs = external_race_time_cache.at(race_id);
|
|
if (index < (int)runs.size()) {
|
|
const auto& run_info = external_race_time_cache.at(race_id).at(index);
|
|
std::string converted =
|
|
get_font_bank(GameTextVersion::JAK2)->convert_utf8_to_game(run_info.first);
|
|
strcpy(Ptr<String>(name_dest_ptr).c()->data(), converted.c_str());
|
|
*(Ptr<float>(time_dest_ptr).c()) = run_info.second;
|
|
} else {
|
|
std::string converted = get_font_bank(GameTextVersion::JAK2)->convert_utf8_to_game("");
|
|
strcpy(Ptr<String>(name_dest_ptr).c()->data(), converted.c_str());
|
|
*(Ptr<float>(time_dest_ptr).c()) = -1.0;
|
|
}
|
|
}
|
|
}
|
|
|
|
void pc_get_external_highscore(u32 highscore_id_ptr,
|
|
s32 index,
|
|
u32 name_dest_ptr,
|
|
u32 time_dest_ptr) {
|
|
std::scoped_lock lock{background_task_lock};
|
|
auto highscore_id = std::string(Ptr<String>(highscore_id_ptr).c()->data());
|
|
if (external_highscores_cache.find(highscore_id) != external_highscores_cache.end()) {
|
|
const auto& runs = external_highscores_cache.at(highscore_id);
|
|
if (index < (int)runs.size()) {
|
|
const auto& run_info = external_highscores_cache.at(highscore_id).at(index);
|
|
std::string converted =
|
|
get_font_bank(GameTextVersion::JAK2)->convert_utf8_to_game(run_info.first);
|
|
strcpy(Ptr<String>(name_dest_ptr).c()->data(), converted.c_str());
|
|
*(Ptr<float>(time_dest_ptr).c()) = run_info.second;
|
|
} else {
|
|
std::string converted = get_font_bank(GameTextVersion::JAK2)->convert_utf8_to_game("");
|
|
strcpy(Ptr<String>(name_dest_ptr).c()->data(), converted.c_str());
|
|
*(Ptr<float>(time_dest_ptr).c()) = -1.0;
|
|
}
|
|
}
|
|
}
|
|
|
|
s32 pc_get_num_external_speedrun_times(u32 speedrun_id_ptr) {
|
|
std::scoped_lock lock{background_task_lock};
|
|
auto speedrun_id = std::string(Ptr<String>(speedrun_id_ptr).c()->data());
|
|
if (external_speedrun_time_cache.find(speedrun_id) != external_speedrun_time_cache.end()) {
|
|
return external_speedrun_time_cache.at(speedrun_id).size();
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
s32 pc_get_num_external_race_times(u32 race_id_ptr) {
|
|
std::scoped_lock lock{background_task_lock};
|
|
auto race_id = std::string(Ptr<String>(race_id_ptr).c()->data());
|
|
if (external_race_time_cache.find(race_id) != external_race_time_cache.end()) {
|
|
return external_race_time_cache.at(race_id).size();
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
s32 pc_get_num_external_highscores(u32 highscore_id_ptr) {
|
|
std::scoped_lock lock{background_task_lock};
|
|
auto highscore_id = std::string(Ptr<String>(highscore_id_ptr).c()->data());
|
|
if (external_highscores_cache.find(highscore_id) != external_highscores_cache.end()) {
|
|
return external_highscores_cache.at(highscore_id).size();
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
void to_json(json& j, const SpeedrunPracticeEntryHistoryAttempt& obj) {
|
|
if (obj.time) {
|
|
j["time"] = obj.time.value();
|
|
} else {
|
|
j["time"] = nullptr;
|
|
}
|
|
}
|
|
|
|
void from_json(const json& j, SpeedrunPracticeEntryHistoryAttempt& obj) {
|
|
if (j["time"].is_null()) {
|
|
obj.time = {};
|
|
} else {
|
|
obj.time = j["time"];
|
|
}
|
|
}
|
|
|
|
void to_json(json& j, const SpeedrunPracticeEntry& obj) {
|
|
json_serialize(name);
|
|
json_serialize(continue_point_name);
|
|
json_serialize(flags);
|
|
json_serialize(completed_task);
|
|
json_serialize(features);
|
|
json_serialize(secrets);
|
|
json_serialize(starting_position);
|
|
json_serialize(starting_rotation);
|
|
json_serialize(starting_camera_position);
|
|
json_serialize(starting_camera_rotation);
|
|
json_serialize(start_zone_v1);
|
|
json_serialize(start_zone_v2);
|
|
json_serialize_optional(end_zone_v1);
|
|
json_serialize_optional(end_zone_v2);
|
|
json_serialize_optional(end_task);
|
|
json_serialize(history);
|
|
}
|
|
|
|
void from_json(const json& j, SpeedrunPracticeEntry& obj) {
|
|
json_deserialize_if_exists(name);
|
|
json_deserialize_if_exists(continue_point_name);
|
|
json_deserialize_if_exists(flags);
|
|
json_deserialize_if_exists(completed_task);
|
|
json_deserialize_if_exists(features);
|
|
json_deserialize_if_exists(secrets);
|
|
json_deserialize_if_exists(starting_position);
|
|
json_deserialize_if_exists(starting_rotation);
|
|
json_deserialize_if_exists(starting_camera_position);
|
|
json_deserialize_if_exists(starting_camera_rotation);
|
|
json_deserialize_if_exists(start_zone_v1);
|
|
json_deserialize_if_exists(start_zone_v2);
|
|
json_deserialize_optional_if_exists(end_zone_v1);
|
|
json_deserialize_optional_if_exists(end_zone_v2);
|
|
json_deserialize_optional_if_exists(end_task);
|
|
json_deserialize_if_exists(history);
|
|
}
|
|
|
|
void to_json(json& j, const SpeedrunCustomCategoryEntry& obj) {
|
|
json_serialize(name);
|
|
json_serialize(secrets);
|
|
json_serialize(features);
|
|
json_serialize(forbidden_features);
|
|
json_serialize(cheats);
|
|
json_serialize(continue_point_name);
|
|
json_serialize(completed_task);
|
|
}
|
|
|
|
void from_json(const json& j, SpeedrunCustomCategoryEntry& obj) {
|
|
json_deserialize_if_exists(name);
|
|
json_deserialize_if_exists(secrets);
|
|
json_deserialize_if_exists(features);
|
|
json_deserialize_if_exists(forbidden_features);
|
|
json_deserialize_if_exists(cheats);
|
|
json_deserialize_if_exists(continue_point_name);
|
|
json_deserialize_if_exists(completed_task);
|
|
}
|
|
|
|
std::vector<SpeedrunPracticeEntry> g_speedrun_practice_entries;
|
|
std::unordered_map<int, SpeedrunPracticeState> g_speedrun_practice_state;
|
|
|
|
s32 pc_sr_mode_get_practice_entries_amount() {
|
|
// load practice entries from the file
|
|
const auto file_path =
|
|
file_util::get_user_features_dir(g_game_version) / "speedrun-practice.json";
|
|
if (!file_util::file_exists(file_path.string())) {
|
|
lg::info("speedrun-practice.json not found, no entries to return!");
|
|
return 0;
|
|
}
|
|
const auto file_contents = safe_parse_json(file_util::read_text_file(file_path));
|
|
if (!file_contents) {
|
|
lg::error("speedrun-practice.json could not be parsed!");
|
|
return 0;
|
|
}
|
|
|
|
g_speedrun_practice_entries = *file_contents;
|
|
|
|
for (size_t i = 0; i < g_speedrun_practice_entries.size(); i++) {
|
|
const auto& entry = g_speedrun_practice_entries.at(i);
|
|
s32 last_session_id = -1;
|
|
s32 total_attempts = 0;
|
|
s32 total_successes = 0;
|
|
s32 session_attempts = 0;
|
|
s32 session_successes = 0;
|
|
double total_time = 0;
|
|
float average_time = 0;
|
|
float fastest_time = 0;
|
|
for (const auto& [history_session, times] : entry.history) {
|
|
s32 session_id = stoi(history_session);
|
|
if (session_id > last_session_id) {
|
|
last_session_id = session_id;
|
|
}
|
|
for (const auto& time : times) {
|
|
total_attempts++;
|
|
if (time.time) {
|
|
total_successes++;
|
|
total_time += *time.time;
|
|
if (fastest_time == 0 || *time.time < fastest_time) {
|
|
fastest_time = *time.time;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (total_successes != 0) {
|
|
average_time = total_time / total_successes;
|
|
}
|
|
g_speedrun_practice_state[i] = {last_session_id + 1, total_attempts, total_successes,
|
|
session_attempts, session_successes, total_time,
|
|
average_time, fastest_time};
|
|
}
|
|
|
|
return g_speedrun_practice_entries.size();
|
|
}
|
|
|
|
void pc_sr_mode_get_practice_entry_name(s32 entry_index, u32 name_str_ptr) {
|
|
std::string name = "";
|
|
if (entry_index < (int)g_speedrun_practice_entries.size()) {
|
|
name = g_speedrun_practice_entries.at(entry_index).name;
|
|
}
|
|
strcpy(Ptr<String>(name_str_ptr).c()->data(), name.c_str());
|
|
}
|
|
|
|
void pc_sr_mode_get_practice_entry_continue_point(s32 entry_index, u32 name_str_ptr) {
|
|
std::string name = "";
|
|
if (entry_index < (int)g_speedrun_practice_entries.size()) {
|
|
name = g_speedrun_practice_entries.at(entry_index).continue_point_name;
|
|
}
|
|
strcpy(Ptr<String>(name_str_ptr).c()->data(), name.c_str());
|
|
}
|
|
|
|
s32 pc_sr_mode_get_practice_entry_history_success(s32 entry_index) {
|
|
return g_speedrun_practice_state.at(entry_index).total_successes;
|
|
}
|
|
|
|
s32 pc_sr_mode_get_practice_entry_history_attempts(s32 entry_index) {
|
|
return g_speedrun_practice_state.at(entry_index).total_attempts;
|
|
}
|
|
|
|
s32 pc_sr_mode_get_practice_entry_session_success(s32 entry_index) {
|
|
return g_speedrun_practice_state.at(entry_index).session_successes;
|
|
}
|
|
|
|
s32 pc_sr_mode_get_practice_entry_session_attempts(s32 entry_index) {
|
|
return g_speedrun_practice_state.at(entry_index).session_attempts;
|
|
}
|
|
|
|
void pc_sr_mode_get_practice_entry_avg_time(s32 entry_index, u32 time_str_ptr) {
|
|
const auto time = fmt::format("{:.2f}", g_speedrun_practice_state.at(entry_index).average_time);
|
|
strcpy(Ptr<String>(time_str_ptr).c()->data(), time.c_str());
|
|
}
|
|
|
|
void pc_sr_mode_get_practice_entry_fastest_time(s32 entry_index, u32 time_str_ptr) {
|
|
const auto time = fmt::format("{:.2f}", g_speedrun_practice_state.at(entry_index).fastest_time);
|
|
strcpy(Ptr<String>(time_str_ptr).c()->data(), time.c_str());
|
|
}
|
|
|
|
u64 pc_sr_mode_record_practice_entry_attempt(s32 entry_index, u32 success_bool, u32 time_ptr) {
|
|
auto& state = g_speedrun_practice_state.at(entry_index);
|
|
const auto was_successful = symbol_to_bool(success_bool);
|
|
state.total_attempts++;
|
|
state.session_attempts++;
|
|
bool ret = false;
|
|
SpeedrunPracticeEntryHistoryAttempt new_history_entry;
|
|
if (was_successful) {
|
|
auto time = Ptr<float>(time_ptr).c();
|
|
new_history_entry.time = *time;
|
|
state.total_successes++;
|
|
state.session_successes++;
|
|
state.total_time += *time;
|
|
state.average_time = state.total_time / state.total_successes;
|
|
if (*time < state.fastest_time) {
|
|
state.fastest_time = *time;
|
|
ret = true;
|
|
}
|
|
}
|
|
// persist to file
|
|
const auto file_path =
|
|
file_util::get_user_features_dir(g_game_version) / "speedrun-practice.json";
|
|
if (!file_util::file_exists(file_path.string())) {
|
|
lg::info("speedrun-practice.json not found, not persisting!");
|
|
} else {
|
|
auto& history = g_speedrun_practice_entries.at(entry_index).history;
|
|
if (history.find(fmt::format("{}", state.current_session_id)) == history.end()) {
|
|
history[fmt::format("{}", state.current_session_id)] = {};
|
|
}
|
|
history[fmt::format("{}", state.current_session_id)].push_back(new_history_entry);
|
|
json data = g_speedrun_practice_entries;
|
|
file_util::write_text_file(file_path, data.dump(2));
|
|
}
|
|
// return
|
|
return bool_to_symbol(ret);
|
|
}
|
|
|
|
void pc_sr_mode_init_practice_info(s32 entry_index, u32 speedrun_practice_obj_ptr) {
|
|
if (entry_index >= (int)g_speedrun_practice_entries.size()) {
|
|
return;
|
|
}
|
|
|
|
auto objective = speedrun_practice_obj_ptr
|
|
? Ptr<SpeedrunPracticeObjective>(speedrun_practice_obj_ptr).c()
|
|
: NULL;
|
|
if (objective) {
|
|
const auto& json_info = g_speedrun_practice_entries.at(entry_index);
|
|
|
|
objective->index = entry_index;
|
|
objective->flags = json_info.flags;
|
|
objective->completed_task = json_info.completed_task;
|
|
objective->features = json_info.features;
|
|
objective->secrets = json_info.secrets;
|
|
auto starting_position =
|
|
objective->starting_position ? Ptr<Vector>(objective->starting_position).c() : NULL;
|
|
if (starting_position) {
|
|
for (int i = 0; i < 4; i++) {
|
|
starting_position->data[i] = json_info.starting_position.at(i) * 4096.0;
|
|
}
|
|
}
|
|
auto starting_rotation =
|
|
objective->starting_rotation ? Ptr<Vector>(objective->starting_rotation).c() : NULL;
|
|
if (starting_rotation) {
|
|
for (int i = 0; i < 4; i++) {
|
|
starting_rotation->data[i] = json_info.starting_rotation.at(i);
|
|
}
|
|
}
|
|
auto starting_camera_position = objective->starting_camera_position
|
|
? Ptr<Vector>(objective->starting_camera_position).c()
|
|
: NULL;
|
|
if (starting_camera_position) {
|
|
for (int i = 0; i < 4; i++) {
|
|
starting_camera_position->data[i] = json_info.starting_camera_position.at(i) * 4096.0;
|
|
}
|
|
}
|
|
auto starting_camera_rotation = objective->starting_camera_rotation
|
|
? Ptr<Vector>(objective->starting_camera_rotation).c()
|
|
: NULL;
|
|
if (starting_camera_rotation) {
|
|
for (int i = 0; i < 16; i++) {
|
|
starting_camera_rotation->data[i] = json_info.starting_camera_rotation.at(i);
|
|
}
|
|
}
|
|
|
|
if (json_info.end_task) {
|
|
objective->end_task = *json_info.end_task;
|
|
} else {
|
|
objective->end_task = 0;
|
|
}
|
|
|
|
auto starting_zone = objective->start_zone_init_params
|
|
? Ptr<ObjectiveZoneInitParams>(objective->start_zone_init_params).c()
|
|
: NULL;
|
|
if (starting_zone) {
|
|
starting_zone->v1[0] = json_info.start_zone_v1.at(0) * 4096.0;
|
|
starting_zone->v1[1] = json_info.start_zone_v1.at(1) * 4096.0;
|
|
starting_zone->v1[2] = json_info.start_zone_v1.at(2) * 4096.0;
|
|
starting_zone->v1[3] = json_info.start_zone_v1.at(3) * 4096.0;
|
|
starting_zone->v2[0] = json_info.start_zone_v2.at(0) * 4096.0;
|
|
starting_zone->v2[1] = json_info.start_zone_v2.at(1) * 4096.0;
|
|
starting_zone->v2[2] = json_info.start_zone_v2.at(2) * 4096.0;
|
|
starting_zone->v2[3] = json_info.start_zone_v2.at(3) * 4096.0;
|
|
}
|
|
|
|
if (json_info.end_zone_v1 && json_info.end_zone_v2) {
|
|
auto ending_zone = objective->end_zone_init_params
|
|
? Ptr<ObjectiveZoneInitParams>(objective->end_zone_init_params).c()
|
|
: NULL;
|
|
if (ending_zone) {
|
|
ending_zone->v1[0] = json_info.end_zone_v1->at(0) * 4096.0;
|
|
ending_zone->v1[1] = json_info.end_zone_v1->at(1) * 4096.0;
|
|
ending_zone->v1[2] = json_info.end_zone_v1->at(2) * 4096.0;
|
|
ending_zone->v1[3] = json_info.end_zone_v1->at(3) * 4096.0;
|
|
ending_zone->v2[0] = json_info.end_zone_v2->at(0) * 4096.0;
|
|
ending_zone->v2[1] = json_info.end_zone_v2->at(1) * 4096.0;
|
|
ending_zone->v2[2] = json_info.end_zone_v2->at(2) * 4096.0;
|
|
ending_zone->v2[3] = json_info.end_zone_v2->at(3) * 4096.0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
std::vector<SpeedrunCustomCategoryEntry> g_speedrun_custom_categories;
|
|
|
|
s32 pc_sr_mode_get_custom_category_amount() {
|
|
// load practice entries from the file
|
|
const auto file_path =
|
|
file_util::get_user_features_dir(g_game_version) / "speedrun-categories.json";
|
|
if (!file_util::file_exists(file_path.string())) {
|
|
lg::info("speedrun-categories.json not found, no entries to return!");
|
|
return 0;
|
|
}
|
|
const auto file_contents = safe_parse_json(file_util::read_text_file(file_path));
|
|
if (!file_contents) {
|
|
lg::error("speedrun-categories.json could not be parsed!");
|
|
return 0;
|
|
}
|
|
|
|
g_speedrun_custom_categories = *file_contents;
|
|
|
|
return g_speedrun_custom_categories.size();
|
|
}
|
|
|
|
void pc_sr_mode_get_custom_category_name(s32 entry_index, u32 name_str_ptr) {
|
|
std::string name = "";
|
|
if (entry_index < (int)g_speedrun_custom_categories.size()) {
|
|
name = g_speedrun_custom_categories.at(entry_index).name;
|
|
}
|
|
strcpy(Ptr<String>(name_str_ptr).c()->data(), name.c_str());
|
|
}
|
|
|
|
void pc_sr_mode_get_custom_category_continue_point(s32 entry_index, u32 name_str_ptr) {
|
|
std::string name = "";
|
|
if (entry_index < (int)g_speedrun_custom_categories.size()) {
|
|
name = g_speedrun_custom_categories.at(entry_index).continue_point_name;
|
|
}
|
|
strcpy(Ptr<String>(name_str_ptr).c()->data(), name.c_str());
|
|
}
|
|
|
|
void pc_sr_mode_init_custom_category_info(s32 entry_index, u32 speedrun_custom_category_ptr) {
|
|
if (entry_index >= (int)g_speedrun_custom_categories.size()) {
|
|
return;
|
|
}
|
|
|
|
auto category = speedrun_custom_category_ptr
|
|
? Ptr<SpeedrunCustomCategory>(speedrun_custom_category_ptr).c()
|
|
: NULL;
|
|
if (category) {
|
|
const auto& json_info = g_speedrun_custom_categories.at(entry_index);
|
|
category->index = entry_index;
|
|
category->secrets = json_info.secrets;
|
|
category->features = json_info.features;
|
|
category->forbidden_features = json_info.forbidden_features;
|
|
category->cheats = json_info.cheats;
|
|
category->completed_task = json_info.completed_task;
|
|
}
|
|
}
|
|
|
|
void pc_sr_mode_dump_new_custom_category(u32 speedrun_custom_category_ptr) {
|
|
const auto file_path =
|
|
file_util::get_user_features_dir(g_game_version) / "speedrun-categories.json";
|
|
if (file_util::file_exists(file_path.string())) {
|
|
// read current categories from file
|
|
const auto file_contents = safe_parse_json(file_util::read_text_file(file_path));
|
|
if (file_contents) {
|
|
g_speedrun_custom_categories = *file_contents;
|
|
}
|
|
}
|
|
|
|
auto category = speedrun_custom_category_ptr
|
|
? Ptr<SpeedrunCustomCategory>(speedrun_custom_category_ptr).c()
|
|
: NULL;
|
|
if (category) {
|
|
SpeedrunCustomCategoryEntry new_category;
|
|
new_category.name = fmt::format("custom-category-{}", g_speedrun_custom_categories.size());
|
|
new_category.secrets = category->secrets;
|
|
new_category.features = category->features;
|
|
new_category.forbidden_features = category->forbidden_features;
|
|
new_category.cheats = category->cheats;
|
|
new_category.completed_task = category->completed_task;
|
|
new_category.continue_point_name = "";
|
|
g_speedrun_custom_categories.push_back(new_category);
|
|
// convert to json and write file
|
|
json data = g_speedrun_custom_categories;
|
|
file_util::write_text_file(file_path, data.dump(2));
|
|
}
|
|
return;
|
|
}
|
|
|
|
} // namespace kmachine_extras
|