Skip to content

qt: apply controls sent by another program while a game runs - #859

Open
tom-tour wants to merge 2 commits into
Eglantine-Community:nx2-experimentalfrom
tom-tour:reload-controls-request
Open

tom-tour wants to merge 2 commits into
Eglantine-Community:nx2-experimentalfrom
tom-tour:reload-controls-request

Conversation

@tom-tour

@tom-tour tom-tour commented Nov 13, 2026 •

Copy link
Copy Markdown

Summary

Launchers that manage the controls of Eden-based emulators (NX2 Launcher, for example) cannot change them while a game runs: the emulator reads qt-config.ini only when it starts, and writes it again from memory when it closes, so an outside change is lost. The only way today is the emulator's own Controls window

As discussed in #853, this adds a small request file that other programs can use:

  1. The program writes a file named reload-controls in the emulator's config folder. It holds the [Controls] keys of the players to change, in the same format as qt-config.ini
  2. The emulator sees it (a QFileSystemWatcher on the config folder), takes it (renamed to reload-controls.applying so the program can't change it while it's read), applies those players to the emulated controllers, saves them in qt-config.ini and removes the file
  3. The program knows the request was handled when the file is gone, and can read qt-config.ini back to check

Only the emulator writes qt-config.ini, the other program only writes the request

Details:

  • Only the players that have keys in the request are read (QtConfig::ApplyPlayerValuesFromFile, built on the existing ReadQtControlPlayerValues used by input profiles). The program sends every key of a player it changes. Other players and the rest of the configuration don't change
  • Only the controllers of the players in the request are refreshed. When their type and connection did not change in the configuration, only their mappings are replaced (EmulatedController::ReloadMappingsFromSettings, HIDCore::ReloadInputMappings), so the game does not see a controller unplugged and doesn't show the controller applet again. The comparison uses the values loaded from the configuration, not the current state, because the game and its controller applet can connect or disconnect controllers themselves. A controller whose type or connection changed goes through the existing ReloadFromSettings
  • During a game, players that follow the global controls are refreshed. Players that use the game's own input profile are left as they are. This checks the game's copy of the players directly, since the setting is switched back to its global value once the game has started
  • While a settings window is open, it owns the controls: the request waits until it closes. A controller being configured is never touched
  • A request left over from a previous run is removed at startup
  • Nothing happens unless a program creates the file, so there is no change for users who do not use such a tool

Type of change

  • Bug fix
  • Game compatibility / game-support
  • Performance
  • New feature
  • Refactor / cleanup
  • CI / build
  • Documentation

Related issues

Closes #853

Testing

  • Tested on Windows
  • Tested on Linux
  • Tested on macOS

Tested on Windows with Fire Emblem Fortune's Weave, by writing the reload-controls file during the game. The new mapping worked right away, without the controller being reconnected or the controller applet showing up, and qt-config.ini kept it after closing

Also checked : a request for player 2 only (player 1 untouched), a game with its own input profile (left as it is), and a request sent while the settings window was open (applied once it closed)

Suggested steps:

  1. Start a game with a keyboard mapping for player 1
  2. Write a config/reload-controls file with a [Controls] section and the player_0_ keys of qt-config.ini, with player_0_button_a set to another key
  3. The file disappears within a second, the new key works in the game without the controller being disconnected, and qt-config.ini already has it
  4. Open Emulation > Configure, write the file, then press OK: the request is applied after the window closes
  5. With a game that has its own input profile for player 1, the request leaves that player unchanged
  6. A request with only player_1_ keys leaves player 1 (player_0_) as it is

Games tested: Fire Emblem Fortune's Weave

Checklist

  • I built it locally and it compiles without new warnings
  • I did not add any copyrighted game data, keys or firmware
  • Docs updated if needed
  • I added AI Disclosure if one was used

Helped by AIs : Refactoring & Code quality

Screenshots / logs

When the request is handled, the log shows: Applying the controls sent by another program

