Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions UnleashedRecomp/app.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint32_t> s_lastQTEPromptMs;

static void Restart(std::vector<std::string> restartArgs = {});
static void Exit();
};
Expand Down
159 changes: 159 additions & 0 deletions UnleashedRecomp/hid/driver/sdl_hid.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,102 @@
#include <os/logger.h>
#include <ui/game_window.h>
#include <kernel/xdm.h>
#include <api/SWA.h>
#include <app.h>

#define TRANSLATE_INPUT(S, X) SDL_GameControllerGetButton(controller, S) << FirstBitLow(X)
#define VIBRATION_TIMEOUT_MS 5000

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<uint32_t>(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::milliseconds>(
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{};
Expand All @@ -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))
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions UnleashedRecomp/locale/config_locale.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
110 changes: 110 additions & 0 deletions UnleashedRecomp/patches/misc_patches.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include <api/SWA.h>
#include <app.h>
#include <ui/game_window.h>
#include <user/achievement_manager.h>
#include <user/persistent_storage_manager.h>
Expand Down Expand Up @@ -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<uint32_t>*)(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<uint32_t>*)(base + 0x3570 + 8) = 18; // Boost row: btn_x X -> RT
*(be<uint32_t>*)(base + 0x362C + 8) = 17; // Guard row: btn_lb LB -> LT
*(be<uint32_t>*)(base + 0x36E8 + 8) = 15; // Attack row: btn_lt LT -> LB
*(be<uint32_t>*)(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<float>*)(base + rec + 0x14) = -q.w; // TL.x
*(be<float>*)(base + rec + 0x1C) = -q.w; // BL.x
*(be<float>*)(base + rec + 0x18) = -q.h * 0.5f; // TL.y
*(be<float>*)(base + rec + 0x28) = -q.h * 0.5f; // TR.y
*(be<float>*)(base + rec + 0x20) = q.h * 0.5f; // BL.y
*(be<float>*)(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<float>*)(base + 0x35F0 + 0x0C) = kBoost.x;
*(be<float>*)(base + 0x35F0 + 0x10) = kBoost.y;
*(be<float>*)(base + 0x36AC + 0x0C) = kGuard.x;
*(be<float>*)(base + 0x36AC + 0x10) = kGuard.y;
*(be<float>*)(base + 0x3768 + 0x0C) = attackL.x;
*(be<float>*)(base + 0x3768 + 0x10) = attackL.y;
*(be<float>*)(base + 0x3824 + 0x0C) = kAttackR.x;
*(be<float>*)(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<uint32_t>*)(base + 0x1CA4 + 0x58) = 40; // btn_lb: square trigger box
*(be<uint32_t>*)(base + 0x1D18 + 0x58) = 80; // btn_lt: wide bumper box
*(be<uint32_t>*)(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::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
}

bool DisableDLCIconMidAsmHook()
{
return Config::DisableDLCIcon;
Expand Down
Loading