diff --git a/.gitignore b/.gitignore index 53d6568c..2ec30817 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ bld/ [Ll]og/ [Ll]ogs/ [Oo]ut/ +/dist/ # Visual Studio 2015/2017 cache/options directory .vs/ diff --git a/UnleashedRecomp/CMakeLists.txt b/UnleashedRecomp/CMakeLists.txt index 01e040ab..486e1508 100644 --- a/UnleashedRecomp/CMakeLists.txt +++ b/UnleashedRecomp/CMakeLists.txt @@ -229,6 +229,7 @@ set(UNLEASHED_RECOMP_CXX_SOURCES "sdl_listener.cpp" "stdafx.cpp" "version.cpp" + "discord/discord_presence.cpp" ${UNLEASHED_RECOMP_KERNEL_CXX_SOURCES} ${UNLEASHED_RECOMP_LOCALE_CXX_SOURCES} diff --git a/UnleashedRecomp/app.cpp b/UnleashedRecomp/app.cpp index b298ebc3..0e0fdaea 100644 --- a/UnleashedRecomp/app.cpp +++ b/UnleashedRecomp/app.cpp @@ -1,5 +1,6 @@ #include "app.h" #include +#include #include #include #include @@ -20,6 +21,7 @@ void App::Restart(std::vector restartArgs) void App::Exit() { Config::Save(); + DiscordPresence::Shutdown(); #ifdef _WIN32 timeEndPeriod(1); @@ -75,6 +77,7 @@ PPC_FUNC(sub_822C1130) AudioPatches::Update(App::s_deltaTime); InspirePatches::Update(); + DiscordPresence::Update(); // Apply subtitles option. if (auto pApplicationDocument = SWA::CApplicationDocument::GetInstance()) diff --git a/UnleashedRecomp/discord/discord_presence.cpp b/UnleashedRecomp/discord/discord_presence.cpp new file mode 100644 index 00000000..ce77cc07 --- /dev/null +++ b/UnleashedRecomp/discord/discord_presence.cpp @@ -0,0 +1,529 @@ +#include "discord_presence.h" +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#include +#include +#endif + +#define DISCORD_APP_ID "1547039038194716693" + +using json = nlohmann::json; + +namespace { +enum : uint32_t { OP_HANDSHAKE = 0, OP_FRAME = 1, OP_CLOSE = 2 }; + +struct Activity { + std::string details; + std::string state; + std::string largeImage, largeText; // art-asset key (or https URL) + tooltip + std::string smallImage, smallText; + bool operator==(const Activity &) const = default; +}; + +std::mutex g_mutex; +Activity g_pending; // guarded by g_mutex +Activity g_lastBuilt; // game thread only +std::atomic g_running{false}; +std::atomic g_nonce{0}; +std::thread g_worker; +int64_t g_startTime = 0; + +#ifdef _WIN32 +using socket_t = HANDLE; +#define INVALID_SOCK INVALID_HANDLE_VALUE +#else +using socket_t = int; +#define INVALID_SOCK (-1) +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif +#endif + +// Discord IPC transport: a named pipe on Windows, a unix socket elsewhere. + +#ifdef _WIN32 +socket_t IpcConnect() { + for (int i = 0; i < 10; i++) { + char path[64]; + snprintf(path, sizeof(path), "\\\\?\\pipe\\discord-ipc-%d", i); + + HANDLE h = CreateFileA(path, GENERIC_READ | GENERIC_WRITE, 0, nullptr, + OPEN_EXISTING, 0, nullptr); + if (h != INVALID_HANDLE_VALUE) + return h; + } + return INVALID_SOCK; +} + +void IpcClose(socket_t s) { + if (s != INVALID_SOCK) + CloseHandle(s); +} + +bool IpcWrite(socket_t s, const void *data, size_t len) { + auto *p = static_cast(data); + while (len) { + DWORD wrote = 0; + if (!WriteFile(s, p, static_cast(len), &wrote, nullptr) || + wrote == 0) + return false; + p += wrote; + len -= wrote; + } + return true; +} + +// >0 bytes read, 0 if nothing pending, <0 if the pipe died. +int IpcRead(socket_t s, void *buf, size_t len) { + DWORD avail = 0; + if (!PeekNamedPipe(s, nullptr, 0, nullptr, &avail, nullptr)) + return -1; + if (avail == 0) + return 0; + + DWORD got = 0; + if (!ReadFile(s, buf, static_cast(std::min(len, avail)), &got, + nullptr)) + return -1; + return static_cast(got); +} +#else +socket_t IpcConnect() { + const char *bases[] = {getenv("XDG_RUNTIME_DIR"), getenv("TMPDIR"), + getenv("TMP"), getenv("TEMP"), "/tmp"}; + // "" = plain path; the others cover Flatpak/Snap Discord. + const char *subs[] = {"", "app/com.discordapp.Discord/", "snap.discord/"}; + + for (const char *base : bases) { + if (!base || !*base) + continue; + + for (const char *sub : subs) { + for (int i = 0; i < 10; i++) { + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + int n = snprintf(addr.sun_path, sizeof(addr.sun_path), + "%s/%sdiscord-ipc-%d", base, sub, i); + if (n < 0 || n >= static_cast(sizeof(addr.sun_path))) + continue; + + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) + return INVALID_SOCK; + +#ifdef __APPLE__ + int on = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); +#endif + if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) == + 0) { + fcntl(fd, F_SETFL, O_NONBLOCK); + return fd; + } + close(fd); + } + } + } + return INVALID_SOCK; +} + +void IpcClose(socket_t s) { + if (s != INVALID_SOCK) + close(s); +} + +bool IpcWrite(socket_t s, const void *data, size_t len) { + auto *p = static_cast(data); + while (len) { + ssize_t w = send(s, p, len, MSG_NOSIGNAL); + if (w > 0) { + p += w; + len -= static_cast(w); + continue; + } + if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)) { + pollfd pfd{s, POLLOUT, 0}; + poll(&pfd, 1, 200); + continue; + } + return false; + } + return true; +} + +int IpcRead(socket_t s, void *buf, size_t len) { + ssize_t r = recv(s, buf, len, 0); + if (r > 0) + return static_cast(r); + if (r == 0) + return -1; // peer closed + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) + return 0; + return -1; +} +#endif + +bool SendFrame(socket_t s, uint32_t op, const std::string &payload) { + uint32_t header[2] = {op, static_cast(payload.size())}; + return IpcWrite(s, header, sizeof(header)) && + (payload.empty() || IpcWrite(s, payload.data(), payload.size())); +} + +bool Handshake(socket_t s) { + return SendFrame(s, OP_HANDSHAKE, + json({{"v", 1}, {"client_id", DISCORD_APP_ID}}).dump()); +} + +bool SendActivity(socket_t s, const Activity &a) { + json activity = json::object(); + if (!a.details.empty()) + activity["details"] = a.details; + if (!a.state.empty()) + activity["state"] = a.state; + activity["timestamps"] = {{"start", g_startTime}}; + + json assets = json::object(); + if (!a.largeImage.empty()) { + assets["large_image"] = a.largeImage; + if (!a.largeText.empty()) + assets["large_text"] = a.largeText; + } + if (!a.smallImage.empty()) { + assets["small_image"] = a.smallImage; + if (!a.smallText.empty()) + assets["small_text"] = a.smallText; + } + if (!assets.empty()) + activity["assets"] = std::move(assets); + +#ifdef _WIN32 + int pid = static_cast(GetCurrentProcessId()); +#else + int pid = static_cast(getpid()); +#endif + json frame = { + {"cmd", "SET_ACTIVITY"}, + {"nonce", std::to_string(g_nonce.fetch_add(1))}, + {"args", {{"pid", pid}, {"activity", activity}}}, + }; + return SendFrame(s, OP_FRAME, frame.dump()); +} + +// Player-facing strings go through Localise(); the "RichPresence_*" keys live +// in locale/locale.cpp. Only stage-id -> key resolution happens here. + +// Fallback for unrecognised stage ids (e.g. custom stages from mods): +// "ActD_SomeMod" -> "Some Mod". +std::string PrettifyStageId(const char *id) { + std::string s = id ? id : ""; + for (const char *prefix : + {"ActD_", "ActN_", "Act_", "CmnTown_", "Town_", "Boss"}) { + if (s.rfind(prefix, 0) == 0) { + s = s.substr(std::string_view(prefix).size()); + break; + } + } + + std::string out; + for (size_t i = 0; i < s.size(); i++) { + char c = s[i]; + if (c == '_') { + out += ' '; + continue; + } + if (i && std::isupper(static_cast(c)) && + !std::isupper(static_cast(s[i - 1]))) { + out += ' '; + } + out += c; + } + return out.empty() ? Localise("RichPresence_Status_InGame") : out; +} + +// Unleashed uses codenamed stage ids: Apotos = "Mykonos", Chun-nan = "China", +// Spagonia = "EU"/"EuropeanCity", Mazuri = "Africa", Holoska = "Snow", Empire +// City = "NY", Adabat = "Beach"/"SouthEastAsia", Shamar = "Petra". Day acts are +// "ActD_*", Werehog night acts "ActN_*Evil". + +struct Region { + std::string_view token; // codename substring found in the stage id + const char *nameKey; // locale key for the region name + const char *image; // Discord art-asset key (upload in the Dev Portal) +}; + +// Match a stage id to its region. +const Region *FindRegion(std::string_view id) { + static const Region kRegions[] = { + {"Mykonos", "RichPresence_Region_Apotos", "apotos"}, + {"China", "RichPresence_Region_Chunnan", "chunnan"}, + {"EULabo", "RichPresence_Region_Spagonia", "spagonia"}, + {"EuropeanCity", "RichPresence_Region_Spagonia", "spagonia"}, + {"EU", "RichPresence_Region_Spagonia", "spagonia"}, + {"Africa", "RichPresence_Region_Mazuri", "mazuri"}, + {"Snow", "RichPresence_Region_Holoska", "holoska"}, + {"NYCity", "RichPresence_Region_EmpireCity", "empirecity"}, + {"NY", "RichPresence_Region_EmpireCity", "empirecity"}, + {"SouthEastAsia", "RichPresence_Region_Adabat", "adabat"}, + {"Beach", "RichPresence_Region_Adabat", "adabat"}, + {"PetraCapital", "RichPresence_Region_Shamar", "shamar"}, + {"PetraLabo", "RichPresence_Region_Shamar", "shamar"}, + {"Petra", "RichPresence_Region_Shamar", "shamar"}, + {"EggmanLand", "RichPresence_Region_Eggmanland", "eggmanland"}, + }; + for (const auto &r : kRegions) + if (id.find(r.token) != std::string_view::npos) + return &r; + return nullptr; +} + +// Stage id -> Discord art-asset key +std::string StageImageKey(const char *id) { + if (!id || !*id) + return ""; + std::string_view s = id; + const Region *r = FindRegion(s); + if (!r) + return ""; + bool night = s.rfind("ActN_", 0) == 0 || + s.find("Evil") != std::string_view::npos || + s.find("_Night") != std::string_view::npos; + return night ? std::string(r->image) + "_night" : r->image; +} + +struct StoryStage { + const char *key; // locale key for the day-time stage name + bool night; // append " (Night)" for the Werehog version +}; + +// Internal stage id -> localised display name. +std::string StageDisplayName(const char *id) { + if (!id || !*id) + return Localise("RichPresence_Status_InGame"); + + std::string_view s = id; + + static const std::unordered_map kStages = { + {"ActD_MykonosAct1", {"RichPresence_Stage_Apotos_Act1", false}}, + {"ActD_MykonosAct2", {"RichPresence_Stage_Apotos_Act2", false}}, + {"ActD_China", {"RichPresence_Stage_Chunnan", false}}, + {"ActD_EU", {"RichPresence_Stage_Spagonia", false}}, + {"ActD_Africa", {"RichPresence_Stage_Mazuri", false}}, + {"ActD_Snow", {"RichPresence_Stage_Holoska", false}}, + {"ActD_NY", {"RichPresence_Stage_EmpireCity", false}}, + {"ActD_Beach", {"RichPresence_Stage_Adabat", false}}, + {"ActD_Petra", {"RichPresence_Stage_Shamar", false}}, + {"ActN_MykonosEvil", {"RichPresence_Stage_Apotos", true}}, + {"ActN_ChinaEvil", {"RichPresence_Stage_Chunnan", true}}, + {"ActN_EUEvil", {"RichPresence_Stage_Spagonia", true}}, + {"ActN_AfricaEvil", {"RichPresence_Stage_Mazuri", true}}, + {"ActN_SnowEvil", {"RichPresence_Stage_Holoska", true}}, + {"ActN_NYEvil", {"RichPresence_Stage_EmpireCity", true}}, + {"ActN_BeachEvil", {"RichPresence_Stage_Adabat", true}}, + {"ActN_PetraEvil", {"RichPresence_Stage_Shamar", true}}, + }; + if (auto it = kStages.find(s); it != kStages.end()) { + std::string out = Localise(it->second.key); + if (it->second.night) + out += " " + Localise("RichPresence_Suffix_Night"); + return out; + } + + static const std::unordered_map kNamed = { + {"Act_EggmanLand", "RichPresence_Stage_Eggmanland"}, + {"BossEggBeetle", "RichPresence_Boss_EggBeetle"}, + {"BossEggLancer", "RichPresence_Boss_EggLancer"}, + {"BossEggRayBird", "RichPresence_Boss_EggDevilRay"}, + {"Title", "RichPresence_Status_Menus"}, + {"StaffRoll", "RichPresence_Credits"}, + }; + if (auto it = kNamed.find(s); it != kNamed.end()) + return Localise(it->second); + + if (s.rfind("Event_", 0) == 0 || s == "Inspire") + return Localise("RichPresence_Status_Cutscene"); + + // Derived name for hubs, extra missions and DLC/ETF variants. + if (const Region *region = FindRegion(s)) { + bool night = s.rfind("ActN_", 0) == 0 || + s.find("Evil") != std::string_view::npos || + s.find("_Night") != std::string_view::npos; + std::string out = Localise(region->nameKey); + if (s.rfind("Town_", 0) == 0 || s.rfind("CmnTown_", 0) == 0) + out += " - " + Localise(night ? "RichPresence_Suffix_HubNight" + : "RichPresence_Suffix_Hub"); + else if (s.find("Sub") != std::string_view::npos) + out += " - " + Localise(night ? "RichPresence_Suffix_NightMission" + : "RichPresence_Suffix_ExtraMission"); + else if (night) + out += " " + Localise("RichPresence_Suffix_Night"); + else if (s.rfind("ActD_", 0) == 0) + out += " " + Localise("RichPresence_Suffix_Day"); + return out; + } + + LOGFN_WARNING("Discord: unmapped stage id \"{}\"", id); + return PrettifyStageId(id); +} + +Activity BuildActivity() { + Activity a; + + if (!App::s_isInit) { + a.details = Localise("RichPresence_Status_Startup"); + return a; + } + + const char *stage = nullptr; + if (auto *doc = SWA::CGameDocument::GetInstance(); doc && doc->m_pMember) { + const char *s = doc->m_pMember->m_StageName.c_str(); + if (s && *s) + stage = s; + } + + if (stage) { + a.details = StageDisplayName(stage); + a.state = Localise(App::s_isWerehog ? "RichPresence_State_Werehog" + : "RichPresence_State_Sonic"); + a.largeImage = StageImageKey(stage); + a.largeText = a.details; + a.smallImage = App::s_isWerehog ? "werehog" : "sonic"; + a.smallText = a.state; + } else { + a.details = Localise("RichPresence_Status_WorldMap"); + // Set to an uploaded key (e.g. "worldmap") if you want art here; empty + // falls back to the application icon. + a.largeImage = ""; + a.largeText = a.details; + } + + return a; +} + +// Owns the socket: connects, reconnects with backoff, and pushes the latest +// activity to Discord (rate-limited). +void WorkerMain() { + socket_t sock = INVALID_SOCK; + Activity sent; + bool haveSent = false; + int backoffMs = 1000; + auto lastSend = std::chrono::steady_clock::now() - std::chrono::hours(1); + + while (g_running.load(std::memory_order_relaxed)) { + if (!Config::DiscordRichPresence) { + if (sock != INVALID_SOCK) { + IpcClose(sock); + sock = INVALID_SOCK; + haveSent = false; + } + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + continue; + } + + if (sock == INVALID_SOCK) { + sock = IpcConnect(); + if (sock == INVALID_SOCK || !Handshake(sock)) { + IpcClose(sock); + sock = INVALID_SOCK; + std::this_thread::sleep_for(std::chrono::milliseconds(backoffMs)); + backoffMs = std::min(backoffMs * 2, 15000); + continue; + } + LOGN("Discord: connected"); + backoffMs = 1000; + haveSent = false; + } + + char scratch[2048]; + if (IpcRead(sock, scratch, sizeof(scratch)) < 0) { + IpcClose(sock); + sock = INVALID_SOCK; + haveSent = false; + continue; + } + + Activity want; + { + std::lock_guard lk(g_mutex); + want = g_pending; + } + + auto now = std::chrono::steady_clock::now(); + bool changed = !haveSent || !(want == sent); + if (changed && now - lastSend >= std::chrono::seconds(4)) { + if (!SendActivity(sock, want)) { + IpcClose(sock); + sock = INVALID_SOCK; + haveSent = false; + continue; + } + sent = want; + haveSent = true; + lastSend = now; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + + if (sock != INVALID_SOCK) { + SendFrame(sock, OP_CLOSE, "{}"); + IpcClose(sock); + } +} +} // namespace + +namespace DiscordPresence { +void Init() { + if (std::string_view(DISCORD_APP_ID) == "0000000000000000") { + LOGN_WARNING("Discord: no application id set, rich presence disabled"); + return; + } + if (g_running.exchange(true)) + return; + + g_startTime = static_cast(std::time(nullptr)); + g_worker = std::thread(WorkerMain); +} + +void Update() { + if (!g_running.load(std::memory_order_relaxed)) + return; + + // Rebuild at most once a second, off the game clock. + static double nextBuild = 0.0; + if (App::s_time < nextBuild) + return; + nextBuild = App::s_time + 1.0; + + Activity a = BuildActivity(); + if (a == g_lastBuilt) + return; + g_lastBuilt = a; + + std::lock_guard lk(g_mutex); + g_pending = std::move(a); +} + +void Shutdown() { + if (!g_running.exchange(false)) + return; + if (g_worker.joinable()) + g_worker.join(); +} +} // namespace DiscordPresence diff --git a/UnleashedRecomp/discord/discord_presence.h b/UnleashedRecomp/discord/discord_presence.h new file mode 100644 index 00000000..68e4484d --- /dev/null +++ b/UnleashedRecomp/discord/discord_presence.h @@ -0,0 +1,8 @@ +#pragma once + +// Discord Rich Presence over the local discord-ipc socket +namespace DiscordPresence { +void Init(); // start the worker thread (call once, after Config::Load) +void Update(); // cheap + self-throttled; call every frame +void Shutdown(); // stop the worker (call on exit) +} // namespace DiscordPresence diff --git a/UnleashedRecomp/locale/locale.cpp b/UnleashedRecomp/locale/locale.cpp index 7544f31c..f00fc996 100644 --- a/UnleashedRecomp/locale/locale.cpp +++ b/UnleashedRecomp/locale/locale.cpp @@ -856,7 +856,56 @@ std::unordered_map> { ELanguage::Spanish, "Cambiar" }, { ELanguage::Italian, "Cambia" } } - } + }, + + /* + Discord Rich Presence strings. These are English-only for now; other + languages fall back to English automatically via Localise(). Translators: + add the remaining ELanguage entries (using the official localised zone + and stage names where they exist). + */ + { "RichPresence_Status_Startup", { { ELanguage::English, "Starting up" } } }, + { "RichPresence_Status_Menus", { { ELanguage::English, "In the menus" } } }, + { "RichPresence_Status_WorldMap", { { ELanguage::English, "On the World Map" } } }, + { "RichPresence_Status_Cutscene", { { ELanguage::English, "Watching a cutscene" } } }, + { "RichPresence_Status_InGame", { { ELanguage::English, "In game" } } }, + { "RichPresence_Credits", { { ELanguage::English, "Credits" } } }, + + { "RichPresence_State_Sonic", { { ELanguage::English, "Playing as Sonic" } } }, + { "RichPresence_State_Werehog", { { ELanguage::English, "Playing as the Werehog" } } }, + + { "RichPresence_Suffix_Day", { { ELanguage::English, "(Day)" } } }, + { "RichPresence_Suffix_Night", { { ELanguage::English, "(Night)" } } }, + { "RichPresence_Suffix_Hub", { { ELanguage::English, "Hub" } } }, + { "RichPresence_Suffix_HubNight", { { ELanguage::English, "Hub (Night)" } } }, + { "RichPresence_Suffix_ExtraMission", { { ELanguage::English, "Extra mission" } } }, + { "RichPresence_Suffix_NightMission", { { ELanguage::English, "Night mission" } } }, + + { "RichPresence_Region_Apotos", { { ELanguage::English, "Apotos" } } }, + { "RichPresence_Region_Chunnan", { { ELanguage::English, "Chun-nan" } } }, + { "RichPresence_Region_Spagonia", { { ELanguage::English, "Spagonia" } } }, + { "RichPresence_Region_Mazuri", { { ELanguage::English, "Mazuri" } } }, + { "RichPresence_Region_Holoska", { { ELanguage::English, "Holoska" } } }, + { "RichPresence_Region_EmpireCity", { { ELanguage::English, "Empire City" } } }, + { "RichPresence_Region_Adabat", { { ELanguage::English, "Adabat" } } }, + { "RichPresence_Region_Shamar", { { ELanguage::English, "Shamar" } } }, + { "RichPresence_Region_Eggmanland", { { ELanguage::English, "Eggmanland" } } }, + + { "RichPresence_Stage_Apotos", { { ELanguage::English, "Apotos - Windmill Isle" } } }, + { "RichPresence_Stage_Apotos_Act1", { { ELanguage::English, "Apotos - Windmill Isle Act 1" } } }, + { "RichPresence_Stage_Apotos_Act2", { { ELanguage::English, "Apotos - Windmill Isle Act 2" } } }, + { "RichPresence_Stage_Chunnan", { { ELanguage::English, "Chun-nan - Dragon Road" } } }, + { "RichPresence_Stage_Spagonia", { { ELanguage::English, "Spagonia - Rooftop Run" } } }, + { "RichPresence_Stage_Mazuri", { { ELanguage::English, "Mazuri - Savannah Citadel" } } }, + { "RichPresence_Stage_Holoska", { { ELanguage::English, "Holoska - Cool Edge" } } }, + { "RichPresence_Stage_EmpireCity", { { ELanguage::English, "Empire City - Skyscraper Scamper" } } }, + { "RichPresence_Stage_Adabat", { { ELanguage::English, "Adabat - Jungle Joyride" } } }, + { "RichPresence_Stage_Shamar", { { ELanguage::English, "Shamar - Arid Sands" } } }, + { "RichPresence_Stage_Eggmanland", { { ELanguage::English, "Eggmanland" } } }, + + { "RichPresence_Boss_EggBeetle", { { ELanguage::English, "Boss: Egg Beetle" } } }, + { "RichPresence_Boss_EggLancer", { { ELanguage::English, "Boss: Egg Lancer" } } }, + { "RichPresence_Boss_EggDevilRay", { { ELanguage::English, "Boss: Egg Devil Ray" } } } }; std::string& Localise(const std::string_view& key) diff --git a/UnleashedRecomp/main.cpp b/UnleashedRecomp/main.cpp index bb241760..10b0993e 100644 --- a/UnleashedRecomp/main.cpp +++ b/UnleashedRecomp/main.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #ifdef _WIN32 #include @@ -319,6 +320,8 @@ int main(int argc, char *argv[]) Config::Save(); } + DiscordPresence::Init(); + if (Config::ShowConsole) os::process::ShowConsole(); diff --git a/UnleashedRecomp/user/config_def.h b/UnleashedRecomp/user/config_def.h index 80eb29c9..e168badf 100644 --- a/UnleashedRecomp/user/config_def.h +++ b/UnleashedRecomp/user/config_def.h @@ -9,6 +9,7 @@ CONFIG_DEFINE_LOCALISED("System", bool, ControlTutorial, true); CONFIG_DEFINE_LOCALISED("System", bool, AchievementNotifications, true); CONFIG_DEFINE_ENUM_LOCALISED("System", ETimeOfDayTransition, TimeOfDayTransition, ETimeOfDayTransition::Xbox); CONFIG_DEFINE("System", bool, ShowConsole, false); +CONFIG_DEFINE("System", bool, DiscordRichPresence, true); CONFIG_DEFINE_ENUM_LOCALISED("Input", ECameraRotationMode, HorizontalCamera, ECameraRotationMode::Normal); CONFIG_DEFINE_ENUM_LOCALISED("Input", ECameraRotationMode, VerticalCamera, ECameraRotationMode::Normal); diff --git a/build-appimage.sh b/build-appimage.sh new file mode 100755 index 00000000..f8af1627 --- /dev/null +++ b/build-appimage.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# +# Package the local build into an AppImage (linuxdeploy + gtk plugin + appimagetool). +# +# Usage: ./build-appimage.sh [preset] (preset passed to build.sh) +# +# Env: OUTDIR= UPDATE_INFORMATION= +# +# The AppImage reads game data (game/ update/ dlc/) from, in order: +# $UR_DATA_DIR | $PWD if it has game//portable.txt | ~/.local/share/UnleashedRecomp +# Config and saves stay in ~/.config/UnleashedRecomp. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$REPO_ROOT" + +PRESET="${1:-linux-release}" +ARCH="$(uname -m)" +APPID="io.github.hedge_dev.unleashedrecomp" +BUILD_DIR="out/build/${PRESET}" +BIN="${BUILD_DIR}/UnleashedRecomp/UnleashedRecomp" +APPDIR="out/AppDir" +TOOLS="out/appimage-tools" +OUTDIR="${OUTDIR:-dist}" + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } + +for cmd in curl git sed install; do + command -v "$cmd" >/dev/null || { echo "missing required tool: $cmd" >&2; exit 1; } +done + +# Always build so the AppImage tracks the current source (build.sh is a fast +# no-op when nothing changed). +log "Building UnleashedRecomp ($PRESET)" +./build.sh "$PRESET" + +# Fetch tooling (cached in $TOOLS). +mkdir -p "$TOOLS" +fetch() { # url dest + if [[ ! -f "$2" ]]; then + log "Downloading $(basename "$2")" + curl -fL --retry 5 --retry-connrefused -o "$2" "$1" + chmod +x "$2" + fi +} +fetch "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage" "$TOOLS/linuxdeploy" +fetch "https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh" "$TOOLS/linuxdeploy-plugin-gtk.sh" +fetch "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage" "$TOOLS/appimagetool" + +export PATH="$REPO_ROOT/$TOOLS:$PATH" +export APPIMAGE_EXTRACT_AND_RUN=1 # run the tool AppImages without FUSE + +log "Assembling AppDir" +rm -rf "$APPDIR" +install -Dm755 "$BIN" "$APPDIR/usr/bin/UnleashedRecomp" +install -Dm644 "UnleashedRecompResources/images/game_icon.png" \ + "$APPDIR/usr/share/icons/hicolor/128x128/apps/${APPID}.png" +[[ -f "flatpak/${APPID}.metainfo.xml" ]] && install -Dm644 "flatpak/${APPID}.metainfo.xml" \ + "$APPDIR/usr/share/metainfo/${APPID}.metainfo.xml" + +# Upstream .desktop, minus the Flatpak-absolute Exec path. +mkdir -p "$APPDIR/usr/share/applications" +sed 's|^Exec=.*|Exec=UnleashedRecomp|' "flatpak/${APPID}.desktop" \ + > "$APPDIR/usr/share/applications/${APPID}.desktop" + +# Custom AppRun: run from a writable data dir with --use-cwd so the game +# doesn't chdir into the read-only AppImage mount. +APPRUN="out/AppRun" +cat > "$APPRUN" <<'EOF' +#!/bin/bash +set -e +APPDIR="$(dirname "$(readlink -f "$0")")" +export APPDIR + +# GTK env from the gtk plugin's hooks (skip if the outer AppRun already ran them). +if [ -z "${GDK_PIXBUF_MODULE_FILE:-}" ]; then + for hook in "$APPDIR"/apprun-hooks/*.sh; do + [ -e "$hook" ] && . "$hook" + done +fi + +export LD_LIBRARY_PATH="$APPDIR/usr/lib:${LD_LIBRARY_PATH:-}" +export PATH="$APPDIR/usr/bin:$PATH" + +if [ -n "${UR_DATA_DIR:-}" ]; then + data_dir="$UR_DATA_DIR" +elif [ -d "$PWD/game" ] || [ -e "$PWD/portable.txt" ]; then + data_dir="$PWD" +else + data_dir="${XDG_DATA_HOME:-$HOME/.local/share}/UnleashedRecomp" +fi +mkdir -p "$data_dir" +cd "$data_dir" + +exec "$APPDIR/usr/bin/UnleashedRecomp" --use-cwd "$@" +EOF +chmod +x "$APPRUN" + +VERSION="$(git describe --tags --always 2>/dev/null || echo dev)" +# Flag uncommitted changes to tracked source so stale packages are obvious +# (ignores the always-dirty SDL submodule). +[[ -n "$(git status --porcelain -- UnleashedRecomp flatpak 2>/dev/null)" ]] && VERSION="${VERSION}-wip" +export LINUXDEPLOY_OUTPUT_VERSION="$VERSION" +export DEPLOY_GTK_VERSION=3 +[[ -n "${UPDATE_INFORMATION:-}" ]] && export LDAI_UPDATE_INFORMATION="$UPDATE_INFORMATION" + +log "Running linuxdeploy (version $VERSION)" +"$TOOLS/linuxdeploy" \ + --appdir "$APPDIR" \ + --executable "$APPDIR/usr/bin/UnleashedRecomp" \ + --desktop-file "$APPDIR/usr/share/applications/${APPID}.desktop" \ + --icon-file "$APPDIR/usr/share/icons/hicolor/128x128/apps/${APPID}.png" \ + --library /usr/lib/libasound.so.2 \ + --plugin gtk \ + --custom-apprun "$APPRUN" \ + --output appimage + +mkdir -p "$OUTDIR" +mv -v ./UnleashedRecomp*-"${ARCH}".AppImage* "$OUTDIR"/ 2>/dev/null || mv -v ./*-"${ARCH}".AppImage* "$OUTDIR"/ +log "Done: $(ls -1 "$OUTDIR"/*.AppImage)" diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..0388dc79 --- /dev/null +++ b/build.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# Build UnleashedRecomp on Linux. +# +# Usage: ./build.sh [preset] [target] +# preset linux-release (default) | linux-relwithdebinfo | linux-debug +# target CMake target (default: UnleashedRecomp) +# +# Env: SKIP_SUBMODULES=1 SKIP_CONFIGURE=1 JOBS= + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$REPO_ROOT" + +PRESET="${1:-linux-release}" +TARGET="${2:-UnleashedRecomp}" +BUILD_DIR="out/build/${PRESET}" +JOBS="${JOBS:-$(nproc)}" + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } + +# Submodules: the presets' CMAKE_TOOLCHAIN_FILE lives in thirdparty/vcpkg. +if [[ "${SKIP_SUBMODULES:-0}" != "1" ]]; then + log "Updating git submodules" + git submodule update --init --recursive +fi + +# Vendored SDL2 passes a pw_proxy* where PipeWire wants a pw_node*; modern +# Clang makes that a hard error. Same handle at runtime, so just cast. +SDL_PIPEWIRE="thirdparty/SDL/src/audio/pipewire/SDL_pipewire.c" +if [[ -f "$SDL_PIPEWIRE" ]] && grep -q 'pw_node_enum_params(node->proxy' "$SDL_PIPEWIRE"; then + log "Patching $SDL_PIPEWIRE" + sed -i 's/pw_node_enum_params(node->proxy,/pw_node_enum_params((struct pw_node *)node->proxy,/' "$SDL_PIPEWIRE" +fi + +if [[ "${SKIP_CONFIGURE:-0}" != "1" || ! -f "${BUILD_DIR}/CMakeCache.txt" ]]; then + log "Configuring preset: $PRESET" + cmake . --preset "$PRESET" +fi + +log "Building '$TARGET' ($JOBS jobs)" +cmake --build "$BUILD_DIR" --target "$TARGET" -j "$JOBS" + +BIN="${BUILD_DIR}/UnleashedRecomp/UnleashedRecomp" +if [[ -x "$BIN" ]]; then + log "Done: $REPO_ROOT/$BIN" +else + log "Done. Output under $REPO_ROOT/$BUILD_DIR" +fi diff --git a/flatpak/io.github.hedge_dev.unleashedrecomp.json b/flatpak/io.github.hedge_dev.unleashedrecomp.json index 5821bad2..76b97f31 100644 --- a/flatpak/io.github.hedge_dev.unleashedrecomp.json +++ b/flatpak/io.github.hedge_dev.unleashedrecomp.json @@ -11,6 +11,8 @@ "--socket=pulseaudio", "--device=all", "--filesystem=host", + "--filesystem=xdg-run/discord-ipc-0", + "--filesystem=xdg-run/app/com.discordapp.Discord:ro", "--filesystem=/media", "--filesystem=/run/media", "--filesystem=/mnt" diff --git a/thirdparty/SDL b/thirdparty/SDL index 1edaad17..aa5bffa6 160000 --- a/thirdparty/SDL +++ b/thirdparty/SDL @@ -1 +1 @@ -Subproject commit 1edaad17218d67b567c149badce9ef0fc67f65fa +Subproject commit aa5bffa6d33da9a575f172c8af682075f4cccfe6