Summary by CodeRabbit

  • New Features
    • Controller control settings can now be applied from another configuration file without restarting the emulator.
    • When controller type and connection status remain unchanged, updated mappings take effect while the controller stays connected.
    • The controller dialog refreshes after settings are applied.

@github-actions
github-actions Bot changed the base branch from nx2 to nx2-experimental November 13, 2026 18:51
@github-actions

Copy link
Copy Markdown

Retargeted to nx2-experimental for pre-build testing. It will be promoted to nx2 after validation.

@coderabbitai

coderabbitai Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a2ef2d22-57c1-4163-8bde-26b101201453

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Launchers that edit the controls had no way to change them during a
game: the configuration is only read at startup and written back on
exit.

The main window now watches the config folder for a "reload-controls"
file holding the [Controls] keys of the players to change, in the
qt-config.ini format. Those players are saved and applied to their
controllers, then the file is removed. Players using the game's own
input profile are left alone, and a controller whose type and
connection did not change stays connected, so the game does not show
the controller applet again. The request waits while a settings window
is open.

Signed-off-by: tom.tourbez <188838116+tom-tour@users.noreply.github.com>
@tom-tour
tom-tour force-pushed the reload-controls-request branch from 2ff5bb9 to e49b6ee Compare November 13, 2026 19:23

const bool players_global = Settings::values.players.UsingGlobal();
Settings::values.players.SetGlobal(true);
for (const std::size_t p : players_in_file) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any player_N_ key in the request makes the whole player be re-read from the request file through ReadPlayerValues/ReadQtPlayerValues. Every key the request leaves out gets its default instead of keeping its current value:

  • connected becomes true only for player 0
  • type becomes Pro Controller
  • buttons, sticks and motion go back to keyboard defaults

It's then saved to qt-config.ini. For example, a request with only player_1_button_a disconnects player 2 and puts the rest of its buttons back on keyboard defaults.

Could the request be read into its own CSimpleIniA, filled from the current settings first, and then have the request's keys applied on top? The other option is to document that a request must contain every key for each player it lists, but that's easy for launchers to get wrong.

Comment thread src/qt_common/config/qt_config.cpp Outdated
// The file uses the same [Controls] keys as this configuration. Only the players that have
// keys in it are read, every other player is left as it is.
const std::string own_path = config_loc;
config_loc = path;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This points the main config at the request and then calls SetUpIni() again on qt-config.ini. If that "rb" open fails (for example, the launcher has the file open on Windows), SetUpIni falls back to "wb", which empties the file, and WriteToIni below then writes only the player keys. Reading the request into its own CSimpleIniA would avoid this and leave the main config's state alone.

Comment thread src/qt_common/config/qt_config.cpp Outdated
auto& game_players = Settings::values.players.GetValue();
const auto& global_players = Settings::values.players.GetValue(true);
for (const std::size_t p : players_in_file) {
if (game_running && !game_players[p].profile_name.empty()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads the game's copy of the players without checking Settings::values.players.UsingGlobal(). Nothing clears that copy when a game shuts down, so after a game with its own input profile, a later game without per-game settings still sees the old profile_name. That player is then skipped, so the new controls are saved but not applied until a restart.

game_players[p].profile_name = "";
players_to_apply.push_back(p);
}
Settings::values.players.SetGlobal(players_global);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When player 0 is Handheld, ReadControlValues also switches the console to handheld mode (use_docked_mode). This path doesn't, so a request that sets player_0_type to Handheld during a docked game leaves the controller Handheld and the console docked, a combination the normal loading code and the settings dialog never allow.

npad_type_in_settings = npad_id_type == NpadIdType::Other
? NpadStyleIndex::Fullkey
: MapSettingsTypeToNPad(player.controller_type);
connected_in_settings = player.connected;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two are only updated here. The controls dialog applies changes with DisableConfiguration() + SaveCurrentConfig() (configure_input_player.cpp:853-854), which changes both the settings and the controller, and nothing calls ReloadFromSettings afterwards during a game. Say player 1 starts as a Pro Controller and is switched to Handheld in the dialog during a game:

  • If the launcher then sends type=Pro, it matches the out-of-date value, so only the mappings reload and the controller stays Handheld while qt-config.ini says Pro.
  • If the launcher sends Handheld again, it doesn't match, so the controller is disconnected and reconnected and the game can show the controller applet, which this change is meant to avoid.

Updating both fields in SaveCurrentConfig() should fix this.

}
const auto player_index = Service::HID::NpadIdTypeToIndex(npad_id_type);
const auto& player = Settings::values.players.GetValue()[player_index];
const auto style = npad_id_type == NpadIdType::Other

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the Other ? Fullkey : MapSettingsTypeToNPad(...) line now appears three times, and the button/stick/motion/ring loops below are copied from ReloadFromSettings. Shared helpers used by both functions would stop them drifting apart.

