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
6 changes: 4 additions & 2 deletions .github/tidy-skip.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# First-party translation units the `host` preset does not compile, so the
# `tidy` job has no compile command for them. A changed .c/.cpp that is neither
# in build/host/compile_commands.json nor listed here fails the job, which keeps
# gaps in tidy coverage visible. Remove a line once its TU builds in `host`
# (most of these need RT64 or generated code; see host-full-nogen in the plan).
# gaps in tidy coverage visible. Listed paths are also excluded from
# clang-tidy-diff (analyzing them without compile flags fails on third-party
# includes). Remove a line once its TU builds in `host` (most of these need
# RT64 or generated code; see host-full-nogen in the plan).
# Paths are repo-relative, one per line, exact match.
modding/examples/ai-boss-difficulty/main.c
modding/examples/core-boot/main.c
Expand Down
27 changes: 25 additions & 2 deletions .github/workflows/tooling.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,32 @@ jobs:
env:
MERGE_BASE: ${{ steps.changes.outputs.merge-base }}
run: |
mapfile -t changed <"$RUNNER_TEMP/changed.txt"
# tidy-skip TUs are absent from the host compile database. The gate
# above allows them; clang-tidy-diff must not analyze them, or it
# invents flags and fails on missing third-party includes (SDL3,
# ultramodern, …).
python3 - "$RUNNER_TEMP/changed.txt" "$RUNNER_TEMP/tidied.txt" <<'EOF'
import sys
skipped = {
line.strip()
for line in open(".github/tidy-skip.txt")
if line.strip() and not line.startswith("#")
}
tidied = [
line.strip()
for line in open(sys.argv[1])
if line.strip() and line.strip() not in skipped
]
open(sys.argv[2], "w").write("\n".join(tidied) + ("\n" if tidied else ""))
print(f"{len(tidied)} tidiable changed files ({len(skipped)} skip-listed)")
EOF
mapfile -t tidied <"$RUNNER_TEMP/tidied.txt"
if [[ ${#tidied[@]} -eq 0 ]]; then
echo 'No tidiable changed files (all listed in tidy-skip.txt).'
exit 0
fi
set +e
git diff -U0 "$MERGE_BASE" HEAD -- "${changed[@]}" |
git diff -U0 "$MERGE_BASE" HEAD -- "${tidied[@]}" |
python3 /usr/lib/llvm-18/share/clang/clang-tidy-diff.py -p1 -path build/host \
-clang-tidy-binary clang-tidy-18 -iregex '.*\.(c|cc|cpp|h|hpp)' -j 4 -quiet >"$RUNNER_TEMP/tidy.log" 2>&1
status=$?
Expand Down
6 changes: 6 additions & 0 deletions docs/CONTROLLERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ while DKR-R native gyro is enabled, because that would add a second motion path.
Raw mapping creation and map import remain on the SDL2 compatibility backend;
SDL3 native mode consumes SDL3's standardized gamepad mappings directly.

Switch Pro and Joy-Con defaults follow Nintendo face etiquette: the printed
A/B/X/Y letters match the binding tokens. SDL2 pins
`SDL_GAMECONTROLLER_USE_BUTTON_LABELS=1`. SDL3's position-mode reports are
remapped in the SDL3 input host before bindings see them. NSO N64 / GameCube
pads are left on their existing label-oriented paths.

## Assignment and remapping

- **Automatic** fills the first available player slots while retaining stable
Expand Down
25,478 changes: 12,739 additions & 12,739 deletions patches/release-prep/v1.0.7/source-capsule-r2/manifest.json

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions runtime-recomp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2598,6 +2598,15 @@ if(BUILD_TESTING)
add_test(NAME DKRControllerMappingPolicy
COMMAND DKRControllerMappingPolicyTests)

add_executable(DKRControllerFaceEtiquetteTests
tests/controller_face_etiquette_tests.cpp
)
target_include_directories(DKRControllerFaceEtiquetteTests PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/src/game"
)
add_test(NAME DKRControllerFaceEtiquette
COMMAND DKRControllerFaceEtiquetteTests)

add_executable(DKRControllerDatabaseTests
tests/controller_database_tests.cpp
)
Expand Down
155 changes: 155 additions & 0 deletions runtime-recomp/src/game/controller_face_etiquette.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#pragma once

#include <algorithm>
#include <array>
#include <cctype>
#include <cstddef>
#include <cstdint>
#include <string_view>

namespace dkr::runtime::controllers {

// Face-button reporting for Nintendo diamond pads. Xbox/PlayStation keep SDL
// position numbering. Switch Pro / Joy-Con are normalised to printed letters
// (physical A reports as SDL A) so stock bindings match Nintendo etiquette.

enum class ControllerFamily : std::uint8_t {
Xbox = 0,
PlayStation = 1,
Switch = 2,
N64 = 3,
GameCube = 4,
};

inline constexpr int kSdlControllerTypeUnknown = 0;
inline constexpr int kSdlControllerTypeXbox360 = 1;
inline constexpr int kSdlControllerTypeXboxOne = 2;
inline constexpr int kSdlControllerTypePs3 = 3;
inline constexpr int kSdlControllerTypePs4 = 4;
inline constexpr int kSdlControllerTypeNintendoSwitchPro = 5;
inline constexpr int kSdlControllerTypeVirtual = 6;
inline constexpr int kSdlControllerTypePs5 = 7;
inline constexpr int kSdlControllerTypeAmazonLuna = 8;
inline constexpr int kSdlControllerTypeGoogleStadia = 9;
inline constexpr int kSdlControllerTypeNvidiaShield = 10;
inline constexpr int kSdlControllerTypeJoyConLeft = 11;
inline constexpr int kSdlControllerTypeJoyConRight = 12;
inline constexpr int kSdlControllerTypeJoyConPair = 13;

inline bool contains_ci(std::string_view haystack, std::string_view needle) {
if (needle.empty() || haystack.size() < needle.size()) {
return false;
}
const auto match_at = [&](std::size_t offset) {
for (std::size_t index = 0; index < needle.size(); ++index) {
const auto left = static_cast<unsigned char>(haystack[offset + index]);
const auto right = static_cast<unsigned char>(needle[index]);
if (std::tolower(left) != std::tolower(right)) {
return false;
}
}
return true;
};
for (std::size_t offset = 0; offset + needle.size() <= haystack.size();
++offset) {
if (match_at(offset)) {
return true;
}
}
return false;
}

inline bool name_looks_like_n64(std::string_view name) {
return contains_ci(name, "n64") || contains_ci(name, "nintendo 64");
}

inline bool name_looks_like_gamecube(std::string_view name) {
return contains_ci(name, "gamecube") || contains_ci(name, "game cube") ||
contains_ci(name, "nso gc") || contains_ci(name, "raphnet gc") ||
contains_ci(name, "gc adapter") ||
contains_ci(name, "gc and n64") ||
contains_ci(name, "gamecube controller");
}

inline bool name_looks_like_switch(std::string_view name) {
if (name_looks_like_n64(name) || name_looks_like_gamecube(name)) {
return false;
}
return contains_ci(name, "switch pro") || contains_ci(name, "joy-con") ||
contains_ci(name, "joycon") ||
contains_ci(name, "nintendo switch") ||
contains_ci(name, "nso snes");
}

inline ControllerFamily family_from_sdl_type(int sdl_type) {
switch (sdl_type) {
case kSdlControllerTypePs3:
case kSdlControllerTypePs4:
case kSdlControllerTypePs5:
return ControllerFamily::PlayStation;
case kSdlControllerTypeNintendoSwitchPro:
case kSdlControllerTypeJoyConLeft:
case kSdlControllerTypeJoyConRight:
case kSdlControllerTypeJoyConPair:
return ControllerFamily::Switch;
default:
return ControllerFamily::Xbox;
}
}

// Prefer N64/GameCube name hits over SDL's Switch-Pro classification: NSO and
// third-party retro pads often report as a generic Nintendo device.
inline ControllerFamily detect_controller_family(std::string_view name,
int sdl_type =
kSdlControllerTypeUnknown) {
if (name_looks_like_n64(name)) {
return ControllerFamily::N64;
}
if (name_looks_like_gamecube(name)) {
return ControllerFamily::GameCube;
}
if (sdl_type != kSdlControllerTypeUnknown) {
if (sdl_type == kSdlControllerTypeVirtual &&
name_looks_like_switch(name)) {
return ControllerFamily::Switch;
}
return family_from_sdl_type(sdl_type);
}
if (name_looks_like_switch(name)) {
return ControllerFamily::Switch;
}
return ControllerFamily::Xbox;
}

constexpr bool uses_nintendo_face_etiquette(ControllerFamily family) {
return family == ControllerFamily::Switch;
}

constexpr std::size_t nintendo_label_face_button(std::size_t button) {
switch (button) {
case 0U:
return 1U;
case 1U:
return 0U;
case 2U:
return 3U;
case 3U:
return 2U;
default:
return button;
}
}

template <typename ButtonArray>
inline void apply_nintendo_face_etiquette(ButtonArray& buttons) {
const auto south = buttons[0];
const auto east = buttons[1];
const auto west = buttons[2];
const auto north = buttons[3];
buttons[0] = east;
buttons[1] = south;
buttons[2] = north;
buttons[3] = west;
}

} // namespace dkr::runtime::controllers
28 changes: 28 additions & 0 deletions runtime-recomp/src/game/runtime_metal_layer.mm
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,41 @@
#import <Cocoa/Cocoa.h>
#import <QuartzCore/CAMetalLayer.h>
#include <SDL.h>
#include <atomic>
#include <cstdio>

static Uint32 fullscreen_event_type() {
static const Uint32 type = SDL_RegisterEvents(1);
return type;
}

static std::atomic<bool> g_macos_input_recovery{false};
static id g_macos_wake_observer = nil;

static void ensure_macos_wake_observer() {
if (g_macos_wake_observer != nil) return;
// Lid sleep leaves SDL_WINDOW_INPUT_FOCUS and HID pads stale. Mark a
// recovery request on wake so the game thread can re-enumerate pads and
// accept input while NSApp is frontmost again.
g_macos_wake_observer = [[[NSWorkspace sharedWorkspace] notificationCenter]
addObserverForName:NSWorkspaceDidWakeNotification
object:nil
queue:nil
usingBlock:^(NSNotification*) {
g_macos_input_recovery.store(true, std::memory_order_release);
}];
}

extern "C" bool dkr_macos_app_is_active(void) {
ensure_macos_wake_observer();
return NSApp != nil && [NSApp isActive];
}

extern "C" bool dkr_macos_take_input_recovery(void) {
ensure_macos_wake_observer();
return g_macos_input_recovery.exchange(false, std::memory_order_acq_rel);
}

extern "C" void dkr_set_window_fullscreen(Uint32 window_id, bool fullscreen) {
// Window operations belong to the main thread. Use SDL's own thread-safe
// queue, which the game's event pump consumes, rather than Cocoa dispatch.
Expand Down
62 changes: 60 additions & 2 deletions runtime-recomp/src/game/runtime_platform.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
#include <SDL_metal.h>
extern "C" bool dkr_show_metal_layer(void* ns_window_ptr, void* layer_ptr);
extern "C" bool dkr_handle_fullscreen_event(const SDL_Event* event);
#if !defined(DKR_IOS)
extern "C" bool dkr_macos_app_is_active(void);
extern "C" bool dkr_macos_take_input_recovery(void);
#endif
#endif
#if defined(DKR_IOS)
#include "../ios/ios_platform.hpp"
Expand Down Expand Up @@ -706,6 +710,42 @@ void RefreshControllers(bool force = true) {
ReconcileControllers();
}

void RefreshSdl3Controllers();

bool WindowAcceptsGameplayInput() {
if (g_window == nullptr) return false;
const Uint32 flags = SDL_GetWindowFlags(g_window);
if ((flags & SDL_WINDOW_INPUT_FOCUS) != 0U) return true;
#if defined(__APPLE__) && !defined(DKR_IOS)
// After Mac lid sleep SDL's INPUT_FOCUS bit often stays clear even while
// the app is frontmost again. Trust NSApp so pads and keys resume.
if ((flags & (SDL_WINDOW_MINIMIZED | SDL_WINDOW_HIDDEN)) != 0U) {
return false;
}
return dkr_macos_app_is_active();
#else
return false;
#endif
}

void RecoverControllersAfterHostActivation() {
g_controller_inventory_dirty.store(true, std::memory_order_relaxed);
if (g_active_input_backend.load(std::memory_order_acquire) ==
dkr::runtime::platform::InputBackend::SDL3Native) {
RefreshSdl3Controllers();
return;
}
// Close and reopen: after sleep GetAttached can stay true for dead HID
// handles, so a dirty-only refresh would keep the zombie pads.
for (ControllerRecord& record : g_controller_devices) {
CloseControllerRecord(record);
}
g_controller_devices.clear();
g_controllers.fill(nullptr);
g_gyro_controllers.fill(nullptr);
RefreshControllers(true);
}

const dkr::runtime::sdl3_input::Device* Sdl3DeviceForInstance(int instance) {
const auto match = std::find_if(
g_sdl3_input_state.devices.begin(), g_sdl3_input_state.devices.end(),
Expand Down Expand Up @@ -1431,6 +1471,9 @@ bool dkr::runtime::platform::initialise() {
// allowing newer Linux/SteamOS SDL builds to expose the built-in Deck pad.
SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1");
SDL_SetHint("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
// Nintendo diamond pads: report by printed letter (A is A). SDL's default
// is already "1"; pin it so Switch Pro / Joy-Con defaults match etiquette.
SDL_SetHint("SDL_GAMECONTROLLER_USE_BUTTON_LABELS", "1");
// SDL may continue sampling connected game controllers while the native
// window is unfocused. DKR-R still gates those samples independently for
// every N64 port in poll_input, so this hint grants capability rather than
Expand Down Expand Up @@ -1802,6 +1845,16 @@ void dkr::runtime::platform::pump_window_events(void*) {
return;
}

#if defined(__APPLE__) && !defined(DKR_IOS)
// After lid sleep, pads often need a forced re-open even when SDL still
// reports the old instance as attached.
if (event.type == SDL_WINDOWEVENT &&
(event.window.event == SDL_WINDOWEVENT_FOCUS_GAINED ||
event.window.event == SDL_WINDOWEVENT_RESTORED)) {
g_controller_inventory_dirty.store(true, std::memory_order_relaxed);
}
#endif

// Overlay lifecycle commands are host-global. Process them before
// forwarding the event to ImGui so a focused widget cannot swallow
// Escape, F1 or Back/View and leave the player trapped in the UI.
Expand All @@ -1828,6 +1881,12 @@ void dkr::runtime::platform::pump_window_events(void*) {

dkr::runtime::ui::handle_runtime_event(&event);
}
#if defined(__APPLE__) && !defined(DKR_IOS)
if (dkr_macos_take_input_recovery()) {
std::scoped_lock lock(g_platform_mutex);
RecoverControllersAfterHostActivation();
}
#endif
if (dkr::runtime::input::consume_shortcut_request(
dkr::runtime::input::ShortcutAction::ToggleOverlay)) {
dkr::runtime::ui::toggle_overlay();
Expand Down Expand Up @@ -2439,8 +2498,7 @@ void dkr::runtime::platform::poll_input() {
RefreshControllers(false);
}
const bool blocked = dkr::runtime::ui::overlay_visible();
const bool window_focused = g_window != nullptr &&
(SDL_GetWindowFlags(g_window) & SDL_WINDOW_INPUT_FOCUS) != 0U;
const bool window_focused = WindowAcceptsGameplayInput();
const bool online_routing =
g_online_input_routing.load(std::memory_order_acquire);
const int configured_keyboard_player =
Expand Down
Loading
Loading