diff --git a/UnleashedRecomp/app.h b/UnleashedRecomp/app.h index 9714d108..e774312a 100644 --- a/UnleashedRecomp/app.h +++ b/UnleashedRecomp/app.h @@ -17,6 +17,10 @@ class App static inline double s_deltaTime; static inline double s_time = 0.0; // How much time elapsed since the game started. + // Steady-clock milliseconds of the most recent QTE prompt window update, stamped + // by QTEPromptActiveMidAsmHook. + static inline std::atomic s_lastQTEPromptMs; + static void Restart(std::vector restartArgs = {}); static void Exit(); }; diff --git a/UnleashedRecomp/hid/driver/sdl_hid.cpp b/UnleashedRecomp/hid/driver/sdl_hid.cpp index 76235524..796b7e74 100644 --- a/UnleashedRecomp/hid/driver/sdl_hid.cpp +++ b/UnleashedRecomp/hid/driver/sdl_hid.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #define TRANSLATE_INPUT(S, X) SDL_GameControllerGetButton(controller, S) << FirstBitLow(X) @@ -12,6 +13,94 @@ class Controller { +private: + bool IsBoostOnRightTriggerActive() + { + const bool userConfigIsBoost = Config::RightTriggerAction == ERightTriggerAction::Boost; + + if (!userConfigIsBoost || App::s_isWerehog) + return false; + + // During a QTE prompt, keep the remap (and the boost aura) alive only while the + // right trigger is held continuously from before the prompt. If it isn't held, + // or gets released while the prompt is up, suspend the remap so the fabricated + // X can't answer the QTE and the real face button can. + bool qteOnScreen = IsQTEPromptOnScreen(); + bool rtHeld = uint8_t(SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERRIGHT) >> 7) >= 30; + + if (!qteOnScreen) + qteRemapReleased = false; + else if (!rtHeld) + qteRemapReleased = true; + + if (qteOnScreen && qteRemapReleased) + return false; + + return true; + } + + bool DetermineChipPlayerStatus() { + auto pGameDocument = SWA::CGameDocument::GetInstance(); + + if (pGameDocument == NULL) + return false; + + const char* stageName = pGameDocument->m_pMember->m_StageName.c_str(); + const bool hasStage = stageName && strlen(stageName); + + if (hasStage == false) + return false; + + // There's no "BossDarkGaia1_2Air" so regex is NOT needeed. + const bool playingAsChip = !strcmp(stageName, "BossDarkGaia1_1Air"); + + return playingAsChip; + } + + uint32_t GetBoostCancelDurationMs() + { + int32_t fps = Config::FPS > 0 ? Config::FPS : 60; + return static_cast(2000 / fps); + } + + bool IsQTEPromptOnScreen() + { + // QTEPromptActiveMidAsmHook stamps this every frame a trick-QTE prompt is on + // screen; treat a recent stamp as "a QTE is waiting". + uint32_t last = App::s_lastQTEPromptMs; + if (last == 0) + return false; + + uint32_t now = uint32_t(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + return now - last < 250; + } + + void ApplyChipControls() + { + auto& pad = state; + + bool lbHeld = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_LEFTSHOULDER) != 0; + bool rbHeld = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) != 0; + bool ltPulled = uint8_t(SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERLEFT) >> 7) >= 30; + bool rtPulled = uint8_t(SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERRIGHT) >> 7) >= 30; + + // Attack: the bumpers drive the guest triggers (vanilla LT & RT punches). + pad.bLeftTrigger = lbHeld ? 255 : 0; + pad.bRightTrigger = rbHeld ? 255 : 0; + + // Guard: LT drives the guest left bumper (vanilla LB guard). + if (ltPulled) + pad.wButtons |= XAMINPUT_GAMEPAD_LEFT_SHOULDER; + else + pad.wButtons &= ~XAMINPUT_GAMEPAD_LEFT_SHOULDER; + + // The physical right bumper is attack now; hide it from the guest. + pad.wButtons &= ~XAMINPUT_GAMEPAD_RIGHT_SHOULDER; + } + + public: SDL_GameController* controller{}; SDL_Joystick* joystick{}; @@ -20,6 +109,15 @@ class Controller XAMINPUT_VIBRATION vibration{ 0, 0 }; int index{}; + // For when user sets Config::RightTriggerAction to ERightTriggerAction this + // increases stability to allow square/X to be recognised by the game while + // right trigger is being pressed down especially when the user is moving + // the thumbsticks. + bool xWasHeldLastPoll{}; + uint32_t xCancelUntilTick{}; + bool qteRemapReleased{}; // Handles the remap suspension. Resets when no prompt is on screen. + // bool chipIsActive{}; + Controller() = default; explicit Controller(int index) : Controller(SDL_GameControllerOpen(index)) @@ -99,6 +197,36 @@ class Controller pad.bLeftTrigger = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERLEFT) >> 7; pad.bRightTrigger = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERRIGHT) >> 7; + + + + if (IsBoostOnRightTriggerActive()) + { + bool xHeldPhysically = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_X) != 0; + bool xRisingEdge = xHeldPhysically && !xWasHeldLastPoll; + bool rtPulled = pad.bRightTrigger >= 30; // TODO: change this to a pressure preference + + // these checks are in place to improve responsiveness of square/X while right trigger is held down + if (xRisingEdge && rtPulled) + xCancelUntilTick = SDL_GetTicks() + GetBoostCancelDurationMs(); + + bool inCancelWindow = SDL_TICKS_PASSED(xCancelUntilTick, SDL_GetTicks()); + + if (inCancelWindow) + pad.wButtons &= ~XAMINPUT_GAMEPAD_X; + else if (xHeldPhysically || rtPulled) + pad.wButtons |= XAMINPUT_GAMEPAD_X; + else + pad.wButtons &= ~XAMINPUT_GAMEPAD_X; + + if (rtPulled) + pad.bRightTrigger = 0; + + xWasHeldLastPoll = xHeldPhysically; + + if (DetermineChipPlayerStatus()) + ApplyChipControls(); + } } void Poll() @@ -129,6 +257,37 @@ class Controller pad.wButtons |= TRANSLATE_INPUT(SDL_CONTROLLER_BUTTON_B, XAMINPUT_GAMEPAD_B); pad.wButtons |= TRANSLATE_INPUT(SDL_CONTROLLER_BUTTON_X, XAMINPUT_GAMEPAD_X); pad.wButtons |= TRANSLATE_INPUT(SDL_CONTROLLER_BUTTON_Y, XAMINPUT_GAMEPAD_Y); + + // when playing day stages keep the right trigger mirrored onto square/X + // so so the game knows the user is boosting. This will remove the actual + // right trigger from the game so sonic wouldn't drift + if (IsBoostOnRightTriggerActive()) + { + bool xHeldPhysically = (pad.wButtons & XAMINPUT_GAMEPAD_X) != 0; + bool xRisingEdge = xHeldPhysically && !xWasHeldLastPoll; + uint8_t rtRaw = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERRIGHT) >> 7; + bool rtPulled = rtRaw >= 30; // TODO: change this to a pressure preference + + // like in Poll() these checks are in place to improve responsiveness + // of square/X while right trigger is held down + if (xRisingEdge && rtPulled) + xCancelUntilTick = SDL_GetTicks() + GetBoostCancelDurationMs(); + + bool inCancelWindow = SDL_TICKS_PASSED(xCancelUntilTick, SDL_GetTicks()); + + if (inCancelWindow) + pad.wButtons &= ~XAMINPUT_GAMEPAD_X; + else if (rtPulled) + pad.wButtons |= XAMINPUT_GAMEPAD_X; + + if (rtPulled) + pad.bRightTrigger = 0; + + xWasHeldLastPoll = xHeldPhysically; + + if (DetermineChipPlayerStatus()) + ApplyChipControls(); + } } void SetVibration(const XAMINPUT_VIBRATION& vibration) diff --git a/UnleashedRecomp/locale/config_locale.cpp b/UnleashedRecomp/locale/config_locale.cpp index 1242e4eb..2e03be74 100644 --- a/UnleashedRecomp/locale/config_locale.cpp +++ b/UnleashedRecomp/locale/config_locale.cpp @@ -216,6 +216,66 @@ CONFIG_DEFINE_ENUM_LOCALE(ETimeOfDayTransition) } }; +// Translation required +// Japanese Notes: This localization should include furigana in its description. +CONFIG_DEFINE_LOCALE(RightTriggerAction) +{ + { ELanguage::English, { "Right Trigger Action", "Choose what the right trigger does in day stages." } }, + { ELanguage::Japanese, { "Right Trigger Action", "Choose what the right trigger does in day stages." } }, // Translation required + { ELanguage::German, { "Right Trigger Action", "Choose what the right trigger does in day stages." } }, // Translation required + { ELanguage::French, { "Right Trigger Action", "Choose what the right trigger does in day stages." } }, // Translation required + { ELanguage::Spanish, { "Right Trigger Action", "Choose what the right trigger does in day stages." } }, // Translation required + { ELanguage::Italian, { "Right Trigger Action", "Choose what the right trigger does in day stages." } } // Translation required +}; + +// Translation required +// Japanese Notes: This localization should include furigana in its description. +CONFIG_DEFINE_ENUM_LOCALE(ERightTriggerAction) +{ + { + ELanguage::English, + { + { ERightTriggerAction::Drift, { "DRIFT", "Default: the right trigger acts as drift, matching the original Xbox 360/PS3 controls." } }, + { ERightTriggerAction::Boost, { "BOOST/H.A", "EXPERIMENTAL: The right trigger acts as boost or homing attack. Drift is still available on the left trigger and X/Square still triggers boost too." } } + } + }, + { + ELanguage::Japanese, + { + { ERightTriggerAction::Drift, { "DRIFT", "" } }, + { ERightTriggerAction::Boost, { "BOOST", "" } } + } + }, + { + ELanguage::German, + { + { ERightTriggerAction::Drift, { "DRIFT", "" } }, + { ERightTriggerAction::Boost, { "BOOST", "" } } + } + }, + { + ELanguage::French, + { + { ERightTriggerAction::Drift, { "DRIFT", "" } }, + { ERightTriggerAction::Boost, { "BOOST", "" } } + } + }, + { + ELanguage::Spanish, + { + { ERightTriggerAction::Drift, { "DRIFT", "" } }, + { ERightTriggerAction::Boost, { "BOOST", "" } } + } + }, + { + ELanguage::Italian, + { + { ERightTriggerAction::Drift, { "DRIFT", "" } }, + { ERightTriggerAction::Boost, { "BOOST", "" } } + } + } +}; + // Japanese Notes: This localization should include furigana. CONFIG_DEFINE_LOCALE(ControllerIcons) { diff --git a/UnleashedRecomp/patches/misc_patches.cpp b/UnleashedRecomp/patches/misc_patches.cpp index ec4e1a8b..660a824e 100644 --- a/UnleashedRecomp/patches/misc_patches.cpp +++ b/UnleashedRecomp/patches/misc_patches.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -39,6 +40,115 @@ bool DisableEvilControlTutorialMidAsmHook(PPCRegister& r4, PPCRegister& r5) return r4.u32 == 1 && r5.u32 == 1; } +// The day-stage boost navigation sign (sub_8239F4A8) hardcodes cast "btn_1" with img +// pattern 2 (X button). When boost is remapped to the right trigger, redirect the +// prompt to the trigger cast so the HUD matches the actual control. The play-screen +// scene's button casts map as: btn_1 = face buttons (0=A 1=B 2=X 3=Y), btn_2 = bumpers +// (0=LB 1=RB), btn_3 = triggers (0=LT 1=RT). +void BoostPromptCastMidAsmHook(PPCRegister& r5) +{ + if (Config::RightTriggerAction == ERightTriggerAction::Boost) + r5.u32 = 0x820116D8; // "btn_3" +} + +void BoostPromptPatternMidAsmHook(PPCRegister& r4) +{ + if (Config::RightTriggerAction == ERightTriggerAction::Boost) + r4.u32 = 1; // RT +} + +// The Gaia Colossus (Chip) control overlay ("Boost / Guard / Attack") lives in the +// "footer" scene of ui_playscreen_su.yncp (a common archive, cached from boot), with +// the button art baked into the asset as sprite crops of the shared x360 button atlas +// (crop order A, B, X, Y, LB, RB, LT, RT; bumpers are double width). +void ChipOverlayCsdPatchMidAsmHook(PPCRegister& r8) +{ + auto base = (uint8_t*)g_memory.Translate(r8.u32); + + // Identify ui_playscreen_su.yncp first: container size in the FAPC header, then + // the footer cast name table. Everything else returns before touching config. + if (*(be*)(base + 4) != 0x4970) + return; + + static constexpr char s_footerCastNames[] = "btn_lb\0\0btn_lt\0\0btn_rt\0\0btn_x"; + if (memcmp(base + 0xC08, s_footerCastNames, sizeof(s_footerCastNames) - 1) != 0) + return; + + if (Config::RightTriggerAction != ERightTriggerAction::Boost) + return; + + // Layout constants, hand-tuned in-game (widths/heights are quad half-extents in + // scene units; X/Y are cast translations). AttackL* is btn_lt's offset relative + // to its parent btn_rt (the pair's spacing); the others are absolute. + struct QuadLayout { float w, h, x, y; }; + constexpr QuadLayout kBoost = { 0.0308f, 0.0556f, -0.1289f, -0.103f }; + constexpr QuadLayout kGuard = { 0.031f, 0.0502f, -0.1369f, -0.0545f }; + constexpr QuadLayout kAttackR = { 0.0625f, 0.05556f, -0.1064f, -0.011f }; + constexpr float kAttackLX = -0.055f; // btn_lt X relative to btn_rt + constexpr float kAttackLY = 0.0f; // btn_lt Y relative to btn_rt + + // The footer's button casts (records at 0x1C30 btn_x, 0x1CA4 btn_lb, 0x1D18 + // btn_lt, 0x1D8C btn_rt) each show their icon via a sprite-index array + // ([0, 0, crop], live entry at +8) into the scene crop table (entry 0 at file + // 0x734, 20-byte records): 13 = X, 15 = LB (double width), 16 = RB, 17 = LT, + // 18 = RT. Redirect the indices instead of rewriting the shared crop table. + *(be*)(base + 0x3570 + 8) = 18; // Boost row: btn_x X -> RT + *(be*)(base + 0x362C + 8) = 17; // Guard row: btn_lb LB -> LT + *(be*)(base + 0x36E8 + 8) = 15; // Attack row: btn_lt LT -> LB + *(be*)(base + 0x37A4 + 8) = 16; // btn_rt RT -> RB + + // Position each cast via its quad corners (8 floats at +0x14: TL BL TR BR as x,y + // pairs, right-edge / vertical-centre anchored) and its info-block translation + // (+0x0C x, +0x10 y). btn_lt is a CHILD of btn_rt, so its translation is relative. + auto setQuad = [&](uint32_t rec, const QuadLayout& q) + { + *(be*)(base + rec + 0x14) = -q.w; // TL.x + *(be*)(base + rec + 0x1C) = -q.w; // BL.x + *(be*)(base + rec + 0x18) = -q.h * 0.5f; // TL.y + *(be*)(base + rec + 0x28) = -q.h * 0.5f; // TR.y + *(be*)(base + rec + 0x20) = q.h * 0.5f; // BL.y + *(be*)(base + rec + 0x30) = q.h * 0.5f; // BR.y + }; + + QuadLayout attackL = { kAttackR.w, kAttackR.h, kAttackLX, kAttackLY }; + setQuad(0x1C30, kBoost); // btn_x + setQuad(0x1CA4, kGuard); // btn_lb + setQuad(0x1D18, attackL); // btn_lt + setQuad(0x1D8C, kAttackR); // btn_rt + + *(be*)(base + 0x35F0 + 0x0C) = kBoost.x; + *(be*)(base + 0x35F0 + 0x10) = kBoost.y; + *(be*)(base + 0x36AC + 0x0C) = kGuard.x; + *(be*)(base + 0x36AC + 0x10) = kGuard.y; + *(be*)(base + 0x3768 + 0x0C) = attackL.x; + *(be*)(base + 0x3768 + 0x10) = attackL.y; + *(be*)(base + 0x3824 + 0x0C) = kAttackR.x; + *(be*)(base + 0x3824 + 0x10) = kAttackR.y; + + // The rendered shape comes from each cast's baked pixel box (u32 width at +0x58, + // height at +0x5C) — the art is fitted into it, which is what stretched the + // swapped icons. Give each cast the box of the art it now shows: the guard cast + // drops from the 80x40 bumper box to the 40x40 trigger box, the attack casts + // grow from 40x40 to 80x40. + *(be*)(base + 0x1CA4 + 0x58) = 40; // btn_lb: square trigger box + *(be*)(base + 0x1D18 + 0x58) = 80; // btn_lt: wide bumper box + *(be*)(base + 0x1D8C + 0x58) = 80; // btn_rt: wide bumper box +} + +// Stamp the time whenever a prompt is on screen (this+101 = active, this+102 = done). +// The input driver keeps the boost trigger remap alive during a QTE only while the +// trigger is held continuously from before the prompt (so the boost aura persists). +// The QTE is edge-triggered, so a held trigger registers no press and can't answer it. +// Releasing the trigger suspends the remap so the real face button can. +void QTEPromptActiveMidAsmHook(PPCRegister& pThis) +{ + bool isActive = *(uint8_t*)g_memory.Translate(pThis.u32 + 101) != 0; + bool isDone = *(uint8_t*)g_memory.Translate(pThis.u32 + 102) != 0; + if (isActive && !isDone) + App::s_lastQTEPromptMs = uint32_t(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); +} + bool DisableDLCIconMidAsmHook() { return Config::DisableDLCIcon; diff --git a/UnleashedRecomp/ui/options_menu.cpp b/UnleashedRecomp/ui/options_menu.cpp index 33ac5c69..d5a2afa3 100644 --- a/UnleashedRecomp/ui/options_menu.cpp +++ b/UnleashedRecomp/ui/options_menu.cpp @@ -1239,6 +1239,7 @@ static void DrawConfigOptions() case 1: // INPUT DrawConfigOption(rowCount++, yOffset, &Config::HorizontalCamera, true); DrawConfigOption(rowCount++, yOffset, &Config::VerticalCamera, true); + DrawConfigOption(rowCount++, yOffset, &Config::RightTriggerAction, true); DrawConfigOption(rowCount++, yOffset, &Config::Vibration, true); DrawConfigOption(rowCount++, yOffset, &Config::AllowBackgroundInput, true); DrawConfigOption(rowCount++, yOffset, &Config::ControllerIcons, true); diff --git a/UnleashedRecomp/user/config.cpp b/UnleashedRecomp/user/config.cpp index bd622b7f..1de78e32 100644 --- a/UnleashedRecomp/user/config.cpp +++ b/UnleashedRecomp/user/config.cpp @@ -30,6 +30,12 @@ CONFIG_DEFINE_ENUM_TEMPLATE(ECameraRotationMode) { "Reverse", ECameraRotationMode::Reverse }, }; +CONFIG_DEFINE_ENUM_TEMPLATE(ERightTriggerAction) +{ + { "Drift", ERightTriggerAction::Drift }, + { "Boost", ERightTriggerAction::Boost } +}; + CONFIG_DEFINE_ENUM_TEMPLATE(EControllerIcons) { { "Auto", EControllerIcons::Auto }, diff --git a/UnleashedRecomp/user/config.h b/UnleashedRecomp/user/config.h index 9e87d12e..ee106d0a 100644 --- a/UnleashedRecomp/user/config.h +++ b/UnleashedRecomp/user/config.h @@ -52,6 +52,12 @@ enum class ECameraRotationMode : uint32_t Reverse }; +enum class ERightTriggerAction : uint32_t +{ + Drift, + Boost +}; + enum class EControllerIcons : uint32_t { Auto, diff --git a/UnleashedRecomp/user/config_def.h b/UnleashedRecomp/user/config_def.h index 80eb29c9..96e909c5 100644 --- a/UnleashedRecomp/user/config_def.h +++ b/UnleashedRecomp/user/config_def.h @@ -12,6 +12,7 @@ CONFIG_DEFINE("System", bool, ShowConsole, false); CONFIG_DEFINE_ENUM_LOCALISED("Input", ECameraRotationMode, HorizontalCamera, ECameraRotationMode::Normal); CONFIG_DEFINE_ENUM_LOCALISED("Input", ECameraRotationMode, VerticalCamera, ECameraRotationMode::Normal); +CONFIG_DEFINE_ENUM_LOCALISED("Input", ERightTriggerAction, RightTriggerAction, ERightTriggerAction::Drift); CONFIG_DEFINE_LOCALISED("Input", bool, Vibration, true); CONFIG_DEFINE_LOCALISED("Input", bool, AllowBackgroundInput, false); CONFIG_DEFINE_ENUM_LOCALISED("Input", EControllerIcons, ControllerIcons, EControllerIcons::Auto); diff --git a/UnleashedRecompLib/config/SWA.toml b/UnleashedRecompLib/config/SWA.toml index 5063e712..5f20b468 100644 --- a/UnleashedRecompLib/config/SWA.toml +++ b/UnleashedRecompLib/config/SWA.toml @@ -197,18 +197,42 @@ address = 0x823A4FF0 registers = ["r4", "r5"] return_on_false = true -# Set default value for XButtonHoming. +# Update Chip Control Tutorial Overlay For Right Trigger Boost +[[midasm_hook]] +name = "ChipOverlayCsdPatchMidAsmHook" +address = 0x830C17A8 +registers = ["r8"] + +# Trick QTE prompt bar Per-frame Update +[[midasm_hook]] +name = "QTEPromptActiveMidAsmHook" +address = 0x823329F8 +registers = ["r3"] + +# Set default value for XButtonHoming [[midasm_hook]] name = "SetXButtonHomingMidAsmHook" address = 0x8237AC90 registers = ["r1"] -# Disable XML reading for XButtonHoming. +# Disable XML reading for XButtonHoming [[midasm_hook]] name = "IsHomingAttackOnJump" address = 0x8237ACE4 jump_address_on_true = 0x8237ACE8 +# Change boost prompt cast for right trigger option +[[midasm_hook]] +name = "BoostPromptCastMidAsmHook" +address = 0x8239F514 +registers = ["r5"] + +# Change boost prompt pattern for right trigger option +[[midasm_hook]] +name = "BoostPromptPatternMidAsmHook" +address = 0x8239F5BC +registers = ["r4"] + # Down force HFR fix [[midasm_hook]] name = "DownForceDeltaTimeFixMidAsmHook"