for (std::size_t index = 0; index < player.motions.size(); ++index) {
motion_params[index] = Common::ParamPackage(player.motions[index]);
}
ring_params[0] = Common::ParamPackage(Settings::values.ringcon_analogs);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This path never calls ReloadColorsFromSettings(), so colours in a request are saved but the game keeps the old ones until the next full reload. Also, ringcon_analogs is reloaded here but ApplyPlayerValuesFromFile never reads it from the request, so it can't change through this path.

Comment thread src/yuzu/main_window.cpp
// Take the request first, so the program that wrote it cannot change or remove it while it
// is read.
Common::FS::RemoveFile(ControlsReloadApplyingPath());
if (!Common::FS::RenameFile(ControlsReloadRequestPath(), ControlsReloadApplyingPath())) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A directory watch in QFileSystemWatcher fires when a file is created or renamed, not when it's written to (inotify on Linux, kqueue on macOS). If a launcher opens reload-controls and writes to it directly, this can run while the file is still empty or half-written. The file is then renamed away and only part of it is applied, and that part is saved. On Windows this rename fails while the launcher still has the file open, and nothing retries it until some other file in the config folder changes.

Could the protocol require writing a temp file and renaming it to reload-controls, documented next to ControlsReloadRequestPath()? Retrying a failed rename on a short timer would also help.

Comment thread src/yuzu/main_window.cpp Outdated

void MainWindow::WatchControlsReloadRequests() {
// A request left from a previous run is stale: the program that wrote it has given up on it.
Common::FS::RemoveFile(ControlsReloadRequestPath());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means a launcher that writes the request and then starts Eglantine loses it. That's fine if it's intended, but it's worth saying so in the protocol description so launcher authors know to wait until Eglantine is running.

- Read the request into its own ini, on top of the current values of
  its players, so keys it leaves out keep their value and qt-config.ini
  is never opened again.
- Record the players using the game's own input profile when the game
  boots, and leave them alone until it stops.
- Switch to handheld mode when player 1 becomes handheld, as loading
  the configuration does.
- Keep the controller's configured style and connection in sync when
  the controls window saves.
- Apply colours, drop the ring-con reload, and share the mapping code
  with ReloadFromSettings.
- Retry a request that can't be taken yet, apply one written before
  startup, and document how to write one.

Signed-off-by: tom.tourbez <188838116+tom-tour@users.noreply.github.com>
@tom-tour

Copy link
Copy Markdown
Author

Thanks for the review, I pushed a commit that should fix all the points

One change on my side : a request written before startup is now applied instead of deleted. Tell me if you'd rather keep deleting it

Tested again with Fire Emblem Fortune's Weave, all good

@aidankinzett

Copy link
Copy Markdown
Collaborator

One change on my side : a request written before startup is now applied instead of deleted. Tell me if you'd rather keep deleting it

yeah this makes sense to me.

thanks for the update, I will review this soon and get back to you

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

[Feature Request] Reload controls from qt-config.ini while a game is running

2 participants